From e97ef19882c743fdee31b0e6f0a364bdcb27c824 Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Thu, 20 Aug 2026 05:01:36 -0400 Subject: [PATCH 1/3] Build generic marketing platform Add campaign planning, budget approvals, resumable provider work, composition, and QA on the shared adapter broker. --- README.md | 1 + docs/AGENT_GUIDE.md | 1 + docs/LOGO_ANIMATION_PIPELINE.md | 2 +- docs/MARKETING_PLATFORM.md | 21 + docs/ROTATO-INTEGRATION.md | 44 +- docs/WORKFLOWS.md | 2 +- examples/marketing/campaign.example.yaml | 33 + examples/marketing/creative-plan.example.json | 18 + examples/marketing/product.example.yaml | 13 + .../marketing/rotato-template.example.json | 6 + package.json | 8 +- scripts/adapters/capture.adapter.mjs | 37 + scripts/adapters/higgsfield.adapter.mjs | 43 ++ scripts/adapters/marketing.adapter.mjs | 47 ++ scripts/adapters/rotato.adapter.mjs | 66 ++ scripts/adapters/workflow.adapter.mjs | 1 - scripts/capture-cli.mjs | 59 ++ scripts/clipkit.mjs | 2 +- scripts/ebay/ebay-cinematic-ads.mjs | 18 +- .../export-competitive-render-handoff.mjs | 2 +- .../prepare-competitive-premium-renders.mjs | 8 +- .../run-competitive-higgsfield-renders.mjs | 32 +- scripts/higgsfield-cli.mjs | 113 +++ scripts/logo/render-all.mjs | 18 +- scripts/logo/vectorize.mjs | 4 +- scripts/logo/verify-variant.tsx | 12 +- scripts/marketing.mjs | 699 ++++++++++++++++++ scripts/marketing/schemas.mjs | 133 ++++ scripts/platform/job-worker.mjs | 2 +- scripts/rotato-cli.mjs | 295 +++++--- src/marketing-timeline.tsx | 140 ++++ src/root.tsx | 87 ++- templates/rotato/README.md | 3 + tests/cli-smoke.test.mjs | 2 +- tests/marketing-platform.test.mjs | 471 ++++++++++++ 35 files changed, 2229 insertions(+), 214 deletions(-) create mode 100644 docs/MARKETING_PLATFORM.md create mode 100644 examples/marketing/campaign.example.yaml create mode 100644 examples/marketing/creative-plan.example.json create mode 100644 examples/marketing/product.example.yaml create mode 100644 examples/marketing/rotato-template.example.json create mode 100644 scripts/adapters/capture.adapter.mjs create mode 100644 scripts/adapters/higgsfield.adapter.mjs create mode 100644 scripts/adapters/marketing.adapter.mjs create mode 100644 scripts/adapters/rotato.adapter.mjs create mode 100644 scripts/capture-cli.mjs create mode 100644 scripts/higgsfield-cli.mjs create mode 100644 scripts/marketing.mjs create mode 100644 scripts/marketing/schemas.mjs create mode 100644 src/marketing-timeline.tsx create mode 100644 templates/rotato/README.md create mode 100644 tests/marketing-platform.test.mjs diff --git a/README.md b/README.md index a982d39..d05c328 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ The menu is convenient for interactive editing. The direct `bun run clipkit -- . - Required: Node.js + `ffmpeg` + `ffprobe` + project CLI/runtime files - Optional: `yt-dlp`, Ollama; AI provider auto mode prefers local Ollama and falls back to configured DeepSeek/OpenAI +- Generic marketing campaigns use the same discovered adapters and job broker; see [Marketing platform](docs/MARKETING_PLATFORM.md). ```bash bun run desktop:env-check:json # machine-readable checks diff --git a/docs/AGENT_GUIDE.md b/docs/AGENT_GUIDE.md index 151258f..40bf8a5 100644 --- a/docs/AGENT_GUIDE.md +++ b/docs/AGENT_GUIDE.md @@ -26,6 +26,7 @@ Use `--dry-run` before any paid provider call or expensive render. Use `--run-id - Do not put secrets in prompts, arguments, manifests, or generated logs. - The AI provider prefers local Ollama, then configured DeepSeek/OpenAI fallback. Use `--provider` to override. Transcription remains separate and requires OpenAI, local whisper.cpp, or YouTube subtitles. - Use existing specialized commands when they provide the right behavior: `caption`, `enhance`, `auto-clips`, `chapter`, `broll`, `voiceover`, `fal-image-edit`, `fal-reference-video`, and `rotato`. +- Use `marketing plan|estimate|approve|execute|inspect|qa|export` for resumable campaigns. Treat technical QA, claims review, visual review, and publication as separate gates. - Generated marketing/B-roll assets require human review and are not product-condition evidence. ## JSON output diff --git a/docs/LOGO_ANIMATION_PIPELINE.md b/docs/LOGO_ANIMATION_PIPELINE.md index 6ea6a0a..e9552f1 100644 --- a/docs/LOGO_ANIMATION_PIPELINE.md +++ b/docs/LOGO_ANIMATION_PIPELINE.md @@ -29,7 +29,7 @@ logo.png ──vectorize──> layered SVG ──build-spec──> LOGO_SPEC.md ## One-time setup ```bash -npm i -D imagetracerjs pngjs +bun add -d imagetracerjs pngjs ``` Only needed for the auto-trace path. If you always have vector source, skip it. diff --git a/docs/MARKETING_PLATFORM.md b/docs/MARKETING_PLATFORM.md new file mode 100644 index 0000000..3f7ce7f --- /dev/null +++ b/docs/MARKETING_PLATFORM.md @@ -0,0 +1,21 @@ +# Marketing platform + +Campaign commands use the shared adapter catalog and filesystem job broker: + +```sh +clipcaptionai marketing plan --campaign examples/marketing/campaign.example.yaml --wait +clipcaptionai marketing estimate --run --wait +clipcaptionai marketing approve --run --budget-credits 12 +clipcaptionai marketing execute --run --wait +clipcaptionai marketing inspect --run +clipcaptionai marketing qa --run --wait +clipcaptionai marketing export --run --wait +``` + +`plan`, `estimate`, `execute`, `qa`, and `export` detach unless `--wait` is supplied. Runs are stored under `campaigns//`; set `CCA_CAMPAIGNS_ROOT` to move them. Assets are content-hashed and events are append-only. Campaign records contain the shared broker job IDs rather than a second queue. + +Approval binds the plan hash, installed-tool capability fingerprint, CLI-derived estimate hash, and total credit budget. Any plan, estimate, adapter, CLI-help, or model change invalidates it. Live generation intents provide current installed-CLI cost argv and submission argv; paid submission additionally requires `--live-execution`. `--dry-run` never submits. Technical QA reports decoding, format, timing, stream, black-frame, silence, caption-zone, CTA, capture-freshness, and Rotato-template checks separately from claims, human visual review, and publication approval. + +Product capture is command-only: manifests own argv arrays, cwd, outputs, seed, repository commit, and device profile. No shell interpolation or GUI automation is used. + +Rotato templates live at `templates/rotato//template.json` beside their real `scene.rotato`. Generate `inspectFingerprint` from the current `rotato inspect --json` result. Semantic slots compile to inspected device indexes and overlay IDs; drift fails with `TEMPLATE_INSPECT_MISMATCH`. diff --git a/docs/ROTATO-INTEGRATION.md b/docs/ROTATO-INTEGRATION.md index eb822bf..0e1c68e 100644 --- a/docs/ROTATO-INTEGRATION.md +++ b/docs/ROTATO-INTEGRATION.md @@ -1,43 +1,15 @@ -# Rotato Integration Notes +# Rotato integration -Optional local integration (the CLI remains usable without Rotato): +Rotato is optional. ClipCaptionAI discovers the installed CLI, hashes its current help contract, and routes work through the shared job broker: -- Rotato desktop app exists at `/Applications/Rotato.app`. -- Rotato CLI exists at `/usr/local/bin/rotato`. -- The local `VideoAssets` folder (see `scripts/rotato-cli.mjs` defaults) is currently empty — mockup renders land under `outputs/`. - -## What We Added - -ClipCaptionAI now has a thin Rotato bridge so mockup rendering can live beside the rest of the video tooling: - -```bash +```sh clipcaptionai rotato doctor -clipcaptionai rotato inspect /path/to/project.rotato --json -clipcaptionai rotato render /path/to/project.rotato --screen-media /path/to/app-capture.mp4 --output outputs/mockups/demo.mp4 +clipcaptionai rotato inspect /path/to/scene.rotato --json --wait +clipcaptionai rotato render /path/to/scene.rotato --screen-media /path/to/capture.mp4 --output outputs/mockups/demo.mp4 --wait ``` -This is intentionally small. It does not generate `.rotato` projects from AI prompts yet. It just makes Rotato reachable from the same CLI surface as captions, B-roll, and renders. - -## Why This Is The Right First Step - -- It keeps Rotato useful without turning ClipCaption into a full mockup editor yet. -- It gives us one CLI entry point for future demo-video workflows. -- It lets future automation swap app recordings, screenshots, and overlay text into existing Rotato scenes. - -## Best Candidates To Absorb Before Deleting Any Old Video-Assets Workspace - -- Reusable `.rotato` scene templates for phones, laptops, browser windows, and side-by-side product demos. -- Naming conventions for screen recordings, overlay copy, and export targets. -- Prebuilt animation recipes such as app reveal, feature tour, testimonial montage, and CTA end cards. -- Any shell scripts or JSON manifests that already map source media into repeatable demo exports. - -## Good Next Step Later - -If we want phase two, the smart move is probably a ClipCaption command that: +Rendering always runs `inspect --json` first. Template folders contain a real `scene.rotato` and `template.json`; semantic slots compile to inspected device indexes and overlay IDs. A changed inspect fingerprint or missing mapped ID fails with `TEMPLATE_INSPECT_MISMATCH`. `--screen-media` and `--screen-media-for` are mutually exclusive. -1. takes a rendered app clip or screenshots, -2. selects a Rotato template, -3. swaps media and overlay text, -4. exports a polished mockup video into `outputs/mockups/`. +The wrapper uses safe argv execution and preserves Rotato app handoff, timeout, codec, size, quality, and wait flags. It never persists overlay mutations. Completed files are hashed and probed. `rotato raw` remains available for advanced debugging through the same safe argv path. -That would give you AI-assisted demo generation without keeping a separate orphaned video-assets repo around. Until then, a Rotato scene must exist locally and a successful bridge command is not proof that a final product video has passed visual QA. +Rotato rendering success is not visual approval or publication readiness; marketing QA records those as separate states. diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 9449c77..30717c8 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -1405,7 +1405,7 @@ Useful `rerender:clip` options: bun run doctor ``` -This checks Node, npm, ffmpeg, ffprobe, yt-dlp, `.env`, and optional OpenAI key presence. +This checks Bun, ffmpeg, ffprobe, yt-dlp, `.env`, and optional OpenAI key presence. ## Other Useful Everyday Commands diff --git a/examples/marketing/campaign.example.yaml b/examples/marketing/campaign.example.yaml new file mode 100644 index 0000000..1eb28ba --- /dev/null +++ b/examples/marketing/campaign.example.yaml @@ -0,0 +1,33 @@ +id: clipcaptionai-launch +product: ./product.example.yaml +objective: Demonstrate a reusable CLI-first marketing workflow. +approvedClaims: + - Campaign work is resumable through shared jobs. +variants: + - id: vertical-demo + width: 1080 + height: 1920 + fps: 30 + durationSeconds: 15 + cta: Try ClipCaptionAI + intents: + - type: generation + provider: higgsfield + model: configured-model + estimateCredits: 8 + prompt: Product UI moving through a clean studio scene. + timeline: + - type: text + startSeconds: 0 + durationSeconds: 12 + text: One workflow, CLI or desktop + transition: fade + - type: end-card + startSeconds: 12 + durationSeconds: 3 + text: Try ClipCaptionAI + captions: + - text: One workflow, CLI or desktop. + startSeconds: 0 + endSeconds: 3 + yPercent: 82 diff --git a/examples/marketing/creative-plan.example.json b/examples/marketing/creative-plan.example.json new file mode 100644 index 0000000..99026b0 --- /dev/null +++ b/examples/marketing/creative-plan.example.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "runId": "clipcaptionai-launch-example", + "campaignId": "clipcaptionai-launch", + "createdAt": "2026-08-20T00:00:00.000Z", + "planHash": "generated-by-marketing-plan", + "capabilityFingerprint": "generated-by-marketing-plan", + "product": { + "id": "clipcaptionai", + "name": "ClipCaptionAI", + "seedVersion": "1", + "approvedClaims": [], + "captureFlows": {}, + "platformProfiles": {} + }, + "approvedClaims": [], + "variants": [] +} diff --git a/examples/marketing/product.example.yaml b/examples/marketing/product.example.yaml new file mode 100644 index 0000000..c7b2cde --- /dev/null +++ b/examples/marketing/product.example.yaml @@ -0,0 +1,13 @@ +id: clipcaptionai +name: ClipCaptionAI +seedVersion: "1" +approvedClaims: + - Create captioned video workflows from a CLI or desktop app. +captureFlows: + demo: + argv: ["bun", "--version"] + outputs: [] +platformProfiles: + desktop-1080p: + width: 1920 + height: 1080 diff --git a/examples/marketing/rotato-template.example.json b/examples/marketing/rotato-template.example.json new file mode 100644 index 0000000..c5f97f8 --- /dev/null +++ b/examples/marketing/rotato-template.example.json @@ -0,0 +1,6 @@ +{ + "inspectFingerprint": "sha256-of-normalized-rotato-inspect-json", + "deviceSlots": {"primary-phone": 0}, + "textOverlays": {"headline": "headline-overlay-id"}, + "imageOverlays": {"brand-mark": "logo-overlay-id"} +} diff --git a/package.json b/package.json index 53bc73c..2d15ce6 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "docs/AGENT_GUIDE.md", "docs/AI_PROVIDERS.md", "docs/TOOL_ADAPTERS.md", + "docs/MARKETING_PLATFORM.md", "docs/PRODUCTION_SUPPORT.md", "docs/RELEASE.md", "docs/WORKFLOWS.md", @@ -60,15 +61,18 @@ "scripts/*.mts", "scripts/adapters/", "scripts/platform/", + "scripts/marketing/", "scripts/ebay/", "scripts/logo/", "assets/logos/", "src/captioned-clip.tsx", "src/index.tsx", "src/prompt-video.tsx", + "src/marketing-timeline.tsx", "src/root.tsx", "src/types.ts", "src/logo/", + "templates/rotato/", "src/listingos-deck-scenes.tsx", "styles/", "tsconfig.json" @@ -78,7 +82,7 @@ "clipkit": "bun scripts/clipkit.mjs", "doctor": "bun scripts/clipkit.mjs doctor", "rotato": "bun scripts/rotato-cli.mjs", - "test": "bun test", + "test": "bun test --timeout 120000", "check": "bun run typecheck && bun run typecheck:desktop && bun run lint && bun run test", "lint": "bunx eslint scripts/ desktop/ tests/", "format": "bunx prettier --write \"scripts/**/*.mjs\" \"desktop/**/*.{mjs,cjs,js,ts,tsx}\" \"tests/**/*.mjs\"", @@ -217,6 +221,7 @@ "src/**/*", "public/**/*", "styles/**/*", + "templates/rotato/**/*", "scene-library/**/*", "custom-scenes-library/**/*", "sfx-library/**/*", @@ -242,6 +247,7 @@ "scripts/**/*", "src/**/*", "styles/**/*", + "templates/rotato/**/*", "public/**/*", "scene-library/**/*", "custom-scenes-library/**/*", diff --git a/scripts/adapters/capture.adapter.mjs b/scripts/adapters/capture.adapter.mjs new file mode 100644 index 0000000..8c156bc --- /dev/null +++ b/scripts/adapters/capture.adapter.mjs @@ -0,0 +1,37 @@ +import path from 'node:path'; + +import {projectRoot} from '../lib.mjs'; + +const action = (id, title, mode = 'job') => ({ + id, + title, + description: `${title} a command-based product capture.`, + mode, + aliases: [], + args: [{name: 'args', type: 'textarea'}], + requirements: [], + secrets: [], + locks: id === 'run' ? ['capture', 'outputs'] : [], + setup: [], +}); + +export default { + metadata: { + id: 'capture', + title: 'Product capture', + description: 'Run manifest-owned capture commands without GUI automation.', + version: '1', + actions: [action('doctor', 'Doctor', 'sync'), action('run', 'Run')], + }, + async build(actionId, input = {}) { + return { + command: process.execPath, + args: [ + path.join(projectRoot, 'scripts', 'capture-cli.mjs'), + actionId, + ...(input.args || []).map(String), + ], + cwd: projectRoot, + }; + }, +}; diff --git a/scripts/adapters/higgsfield.adapter.mjs b/scripts/adapters/higgsfield.adapter.mjs new file mode 100644 index 0000000..76b4e88 --- /dev/null +++ b/scripts/adapters/higgsfield.adapter.mjs @@ -0,0 +1,43 @@ +import path from 'node:path'; + +import {projectRoot} from '../lib.mjs'; + +const action = (id, title, mode = 'job', locks = []) => ({ + id, + title, + description: `${title} through the installed Higgsfield CLI.`, + mode, + aliases: [], + args: [{name: 'args', type: 'textarea'}], + requirements: ['higgsfield'], + secrets: ['HIGGSFIELD_API_KEY'], + locks, + setup: [], +}); + +export default { + metadata: { + id: 'higgsfield', + title: 'Higgsfield', + description: 'Estimate and resume budget-gated Higgsfield generation.', + version: '1', + actions: [ + action('doctor', 'Doctor', 'sync'), + action('estimate', 'Estimate'), + action('submit', 'Submit', 'job', ['higgsfield-credits']), + action('poll', 'Poll'), + action('collect', 'Collect', 'job', ['outputs']), + ], + }, + async build(actionId, input = {}) { + return { + command: process.execPath, + args: [ + path.join(projectRoot, 'scripts', 'higgsfield-cli.mjs'), + actionId, + ...(input.args || []).map(String), + ], + cwd: projectRoot, + }; + }, +}; diff --git a/scripts/adapters/marketing.adapter.mjs b/scripts/adapters/marketing.adapter.mjs new file mode 100644 index 0000000..7556d58 --- /dev/null +++ b/scripts/adapters/marketing.adapter.mjs @@ -0,0 +1,47 @@ +import path from 'node:path'; + +import {projectRoot} from '../lib.mjs'; + +const passthrough = [{name: 'args', label: 'Arguments', type: 'textarea'}]; +const entry = (id, title, mode = 'job', locks = []) => ({ + id, + title, + description: `${title} a marketing campaign.`, + mode, + aliases: [], + args: passthrough, + requirements: ['bun'], + secrets: [], + locks, + setup: [], +}); + +export default { + metadata: { + id: 'marketing', + title: 'Marketing campaigns', + description: 'Plan, budget, execute, inspect, QA, and export campaign runs.', + version: '1', + actions: [ + entry('doctor', 'Doctor', 'sync'), + entry('plan', 'Plan', 'job', ['campaigns']), + entry('estimate', 'Estimate', 'job', ['campaigns']), + entry('approve', 'Approve', 'sync', ['campaigns']), + entry('execute', 'Execute', 'job', ['campaigns', 'outputs', 'gpu']), + entry('inspect', 'Inspect', 'sync'), + entry('qa', 'QA', 'job', ['campaigns']), + entry('export', 'Export', 'job', ['campaigns', 'outputs']), + ], + }, + async build(actionId, input = {}) { + return { + command: process.execPath, + args: [ + path.join(projectRoot, 'scripts', 'marketing.mjs'), + actionId, + ...(input.args || []).map(String), + ], + cwd: projectRoot, + }; + }, +}; diff --git a/scripts/adapters/rotato.adapter.mjs b/scripts/adapters/rotato.adapter.mjs new file mode 100644 index 0000000..afac6c7 --- /dev/null +++ b/scripts/adapters/rotato.adapter.mjs @@ -0,0 +1,66 @@ +import {spawnSync} from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import {projectRoot} from '../lib.mjs'; + +const action = (id, title, mode = 'job', locks = []) => ({ + id, + title, + description: `${title} with the installed Rotato CLI.`, + mode, + aliases: id === 'render' ? ['rotato', 'mockup'] : [], + args: [{name: 'args', type: 'textarea'}], + requirements: ['rotato'], + secrets: [], + locks, + setup: [], +}); + +export default { + metadata: { + id: 'rotato', + title: 'Rotato', + description: 'Inspect and render Rotato projects with capability validation.', + version: '1', + actions: [ + action('doctor', 'Doctor', 'sync'), + action('inspect', 'Inspect'), + action('render', 'Render', 'job', ['rotato', 'outputs']), + action('raw', 'Raw passthrough', 'job', ['rotato']), + ], + }, + async build(actionId, input = {}) { + return { + command: process.execPath, + args: [ + path.join(projectRoot, 'scripts', 'rotato-cli.mjs'), + actionId, + ...(input.args || []).map(String), + ], + cwd: projectRoot, + }; + }, + async collect(actionId, input = {}) { + if (actionId !== 'render') return {}; + const args = (input.args || []).map(String); + const index = args.indexOf('--output'); + if (index < 0) return {}; + const output = path.resolve(args[index + 1]); + if (!fs.existsSync(output)) return {}; + const probe = spawnSync( + 'ffprobe', + ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', output], + {encoding: 'utf8'}, + ); + return { + artifacts: [output], + artifact: { + path: output, + hash: crypto.createHash('sha256').update(fs.readFileSync(output)).digest('hex'), + media: probe.status === 0 ? JSON.parse(probe.stdout) : null, + }, + }; + }, +}; diff --git a/scripts/adapters/workflow.adapter.mjs b/scripts/adapters/workflow.adapter.mjs index 96bb719..64c4e85 100644 --- a/scripts/adapters/workflow.adapter.mjs +++ b/scripts/adapters/workflow.adapter.mjs @@ -53,7 +53,6 @@ const workflows = [ workflow('enhance', 'Enhance Video', 'Add contextual B-roll and captions.', [], videoArg), workflow('broll', 'Find B-roll', 'Find reusable B-roll from a prompt.', ['finder']), workflow('video', 'Model Video Run', 'Plan, render, inspect, and QA model-directed video.'), - workflow('rotato', 'Rotato', 'Use the compatibility Rotato wrapper.', ['mockup']), workflow('voiceover', 'Voiceover', 'Generate ElevenLabs narration.', ['elevenlabs']), workflow('fal-image-edit', 'fal Image Edit', 'Create a reviewed image edit.'), workflow('fal-reference-video', 'fal Reference Video', 'Create a reviewed reference video.'), diff --git a/scripts/capture-cli.mjs b/scripts/capture-cli.mjs new file mode 100644 index 0000000..7ecaa12 --- /dev/null +++ b/scripts/capture-cli.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import {spawnSync} from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import yaml from 'js-yaml'; + +import {parseArgs, requireArg} from './lib.mjs'; +import {ProductManifest} from './marketing/schemas.mjs'; +import {hashValue} from './platform/catalog.mjs'; +import {writeJsonAtomic} from './platform/jobs.mjs'; + +const action = process.argv[2]; +const args = parseArgs(process.argv.slice(3)); +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const readManifest = () => { + if (args.plan) + return ProductManifest.parse( + JSON.parse(fs.readFileSync(path.resolve(String(args.plan)), 'utf8')).product, + ); + const file = path.resolve(requireArg(args, 'manifest')); + return ProductManifest.parse(yaml.load(fs.readFileSync(file, 'utf8'))); +}; + +if (!action || action === '--help') + console.log('Usage: clipcaptionai capture doctor|run --manifest --flow '); +else if (action === 'doctor') console.log(JSON.stringify({ok: true, mode: 'command'})); +else if (action === 'run') { + const manifest = readManifest(); + const flowId = requireArg(args, 'flow'); + const flow = manifest.captureFlows[flowId]; + if (!flow) throw new Error(`Unknown capture flow: ${flowId}`); + const cwd = path.resolve(flow.cwd || manifest.repositoryRoot || process.cwd()); + const result = spawnSync(flow.argv[0], flow.argv.slice(1), {cwd, encoding: 'utf8', shell: false}); + if (result.error || result.status !== 0) + throw new Error(result.stderr || result.error?.message || 'Capture failed.'); + let repositoryCommit = manifest.repositoryCommit || null; + if (!repositoryCommit) { + const git = spawnSync('git', ['rev-parse', 'HEAD'], {cwd, encoding: 'utf8', shell: false}); + if (git.status === 0) repositoryCommit = git.stdout.trim(); + } + const capturedAt = new Date().toISOString(); + const artifacts = flow.outputs.map((output) => { + const file = path.resolve(cwd, output); + if (!fs.existsSync(file)) throw new Error(`Capture output missing: ${file}`); + return {path: file, hash: sha256(file)}; + }); + const record = { + repositoryCommit, + flowId, + flowHash: hashValue(flow), + seedVersion: manifest.seedVersion, + platformProfile: args.profile || null, + capturedAt, + artifacts, + }; + if (args.record) writeJsonAtomic(path.resolve(String(args.record)), record); + console.log(JSON.stringify(record)); +} else throw new Error(`Unknown capture action: ${action}`); diff --git a/scripts/clipkit.mjs b/scripts/clipkit.mjs index d891f29..52151f3 100644 --- a/scripts/clipkit.mjs +++ b/scripts/clipkit.mjs @@ -362,7 +362,7 @@ const latestOutputDir = () => { }; const printDoctor = () => { - const required = ['node', 'npm', 'ffmpeg', 'ffprobe', 'yt-dlp']; + const required = ['bun', 'ffmpeg', 'ffprobe', 'yt-dlp']; const optional = ['git', 'whisper-cli', 'rotato']; const rows = [ ...required.map((name) => ({name, required: true, ok: commandExists(name)})), diff --git a/scripts/ebay/ebay-cinematic-ads.mjs b/scripts/ebay/ebay-cinematic-ads.mjs index 4cab2e7..d217c2d 100644 --- a/scripts/ebay/ebay-cinematic-ads.mjs +++ b/scripts/ebay/ebay-cinematic-ads.mjs @@ -603,10 +603,10 @@ const writeHiggsProductionKit = ({listing, listingDir, listingPlan = null}) => { const preflightScript = [ '#!/usr/bin/env bash', 'set -euo pipefail', - 'npm exec --package=@higgsfield/cli -- higgs version', - 'npm exec --package=@higgsfield/cli -- higgs workspace status || true', - 'npm exec --package=@higgsfield/cli -- higgs account status', - 'npm exec --package=@higgsfield/cli -- higgs model get seedance_2_0 >/dev/null', + 'higgs version', + 'higgs workspace status || true', + 'higgs account status', + 'higgs model get seedance_2_0 >/dev/null', '', ].join('\n'); @@ -618,7 +618,7 @@ const writeHiggsProductionKit = ({listing, listingDir, listingPlan = null}) => { ...renderJobs.flatMap((job) => [ `echo ${shellQuote(`--- ${job.id}`)}`, [ - 'npm exec --package=@higgsfield/cli -- higgs generate cost', + 'higgs generate cost', shellQuote(job.model), '--prompt', shellQuote(job.prompt), @@ -647,7 +647,7 @@ const writeHiggsProductionKit = ({listing, listingDir, listingPlan = null}) => { ...renderJobs.flatMap((job) => [ `echo ${shellQuote(`--- ${job.id}`)}`, [ - 'npm exec --package=@higgsfield/cli -- higgs generate create', + 'higgs generate create', shellQuote(job.model), '--prompt', shellQuote(job.prompt), @@ -1202,7 +1202,7 @@ const competitivePlan = async () => { } if (plan.selected.length > 0) { - execFileSync('npm', intelArgs, {cwd: projectRoot, stdio: 'inherit'}); + execFileSync('bun', intelArgs, {cwd: projectRoot, stdio: 'inherit'}); } let controlLoop = null; @@ -1257,7 +1257,7 @@ const competitivePlan = async () => { if (args['control-loop-dry-run']) { controlLoopArgs.push('--dry-run'); } - execFileSync('npm', controlLoopArgs, {cwd: projectRoot, stdio: 'inherit'}); + execFileSync('bun', controlLoopArgs, {cwd: projectRoot, stdio: 'inherit'}); const premiumPlanDir = path.join(creativeOutDir, 'competitive-premium-render-plan'); controlLoop = { control_loop_manifest: path.join( @@ -1406,7 +1406,7 @@ const findBroll = () => { findArgs.push('--movie-scenes'); } - execFileSync('npm', findArgs, {cwd: projectRoot, stdio: 'inherit'}); + execFileSync('bun', findArgs, {cwd: projectRoot, stdio: 'inherit'}); const runDir = path.join(runRoot, runName); const storyBrollDir = path.join(projectDir, 'story-broll'); diff --git a/scripts/ebay/export-competitive-render-handoff.mjs b/scripts/ebay/export-competitive-render-handoff.mjs index 0045251..da13203 100644 --- a/scripts/ebay/export-competitive-render-handoff.mjs +++ b/scripts/ebay/export-competitive-render-handoff.mjs @@ -91,7 +91,7 @@ const flattenJobs = (plan) => { const higgsCreateArgsForJob = (job) => { const args = [ - 'npm exec --package=@higgsfield/cli -- higgs generate create', + 'higgs generate create', shellQuote(job.model), '--prompt', shellQuote(job.prompt), diff --git a/scripts/ebay/prepare-competitive-premium-renders.mjs b/scripts/ebay/prepare-competitive-premium-renders.mjs index 8588f6e..e83d8ff 100644 --- a/scripts/ebay/prepare-competitive-premium-renders.mjs +++ b/scripts/ebay/prepare-competitive-premium-renders.mjs @@ -361,11 +361,7 @@ const writePerListingArtifacts = ({packet}) => { `echo ${shellQuote(`Estimating competitive premium renders for ${packet.item_id} - ${packet.title}`)}`, ...packet.jobs.flatMap((job) => [ `echo ${shellQuote(`--- ${job.id}`)}`, - [ - 'npm exec --package=@higgsfield/cli -- higgs generate cost', - shellQuote(job.model), - ...higgsParamArgsForJob(job), - ].join(' '), + ['higgs generate cost', shellQuote(job.model), ...higgsParamArgsForJob(job)].join(' '), ]), '', ].join('\n'); @@ -379,7 +375,7 @@ const writePerListingArtifacts = ({packet}) => { ...packet.jobs.flatMap((job) => [ `echo ${shellQuote(`--- ${job.id}`)}`, [ - 'npm exec --package=@higgsfield/cli -- higgs generate create', + 'higgs generate create', shellQuote(job.model), ...higgsParamArgsForJob(job), '--wait', diff --git a/scripts/ebay/run-competitive-higgsfield-renders.mjs b/scripts/ebay/run-competitive-higgsfield-renders.mjs index 8702637..130caa8 100644 --- a/scripts/ebay/run-competitive-higgsfield-renders.mjs +++ b/scripts/ebay/run-competitive-higgsfield-renders.mjs @@ -4,6 +4,7 @@ import path from 'node:path'; import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; import {ensureDir, parseArgs, projectRoot} from '../lib.mjs'; +import {commandPath} from '../command-utils.mjs'; const scriptName = path.basename(fileURLToPath(import.meta.url)); const args = parseArgs(process.argv.slice(2)); @@ -94,15 +95,28 @@ const completedJobFromFile = (file) => { }; const runHiggs = (commandArgs) => { - const result = spawnSync( - 'npm', - ['exec', '--yes', '--package=@higgsfield/cli', '--', 'higgs', ...commandArgs], - { - cwd: projectRoot, - encoding: 'utf8', - maxBuffer: 1024 * 1024 * 20, - }, - ); + const executable = + process.env.CCA_HIGGSFIELD_BIN || + commandPath('higgsfield') || + commandPath('higgs') || + [ + path.join(projectRoot, 'node_modules', '.bin', 'higgs'), + path.join(projectRoot, 'node_modules', '.bin', 'higgsfield'), + ].find((candidate) => fs.existsSync(candidate)); + if (!executable) + return { + ok: false, + status: 127, + stdout: '', + stderr: 'Installed Higgsfield CLI not found.', + parsed: null, + }; + const result = spawnSync(executable, commandArgs, { + cwd: projectRoot, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 20, + shell: false, + }); return { ok: result.status === 0, status: result.status, diff --git a/scripts/higgsfield-cli.mjs b/scripts/higgsfield-cli.mjs new file mode 100644 index 0000000..43d15d3 --- /dev/null +++ b/scripts/higgsfield-cli.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import {spawnSync} from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import {commandPath} from './command-utils.mjs'; +import {parseArgs, projectRoot, requireArg} from './lib.mjs'; +import {hashValue} from './platform/catalog.mjs'; + +const argv = process.argv.slice(2); +const action = argv[0]; +const args = parseArgs(argv.slice(1)); +const candidates = [ + process.env.CCA_HIGGSFIELD_BIN, + commandPath('higgsfield'), + commandPath('higgs'), + path.join(projectRoot, 'node_modules', '.bin', 'higgs'), + path.join(projectRoot, 'node_modules', '.bin', 'higgsfield'), + path.join(projectRoot, 'bin', 'higgsfield'), +]; +const executable = candidates.find((candidate) => candidate && fs.existsSync(candidate)); +const invoke = (items, options = {}) => { + if (!executable) throw new Error('Installed or project-local Higgsfield CLI not found.'); + const result = spawnSync(executable, items, { + cwd: projectRoot, + encoding: 'utf8', + shell: false, + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error(result.stderr || `Higgsfield CLI exited with ${result.status}`); + return result; +}; +const capabilities = () => { + const help = invoke(['--help']).stdout; + return {executable, fingerprint: crypto.createHash('sha256').update(help).digest('hex')}; +}; +const stripWrapperFlags = (items) => { + const flags = new Set([ + '--approval-file', + '--plan-hash', + '--capability-fingerprint', + '--intent-key', + '--estimated-credits', + '--total-spent-credits', + ]); + const result = []; + for (let index = 0; index < items.length; index += 1) { + if (flags.has(items[index])) index += 1; + else if (!['--live-execution', '--dry-run'].includes(items[index])) result.push(items[index]); + } + return result; +}; + +if (!action || action === '--help') { + console.log('Usage: clipcaptionai higgsfield doctor|estimate|submit|poll|collect [args]'); +} else if (action === 'doctor') { + console.log(JSON.stringify({ok: true, ...capabilities()})); +} else if (action === 'estimate') { + const capability = capabilities(); + if (args.credits !== undefined || args['estimated-credits'] !== undefined) { + const credits = Number(args.credits ?? args['estimated-credits']); + if (!Number.isFinite(credits) || credits < 0) + throw new Error('Credits must be a non-negative number.'); + console.log(JSON.stringify({credits, capability})); + } else { + const commandArgs = stripWrapperFlags(argv.slice(1)); + if (commandArgs.length === 0) + throw new Error('Estimate requires --credits or installed-CLI cost argv.'); + process.stdout.write(invoke(commandArgs).stdout); + } +} else if (action === 'submit') { + capabilities(); + const run = JSON.parse(fs.readFileSync(path.resolve(requireArg(args, 'approval-file')), 'utf8')); + const approval = run.approval; + const planHash = requireArg(args, 'plan-hash'); + const capabilityFingerprint = requireArg(args, 'capability-fingerprint'); + const intentKey = requireArg(args, 'intent-key'); + const estimated = Number(requireArg(args, 'estimated-credits')); + const spent = Number(args['total-spent-credits'] || 0); + if ( + !approval || + approval.planHash !== planHash || + approval.capabilityFingerprint !== capabilityFingerprint || + approval.estimateHash !== hashValue(run.estimates || {}) || + Number(run.estimates?.[intentKey]) !== estimated + ) + throw new Error('APPROVAL_INVALID'); + if ( + !Number.isFinite(estimated) || + !Number.isFinite(spent) || + estimated < 0 || + spent < 0 || + estimated > approval.budgetCredits || + estimated + spent > approval.budgetCredits + ) + throw new Error('BUDGET_EXCEEDED'); + if (argv.includes('--dry-run')) + console.log(JSON.stringify({submitted: false, dryRun: true, estimatedCredits: estimated})); + else { + if (!argv.includes('--live-execution')) throw new Error('LIVE_EXECUTION_REQUIRED'); + const result = invoke(stripWrapperFlags(argv.slice(1))); + process.stdout.write(result.stdout); + } +} else if (['poll', 'collect'].includes(action)) { + capabilities(); + const result = invoke(stripWrapperFlags(argv.slice(1))); + process.stdout.write(result.stdout); +} else { + throw new Error(`Unknown Higgsfield action: ${action}`); +} diff --git a/scripts/logo/render-all.mjs b/scripts/logo/render-all.mjs index c7f3f9b..4e934e8 100644 --- a/scripts/logo/render-all.mjs +++ b/scripts/logo/render-all.mjs @@ -2,9 +2,9 @@ /** * render-all.mjs — Step 5: batch-render every brand x variant composition. * - * npm run logo:render # everything - * npm run logo:render -- --slug listingos # one brand - * npm run logo:render -- --format webm # transparent background + * bun run logo:render # everything + * bun run logo:render -- --slug listingos # one brand + * bun run logo:render -- --format webm # transparent background * * Composition ids come from src/logo/registry.ts and follow Logo--. */ @@ -25,9 +25,13 @@ const format = arg('--format', 'mp4'); const outRoot = arg('--out', path.join('outputs', 'logo-animations')); // Ask Remotion for the composition list rather than duplicating the registry here. -const raw = execFileSync('npx', ['remotion', 'compositions', 'src/showcase-index.tsx', '--quiet'], { - encoding: 'utf8', -}); +const raw = execFileSync( + 'bunx', + ['remotion', 'compositions', 'src/showcase-index.tsx', '--quiet'], + { + encoding: 'utf8', + }, +); const ids = (raw.match(/\S+/g) ?? []) .filter((id) => id && id.startsWith('Logo-')) @@ -50,7 +54,7 @@ for (const id of ids) { process.stdout.write(` ${id} ... `); try { execFileSync( - 'npx', + 'bunx', [ 'remotion', 'render', diff --git a/scripts/logo/vectorize.mjs b/scripts/logo/vectorize.mjs index 6fb3131..a6ecb57 100644 --- a/scripts/logo/vectorize.mjs +++ b/scripts/logo/vectorize.mjs @@ -15,7 +15,7 @@ * hand-layered logo.svg in the same folder and skip this step entirely — the * spec generator reads either one. * - * Deps (pure JS, no native build): npm i -D imagetracerjs pngjs + * Deps (pure JS, no native build): bun add -d imagetracerjs pngjs */ import fs from 'node:fs'; @@ -51,7 +51,7 @@ try { ({PNG} = await import('pngjs')); } catch { console.error( - 'Missing tracer dependencies. Install them once with:\n\n npm i -D imagetracerjs pngjs\n', + 'Missing tracer dependencies. Install them once with:\n\n bun add -d imagetracerjs pngjs\n', ); process.exit(1); } diff --git a/scripts/logo/verify-variant.tsx b/scripts/logo/verify-variant.tsx index 2eef9ae..19d2332 100644 --- a/scripts/logo/verify-variant.tsx +++ b/scripts/logo/verify-variant.tsx @@ -6,7 +6,7 @@ * missing layers, NaN transforms, an animation that never resolves to the static * logo — without paying for a full Remotion video render. * - * npx tsx scripts/logo/verify-variant.tsx --slug listingos --variant 01-scan-in + * bunx tsx scripts/logo/verify-variant.tsx --slug listingos --variant 01-scan-in * * Exits non-zero on failure, so it drops straight into CI. */ @@ -58,7 +58,7 @@ const run = async () => { let markup: string; try { markup = renderToStaticMarkup( - React.createElement(Variant, {logo, frame, fps: 30, width: 1080, height: 1080}) + React.createElement(Variant, {logo, frame, fps: 30, width: 1080, height: 1080}), ); } catch (err) { failures.push(`frame ${frame}: threw ${(err as Error).message}`); @@ -82,7 +82,7 @@ const run = async () => { ...(markup.match(/opacity="[\d.]+"/g) ?? []), ...(markup.match(/stroke-dashoffset="[-\d.]+"/g) ?? []), ...(markup.match(/(?:translate|scale|rotate)\([^)]*\)/g) ?? []), - ].join('|') + ].join('|'), ); const file = path.join(outDir, `${slug}-${meta.id}-f${String(frame).padStart(3, '0')}.svg`); @@ -91,21 +91,21 @@ const run = async () => { `` + `` + markup.replace(/^]*>/, '').replace(/<\/svg>$/, '') + - `` + ``, ); console.log(` frame ${String(frame).padStart(3)} -> ${file}`); } // The final frame must be the resolved logo: no lingering transforms, full opacity. const finalMarkup = renderToStaticMarkup( - React.createElement(Variant, {logo, frame: last, fps: 30, width: 1080, height: 1080}) + React.createElement(Variant, {logo, frame: last, fps: 30, width: 1080, height: 1080}), ); const lowOpacity = [...finalMarkup.matchAll(/opacity="([\d.]+)"/g)] .map((m) => Number(m[1])) .filter((o) => o < 0.99); if (lowOpacity.length) { failures.push( - `final frame still has faded elements (opacity ${lowOpacity.join(', ')}) — animation does not resolve` + `final frame still has faded elements (opacity ${lowOpacity.join(', ')}) — animation does not resolve`, ); } diff --git a/scripts/marketing.mjs b/scripts/marketing.mjs new file mode 100644 index 0000000..18b33f1 --- /dev/null +++ b/scripts/marketing.mjs @@ -0,0 +1,699 @@ +#!/usr/bin/env node +import {execFileSync, spawnSync} from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import yaml from 'js-yaml'; + +import {commandPath} from './command-utils.mjs'; +import {ensureDir, parseArgs, projectRoot, requireArg} from './lib.mjs'; +import {hashValue, serializeCatalog} from './platform/catalog.mjs'; +import {writeJsonAtomic} from './platform/jobs.mjs'; +import { + AssetRecord, + CampaignBrief, + CampaignRun, + CreativePlan, + ProductManifest, + QAReport, +} from './marketing/schemas.mjs'; + +const action = process.argv[2]; +const args = parseArgs(process.argv.slice(3)); +const campaignsRoot = path.resolve( + process.env.CCA_CAMPAIGNS_ROOT || path.join(projectRoot, 'campaigns'), +); +const now = () => new Date().toISOString(); +const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8')); +const fileHash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const print = (value) => console.log(JSON.stringify(value)); +const appendEvent = (directory, type, data = {}) => + fs.appendFileSync( + path.join(directory, 'events.ndjson'), + `${JSON.stringify({at: now(), type, ...data})}\n`, + ); + +const runDirectory = (value) => { + const requested = path.resolve(String(value)); + if (fs.existsSync(requested) && fs.statSync(requested).isDirectory()) return requested; + const byId = path.join(campaignsRoot, String(value)); + if (fs.existsSync(byId)) return byId; + throw new Error(`Campaign run not found: ${value}`); +}; + +const loadRun = (value) => { + const directory = runDirectory(value); + return { + directory, + plan: CreativePlan.parse(readJson(path.join(directory, 'plan.json'))), + run: CampaignRun.parse(readJson(path.join(directory, 'run.json'))), + assets: zAssets(readJson(path.join(directory, 'assets', 'index.json'))), + }; +}; + +const zAssets = (value) => { + if (!Array.isArray(value)) throw new Error('assets/index.json must contain an array.'); + return value.map((entry) => AssetRecord.parse(entry)); +}; + +const saveRun = (directory, run, patch = {}) => { + const next = CampaignRun.parse({...run, ...patch, updatedAt: now()}); + writeJsonAtomic(path.join(directory, 'run.json'), next); + return next; +}; + +const currentCapabilities = async () => { + const catalog = await serializeCatalog(); + const toolHelp = ['rotato', 'higgsfield', 'higgs'].map((name) => { + const executable = commandPath(name); + if (!executable) return {name, available: false}; + const result = spawnSync(executable, ['--help'], {encoding: 'utf8', shell: false}); + return { + name, + available: result.status === 0, + fingerprint: result.status === 0 ? hashValue(result.stdout) : null, + }; + }); + return hashValue({ + adapters: catalog.map(({id, version, capabilityFingerprint}) => ({ + id, + version, + capabilityFingerprint, + })), + toolHelp, + }); +}; + +const currentPlanHash = (plan) => hashValue({...plan, planHash: ''}); + +const requireCurrentApproval = async ({plan, run}) => { + const capabilityFingerprint = await currentCapabilities(); + const planHash = currentPlanHash(plan); + if (planHash !== plan.planHash || run.planHash !== planHash) + throw new Error('PLAN_HASH_MISMATCH'); + if ( + capabilityFingerprint !== plan.capabilityFingerprint || + run.capabilityFingerprint !== capabilityFingerprint + ) + throw new Error('CAPABILITY_FINGERPRINT_CHANGED'); + if ( + !run.approval || + run.approval.planHash !== planHash || + run.approval.capabilityFingerprint !== capabilityFingerprint || + run.approval.estimateHash !== hashValue(run.estimates) + ) + throw new Error('APPROVAL_REQUIRED'); + if ((run.estimateCredits ?? 0) > run.approval.budgetCredits) throw new Error('BUDGET_EXCEEDED'); +}; + +const resolveProduct = (campaignPath, value) => { + if (typeof value !== 'string') return ProductManifest.parse(value); + const file = path.resolve(path.dirname(campaignPath), value); + return ProductManifest.parse(yaml.load(fs.readFileSync(file, 'utf8'))); +}; + +const planCampaign = async () => { + const campaignPath = path.resolve(requireArg(args, 'campaign')); + const campaign = CampaignBrief.parse(yaml.load(fs.readFileSync(campaignPath, 'utf8'))); + const product = resolveProduct(campaignPath, campaign.product); + const runId = String(args['run-id'] || `${campaign.id}-${Date.now().toString(36)}`); + if (!/^[a-zA-Z0-9._-]+$/.test(runId)) + throw new Error('Run id may only contain letters, numbers, dots, underscores, and hyphens.'); + const directory = path.join(campaignsRoot, runId); + if (fs.existsSync(directory)) throw new Error(`Campaign run already exists: ${directory}`); + for (const child of [ + 'assets', + 'artifacts/source', + 'artifacts/generated', + 'artifacts/mockups', + 'artifacts/previews', + 'artifacts/final', + 'qa', + ]) + ensureDir(path.join(directory, child)); + fs.copyFileSync(campaignPath, path.join(directory, 'campaign.yaml')); + const createdAt = now(); + const capabilityFingerprint = await currentCapabilities(); + const draft = { + schemaVersion: 1, + runId, + campaignId: campaign.id, + createdAt, + planHash: '', + capabilityFingerprint, + product, + approvedClaims: [...new Set([...product.approvedClaims, ...campaign.approvedClaims])], + variants: campaign.variants.map((variant) => ({ + ...variant, + intents: variant.intents.map((intent) => ({ + ...intent, + source: intent.source ? path.resolve(path.dirname(campaignPath), intent.source) : undefined, + output: intent.output ? path.resolve(path.dirname(campaignPath), intent.output) : undefined, + })), + })), + }; + const creativePlan = CreativePlan.parse({...draft, planHash: hashValue(draft)}); + const run = CampaignRun.parse({ + schemaVersion: 1, + id: runId, + status: 'planned', + planHash: creativePlan.planHash, + capabilityFingerprint, + createdAt, + updatedAt: createdAt, + jobIds: process.env.CCA_JOB_ID ? [process.env.CCA_JOB_ID] : [], + reviews: { + technical: 'pending', + contentClaims: 'pending', + visualHuman: 'pending', + publication: 'blocked', + }, + }); + writeJsonAtomic(path.join(directory, 'plan.json'), creativePlan); + writeJsonAtomic(path.join(directory, 'run.json'), run); + writeJsonAtomic(path.join(directory, 'assets', 'index.json'), []); + fs.writeFileSync(path.join(directory, 'events.ndjson'), ''); + appendEvent(directory, 'campaign.planned', { + jobId: process.env.CCA_JOB_ID, + planHash: creativePlan.planHash, + }); + print({runId, directory, planHash: creativePlan.planHash}); +}; + +const estimateCampaign = async () => { + const state = loadRun(requireArg(args, 'run')); + const estimates = {}; + for (const variant of state.plan.variants) + for (const intent of variant.intents) { + const key = hashValue({ + intent, + adapterVersion: '1', + capabilityFingerprint: state.plan.capabilityFingerprint, + }); + let credits = intent.estimateCredits; + if (intent.type === 'generation' && intent.estimateArgv.length > 0) { + const result = spawnSync( + process.execPath, + [ + path.join(projectRoot, 'scripts', 'higgsfield-cli.mjs'), + 'estimate', + ...intent.estimateArgv, + ], + {cwd: projectRoot, encoding: 'utf8', shell: false}, + ); + if (result.error || result.status !== 0) + throw new Error(`Higgsfield estimate failed: ${result.stderr || result.error?.message}`); + const estimated = JSON.parse(result.stdout.trim().split('\n').at(-1)); + credits = Number(estimated.credits); + if (!Number.isFinite(credits) || credits < 0) + throw new Error('Higgsfield estimate did not return non-negative credits.'); + } + estimates[key] = credits; + } + const estimateCredits = Object.values(estimates).reduce((sum, credits) => sum + credits, 0); + const run = saveRun(state.directory, state.run, { + status: 'estimated', + estimateCredits, + estimates, + jobIds: [ + ...new Set([ + ...state.run.jobIds, + ...(process.env.CCA_JOB_ID ? [process.env.CCA_JOB_ID] : []), + ]), + ], + }); + appendEvent(state.directory, 'campaign.estimated', { + jobId: process.env.CCA_JOB_ID, + estimateCredits, + }); + print({runId: run.id, estimateCredits}); +}; + +const approveCampaign = async () => { + const state = loadRun(requireArg(args, 'run')); + if (state.run.estimateCredits === undefined) throw new Error('ESTIMATE_REQUIRED'); + const budgetCredits = Number(requireArg(args, 'budget-credits')); + if (!Number.isFinite(budgetCredits) || budgetCredits < 0) + throw new Error('Budget credits must be a non-negative number.'); + await requireCurrentApproval({ + ...state, + run: { + ...state.run, + approval: { + planHash: state.plan.planHash, + capabilityFingerprint: state.plan.capabilityFingerprint, + estimateHash: hashValue(state.run.estimates), + budgetCredits, + approvedAt: now(), + }, + }, + }); + const run = saveRun(state.directory, state.run, { + status: 'approved', + approval: { + planHash: state.plan.planHash, + capabilityFingerprint: state.plan.capabilityFingerprint, + estimateHash: hashValue(state.run.estimates), + budgetCredits, + approvedAt: now(), + }, + }); + appendEvent(state.directory, 'campaign.approved', {budgetCredits}); + print(run); +}; + +const copyAsset = (state, variant, intent, source, type, extra = {}) => { + if (!fs.existsSync(source)) throw new Error(`Asset source not found: ${source}`); + const hash = fileHash(source); + const extension = path.extname(source); + const target = path.join( + state.directory, + 'artifacts', + type, + `${variant.id}-${hash.slice(0, 12)}${extension}`, + ); + if (!fs.existsSync(target)) fs.copyFileSync(source, target); + const adapter = + type === 'source' + ? 'filesystem' + : type === 'mockups' + ? 'rotato' + : type === 'generated' + ? 'higgsfield' + : 'capture'; + const adapterVersion = extra.adapterVersion || '1'; + const idempotencyKey = hashValue({ + intent, + inputHashes: [hash], + adapterVersion, + provider: intent.provider, + model: intent.model, + capabilityFingerprint: state.plan.capabilityFingerprint, + }); + const mediaResult = spawnSync( + 'ffprobe', + ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', target], + {encoding: 'utf8', shell: false}, + ); + return AssetRecord.parse({ + id: `${variant.id}-${idempotencyKey.slice(0, 12)}`, + type, + path: target, + hash: fileHash(target), + createdAt: now(), + provenance: { + variantId: variant.id, + idempotencyKey, + adapter, + adapterVersion, + timelineHash: hashValue(variant.timeline), + ...extra, + }, + media: mediaResult.status === 0 ? JSON.parse(mediaResult.stdout) : undefined, + }); +}; + +const executeCampaign = async () => { + const state = loadRun(requireArg(args, 'run')); + await requireCurrentApproval(state); + const live = args['live-execution'] === true; + const generated = state.plan.variants.flatMap((variant) => + variant.intents.filter((intent) => intent.type === 'generation'), + ); + if (live && generated.length === 0) + throw new Error('No paid generation intents exist in this plan.'); + const assets = [...state.assets]; + const providerJobs = {...state.run.providerJobs}; + let spentCredits = 0; + for (const variant of state.plan.variants) { + for (const intent of variant.intents) { + if (intent.type === 'generation') { + if (!live) continue; + if (intent.argv.length === 0) + throw new Error('Generation intents require discovered Higgsfield argv.'); + if (intent.estimateArgv.length === 0) + throw new Error('Live generation intents require installed-CLI cost argv.'); + const key = hashValue({ + intent, + adapterVersion: '1', + capabilityFingerprint: state.plan.capabilityFingerprint, + }); + const approvedCredits = state.run.estimates[key]; + if (!Number.isFinite(approvedCredits)) throw new Error('APPROVED_ESTIMATE_REQUIRED'); + if (!providerJobs[key]) { + const result = spawnSync( + process.execPath, + [ + path.join(projectRoot, 'scripts', 'higgsfield-cli.mjs'), + 'submit', + '--approval-file', + path.join(state.directory, 'run.json'), + '--plan-hash', + state.plan.planHash, + '--capability-fingerprint', + state.plan.capabilityFingerprint, + '--intent-key', + key, + '--estimated-credits', + String(approvedCredits), + '--total-spent-credits', + String(spentCredits), + '--live-execution', + ...intent.argv, + ], + {cwd: projectRoot, encoding: 'utf8', shell: false}, + ); + if (result.error || result.status !== 0) + throw new Error( + `Higgsfield submission failed: ${result.stderr || result.error?.message}`, + ); + const output = result.stdout.trim(); + providerJobs[key] = output ? JSON.parse(output.split('\n').at(-1)) : {submitted: true}; + saveRun(state.directory, state.run, { + status: 'awaiting-assets', + providerJobs, + }); + appendEvent(state.directory, 'generation.submitted', { + jobId: process.env.CCA_JOB_ID, + idempotencyKey: key, + providerJob: providerJobs[key], + }); + } + spentCredits += approvedCredits; + const generatedPath = + intent.output || providerJobs[key].output || providerJobs[key].result_path; + if (generatedPath && fs.existsSync(generatedPath)) + assets.push( + copyAsset(state, variant, intent, generatedPath, 'generated', { + providerJob: providerJobs[key], + }), + ); + continue; + } + if (intent.type === 'capture') { + const result = spawnSync( + process.execPath, + [ + path.join(projectRoot, 'scripts', 'capture-cli.mjs'), + 'run', + '--plan', + path.join(state.directory, 'plan.json'), + '--flow', + intent.flow, + ...(intent.provider ? ['--profile', intent.provider] : []), + ], + { + cwd: projectRoot, + encoding: 'utf8', + shell: false, + }, + ); + if (result.error || result.status !== 0) + throw new Error(`Capture flow failed: ${result.stderr || result.error?.message}`); + const record = JSON.parse(result.stdout); + for (const artifact of record.artifacts) { + assets.push( + copyAsset(state, variant, intent, artifact.path, 'source', { + adapter: 'capture', + repositoryCommit: record.repositoryCommit, + flowHash: record.flowHash, + seedVersion: record.seedVersion, + platformProfile: record.platformProfile, + capturedAt: record.capturedAt, + }), + ); + } + continue; + } + if (intent.type === 'mockup') { + if (intent.argv.length === 0) throw new Error('Mockup intents require Rotato render argv.'); + const result = spawnSync( + process.execPath, + [path.join(projectRoot, 'scripts', 'rotato-cli.mjs'), 'render', ...intent.argv, '--json'], + {cwd: projectRoot, encoding: 'utf8', shell: false}, + ); + if (result.error || result.status !== 0) + throw new Error(`Rotato render failed: ${result.stderr || result.error?.message}`); + const rendered = JSON.parse(result.stdout.trim().split('\n').at(-1)); + if (!rendered.artifact?.path) throw new Error('Rotato render did not return an artifact.'); + assets.push( + copyAsset(state, variant, intent, rendered.artifact.path, 'mockups', { + templateValidated: rendered.artifact.templateValidated, + inspectFingerprint: rendered.artifact.inspectFingerprint, + }), + ); + continue; + } + if (intent.source) { + assets.push(copyAsset(state, variant, intent, intent.source, 'source')); + } + } + } + writeJsonAtomic(path.join(state.directory, 'assets', 'index.json'), [ + ...new Map(assets.map((asset) => [asset.id, asset])).values(), + ]); + const run = saveRun(state.directory, state.run, { + status: + live && Object.keys(providerJobs).length > 0 && assets.length === 0 + ? 'awaiting-assets' + : live + ? 'executed' + : 'dry-run-complete', + providerJobs, + jobIds: [ + ...new Set([ + ...state.run.jobIds, + ...(process.env.CCA_JOB_ID ? [process.env.CCA_JOB_ID] : []), + ]), + ], + }); + appendEvent(state.directory, 'campaign.executed', { + jobId: process.env.CCA_JOB_ID, + live, + assetCount: assets.length, + }); + print({runId: run.id, status: run.status, live, assets: assets.length}); +}; + +const inspectCampaign = () => { + const state = loadRun(requireArg(args, 'run')); + const reports = fs + .readdirSync(path.join(state.directory, 'qa')) + .filter((name) => name.endsWith('.json')) + .map((name) => readJson(path.join(state.directory, 'qa', name))); + print({...state, reports}); +}; + +const probe = (file) => + JSON.parse( + execFileSync('ffprobe', ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', file], { + encoding: 'utf8', + }), + ); +const mediaChecks = (variant, asset) => { + const checks = []; + if (!asset) + return [{name: 'asset', passed: false, detail: 'No source or final asset is registered.'}]; + try { + const metadata = probe(asset.path); + const video = metadata.streams?.find((stream) => stream.codec_type === 'video'); + const audio = metadata.streams?.find((stream) => stream.codec_type === 'audio'); + const duration = Number(metadata.format?.duration || video?.duration || 0); + checks.push({ + name: 'decoding', + passed: Boolean(video), + detail: video ? `Video codec ${video.codec_name || 'unknown'}.` : 'No video stream.', + }); + checks.push({ + name: 'dimensions', + passed: Number(video?.width) === variant.width && Number(video?.height) === variant.height, + detail: `${video?.width || 0}x${video?.height || 0}, expected ${variant.width}x${variant.height}.`, + }); + checks.push({ + name: 'aspect-ratio', + passed: video && Math.abs(video.width / video.height - variant.width / variant.height) < 0.01, + detail: 'Compared probed and planned aspect ratios.', + }); + checks.push({ + name: 'duration', + passed: Math.abs(duration - variant.durationSeconds) <= 0.5, + detail: `${duration}s, expected ${variant.durationSeconds}s.`, + }); + checks.push({ + name: 'streams', + passed: Boolean(video && audio), + detail: audio ? 'Video and audio streams present.' : 'Audio stream missing.', + }); + } catch (error) { + checks.push({name: 'decoding', passed: false, detail: String(error.message)}); + } + return checks; +}; + +const detectSignal = (asset, filter, pattern) => { + if (!asset) return {passed: false, detail: 'No asset to analyze.'}; + const result = spawnSync( + 'ffmpeg', + ['-hide_banner', '-i', asset.path, ...filter, '-f', 'null', '-'], + {encoding: 'utf8', shell: false}, + ); + if (result.error) return {passed: false, detail: result.error.message}; + if (result.status !== 0) + return { + passed: false, + detail: String(result.stderr || `ffmpeg exited ${result.status}`).trim(), + }; + const detected = pattern.test(`${result.stdout || ''}\n${result.stderr || ''}`); + return {passed: !detected, detail: detected ? 'Signal detected.' : 'No signal detected.'}; +}; + +const detectBlackBoundary = (asset, durationSeconds) => { + if (!asset) return {passed: false, detail: 'No asset to analyze.'}; + const result = spawnSync( + 'ffmpeg', + ['-hide_banner', '-i', asset.path, '-vf', 'blackdetect=d=0.25:pix_th=0.10', '-f', 'null', '-'], + {encoding: 'utf8', shell: false}, + ); + if (result.error || result.status !== 0) + return {passed: false, detail: result.error?.message || String(result.stderr).trim()}; + const text = `${result.stdout || ''}\n${result.stderr || ''}`; + const ranges = [...text.matchAll(/black_start:([\d.]+).*?black_end:([\d.]+)/g)].map( + ([, start, end]) => ({start: Number(start), end: Number(end)}), + ); + const detected = ranges.some((range) => range.start <= 0.5 || range.end >= durationSeconds - 0.5); + return { + passed: !detected, + detail: detected ? 'Black frames detected at the lead or tail.' : 'No black lead or tail.', + }; +}; + +const qaCampaign = () => { + const state = loadRun(requireArg(args, 'run')); + const reports = []; + for (const variant of state.plan.variants) { + const asset = [...state.assets] + .reverse() + .find((entry) => entry.provenance.variantId === variant.id); + const checks = mediaChecks(variant, asset); + checks.push({ + name: 'black-lead-tail', + ...detectBlackBoundary(asset, variant.durationSeconds), + }); + checks.push({ + name: 'silence', + ...detectSignal(asset, ['-af', 'silencedetect=n=-50dB:d=1'], /silence_start/), + }); + checks.push({ + name: 'caption-safe-zone', + passed: variant.captions.every( + (caption) => + caption.startSeconds >= 0 && + caption.endSeconds <= variant.durationSeconds && + caption.yPercent >= 10 && + caption.yPercent <= 90, + ), + detail: 'Caption timing and vertical placement fit the safe zone.', + }); + checks.push({ + name: 'cta-end-card', + passed: Boolean(variant.cta && variant.timeline.some((entry) => entry.type === 'end-card')), + detail: 'CTA and end-card metadata are required.', + }); + const captureAssets = state.assets.filter( + (entry) => + entry.provenance.variantId === variant.id && entry.provenance.adapter === 'capture', + ); + checks.push({ + name: 'capture-freshness', + passed: captureAssets.every( + (entry) => Date.now() - Date.parse(entry.provenance.capturedAt) < 30 * 86_400_000, + ), + detail: 'Capture artifacts must be newer than 30 days.', + }); + const mockups = state.assets.filter( + (entry) => entry.provenance.variantId === variant.id && entry.provenance.adapter === 'rotato', + ); + checks.push({ + name: 'rotato-template', + passed: mockups.every((entry) => entry.provenance.templateValidated === true), + detail: 'Rotato assets require a validated template mapping.', + }); + const passed = checks.every((check) => check.passed); + const report = QAReport.parse({ + variantId: variant.id, + createdAt: now(), + technical: {status: passed ? 'passed' : 'failed', checks}, + contentClaims: {status: 'pending', approvedClaims: state.plan.approvedClaims}, + visualHuman: {status: 'pending'}, + publication: {status: 'blocked'}, + }); + writeJsonAtomic(path.join(state.directory, 'qa', `${variant.id}.json`), report); + reports.push(report); + } + const technical = reports.every((report) => report.technical.status === 'passed') + ? 'passed' + : 'failed'; + saveRun(state.directory, state.run, { + status: 'qa-complete', + reviews: {...state.run.reviews, technical}, + jobIds: [ + ...new Set([ + ...state.run.jobIds, + ...(process.env.CCA_JOB_ID ? [process.env.CCA_JOB_ID] : []), + ]), + ], + }); + appendEvent(state.directory, 'campaign.qa', {jobId: process.env.CCA_JOB_ID, technical}); + print({runId: state.run.id, technical, reports}); +}; + +const exportCampaign = () => { + const state = loadRun(requireArg(args, 'run')); + const qa = fs + .readdirSync(path.join(state.directory, 'qa')) + .filter((name) => name.endsWith('.json')) + .map((name) => readJson(path.join(state.directory, 'qa', name))); + const run = saveRun(state.directory, state.run, { + status: 'exported', + jobIds: [ + ...new Set([ + ...state.run.jobIds, + ...(process.env.CCA_JOB_ID ? [process.env.CCA_JOB_ID] : []), + ]), + ], + }); + const manifest = { + schemaVersion: 1, + exportedAt: now(), + run, + plan: state.plan, + assets: state.assets, + qa, + publishReady: state.run.reviews.publication === 'approved', + }; + const target = path.join(state.directory, 'artifacts', 'final', 'export.json'); + writeJsonAtomic(target, manifest); + appendEvent(state.directory, 'campaign.exported', {jobId: process.env.CCA_JOB_ID, path: target}); + print({runId: state.run.id, path: target, publishReady: manifest.publishReady}); +}; + +const doctor = async () => + print({ + ok: true, + campaignsRoot, + capabilityFingerprint: await currentCapabilities(), + tools: {ffprobe: Boolean(spawnSync('ffprobe', ['-version'], {stdio: 'ignore'}).status === 0)}, + }); + +const handlers = { + doctor, + plan: planCampaign, + estimate: estimateCampaign, + approve: approveCampaign, + execute: executeCampaign, + inspect: inspectCampaign, + qa: qaCampaign, + export: exportCampaign, +}; +if (!handlers[action]) throw new Error(`Unknown marketing action: ${action || ''}`); +await handlers[action](); diff --git a/scripts/marketing/schemas.mjs b/scripts/marketing/schemas.mjs new file mode 100644 index 0000000..04a4c15 --- /dev/null +++ b/scripts/marketing/schemas.mjs @@ -0,0 +1,133 @@ +import {z} from 'zod'; + +const command = z.object({ + argv: z.array(z.string()).min(1), + cwd: z.string().optional(), + outputs: z.array(z.string()).default([]), +}); + +export const ProductManifest = z.object({ + id: z.string().min(1), + name: z.string().min(1), + repositoryRoot: z.string().optional(), + repositoryCommit: z.string().optional(), + seedVersion: z.string().default('1'), + approvedClaims: z.array(z.string()).default([]), + captureFlows: z.record(z.string(), command).default({}), + platformProfiles: z.record(z.string(), z.record(z.string(), z.unknown())).default({}), +}); + +const intent = z.object({ + type: z.enum(['source', 'capture', 'generation', 'mockup']).default('source'), + source: z.string().optional(), + flow: z.string().optional(), + provider: z.string().optional(), + model: z.string().optional(), + estimateCredits: z.number().nonnegative().default(0), + prompt: z.string().optional(), + template: z.string().optional(), + argv: z.array(z.string()).default([]), + estimateArgv: z.array(z.string()).default([]), + output: z.string().optional(), +}); + +const timelineEntry = z.object({ + type: z.enum(['video', 'image', 'text', 'end-card']), + startSeconds: z.number().nonnegative(), + durationSeconds: z.number().positive(), + src: z.string().optional(), + text: z.string().optional(), + transition: z.enum(['cut', 'fade']).default('cut'), +}); + +const variant = z.object({ + id: z.string().min(1), + width: z.number().int().positive().default(1080), + height: z.number().int().positive().default(1920), + fps: z.number().positive().default(30), + durationSeconds: z.number().positive().default(15), + cta: z.string().min(1), + intents: z.array(intent).default([]), + timeline: z.array(timelineEntry).default([]), + captions: z + .array( + z.object({ + text: z.string(), + startSeconds: z.number(), + endSeconds: z.number(), + yPercent: z.number().min(0).max(100).default(82), + }), + ) + .default([]), +}); + +export const CampaignBrief = z.object({ + id: z.string().min(1), + product: z.union([z.string(), ProductManifest]), + objective: z.string().min(1), + approvedClaims: z.array(z.string()).default([]), + variants: z.array(variant).min(1), +}); + +export const CreativePlan = z.object({ + schemaVersion: z.literal(1), + runId: z.string(), + campaignId: z.string(), + createdAt: z.string(), + planHash: z.string(), + capabilityFingerprint: z.string(), + product: ProductManifest, + approvedClaims: z.array(z.string()), + variants: z.array(variant), +}); + +export const CampaignRun = z.object({ + schemaVersion: z.literal(1), + id: z.string(), + status: z.string(), + planHash: z.string(), + capabilityFingerprint: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + jobIds: z.array(z.string()).default([]), + estimateCredits: z.number().nonnegative().optional(), + estimates: z.record(z.string(), z.number().nonnegative()).default({}), + approval: z + .object({ + planHash: z.string(), + capabilityFingerprint: z.string(), + estimateHash: z.string(), + budgetCredits: z.number().nonnegative(), + approvedAt: z.string(), + }) + .optional(), + providerJobs: z.record(z.string(), z.record(z.string(), z.unknown())).default({}), + reviews: z.object({ + technical: z.string(), + contentClaims: z.string(), + visualHuman: z.string(), + publication: z.string(), + }), +}); + +export const AssetRecord = z.object({ + id: z.string(), + type: z.string(), + path: z.string(), + hash: z.string(), + createdAt: z.string(), + provenance: z.record(z.string(), z.unknown()), + media: z.record(z.string(), z.unknown()).optional(), +}); + +export const QAReport = z.object({ + variantId: z.string(), + createdAt: z.string(), + technical: z.object({ + status: z.enum(['passed', 'failed']), + checks: z.array(z.object({name: z.string(), passed: z.boolean(), detail: z.string()})), + }), + contentClaims: z.object({status: z.string(), approvedClaims: z.array(z.string())}), + visualHuman: z.object({status: z.string()}), + publication: z.object({status: z.string()}), +}); diff --git a/scripts/platform/job-worker.mjs b/scripts/platform/job-worker.mjs index 6cf9200..ad596fc 100644 --- a/scripts/platform/job-worker.mjs +++ b/scripts/platform/job-worker.mjs @@ -65,7 +65,7 @@ try { }); const child = spawn(spec.command, spec.args, { cwd: spec.cwd, - env: {...process.env, ...spec.env}, + env: {...process.env, CCA_JOB_ID: id, ...spec.env}, shell: false, detached: process.platform !== 'win32', windowsHide: true, diff --git a/scripts/rotato-cli.mjs b/scripts/rotato-cli.mjs index ac0d4ea..827f17e 100644 --- a/scripts/rotato-cli.mjs +++ b/scripts/rotato-cli.mjs @@ -1,134 +1,229 @@ #!/usr/bin/env node +import {spawnSync} from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import {spawnSync} from 'node:child_process'; -import {projectRoot, ensureDir} from './lib.mjs'; -import {commandExists, commandPath} from './command-utils.mjs'; -const args = process.argv.slice(2); +import {commandPath} from './command-utils.mjs'; +import {ensureDir, projectRoot} from './lib.mjs'; +import {hashValue} from './platform/catalog.mjs'; -const helpText = `ClipCaptionAI Rotato bridge +const argv = process.argv.slice(2); +const help = `ClipCaptionAI Rotato adapter Usage: - bun run rotato -- doctor - bun run rotato -- inspect /path/to/project.rotato [--json] - bun run rotato -- render /path/to/project.rotato --output /path/to/output.mp4 [rotato flags] - -Examples: clipcaptionai rotato doctor - clipcaptionai rotato inspect ~/Desktop/demo.rotato --json - clipcaptionai rotato render ~/Desktop/demo.rotato --screen-media ~/Desktop/app.mp4 --output outputs/mockups/demo.mp4 - -Notes: - - This is a thin wrapper around the local Rotato CLI. - - Use \`rotato inspect\` first when you need device indexes or 2D overlay ids. - - Repeated flags like --screen-media, --screen-media-for, --set-2d-text, and --set-2d-image are passed through. + clipcaptionai rotato inspect [--json] + clipcaptionai rotato render --output [Rotato flags] + clipcaptionai rotato render --template --screen-slot --output + clipcaptionai rotato raw `; +const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); +const resolveFile = (value) => path.resolve(String(value).replace(/^['"]|['"]$/g, '')); +const rotatoPath = () => + commandPath('rotato') || + (fs.existsSync('/usr/local/bin/rotato') ? '/usr/local/bin/rotato' : null); + +const invoke = (command, args, options = {}) => { + const result = spawnSync(command, args, { + cwd: projectRoot, + encoding: 'utf8', + shell: false, + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr || `rotato exited with ${result.status}`); + return result; +}; -const pathFlags = new Set(['--output', '--screen-media', '--set-2d-image']); -const pairedPathFlags = new Set(['--screen-media-for']); -const textPairFlags = new Set(['--set-2d-text']); +const capabilities = () => { + const executable = rotatoPath(); + if (!executable) throw new Error('Rotato CLI is not installed or on PATH.'); + const helpText = invoke(executable, ['--help']).stdout; + return {executable, help: helpText, fingerprint: sha256(helpText)}; +}; -const printHelp = () => { - console.log(helpText); +const inspect = (executable, scene) => { + const result = invoke(executable, ['inspect', scene, '--json']); + try { + const data = JSON.parse(result.stdout); + return {raw: result.stdout, data, fingerprint: hashValue(data)}; + } catch { + throw new Error('Rotato inspect did not return valid JSON.'); + } }; -const resolveInputPath = (value) => path.resolve(value.replace(/^['"]|['"]$/g, '')); +const takePair = (items, index, flag) => { + const first = items[index + 1]; + const second = items[index + 2]; + if (!first || !second) throw new Error(`Missing values for ${flag}`); + return [first, second]; +}; -const resolveOutputPath = (value) => { - const resolved = resolveInputPath(value); - ensureDir(path.dirname(resolved)); - return resolved; +const inspectIds = (value, indexes = new Set(), ids = new Set()) => { + if (Array.isArray(value)) value.forEach((entry) => inspectIds(entry, indexes, ids)); + else if (value && typeof value === 'object') + for (const [key, entry] of Object.entries(value)) { + if (/index$/i.test(key) && Number.isInteger(entry)) indexes.add(entry); + if (/(^|_)id$/i.test(key) && typeof entry === 'string') ids.add(entry); + inspectIds(entry, indexes, ids); + } + return {indexes, ids}; }; -const buildRotatoArgs = (mode, modeArgs) => { - if (modeArgs.length === 0) { - throw new Error(`Missing .rotato project path for "${mode}".`); +const compileRender = (items) => { + const templateIndex = items.indexOf('--template'); + const templateId = templateIndex >= 0 ? items[templateIndex + 1] : null; + const positionalScene = items[0] && !items[0].startsWith('--') ? resolveFile(items[0]) : null; + let scene = positionalScene; + let template; + if (templateId) { + const directory = path.join( + path.resolve( + process.env.CCA_ROTATO_TEMPLATES_ROOT || path.join(projectRoot, 'templates', 'rotato'), + ), + templateId, + ); + template = JSON.parse(fs.readFileSync(path.join(directory, 'template.json'), 'utf8')); + scene = path.join(directory, 'scene.rotato'); } - - const projectPath = resolveInputPath(modeArgs[0]); - if (!fs.existsSync(projectPath)) { - throw new Error(`Rotato project not found: ${projectPath}`); + if (!scene || !fs.existsSync(scene)) throw new Error(`Rotato project not found: ${scene || ''}`); + if (items.includes('--screen-media') && items.includes('--screen-media-for')) + throw new Error('ROTATO_SCREEN_MODE_CONFLICT'); + + const capability = capabilities(); + const inspected = inspect(capability.executable, scene); + if (template && template.inspectFingerprint !== inspected.fingerprint) + throw new Error('TEMPLATE_INSPECT_MISMATCH'); + if (template) { + const {indexes: deviceIndexes, ids: overlayIds} = inspectIds(inspected.data); + const validDevices = Object.values(template.deviceSlots || {}).every((index) => + deviceIndexes.has(index), + ); + const validOverlays = [ + ...Object.values(template.textOverlays || {}), + ...Object.values(template.imageOverlays || {}), + ].every((id) => overlayIds.has(id)); + if (!validDevices || !validOverlays) throw new Error('TEMPLATE_INSPECT_MISMATCH'); } - const forward = [mode, projectPath]; - for (let index = 1; index < modeArgs.length; index += 1) { - const arg = modeArgs[index]; - forward.push(arg); - - if (pathFlags.has(arg)) { - const value = modeArgs[index + 1]; - if (!value) { - throw new Error(`Missing value for ${arg}`); - } - forward.push(arg === '--output' ? resolveOutputPath(value) : resolveInputPath(value)); + const forward = ['render', scene]; + for (let index = positionalScene ? 1 : 0; index < items.length; index += 1) { + const flag = items[index]; + if (flag === '--template') { index += 1; continue; } - - if (pairedPathFlags.has(arg) || textPairFlags.has(arg)) { - const first = modeArgs[index + 1]; - const second = modeArgs[index + 2]; - if (!first || !second) { - throw new Error(`Missing values for ${arg}`); - } - forward.push(first); - forward.push(pairedPathFlags.has(arg) ? resolveInputPath(second) : second); + if (['--screen-slot', '--text-slot', '--image-slot'].includes(flag)) { + const [slot, value] = takePair(items, index, flag); + const maps = { + '--screen-slot': template?.deviceSlots, + '--text-slot': template?.textOverlays, + '--image-slot': template?.imageOverlays, + }; + const target = maps[flag]?.[slot]; + if (target === undefined) throw new Error(`Unknown Rotato template slot: ${slot}`); + const compiled = { + '--screen-slot': '--screen-media-for', + '--text-slot': '--set-2d-text', + '--image-slot': '--set-2d-image', + }[flag]; + forward.push(compiled, String(target), flag === '--text-slot' ? value : resolveFile(value)); index += 2; + continue; } + if (['--output', '--screen-media'].includes(flag)) { + const value = items[index + 1]; + if (!value) throw new Error(`Missing value for ${flag}`); + const resolved = resolveFile(value); + if (flag === '--output') ensureDir(path.dirname(resolved)); + forward.push(flag, resolved); + index += 1; + continue; + } + if (flag === '--screen-media-for') { + const [device, value] = takePair(items, index, flag); + forward.push(flag, device, resolveFile(value)); + index += 2; + continue; + } + if (flag === '--set-2d-text') { + const [overlay, value] = takePair(items, index, flag); + forward.push(flag, overlay, value); + index += 2; + continue; + } + if (flag === '--set-2d-image') { + const [overlay, value] = takePair(items, index, flag); + forward.push(flag, overlay, resolveFile(value)); + index += 2; + continue; + } + forward.push(flag); } - - return forward; -}; - -const printDoctor = () => { - const rotatoCliPath = commandPath('rotato'); - const rotatoAppPath = '/Applications/Rotato.app'; - const appInstalled = fs.existsSync(rotatoAppPath); - - console.log('ClipCaptionAI Rotato doctor'); - console.log('==========================='); - console.log(`${rotatoCliPath ? 'OK ' : 'MISS'} rotato CLI (${rotatoCliPath ?? 'not found'})`); - console.log(`${appInstalled ? 'OK ' : 'MISS'} Rotato app (${rotatoAppPath})`); - - if (!rotatoCliPath || !appInstalled) { - process.exit(1); - } + return {capability, inspected, forward}; }; const main = () => { - if (args.length === 0 || args.includes('--help') || args.includes('-h')) { - printHelp(); - return; - } - - if (args[0] === 'doctor') { - printDoctor(); - return; - } - - if (!commandExists('rotato')) { - throw new Error( - 'Rotato CLI is not installed or not on PATH. Run "clipcaptionai rotato doctor" first.', + const [action, ...items] = argv; + if (!action || action === '--help' || action === '-h') return console.log(help); + if (action === 'doctor') { + const capability = capabilities(); + return console.log( + JSON.stringify({ + ok: true, + executable: capability.executable, + fingerprint: capability.fingerprint, + appInstalled: fs.existsSync('/Applications/Rotato.app'), + }), ); } - - const mode = args[0]; - if (!['inspect', 'render'].includes(mode)) { - throw new Error(`Unsupported Rotato action "${mode}". Use inspect, render, or doctor.`); - } - - const forwardArgs = buildRotatoArgs(mode, args.slice(1)); - const result = spawnSync('rotato', forwardArgs, { - cwd: projectRoot, - stdio: 'inherit', - }); - - if (result.error) { - throw result.error; + const {executable} = capabilities(); + if (action === 'raw') + return process.exit(invoke(executable, items, {stdio: 'inherit'}).status || 0); + if (action === 'inspect') { + const scene = resolveFile(items[0]); + if (!fs.existsSync(scene)) throw new Error(`Rotato project not found: ${scene}`); + const result = inspect(executable, scene); + return process.stdout.write( + items.includes('--json') ? JSON.stringify(result.data) : result.raw, + ); } - - process.exit(result.status ?? 0); + if (action !== 'render') throw new Error(`Unsupported Rotato action: ${action}`); + const compiled = compileRender(items); + const outputIndex = compiled.forward.indexOf('--output'); + const output = outputIndex >= 0 ? compiled.forward[outputIndex + 1] : null; + const wantsJson = compiled.forward.includes('--json'); + const result = invoke( + compiled.capability.executable, + compiled.forward, + wantsJson ? {} : {stdio: 'inherit'}, + ); + if (output && fs.existsSync(output)) { + const media = spawnSync( + 'ffprobe', + ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', output], + {encoding: 'utf8'}, + ); + const artifact = { + path: output, + hash: sha256(fs.readFileSync(output)), + media: media.status === 0 ? JSON.parse(media.stdout) : null, + templateValidated: true, + capabilityFingerprint: compiled.capability.fingerprint, + inspectFingerprint: compiled.inspected.fingerprint, + }; + if (wantsJson) { + const lastLine = result.stdout.trim().split('\n').filter(Boolean).at(-1); + let rotato; + try { + rotato = lastLine ? JSON.parse(lastLine) : null; + } catch { + rotato = {stdout: result.stdout}; + } + console.log(JSON.stringify({artifact, rotato})); + } + } else if (wantsJson) process.stdout.write(result.stdout); }; main(); diff --git a/src/marketing-timeline.tsx b/src/marketing-timeline.tsx new file mode 100644 index 0000000..7fb565a --- /dev/null +++ b/src/marketing-timeline.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { + AbsoluteFill, + Audio, + Img, + OffthreadVideo, + Sequence, + interpolate, + useCurrentFrame, + useVideoConfig, +} from 'remotion'; + +export type MarketingTimelineProps = { + width: number; + height: number; + fps: number; + durationSeconds: number; + timeline: Array<{ + type: 'video' | 'image' | 'text' | 'end-card'; + startSeconds: number; + durationSeconds: number; + src?: string; + text?: string; + transition?: 'cut' | 'fade'; + }>; + captions: Array<{text: string; startSeconds: number; endSeconds: number; yPercent?: number}>; + voice?: string; + music?: string; + overlays?: Array<{src: string; startSeconds: number; durationSeconds: number}>; +}; + +export const marketingTimelineDefaultProps: MarketingTimelineProps = { + width: 1080, + height: 1920, + fps: 30, + durationSeconds: 15, + timeline: [{type: 'end-card', startSeconds: 12, durationSeconds: 3, text: 'Learn more'}], + captions: [], +}; + +const TimelineEntry: React.FC<{entry: MarketingTimelineProps['timeline'][number]}> = ({entry}) => { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const fadeFrames = Math.min(fps / 3, (entry.durationSeconds * fps) / 2); + const opacity = + entry.transition === 'fade' + ? interpolate( + frame, + [0, fadeFrames, entry.durationSeconds * fps - fadeFrames, entry.durationSeconds * fps], + [0, 1, 1, 0], + {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}, + ) + : 1; + if (entry.type === 'video' && entry.src) + return ( + + ); + if (entry.type === 'image' && entry.src) + return ( + + ); + return ( + + {entry.text} + + ); +}; + +export const MarketingTimeline: React.FC = ({ + timeline, + captions, + voice, + music, + overlays = [], +}) => { + const {fps} = useVideoConfig(); + const frame = useCurrentFrame(); + const currentCaption = captions.find( + (caption) => frame >= caption.startSeconds * fps && frame < caption.endSeconds * fps, + ); + return ( + + {timeline.map((entry, index) => ( + + + + ))} + {overlays.map((entry, index) => ( + + + + ))} + {voice ? + ); +}; diff --git a/src/root.tsx b/src/root.tsx index 2244355..3139d3f 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -4,6 +4,11 @@ import {CaptionedClip, captionedClipDefaultProps} from './captioned-clip'; import type {CaptionedClipProps} from './types'; import {PromptVideo, promptVideoDefaultProps, type PromptVideoProps} from './prompt-video'; import {warnOnMissingFonts} from './fonts'; +import { + MarketingTimeline, + marketingTimelineDefaultProps, + type MarketingTimelineProps, +} from './marketing-timeline'; // Fail loudly on malformed caption payloads at the composition boundary — // a missing startMs/endMs used to propagate NaN into interpolation and @@ -34,40 +39,60 @@ const validateCaptionedClipProps = (props: CaptionedClipProps) => { export const Root = () => { return ( <> - { - warnOnMissingFonts(); - validateCaptionedClipProps(props); - return { + { + warnOnMissingFonts(); + validateCaptionedClipProps(props); + return { + fps: props.fps, + width: props.width, + height: props.height, + durationInFrames: props.durationInFrames, + }; + }} + /> + ({ fps: props.fps, width: props.width, height: props.height, - durationInFrames: props.durationInFrames, - }; - }} - /> - ({ - fps: props.fps, - width: props.width, - height: props.height, - durationInFrames: Math.max(1, Math.round(props.shots.reduce((sum, shot) => sum + shot.durationSeconds, 0) * props.fps)), - })} - /> + durationInFrames: Math.max( + 1, + Math.round( + props.shots.reduce((sum, shot) => sum + shot.durationSeconds, 0) * props.fps, + ), + ), + })} + /> + ({ + fps: props.fps, + width: props.width, + height: props.height, + durationInFrames: Math.max(1, Math.round(props.durationSeconds * props.fps)), + })} + /> ); }; diff --git a/templates/rotato/README.md b/templates/rotato/README.md new file mode 100644 index 0000000..82a0864 --- /dev/null +++ b/templates/rotato/README.md @@ -0,0 +1,3 @@ +# Rotato templates + +Create `/scene.rotato` from Rotato and place `template.json` beside it. The JSON contract is shown in `examples/marketing/rotato-template.example.json`. ClipCaptionAI inspects the scene before every render and rejects changed or invalid slot mappings. diff --git a/tests/cli-smoke.test.mjs b/tests/cli-smoke.test.mjs index 2287c74..67db6a2 100644 --- a/tests/cli-smoke.test.mjs +++ b/tests/cli-smoke.test.mjs @@ -3931,7 +3931,7 @@ test('competitive voiceover plan exporter replaces generic marketplace narration test( 'eBay main photo candidate generator writes buyer-safe image manifest', - {skip: spawnSync('which', ['magick']).status !== 0}, + {skip: spawnSync('which', ['magick']).status !== 0, timeout: 20_000}, () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clipcaptionai-main-photo-')); const sourceRoot = path.join(tempRoot, 'supplier-video-repairs'); diff --git a/tests/marketing-platform.test.mjs b/tests/marketing-platform.test.mjs new file mode 100644 index 0000000..abab9ab --- /dev/null +++ b/tests/marketing-platform.test.mjs @@ -0,0 +1,471 @@ +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import yaml from 'js-yaml'; + +import {hashValue, serializeCatalog} from '../scripts/platform/catalog.mjs'; + +const root = path.resolve(import.meta.dirname, '..'); +const cli = path.join(root, 'bin', 'clipcaptionai.js'); +const executable = (file, source) => { + fs.writeFileSync(file, `#!/usr/bin/env node\n${source}`); + fs.chmodSync(file, 0o755); +}; +const run = (command, args, env) => spawnSync(command, args, {cwd: root, env, encoding: 'utf8'}); +const runCli = (args, env) => run(process.execPath, [cli, ...args], env); +const parseChildJson = (result) => { + assert.equal(result.status, 0, result.stderr || result.stdout); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.ok, true, result.stdout); + const text = envelope.data.logs?.stdout?.text ?? envelope.data.stdout; + return text ? JSON.parse(text.trim().split('\n').at(-1)) : envelope.data; +}; + +const fixture = (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cca-marketing-')); + const bin = path.join(directory, 'bin'); + const campaigns = path.join(directory, 'campaigns'); + const state = path.join(directory, 'state'); + fs.mkdirSync(bin, {recursive: true}); + const helpFile = path.join(directory, 'higgs-help.txt'); + fs.writeFileSync(helpFile, 'fake higgs v1'); + executable( + path.join(bin, 'rotato'), + ` +const fs=require('fs'); const args=process.argv.slice(2); +if(args[0]==='--help') console.log('inspect render fake-v1'); +else if(args[0]==='inspect') console.log(JSON.stringify({devices:[{index:0}],overlays:[{id:'headline'}]})); +else if(args[0]==='render'){const i=args.indexOf('--output'); if(i>=0)fs.writeFileSync(args[i+1],'video'); console.log(JSON.stringify({ok:true}));} +`, + ); + executable( + path.join(bin, 'higgs'), + ` +const fs=require('fs'); const args=process.argv.slice(2); +if(args[0]==='--help') console.log(fs.readFileSync(${JSON.stringify(helpFile)},'utf8')); +else if(args.includes('cost')) console.log(JSON.stringify({credits:3})); +else {const i=args.indexOf('--output'); if(i>=0)fs.writeFileSync(args[i+1],'generated'); if(process.env.HIGGS_CALLS)fs.appendFileSync(process.env.HIGGS_CALLS,args.join(' ')+'\\n'); console.log(JSON.stringify({id:'fake-job',status:'complete'}));} +`, + ); + executable( + path.join(bin, 'ffprobe'), + `console.log(JSON.stringify({streams:[{codec_type:'video',codec_name:'h264',width:1080,height:1920},{codec_type:'audio',codec_name:'aac'}],format:{duration:'5'}}));`, + ); + executable(path.join(bin, 'ffmpeg'), ``); + const env = { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + CCA_CAMPAIGNS_ROOT: campaigns, + CCA_STATE_ROOT: state, + CCA_DISABLE_OLLAMA: '1', + CCA_HIGGSFIELD_BIN: path.join(bin, 'higgs'), + HIGGS_CALLS: path.join(directory, 'higgs-calls.txt'), + }; + t.after(() => fs.rmSync(directory, {recursive: true, force: true})); + return {directory, bin, campaigns, env, helpFile}; +}; + +test('marketing tools are catalog-driven for CLI and desktop consumers', async () => { + const catalog = await serializeCatalog({root}); + assert.deepEqual( + ['capture', 'higgsfield', 'marketing', 'rotato'], + catalog + .map((adapter) => adapter.id) + .filter((id) => ['capture', 'higgsfield', 'marketing', 'rotato'].includes(id)), + ); + assert.deepEqual( + catalog.find((adapter) => adapter.id === 'marketing').actions.map((action) => action.id), + ['doctor', 'plan', 'estimate', 'approve', 'execute', 'inspect', 'qa', 'export'], + ); +}); + +test( + 'marketing campaign runs through the shared broker without paid submission', + {timeout: 60_000}, + async (t) => { + const fx = fixture(t); + const source = path.join(fx.directory, 'source.mp4'); + fs.writeFileSync(source, 'fake-video'); + const product = path.join(fx.directory, 'product.yaml'); + fs.writeFileSync(product, yaml.dump({id: 'demo', name: 'Demo', approvedClaims: ['It works.']})); + const campaign = path.join(fx.directory, 'campaign.yaml'); + fs.writeFileSync( + campaign, + yaml.dump({ + id: 'demo-campaign', + product: './product.yaml', + objective: 'Test the vertical.', + variants: [ + { + id: 'vertical', + width: 1080, + height: 1920, + fps: 30, + durationSeconds: 5, + cta: 'Try it', + intents: [{type: 'source', source: './source.mp4'}], + timeline: [ + {type: 'video', startSeconds: 0, durationSeconds: 4, src: source}, + {type: 'end-card', startSeconds: 4, durationSeconds: 1, text: 'Try it'}, + ], + captions: [{text: 'Hello', startSeconds: 0, endSeconds: 1, yPercent: 82}], + }, + ], + }), + ); + + parseChildJson( + runCli( + ['marketing', 'plan', '--campaign', campaign, '--run-id', 'demo-run', '--wait', '--json'], + fx.env, + ), + ); + parseChildJson( + runCli(['marketing', 'estimate', '--run', 'demo-run', '--wait', '--json'], fx.env), + ); + parseChildJson( + runCli( + ['marketing', 'approve', '--run', 'demo-run', '--budget-credits', '0', '--json'], + fx.env, + ), + ); + parseChildJson( + runCli( + ['marketing', 'execute', '--run', 'demo-run', '--dry-run', '--wait', '--json'], + fx.env, + ), + ); + const qa = parseChildJson( + runCli(['marketing', 'qa', '--run', 'demo-run', '--wait', '--json'], fx.env), + ); + assert.equal(qa.technical, 'passed'); + const exported = parseChildJson( + runCli(['marketing', 'export', '--run', 'demo-run', '--wait', '--json'], fx.env), + ); + assert.ok(fs.existsSync(exported.path)); + const inspected = parseChildJson( + runCli(['marketing', 'inspect', '--run', 'demo-run', '--json'], fx.env), + ); + assert.equal(inspected.run.reviews.technical, 'passed'); + assert.equal(inspected.run.reviews.publication, 'blocked'); + assert.ok(inspected.run.jobIds.length >= 5); + assert.equal(fs.existsSync(fx.env.HIGGS_CALLS), false); + assert.deepEqual(fs.readdirSync(path.join(fx.campaigns, 'demo-run', 'artifacts')).sort(), [ + 'final', + 'generated', + 'mockups', + 'previews', + 'source', + ]); + }, +); + +test( + 'live generation is budget-gated and idempotent across resumed execution', + {timeout: 60_000}, + (t) => { + const fx = fixture(t); + const generated = path.join(fx.directory, 'generated.mp4'); + const product = path.join(fx.directory, 'product.yaml'); + fs.writeFileSync(product, yaml.dump({id: 'demo', name: 'Demo'})); + const campaign = path.join(fx.directory, 'campaign.yaml'); + fs.writeFileSync( + campaign, + yaml.dump({ + id: 'paid', + product: './product.yaml', + objective: 'Test paid resume.', + variants: [ + { + id: 'v', + durationSeconds: 5, + cta: 'Go', + intents: [ + { + type: 'generation', + provider: 'higgsfield', + model: 'fake', + estimateCredits: 3, + estimateArgv: ['generate', 'cost', 'fake'], + argv: ['generate', 'create', '--output', generated], + output: generated, + }, + ], + timeline: [{type: 'end-card', startSeconds: 4, durationSeconds: 1, text: 'Go'}], + }, + ], + }), + ); + parseChildJson( + runCli( + ['marketing', 'plan', '--campaign', campaign, '--run-id', 'paid-run', '--wait', '--json'], + fx.env, + ), + ); + parseChildJson( + runCli(['marketing', 'estimate', '--run', 'paid-run', '--wait', '--json'], fx.env), + ); + parseChildJson( + runCli( + ['marketing', 'approve', '--run', 'paid-run', '--budget-credits', '3', '--json'], + fx.env, + ), + ); + for (let attempt = 0; attempt < 2; attempt += 1) + parseChildJson( + runCli( + ['marketing', 'execute', '--run', 'paid-run', '--live-execution', '--wait', '--json'], + fx.env, + ), + ); + assert.equal(fs.readFileSync(fx.env.HIGGS_CALLS, 'utf8').trim().split('\n').length, 1); + const runRecord = JSON.parse( + fs.readFileSync(path.join(fx.campaigns, 'paid-run', 'run.json'), 'utf8'), + ); + assert.equal(Object.keys(runRecord.providerJobs).length, 1); + const assets = JSON.parse( + fs.readFileSync(path.join(fx.campaigns, 'paid-run', 'assets', 'index.json'), 'utf8'), + ); + assert.equal(assets.length, 1); + assert.equal(assets[0].provenance.adapter, 'higgsfield'); + }, +); + +test( + 'campaign approval is invalidated by plan or capability drift', + {timeout: 60_000}, + async (t) => { + const fx = fixture(t); + const product = path.join(fx.directory, 'product.yaml'); + fs.writeFileSync(product, yaml.dump({id: 'demo', name: 'Demo'})); + const campaign = path.join(fx.directory, 'campaign.yaml'); + fs.writeFileSync( + campaign, + yaml.dump({ + id: 'drift', + product: './product.yaml', + objective: 'Drift test', + variants: [ + { + id: 'v', + durationSeconds: 5, + cta: 'Go', + timeline: [{type: 'end-card', startSeconds: 4, durationSeconds: 1, text: 'Go'}], + }, + ], + }), + ); + parseChildJson( + runCli( + ['marketing', 'plan', '--campaign', campaign, '--run-id', 'drift-run', '--wait', '--json'], + fx.env, + ), + ); + parseChildJson( + runCli(['marketing', 'estimate', '--run', 'drift-run', '--wait', '--json'], fx.env), + ); + parseChildJson( + runCli( + ['marketing', 'approve', '--run', 'drift-run', '--budget-credits', '0', '--json'], + fx.env, + ), + ); + const planPath = path.join(fx.campaigns, 'drift-run', 'plan.json'); + const originalPlan = fs.readFileSync(planPath, 'utf8'); + const changedPlan = JSON.parse(originalPlan); + changedPlan.variants[0].cta = 'Changed'; + fs.writeFileSync(planPath, JSON.stringify(changedPlan)); + const planDrift = runCli( + ['marketing', 'execute', '--run', 'drift-run', '--wait', '--json'], + fx.env, + ); + assert.equal(JSON.parse(planDrift.stdout).ok, false); + assert.match(planDrift.stdout, /PLAN_HASH_MISMATCH/); + fs.writeFileSync(planPath, originalPlan); + fs.writeFileSync(fx.helpFile, 'fake higgs v2'); + const drift = runCli( + ['marketing', 'execute', '--run', 'drift-run', '--wait', '--json'], + fx.env, + ); + assert.equal(JSON.parse(drift.stdout).ok, false); + assert.match(drift.stdout, /CAPABILITY_FINGERPRINT_CHANGED/); + }, +); + +test('Rotato templates compile semantic slots and reject drift or screen conflicts', (t) => { + const fx = fixture(t); + const templates = path.join(fx.directory, 'templates'); + const template = path.join(templates, 'phone'); + fs.mkdirSync(template, {recursive: true}); + fs.writeFileSync(path.join(template, 'scene.rotato'), 'scene'); + const inspected = {devices: [{index: 0}], overlays: [{id: 'headline'}]}; + fs.writeFileSync( + path.join(template, 'template.json'), + JSON.stringify({ + inspectFingerprint: hashValue(inspected), + deviceSlots: {phone: 0}, + textOverlays: {headline: 'headline'}, + imageOverlays: {}, + }), + ); + const media = path.join(fx.directory, 'screen.mp4'); + const output = path.join(fx.directory, 'render.mp4'); + fs.writeFileSync(media, 'screen'); + const env = {...fx.env, CCA_ROTATO_TEMPLATES_ROOT: templates}; + const rendered = run( + process.execPath, + [ + path.join(root, 'scripts', 'rotato-cli.mjs'), + 'render', + '--template', + 'phone', + '--screen-slot', + 'phone', + media, + '--text-slot', + 'headline', + 'Hello', + '--output', + output, + '--json', + ], + env, + ); + assert.equal(rendered.status, 0, rendered.stderr); + assert.ok(fs.existsSync(output)); + const conflict = run( + process.execPath, + [ + path.join(root, 'scripts', 'rotato-cli.mjs'), + 'render', + path.join(template, 'scene.rotato'), + '--screen-media', + media, + '--screen-media-for', + '0', + media, + ], + env, + ); + assert.match(conflict.stderr, /ROTATO_SCREEN_MODE_CONFLICT/); + const config = JSON.parse(fs.readFileSync(path.join(template, 'template.json'))); + config.inspectFingerprint = 'stale'; + fs.writeFileSync(path.join(template, 'template.json'), JSON.stringify(config)); + const stale = run( + process.execPath, + [ + path.join(root, 'scripts', 'rotato-cli.mjs'), + 'render', + '--template', + 'phone', + '--output', + output, + ], + env, + ); + assert.match(stale.stderr, /TEMPLATE_INSPECT_MISMATCH/); +}); + +test('Higgsfield submission enforces live approval and budgets', (t) => { + const fx = fixture(t); + const approvalFile = path.join(fx.directory, 'run.json'); + const estimates = {intent: 3}; + fs.writeFileSync( + approvalFile, + JSON.stringify({ + estimates, + approval: { + planHash: 'plan', + capabilityFingerprint: 'cap', + estimateHash: hashValue(estimates), + budgetCredits: 5, + }, + }), + ); + const base = [ + path.join(root, 'scripts', 'higgsfield-cli.mjs'), + 'submit', + '--approval-file', + approvalFile, + '--plan-hash', + 'plan', + '--capability-fingerprint', + 'cap', + '--intent-key', + 'intent', + '--estimated-credits', + '3', + ]; + const dry = run(process.execPath, [...base, '--dry-run', 'generate', 'create'], fx.env); + assert.equal(dry.status, 0, dry.stderr); + assert.equal(fs.existsSync(fx.env.HIGGS_CALLS), false); + const changedEstimate = JSON.parse(fs.readFileSync(approvalFile, 'utf8')); + changedEstimate.estimates.intent = 4; + fs.writeFileSync(approvalFile, JSON.stringify(changedEstimate)); + const invalidated = run(process.execPath, [...base, '--dry-run', 'generate', 'create'], fx.env); + assert.match(invalidated.stderr, /APPROVAL_INVALID/); + fs.writeFileSync( + approvalFile, + JSON.stringify({ + estimates, + approval: { + planHash: 'plan', + capabilityFingerprint: 'cap', + estimateHash: hashValue(estimates), + budgetCredits: 5, + }, + }), + ); + const noLive = run(process.execPath, [...base, 'generate', 'create'], fx.env); + assert.match(noLive.stderr, /LIVE_EXECUTION_REQUIRED/); + const over = run( + process.execPath, + [...base, '--total-spent-credits', '4', '--live-execution', 'generate', 'create'], + fx.env, + ); + assert.match(over.stderr, /BUDGET_EXCEEDED/); + const live = run(process.execPath, [...base, '--live-execution', 'generate', 'create'], fx.env); + assert.equal(live.status, 0, live.stderr); + assert.match(fs.readFileSync(fx.env.HIGGS_CALLS, 'utf8'), /generate create/); +}); + +test('command capture records reproducible provenance', (t) => { + const fx = fixture(t); + const output = path.join(fx.directory, 'capture.mp4'); + const capture = path.join(fx.bin, 'capture-demo'); + executable(capture, `require('fs').writeFileSync(${JSON.stringify(output)},'capture')`); + const manifest = path.join(fx.directory, 'product.yaml'); + fs.writeFileSync( + manifest, + yaml.dump({ + id: 'demo', + name: 'Demo', + repositoryCommit: 'abc123', + seedVersion: '7', + captureFlows: {hero: {argv: [capture], outputs: [output]}}, + }), + ); + const result = run( + process.execPath, + [ + path.join(root, 'scripts', 'capture-cli.mjs'), + 'run', + '--manifest', + manifest, + '--flow', + 'hero', + '--profile', + 'desktop', + ], + fx.env, + ); + assert.equal(result.status, 0, result.stderr); + const record = JSON.parse(result.stdout); + assert.equal(record.repositoryCommit, 'abc123'); + assert.equal(record.seedVersion, '7'); + assert.equal(record.platformProfile, 'desktop'); + assert.equal(record.artifacts[0].hash.length, 64); +}); From 494856ce8324e66eaa8e41c2710e0b2e78566580 Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Thu, 20 Aug 2026 05:10:57 -0400 Subject: [PATCH 2/3] Fingerprint installed provider tools Bind campaign approvals to the exact Rotato and Higgsfield executables selected at runtime. --- scripts/marketing.mjs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/marketing.mjs b/scripts/marketing.mjs index 18b33f1..fa417be 100644 --- a/scripts/marketing.mjs +++ b/scripts/marketing.mjs @@ -64,8 +64,27 @@ const saveRun = (directory, run, patch = {}) => { const currentCapabilities = async () => { const catalog = await serializeCatalog(); - const toolHelp = ['rotato', 'higgsfield', 'higgs'].map((name) => { - const executable = commandPath(name); + const tools = [ + { + name: 'rotato', + executable: + commandPath('rotato') || + (fs.existsSync('/usr/local/bin/rotato') ? '/usr/local/bin/rotato' : null), + }, + { + name: 'higgsfield', + executable: + process.env.CCA_HIGGSFIELD_BIN || + commandPath('higgsfield') || + commandPath('higgs') || + [ + path.join(projectRoot, 'node_modules', '.bin', 'higgs'), + path.join(projectRoot, 'node_modules', '.bin', 'higgsfield'), + path.join(projectRoot, 'bin', 'higgsfield'), + ].find((candidate) => fs.existsSync(candidate)), + }, + ]; + const toolHelp = tools.map(({name, executable}) => { if (!executable) return {name, available: false}; const result = spawnSync(executable, ['--help'], {encoding: 'utf8', shell: false}); return { From 39315c4570d6f8225b818c5c6748e2f7c4662d07 Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Thu, 20 Aug 2026 05:30:29 -0400 Subject: [PATCH 3/3] Resolve marketing review findings Fail closed at budget and capture boundaries, preserve Rotato provenance through the broker, and make resumed spend accounting deterministic. --- docs/MARKETING_PLATFORM.md | 2 +- scripts/adapters/rotato.adapter.mjs | 37 +++++++-- scripts/capture-cli.mjs | 16 +++- scripts/clipkit.mjs | 3 +- .../prepare-competitive-premium-renders.mjs | 2 + .../run-competitive-higgsfield-renders.mjs | 11 ++- scripts/higgsfield-cli.mjs | 9 +- scripts/marketing.mjs | 16 ++-- scripts/marketing/schemas.mjs | 50 ++++++++--- scripts/rotato-cli.mjs | 32 ++++--- src/marketing-timeline.tsx | 17 +++- src/root.tsx | 24 ++++-- tests/marketing-platform.test.mjs | 83 ++++++++++++++++--- 13 files changed, 232 insertions(+), 70 deletions(-) diff --git a/docs/MARKETING_PLATFORM.md b/docs/MARKETING_PLATFORM.md index 3f7ce7f..0b46fab 100644 --- a/docs/MARKETING_PLATFORM.md +++ b/docs/MARKETING_PLATFORM.md @@ -1,6 +1,6 @@ # Marketing platform -Campaign commands use the shared adapter catalog and filesystem job broker: +Campaign commands use the shared adapter catalog and filesystem job broker. The examples assume an installed package; from a repository checkout replace `clipcaptionai` with `bun run clipkit --`. ```sh clipcaptionai marketing plan --campaign examples/marketing/campaign.example.yaml --wait diff --git a/scripts/adapters/rotato.adapter.mjs b/scripts/adapters/rotato.adapter.mjs index afac6c7..94397fe 100644 --- a/scripts/adapters/rotato.adapter.mjs +++ b/scripts/adapters/rotato.adapter.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {projectRoot} from '../lib.mjs'; +import {jobPaths} from '../platform/jobs.mjs'; const action = (id, title, mode = 'job', locks = []) => ({ id, @@ -32,34 +33,54 @@ export default { ], }, async build(actionId, input = {}) { + const args = (input.args || []).map(String); + if (actionId === 'render' && !args.includes('--json')) args.push('--json'); return { command: process.execPath, - args: [ - path.join(projectRoot, 'scripts', 'rotato-cli.mjs'), - actionId, - ...(input.args || []).map(String), - ], + args: [path.join(projectRoot, 'scripts', 'rotato-cli.mjs'), actionId, ...args], cwd: projectRoot, }; }, - async collect(actionId, input = {}) { + async collect(actionId, input = {}, context = {}) { if (actionId !== 'render') return {}; const args = (input.args || []).map(String); const index = args.indexOf('--output'); - if (index < 0) return {}; + if (index < 0 || !args[index + 1]) return {}; const output = path.resolve(args[index + 1]); if (!fs.existsSync(output)) return {}; + if (context.job?.id) { + const lines = fs + .readFileSync(jobPaths(context.job.id).stdout, 'utf8') + .trim() + .split('\n') + .reverse(); + for (const line of lines) + try { + const parsed = JSON.parse(line); + if (parsed.artifact?.path === output) + return {artifacts: [output], artifact: parsed.artifact}; + } catch { + // Keep scanning prior output lines before using the local fallback. + } + } const probe = spawnSync( 'ffprobe', ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', output], {encoding: 'utf8'}, ); + let media = null; + if (probe.status === 0) + try { + media = JSON.parse(probe.stdout); + } catch { + media = null; + } return { artifacts: [output], artifact: { path: output, hash: crypto.createHash('sha256').update(fs.readFileSync(output)).digest('hex'), - media: probe.status === 0 ? JSON.parse(probe.stdout) : null, + media, }, }; }, diff --git a/scripts/capture-cli.mjs b/scripts/capture-cli.mjs index 7ecaa12..dcabd46 100644 --- a/scripts/capture-cli.mjs +++ b/scripts/capture-cli.mjs @@ -22,8 +22,10 @@ const readManifest = () => { return ProductManifest.parse(yaml.load(fs.readFileSync(file, 'utf8'))); }; -if (!action || action === '--help') - console.log('Usage: clipcaptionai capture doctor|run --manifest --flow '); +if (!action || action === '--help' || action === '-h') + console.log( + 'Usage: clipcaptionai capture doctor|run (--manifest |--plan ) --flow [--profile ] [--record ] [--timeout ]', + ); else if (action === 'doctor') console.log(JSON.stringify({ok: true, mode: 'command'})); else if (action === 'run') { const manifest = readManifest(); @@ -31,7 +33,15 @@ else if (action === 'run') { const flow = manifest.captureFlows[flowId]; if (!flow) throw new Error(`Unknown capture flow: ${flowId}`); const cwd = path.resolve(flow.cwd || manifest.repositoryRoot || process.cwd()); - const result = spawnSync(flow.argv[0], flow.argv.slice(1), {cwd, encoding: 'utf8', shell: false}); + const timeout = Number(args.timeout ?? 600_000); + if (!Number.isFinite(timeout) || timeout <= 0) + throw new Error('Capture timeout must be a positive number of milliseconds.'); + const result = spawnSync(flow.argv[0], flow.argv.slice(1), { + cwd, + encoding: 'utf8', + shell: false, + timeout, + }); if (result.error || result.status !== 0) throw new Error(result.stderr || result.error?.message || 'Capture failed.'); let repositoryCommit = manifest.repositoryCommit || null; diff --git a/scripts/clipkit.mjs b/scripts/clipkit.mjs index 52151f3..09ee6f4 100644 --- a/scripts/clipkit.mjs +++ b/scripts/clipkit.mjs @@ -19,6 +19,7 @@ import { import {buildBrollCaptionArgs, mergeStyleConfig, slugify, timestampSlug} from './clipkit-lib.mjs'; import {commandExists} from './command-utils.mjs'; import {runPlatformCli} from './platform/cli.mjs'; +loadEnv(); const require = createRequire(import.meta.url); const packageJson = require('../package.json'); const defaultFramePath = @@ -1367,7 +1368,7 @@ Examples: program, 'video', 'Plan, render, inspect, and QA model-directed videos.', - (args) => run('node', ['scripts/video.mjs', ...args]), + (args) => run('bun', ['scripts/video.mjs', ...args]), ); configurePassthroughCommand( program, diff --git a/scripts/ebay/prepare-competitive-premium-renders.mjs b/scripts/ebay/prepare-competitive-premium-renders.mjs index e83d8ff..de89502 100644 --- a/scripts/ebay/prepare-competitive-premium-renders.mjs +++ b/scripts/ebay/prepare-competitive-premium-renders.mjs @@ -357,6 +357,7 @@ const writePerListingArtifacts = ({packet}) => { const estimateScript = [ '#!/usr/bin/env bash', 'set -euo pipefail', + 'command -v higgs >/dev/null 2>&1 || { echo "Install the Higgsfield CLI (higgs) and retry." >&2; exit 1; }', `cd ${shellQuote(projectRoot)}`, `echo ${shellQuote(`Estimating competitive premium renders for ${packet.item_id} - ${packet.title}`)}`, ...packet.jobs.flatMap((job) => [ @@ -369,6 +370,7 @@ const writePerListingArtifacts = ({packet}) => { const renderScript = [ '#!/usr/bin/env bash', 'set -euo pipefail', + 'command -v higgs >/dev/null 2>&1 || { echo "Install the Higgsfield CLI (higgs) and retry." >&2; exit 1; }', `cd ${shellQuote(projectRoot)}`, `mkdir -p ${shellQuote(path.join(packet.project_dir, 'higgsfield-renders'))}`, `echo ${shellQuote(`Rendering competitive premium shots for ${packet.item_id} - ${packet.title}`)}`, diff --git a/scripts/ebay/run-competitive-higgsfield-renders.mjs b/scripts/ebay/run-competitive-higgsfield-renders.mjs index 130caa8..1719139 100644 --- a/scripts/ebay/run-competitive-higgsfield-renders.mjs +++ b/scripts/ebay/run-competitive-higgsfield-renders.mjs @@ -3,10 +3,11 @@ import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; -import {ensureDir, parseArgs, projectRoot} from '../lib.mjs'; +import {ensureDir, loadEnv, parseArgs, projectRoot} from '../lib.mjs'; import {commandPath} from '../command-utils.mjs'; const scriptName = path.basename(fileURLToPath(import.meta.url)); +loadEnv(); const args = parseArgs(process.argv.slice(2)); const usage = ` @@ -117,6 +118,14 @@ const runHiggs = (commandArgs) => { maxBuffer: 1024 * 1024 * 20, shell: false, }); + if (result.error) + return { + ok: false, + status: result.status ?? 127, + stdout: '', + stderr: String(result.error.message ?? result.error), + parsed: null, + }; return { ok: result.status === 0, status: result.status, diff --git a/scripts/higgsfield-cli.mjs b/scripts/higgsfield-cli.mjs index 43d15d3..42f47b5 100644 --- a/scripts/higgsfield-cli.mjs +++ b/scripts/higgsfield-cli.mjs @@ -5,10 +5,11 @@ import fs from 'node:fs'; import path from 'node:path'; import {commandPath} from './command-utils.mjs'; -import {parseArgs, projectRoot, requireArg} from './lib.mjs'; +import {loadEnv, parseArgs, projectRoot, requireArg} from './lib.mjs'; import {hashValue} from './platform/catalog.mjs'; const argv = process.argv.slice(2); +loadEnv(); const action = argv[0]; const args = parseArgs(argv.slice(1)); const candidates = [ @@ -80,6 +81,7 @@ if (!action || action === '--help') { const intentKey = requireArg(args, 'intent-key'); const estimated = Number(requireArg(args, 'estimated-credits')); const spent = Number(args['total-spent-credits'] || 0); + const budget = Number(approval?.budgetCredits); if ( !approval || approval.planHash !== planHash || @@ -91,10 +93,11 @@ if (!action || action === '--help') { if ( !Number.isFinite(estimated) || !Number.isFinite(spent) || + !Number.isFinite(budget) || estimated < 0 || spent < 0 || - estimated > approval.budgetCredits || - estimated + spent > approval.budgetCredits + estimated > budget || + estimated + spent > budget ) throw new Error('BUDGET_EXCEEDED'); if (argv.includes('--dry-run')) diff --git a/scripts/marketing.mjs b/scripts/marketing.mjs index fa417be..e2154ef 100644 --- a/scripts/marketing.mjs +++ b/scripts/marketing.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import yaml from 'js-yaml'; import {commandPath} from './command-utils.mjs'; -import {ensureDir, parseArgs, projectRoot, requireArg} from './lib.mjs'; +import {ensureDir, loadEnv, parseArgs, projectRoot, requireArg} from './lib.mjs'; import {hashValue, serializeCatalog} from './platform/catalog.mjs'; import {writeJsonAtomic} from './platform/jobs.mjs'; import { @@ -20,6 +20,7 @@ import { const action = process.argv[2]; const args = parseArgs(process.argv.slice(3)); +loadEnv(); const campaignsRoot = path.resolve( process.env.CCA_CAMPAIGNS_ROOT || path.join(projectRoot, 'campaigns'), ); @@ -343,7 +344,9 @@ const executeCampaign = async () => { throw new Error('No paid generation intents exist in this plan.'); const assets = [...state.assets]; const providerJobs = {...state.run.providerJobs}; - let spentCredits = 0; + const spentKeys = new Set(Object.keys(providerJobs)); + const spentCredits = () => + [...spentKeys].reduce((sum, key) => sum + (state.run.estimates[key] ?? 0), 0); for (const variant of state.plan.variants) { for (const intent of variant.intents) { if (intent.type === 'generation') { @@ -376,7 +379,7 @@ const executeCampaign = async () => { '--estimated-credits', String(approvedCredits), '--total-spent-credits', - String(spentCredits), + String(spentCredits()), '--live-execution', ...intent.argv, ], @@ -398,7 +401,7 @@ const executeCampaign = async () => { providerJob: providerJobs[key], }); } - spentCredits += approvedCredits; + spentKeys.add(key); const generatedPath = intent.output || providerJobs[key].output || providerJobs[key].result_path; if (generatedPath && fs.existsSync(generatedPath)) @@ -410,6 +413,7 @@ const executeCampaign = async () => { continue; } if (intent.type === 'capture') { + if (!intent.flow) throw new Error('Capture intents require a flow id.'); const result = spawnSync( process.execPath, [ @@ -530,7 +534,9 @@ const mediaChecks = (variant, asset) => { }); checks.push({ name: 'aspect-ratio', - passed: video && Math.abs(video.width / video.height - variant.width / variant.height) < 0.01, + passed: Boolean( + video && Math.abs(video.width / video.height - variant.width / variant.height) < 0.01, + ), detail: 'Compared probed and planned aspect ratios.', }); checks.push({ diff --git a/scripts/marketing/schemas.mjs b/scripts/marketing/schemas.mjs index 04a4c15..6a82c89 100644 --- a/scripts/marketing/schemas.mjs +++ b/scripts/marketing/schemas.mjs @@ -31,14 +31,29 @@ const intent = z.object({ output: z.string().optional(), }); -const timelineEntry = z.object({ - type: z.enum(['video', 'image', 'text', 'end-card']), - startSeconds: z.number().nonnegative(), - durationSeconds: z.number().positive(), - src: z.string().optional(), - text: z.string().optional(), - transition: z.enum(['cut', 'fade']).default('cut'), -}); +const timelineEntry = z + .object({ + type: z.enum(['video', 'image', 'text', 'end-card']), + startSeconds: z.number().nonnegative(), + durationSeconds: z.number().positive(), + src: z.string().optional(), + text: z.string().optional(), + transition: z.enum(['cut', 'fade']).default('cut'), + }) + .superRefine((entry, context) => { + if (['video', 'image'].includes(entry.type) && !entry.src) + context.addIssue({ + code: 'custom', + path: ['src'], + message: `${entry.type} entries require src.`, + }); + if (['text', 'end-card'].includes(entry.type) && !entry.text) + context.addIssue({ + code: 'custom', + path: ['text'], + message: `${entry.type} entries require text.`, + }); + }); const variant = z.object({ id: z.string().min(1), @@ -84,7 +99,16 @@ export const CreativePlan = z.object({ export const CampaignRun = z.object({ schemaVersion: z.literal(1), id: z.string(), - status: z.string(), + status: z.enum([ + 'planned', + 'estimated', + 'approved', + 'awaiting-assets', + 'executed', + 'dry-run-complete', + 'qa-complete', + 'exported', + ]), planHash: z.string(), capabilityFingerprint: z.string(), createdAt: z.string(), @@ -103,10 +127,10 @@ export const CampaignRun = z.object({ .optional(), providerJobs: z.record(z.string(), z.record(z.string(), z.unknown())).default({}), reviews: z.object({ - technical: z.string(), - contentClaims: z.string(), - visualHuman: z.string(), - publication: z.string(), + technical: z.enum(['pending', 'passed', 'failed']), + contentClaims: z.enum(['pending', 'approved', 'rejected']), + visualHuman: z.enum(['pending', 'approved', 'rejected']), + publication: z.enum(['blocked', 'approved', 'rejected']), }), }); diff --git a/scripts/rotato-cli.mjs b/scripts/rotato-cli.mjs index 827f17e..38b1711 100644 --- a/scripts/rotato-cli.mjs +++ b/scripts/rotato-cli.mjs @@ -5,10 +5,11 @@ import fs from 'node:fs'; import path from 'node:path'; import {commandPath} from './command-utils.mjs'; -import {ensureDir, projectRoot} from './lib.mjs'; +import {ensureDir, loadEnv, projectRoot} from './lib.mjs'; import {hashValue} from './platform/catalog.mjs'; const argv = process.argv.slice(2); +loadEnv(); const help = `ClipCaptionAI Rotato adapter Usage: @@ -56,7 +57,7 @@ const inspect = (executable, scene) => { const takePair = (items, index, flag) => { const first = items[index + 1]; const second = items[index + 2]; - if (!first || !second) throw new Error(`Missing values for ${flag}`); + if (first === undefined || second === undefined) throw new Error(`Missing values for ${flag}`); return [first, second]; }; @@ -199,30 +200,35 @@ const main = () => { compiled.forward, wantsJson ? {} : {stdio: 'inherit'}, ); - if (output && fs.existsSync(output)) { + if (wantsJson && output && fs.existsSync(output)) { const media = spawnSync( 'ffprobe', ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', output], {encoding: 'utf8'}, ); + let mediaInfo = null; + if (media.status === 0) + try { + mediaInfo = JSON.parse(media.stdout); + } catch { + mediaInfo = null; + } const artifact = { path: output, hash: sha256(fs.readFileSync(output)), - media: media.status === 0 ? JSON.parse(media.stdout) : null, + media: mediaInfo, templateValidated: true, capabilityFingerprint: compiled.capability.fingerprint, inspectFingerprint: compiled.inspected.fingerprint, }; - if (wantsJson) { - const lastLine = result.stdout.trim().split('\n').filter(Boolean).at(-1); - let rotato; - try { - rotato = lastLine ? JSON.parse(lastLine) : null; - } catch { - rotato = {stdout: result.stdout}; - } - console.log(JSON.stringify({artifact, rotato})); + const lastLine = result.stdout.trim().split('\n').filter(Boolean).at(-1); + let rotato; + try { + rotato = lastLine ? JSON.parse(lastLine) : null; + } catch { + rotato = {stdout: result.stdout}; } + console.log(JSON.stringify({artifact, rotato})); } else if (wantsJson) process.stdout.write(result.stdout); }; diff --git a/src/marketing-timeline.tsx b/src/marketing-timeline.tsx index 7fb565a..a53427b 100644 --- a/src/marketing-timeline.tsx +++ b/src/marketing-timeline.tsx @@ -38,6 +38,15 @@ export const marketingTimelineDefaultProps: MarketingTimelineProps = { captions: [], }; +export const scheduledFrames = ( + entry: {startSeconds: number; durationSeconds: number}, + fps: number, +) => { + const start = Math.round(entry.startSeconds * fps); + const end = Math.max(start + 1, Math.round((entry.startSeconds + entry.durationSeconds) * fps)); + return {start, end, duration: end - start}; +}; + const TimelineEntry: React.FC<{entry: MarketingTimelineProps['timeline'][number]}> = ({entry}) => { const frame = useCurrentFrame(); const {fps} = useVideoConfig(); @@ -99,8 +108,8 @@ export const MarketingTimeline: React.FC = ({ {timeline.map((entry, index) => ( @@ -108,8 +117,8 @@ export const MarketingTimeline: React.FC = ({ {overlays.map((entry, index) => ( diff --git a/src/root.tsx b/src/root.tsx index 3139d3f..1124af0 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -7,6 +7,7 @@ import {warnOnMissingFonts} from './fonts'; import { MarketingTimeline, marketingTimelineDefaultProps, + scheduledFrames, type MarketingTimelineProps, } from './marketing-timeline'; @@ -86,12 +87,23 @@ export const Root = () => { height={1920} durationInFrames={450} defaultProps={marketingTimelineDefaultProps} - calculateMetadata={({props}: {props: MarketingTimelineProps}) => ({ - fps: props.fps, - width: props.width, - height: props.height, - durationInFrames: Math.max(1, Math.round(props.durationSeconds * props.fps)), - })} + calculateMetadata={({props}: {props: MarketingTimelineProps}) => { + const scheduledEnd = Math.max( + 0, + ...props.timeline.map((entry) => scheduledFrames(entry, props.fps).end), + ...(props.overlays ?? []).map((entry) => scheduledFrames(entry, props.fps).end), + ); + return { + fps: props.fps, + width: props.width, + height: props.height, + durationInFrames: Math.max( + 1, + Math.round(props.durationSeconds * props.fps), + scheduledEnd, + ), + }; + }} /> ); diff --git a/tests/marketing-platform.test.mjs b/tests/marketing-platform.test.mjs index abab9ab..4e7bac5 100644 --- a/tests/marketing-platform.test.mjs +++ b/tests/marketing-platform.test.mjs @@ -46,7 +46,7 @@ else if(args[0]==='render'){const i=args.indexOf('--output'); if(i>=0)fs.writeFi ` const fs=require('fs'); const args=process.argv.slice(2); if(args[0]==='--help') console.log(fs.readFileSync(${JSON.stringify(helpFile)},'utf8')); -else if(args.includes('cost')) console.log(JSON.stringify({credits:3})); +else if(args.includes('cost')) console.log(JSON.stringify({credits:args.includes('fake-two')?2:3})); else {const i=args.indexOf('--output'); if(i>=0)fs.writeFileSync(args[i+1],'generated'); if(process.env.HIGGS_CALLS)fs.appendFileSync(process.env.HIGGS_CALLS,args.join(' ')+'\\n'); console.log(JSON.stringify({id:'fake-job',status:'complete'}));} `, ); @@ -71,10 +71,10 @@ else {const i=args.indexOf('--output'); if(i>=0)fs.writeFileSync(args[i+1],'gene test('marketing tools are catalog-driven for CLI and desktop consumers', async () => { const catalog = await serializeCatalog({root}); assert.deepEqual( - ['capture', 'higgsfield', 'marketing', 'rotato'], catalog .map((adapter) => adapter.id) .filter((id) => ['capture', 'higgsfield', 'marketing', 'rotato'].includes(id)), + ['capture', 'higgsfield', 'marketing', 'rotato'], ); assert.deepEqual( catalog.find((adapter) => adapter.id === 'marketing').actions.map((action) => action.id), @@ -165,10 +165,20 @@ test( test( 'live generation is budget-gated and idempotent across resumed execution', - {timeout: 60_000}, + {timeout: 120_000}, (t) => { const fx = fixture(t); const generated = path.join(fx.directory, 'generated.mp4'); + const generatedTwo = path.join(fx.directory, 'generated-two.mp4'); + const sharedIntent = { + type: 'generation', + provider: 'higgsfield', + model: 'fake', + estimateCredits: 3, + estimateArgv: ['generate', 'cost', 'fake'], + argv: ['generate', 'create', '--output', generated], + output: generated, + }; const product = path.join(fx.directory, 'product.yaml'); fs.writeFileSync(product, yaml.dump({id: 'demo', name: 'Demo'})); const campaign = path.join(fx.directory, 'campaign.yaml'); @@ -183,15 +193,23 @@ test( id: 'v', durationSeconds: 5, cta: 'Go', + intents: [sharedIntent], + timeline: [{type: 'end-card', startSeconds: 4, durationSeconds: 1, text: 'Go'}], + }, + { + id: 'v2', + durationSeconds: 5, + cta: 'Go', intents: [ + sharedIntent, { type: 'generation', provider: 'higgsfield', - model: 'fake', - estimateCredits: 3, - estimateArgv: ['generate', 'cost', 'fake'], - argv: ['generate', 'create', '--output', generated], - output: generated, + model: 'fake-two', + estimateCredits: 2, + estimateArgv: ['generate', 'cost', 'fake-two'], + argv: ['generate', 'create', '--output', generatedTwo], + output: generatedTwo, }, ], timeline: [{type: 'end-card', startSeconds: 4, durationSeconds: 1, text: 'Go'}], @@ -210,7 +228,7 @@ test( ); parseChildJson( runCli( - ['marketing', 'approve', '--run', 'paid-run', '--budget-credits', '3', '--json'], + ['marketing', 'approve', '--run', 'paid-run', '--budget-credits', '5', '--json'], fx.env, ), ); @@ -221,15 +239,15 @@ test( fx.env, ), ); - assert.equal(fs.readFileSync(fx.env.HIGGS_CALLS, 'utf8').trim().split('\n').length, 1); + assert.equal(fs.readFileSync(fx.env.HIGGS_CALLS, 'utf8').trim().split('\n').length, 2); const runRecord = JSON.parse( fs.readFileSync(path.join(fx.campaigns, 'paid-run', 'run.json'), 'utf8'), ); - assert.equal(Object.keys(runRecord.providerJobs).length, 1); + assert.equal(Object.keys(runRecord.providerJobs).length, 2); const assets = JSON.parse( fs.readFileSync(path.join(fx.campaigns, 'paid-run', 'assets', 'index.json'), 'utf8'), ); - assert.equal(assets.length, 1); + assert.equal(assets.length, 3); assert.equal(assets[0].provenance.adapter, 'higgsfield'); }, ); @@ -336,6 +354,15 @@ test('Rotato templates compile semantic slots and reject drift or screen conflic ); assert.equal(rendered.status, 0, rendered.stderr); assert.ok(fs.existsSync(output)); + const brokerOutput = path.join(fx.directory, 'broker-render.mp4'); + const brokerResult = runCli( + ['rotato', 'render', '--template', 'phone', '--output', brokerOutput, '--wait', '--json'], + env, + ); + assert.equal(brokerResult.status, 0, brokerResult.stderr); + const brokerArtifact = JSON.parse(brokerResult.stdout).data.result.artifact; + assert.equal(brokerArtifact.templateValidated, true); + assert.equal(brokerArtifact.capabilityFingerprint.length, 64); const conflict = run( process.execPath, [ @@ -419,6 +446,23 @@ test('Higgsfield submission enforces live approval and budgets', (t) => { }, }), ); + const missingBudget = JSON.parse(fs.readFileSync(approvalFile, 'utf8')); + delete missingBudget.approval.budgetCredits; + fs.writeFileSync(approvalFile, JSON.stringify(missingBudget)); + const malformed = run(process.execPath, [...base, '--dry-run', 'generate', 'create'], fx.env); + assert.match(malformed.stderr, /BUDGET_EXCEEDED/); + fs.writeFileSync( + approvalFile, + JSON.stringify({ + estimates, + approval: { + planHash: 'plan', + capabilityFingerprint: 'cap', + estimateHash: hashValue(estimates), + budgetCredits: 5, + }, + }), + ); const noLive = run(process.execPath, [...base, 'generate', 'create'], fx.env); assert.match(noLive.stderr, /LIVE_EXECUTION_REQUIRED/); const over = run( @@ -468,4 +512,19 @@ test('command capture records reproducible provenance', (t) => { assert.equal(record.seedVersion, '7'); assert.equal(record.platformProfile, 'desktop'); assert.equal(record.artifacts[0].hash.length, 64); + const invalidTimeout = run( + process.execPath, + [ + path.join(root, 'scripts', 'capture-cli.mjs'), + 'run', + '--manifest', + manifest, + '--flow', + 'hero', + '--timeout', + '0', + ], + fx.env, + ); + assert.match(invalidTimeout.stderr, /positive number/); });