From 2b6549b3200eb4412d29f146efa5f04bfacb368e Mon Sep 17 00:00:00 2001 From: Jonathan Gan Date: Fri, 21 Aug 2026 04:48:19 -0400 Subject: [PATCH 1/6] Add stock carousel marketing workflow --- .env.example | 4 + .gitignore | 2 + campaigns/.gitkeep | 1 + docs/MARKETING_PLATFORM.md | 40 +++ docs/ROTATO-INTEGRATION.md | 2 + docs/TOOL_ADAPTERS.md | 22 +- examples/marketing/slideshow.example.yaml | 84 +++++ scripts/adapters/marketing.adapter.mjs | 2 +- scripts/adapters/rotato.adapter.mjs | 1 + scripts/adapters/stock.adapter.mjs | 52 +++ scripts/lib.mjs | 2 +- scripts/marketing.mjs | 377 ++++++++++++++++++++-- scripts/marketing/schemas.mjs | 113 +++++-- scripts/rotato-cli.mjs | 42 ++- scripts/stock-cli.mjs | 140 ++++++++ src/captioned-clip.tsx | 102 ++---- src/marketing-timeline.tsx | 152 ++++++++- tests/marketing-platform.test.mjs | 309 +++++++++++++++++- 18 files changed, 1298 insertions(+), 149 deletions(-) create mode 100644 campaigns/.gitkeep create mode 100644 examples/marketing/slideshow.example.yaml create mode 100644 scripts/adapters/stock.adapter.mjs create mode 100644 scripts/stock-cli.mjs diff --git a/.env.example b/.env.example index d63faee..978a9f6 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,10 @@ DEEPSEEK_API_KEY= # Optional. Enables YouTube Data API search for stronger B-roll discovery. YOUTUBE_API_KEY= +# Optional. Enables licensed portrait stock-image search and download for slideshow campaigns. +# Request a key from https://www.pexels.com/api/documentation/ +PEXELS_API_KEY= + # Optional. Transcription backend preference: # auto | local-whispercpp | openai | youtube TRANSCRIBE_PROVIDER=local-whispercpp diff --git a/.gitignore b/.gitignore index f28f7c4..9730ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ outputs/* !outputs/.gitkeep projects/* !projects/.gitkeep +campaigns/* +!campaigns/.gitkeep *.log links.txt broll-prompts*.txt diff --git a/campaigns/.gitkeep b/campaigns/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/campaigns/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/MARKETING_PLATFORM.md b/docs/MARKETING_PLATFORM.md index 0b46fab..68b8095 100644 --- a/docs/MARKETING_PLATFORM.md +++ b/docs/MARKETING_PLATFORM.md @@ -16,6 +16,46 @@ clipcaptionai marketing export --run --wait 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. +`execute` resolves the campaign timeline, renders the `MarketingTimeline` Remotion composition, normalizes audio to -16 LUFS by default, and registers a content-hashed final MP4. Timeline videos can be trimmed, muted, volume-adjusted, and fit for the placement; campaigns can add a `voice` narration track, a separate `music` bed, and brand colors. Set `audioTargetLufs` per variant when a delivery profile needs another target. `qa` requires that final artifact and never substitutes a source, generated, capture, or Rotato intermediate. + 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. +## Native value-first slideshows + +Slideshow campaigns use the same plan, budget, job, render, QA, and export flow as video campaigns. A variant declares `slides` instead of a raw `timeline`. Set `format: carousel` to render one finished PNG per slide, or retain the default `format: video` to expand the slides into an animated video with a CTA end card. + +```yaml +variants: + - id: health-week-saveable + format: carousel + cta: Review your day with PrepAI + slides: + - src: /absolute/path/from/stock-manifest.json + eyebrow: SAVE THIS + headline: 5 realistic ways to support your health this week + body: Pick one that fits your needs and routine. + durationSeconds: 2.2 + motion: push-in + sourceType: stock + attribution: + provider: pexels + creator: Photographer name + creatorUrl: https://www.pexels.com/@photographer + sourceUrl: https://www.pexels.com/photo/123 + licenseUrl: https://www.pexels.com/license/ +``` + +`motion` supports `push-in`, `pan-left`, and `pan-right` for video output; `textPosition` supports `top`, `center`, and `bottom`. Carousel output preserves the declared slide count exactly. Stock slides must include creator, source, provider, and license metadata. `execute` registers those source assets and `qa` fails the `stock-provenance` check when the record is incomplete. + +Acquire images through the shared adapter before authoring the campaign: + +```sh +clipcaptionai stock doctor +clipcaptionai stock download --query "healthy morning walk sunlight" --count 8 --out outputs/stock/health-week --wait --json +``` + +The adapter requests portrait Pexels originals using the API's `orientation=portrait` and `size=large` filters, then rejects images below 1080×1920. The manifest is the source of truth when an agent fills slide paths and attribution. Reuse the downloaded library across hook, ordering, wording, pacing, and CTA variants instead of downloading duplicate images. + +Value slides must remain truthful and legible. Health campaigns require content/claims review; do not invent supplement, treatment, skin, weight-loss, or outcome claims. Stock subjects cannot be presented as customers or as endorsing the product. Promotion may be low-pressure, but it must remain identifiable rather than disguised as independent advice. Pexels downloads are per-campaign creative inputs, not a bulk collection or AI-training dataset; preserve the manifest's Pexels and photographer links. + 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 0e1c68e..559e507 100644 --- a/docs/ROTATO-INTEGRATION.md +++ b/docs/ROTATO-INTEGRATION.md @@ -10,6 +10,8 @@ clipcaptionai rotato render /path/to/scene.rotato --screen-media /path/to/captur 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. +Use `clipcaptionai rotato templates --json` to discover the active local template library and its semantic screen slots. Set `CCA_ROTATO_TEMPLATES_ROOT` when the library lives outside the repository. + 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. Rotato rendering success is not visual approval or publication readiness; marketing QA records those as separate states. diff --git a/docs/TOOL_ADAPTERS.md b/docs/TOOL_ADAPTERS.md index efc98b6..e3f85af 100644 --- a/docs/TOOL_ADAPTERS.md +++ b/docs/TOOL_ADAPTERS.md @@ -8,6 +8,7 @@ clipcaptionai adapters describe remotion clipcaptionai remotion compositions --wait clipcaptionai ollama pull qwen3:4b clipcaptionai ytdlp formats URL --json +clipcaptionai stock download --query "morning sunlight walk" --count 6 --out outputs/stock/health-week --wait clipcaptionai workflow list clipcaptionai workflow run caption --video input.mp4 --wait ``` @@ -34,11 +35,20 @@ export default { title: 'Example', description: 'Example tool', version: '1', - actions: [{ - id: 'run', title: 'Run', description: 'Run it', mode: 'job', aliases: [], - args: [{name: 'args', type: 'array'}], requirements: ['example'], - secrets: [], locks: ['outputs'], setup: [], - }], + actions: [ + { + id: 'run', + title: 'Run', + description: 'Run it', + mode: 'job', + aliases: [], + args: [{name: 'args', type: 'array'}], + requirements: ['example'], + secrets: [], + locks: ['outputs'], + setup: [], + }, + ], }, build(_action, input) { return {command: 'example', args: input.args ?? []}; @@ -48,6 +58,8 @@ export default { Catalog validation fails on duplicate IDs or malformed metadata. Adding a valid adapter requires no routing or desktop UI edit. +The `stock` adapter uses the Pexels API with portrait, large-image, and minimum 1080×1920 filters. Downloads include a manifest with the creator, Pexels source URL, license URL, dimensions, and SHA-256 hash. Results link back to [Pexels](https://www.pexels.com/) and preserve photographer credit. Configure `PEXELS_API_KEY`; the key is never passed to the desktop renderer or printed in job logs. + ## Job storage Jobs use the platform application-data directory, or `CCA_STATE_ROOT` in isolated automation. Each job owns an atomic JSON record plus redacted stdout/stderr logs and result data. Workers survive CLI/desktop closure. Resource locks are atomic filesystem directories; stale running jobs are marked `interrupted` and are never retried automatically. diff --git a/examples/marketing/slideshow.example.yaml b/examples/marketing/slideshow.example.yaml new file mode 100644 index 0000000..8d62e47 --- /dev/null +++ b/examples/marketing/slideshow.example.yaml @@ -0,0 +1,84 @@ +id: prepai-value-slides +product: ./product.example.yaml +objective: Test saveable wellness education with a transparent, low-pressure PrepAI mention. +approvedClaims: + - PrepAI helps people review movement, meal, and recovery context and choose one useful action. + +x-base: &base + width: 1080 + height: 1920 + fps: 30 + format: carousel + cta: Review your day with PrepAI + theme: + backgroundColor: '#10263B' + foregroundColor: '#F7FAFC' + accentColor: '#08B8AE' + +variants: + - <<: *base + id: realistic-health-week + slides: + - src: /replace/from/stock-manifest/01-morning-walk.jpg + eyebrow: SAVE THIS + headline: 5 realistic ways to support your health this week + body: Pick one that fits your needs and routine. + durationSeconds: 2.4 + motion: push-in + sourceType: stock + attribution: + provider: pexels + creator: Replace from manifest + sourceUrl: https://www.pexels.com/photo/replace-1 + licenseUrl: https://www.pexels.com/license/ + - src: /replace/from/stock-manifest/02-balanced-meal.jpg + headline: Build one meal around foods you already enjoy + body: Make the change easy enough to repeat. + durationSeconds: 2.2 + motion: pan-left + sourceType: stock + attribution: + provider: pexels + creator: Replace from manifest + sourceUrl: https://www.pexels.com/photo/replace-2 + licenseUrl: https://www.pexels.com/license/ + - src: /replace/from/stock-manifest/03-walk.jpg + headline: Add a short outdoor walk + body: Start with a duration that feels sustainable. + durationSeconds: 2.2 + motion: pan-right + sourceType: stock + attribution: + provider: pexels + creator: Replace from manifest + sourceUrl: https://www.pexels.com/photo/replace-3 + licenseUrl: https://www.pexels.com/license/ + - src: /replace/with/owned-prepai-screen.png + eyebrow: DAILY CHECK-IN + headline: Use PrepAI to connect today's context + body: Review movement, meals, and recovery before choosing one next step. + durationSeconds: 2.4 + motion: push-in + sourceType: owned + - src: /replace/from/stock-manifest/05-sleep.jpg + headline: Protect a consistent wind-down window + body: Adjust gradually instead of chasing a perfect routine. + durationSeconds: 2.2 + motion: pan-left + sourceType: stock + attribution: + provider: pexels + creator: Replace from manifest + sourceUrl: https://www.pexels.com/photo/replace-5 + licenseUrl: https://www.pexels.com/license/ + - src: /replace/from/stock-manifest/06-journal.jpg + headline: Notice what helped—not only what went wrong + body: Keep the habit useful, not punitive. + durationSeconds: 2.2 + motion: pan-right + sourceType: stock + attribution: + provider: pexels + creator: Replace from manifest + sourceUrl: https://www.pexels.com/photo/replace-6 + licenseUrl: https://www.pexels.com/license/ diff --git a/scripts/adapters/marketing.adapter.mjs b/scripts/adapters/marketing.adapter.mjs index 7556d58..2319e81 100644 --- a/scripts/adapters/marketing.adapter.mjs +++ b/scripts/adapters/marketing.adapter.mjs @@ -21,7 +21,7 @@ export default { id: 'marketing', title: 'Marketing campaigns', description: 'Plan, budget, execute, inspect, QA, and export campaign runs.', - version: '1', + version: '4', actions: [ entry('doctor', 'Doctor', 'sync'), entry('plan', 'Plan', 'job', ['campaigns']), diff --git a/scripts/adapters/rotato.adapter.mjs b/scripts/adapters/rotato.adapter.mjs index 94397fe..851cee3 100644 --- a/scripts/adapters/rotato.adapter.mjs +++ b/scripts/adapters/rotato.adapter.mjs @@ -27,6 +27,7 @@ export default { version: '1', actions: [ action('doctor', 'Doctor', 'sync'), + action('templates', 'Templates', 'sync'), action('inspect', 'Inspect'), action('render', 'Render', 'job', ['rotato', 'outputs']), action('raw', 'Raw passthrough', 'job', ['rotato']), diff --git a/scripts/adapters/stock.adapter.mjs b/scripts/adapters/stock.adapter.mjs new file mode 100644 index 0000000..ed15c49 --- /dev/null +++ b/scripts/adapters/stock.adapter.mjs @@ -0,0 +1,52 @@ +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} licensed, high-resolution stock images.`, + mode, + aliases: [], + args: [{name: 'args', label: 'Arguments', type: 'textarea'}], + requirements: ['bun'], + secrets: id === 'doctor' ? [] : ['PEXELS_API_KEY'], + locks, + setup: [], +}); + +export default { + metadata: { + id: 'stock', + title: 'Licensed stock images', + description: 'Search and download portrait Pexels originals with reusable provenance.', + version: '1', + actions: [ + action('doctor', 'Inspect', 'sync'), + action('search', 'Search'), + action('download', 'Download', 'job', ['stock', 'outputs']), + ], + }, + async build(actionId, input = {}) { + return { + command: process.execPath, + args: [ + path.join(projectRoot, 'scripts', 'stock-cli.mjs'), + actionId, + ...(input.args || []).map(String), + ], + cwd: projectRoot, + }; + }, + async collect(actionId, input = {}) { + if (actionId !== 'download') return {}; + const args = (input.args || []).map(String); + const index = args.indexOf('--out'); + if (index < 0 || !args[index + 1]) return {}; + const manifest = path.join(path.resolve(projectRoot, args[index + 1]), 'stock-manifest.json'); + if (!fs.existsSync(manifest)) return {}; + const data = JSON.parse(fs.readFileSync(manifest, 'utf8')); + return {artifacts: [manifest, ...data.files.map((entry) => entry.path)], result: data}; + }, +}; diff --git a/scripts/lib.mjs b/scripts/lib.mjs index e3686ed..8912f74 100644 --- a/scripts/lib.mjs +++ b/scripts/lib.mjs @@ -140,7 +140,7 @@ export const videoToSrc = (videoPath) => { } } - return `media/${stagedName}`; + return `public/media/${stagedName}`; }; export const probeVideo = (videoPath) => { diff --git a/scripts/marketing.mjs b/scripts/marketing.mjs index e2154ef..f2204fc 100644 --- a/scripts/marketing.mjs +++ b/scripts/marketing.mjs @@ -6,7 +6,15 @@ import path from 'node:path'; import yaml from 'js-yaml'; import {commandPath} from './command-utils.mjs'; -import {ensureDir, loadEnv, parseArgs, projectRoot, requireArg} from './lib.mjs'; +import { + ensureDir, + loadEnv, + parseArgs, + projectRoot, + publicMediaRoot, + requireArg, + videoToSrc, +} from './lib.mjs'; import {hashValue, serializeCatalog} from './platform/catalog.mjs'; import {writeJsonAtomic} from './platform/jobs.mjs'; import { @@ -132,9 +140,68 @@ const resolveProduct = (campaignPath, value) => { return ProductManifest.parse(yaml.load(fs.readFileSync(file, 'utf8'))); }; +const expandSlides = (variant) => { + if (variant.slides.length === 0) return variant; + let startSeconds = 0; + const timeline = []; + const intents = [...variant.intents]; + for (const slide of variant.slides) { + timeline.push( + { + type: 'image', + startSeconds, + durationSeconds: slide.durationSeconds, + src: slide.src, + motion: slide.motion, + fit: slide.fit, + transition: 'fade', + }, + { + type: 'slide-text', + startSeconds, + durationSeconds: slide.durationSeconds, + eyebrow: slide.eyebrow, + headline: slide.headline, + body: slide.body, + textPosition: slide.textPosition, + transition: 'fade', + }, + ); + intents.push({ + type: 'source', + source: slide.src, + provenance: { + sourceType: slide.sourceType, + ...(slide.attribution || {}), + }, + }); + startSeconds += slide.durationSeconds; + } + if (variant.format === 'carousel') + return {...variant, durationSeconds: startSeconds, timeline: [], intents}; + timeline.push({ + type: 'end-card', + startSeconds, + durationSeconds: variant.endCardDurationSeconds, + text: variant.cta, + transition: 'fade', + }); + return { + ...variant, + durationSeconds: startSeconds + variant.endCardDurationSeconds, + slides: [], + timeline, + intents, + }; +}; + const planCampaign = async () => { const campaignPath = path.resolve(requireArg(args, 'campaign')); const campaign = CampaignBrief.parse(yaml.load(fs.readFileSync(campaignPath, 'utf8'))); + const variants = CampaignBrief.parse({ + ...campaign, + variants: campaign.variants.map(expandSlides), + }).variants; 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)) @@ -163,14 +230,30 @@ const planCampaign = async () => { 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, - })), - })), + variants: variants.map((variant) => { + return { + ...variant, + music: variant.music ? path.resolve(path.dirname(campaignPath), variant.music) : undefined, + voice: variant.voice ? path.resolve(path.dirname(campaignPath), variant.voice) : undefined, + slides: variant.slides.map((slide) => ({ + ...slide, + src: path.resolve(path.dirname(campaignPath), slide.src), + })), + timeline: variant.timeline.map((entry) => ({ + ...entry, + src: entry.src ? path.resolve(path.dirname(campaignPath), entry.src) : undefined, + })), + 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({ @@ -333,6 +416,172 @@ const copyAsset = (state, variant, intent, source, type, extra = {}) => { }); }; +const renderVariant = (state, variant) => { + const propsPath = path.join(state.directory, 'artifacts', 'previews', `${variant.id}-props.json`); + const renderPath = path.join(state.directory, 'artifacts', 'previews', `${variant.id}.mp4`); + const normalizedPath = path.join( + state.directory, + 'artifacts', + 'previews', + `${variant.id}-normalized.mp4`, + ); + const props = { + width: variant.width, + height: variant.height, + fps: variant.fps, + durationSeconds: variant.durationSeconds, + timeline: variant.timeline.map((entry) => ({ + ...entry, + src: entry.src ? videoToSrc(entry.src) : undefined, + })), + captions: variant.captions, + voice: variant.voice ? videoToSrc(variant.voice) : undefined, + music: variant.music ? videoToSrc(variant.music) : undefined, + musicVolume: variant.musicVolume, + theme: variant.theme, + }; + writeJsonAtomic(propsPath, props); + const result = spawnSync( + 'bunx', + [ + 'remotion', + 'render', + path.join(projectRoot, 'src', 'index.tsx'), + 'MarketingTimeline', + renderPath, + `--props=${propsPath}`, + '--codec=h264', + '--concurrency=1', + `--public-dir=${path.dirname(publicMediaRoot)}`, + '--overwrite', + ], + {cwd: projectRoot, encoding: 'utf8', shell: false}, + ); + if (result.error || result.status !== 0) + throw new Error(`Marketing render failed: ${result.stderr || result.error?.message}`); + const audioTargetLufs = variant.audioTargetLufs ?? -16; + const normalized = spawnSync( + 'ffmpeg', + [ + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-i', + renderPath, + '-map', + '0:v:0', + '-map', + '0:a:0?', + '-c:v', + 'copy', + '-af', + `loudnorm=I=${audioTargetLufs}:TP=-1.5:LRA=11`, + '-c:a', + 'aac', + '-b:a', + '192k', + normalizedPath, + ], + {cwd: projectRoot, encoding: 'utf8', shell: false}, + ); + if (normalized.error || normalized.status !== 0) + throw new Error( + `Marketing audio normalization failed: ${normalized.stderr || normalized.error?.message}`, + ); + const asset = copyAsset( + state, + variant, + {type: 'source', source: normalizedPath}, + normalizedPath, + 'final', + { + adapter: 'remotion', + adapterVersion: '3', + composition: 'MarketingTimeline', + propsPath, + audioNormalization: 'ffmpeg-loudnorm', + audioTargetLufs, + }, + ); + fs.rmSync(renderPath, {force: true}); + fs.rmSync(normalizedPath, {force: true}); + return asset; +}; + +const renderCarousel = (state, variant) => + variant.slides.map((slide, index) => { + const number = index + 1; + const basename = `${variant.id}-slide-${String(number).padStart(2, '0')}`; + const propsPath = path.join(state.directory, 'artifacts', 'previews', `${basename}-props.json`); + const renderPath = path.join(state.directory, 'artifacts', 'previews', `${basename}.png`); + writeJsonAtomic(propsPath, { + width: variant.width, + height: variant.height, + fps: variant.fps, + durationSeconds: 1, + timeline: [ + { + type: 'image', + startSeconds: 0, + durationSeconds: 1, + src: videoToSrc(slide.src), + fit: slide.fit, + transition: 'cut', + }, + { + type: 'slide-text', + startSeconds: 0, + durationSeconds: 1, + eyebrow: slide.eyebrow, + headline: slide.headline, + body: slide.body, + textPosition: slide.textPosition, + transition: 'cut', + }, + ], + captions: [], + theme: variant.theme, + }); + const result = spawnSync( + 'bunx', + [ + 'remotion', + 'still', + path.join(projectRoot, 'src', 'index.tsx'), + 'MarketingTimeline', + renderPath, + `--props=${propsPath}`, + '--frame=0', + '--image-format=png', + `--public-dir=${path.dirname(publicMediaRoot)}`, + '--overwrite', + ], + {cwd: projectRoot, encoding: 'utf8', shell: false}, + ); + if (result.error || result.status !== 0) + throw new Error( + `Marketing carousel render failed: ${result.stderr || result.error?.message}`, + ); + const asset = copyAsset( + state, + variant, + {type: 'source', source: renderPath, slideIndex: number}, + renderPath, + 'final', + { + adapter: 'remotion', + adapterVersion: '3', + composition: 'MarketingTimeline', + outputFormat: 'carousel', + slideIndex: number, + propsPath, + }, + ); + fs.rmSync(renderPath, {force: true}); + return asset; + }); + const executeCampaign = async () => { const state = loadRun(requireArg(args, 'run')); await requireCurrentApproval(state); @@ -468,9 +717,16 @@ const executeCampaign = async () => { continue; } if (intent.source) { - assets.push(copyAsset(state, variant, intent, intent.source, 'source')); + assets.push( + copyAsset(state, variant, intent, intent.source, 'source', intent.provenance || {}), + ); } } + assets.push( + ...(variant.format === 'carousel' + ? renderCarousel(state, variant) + : [renderVariant(state, variant)]), + ); } writeJsonAtomic(path.join(state.directory, 'assets', 'index.json'), [ ...new Map(assets.map((asset) => [asset.id, asset])).values(), @@ -495,7 +751,13 @@ const executeCampaign = async () => { live, assetCount: assets.length, }); - print({runId: run.id, status: run.status, live, assets: assets.length}); + print({ + runId: run.id, + status: run.status, + live, + assets: assets.length, + finals: assets.filter((asset) => asset.type === 'final').map((asset) => asset.path), + }); }; const inspectCampaign = () => { @@ -516,7 +778,7 @@ const probe = (file) => const mediaChecks = (variant, asset) => { const checks = []; if (!asset) - return [{name: 'asset', passed: false, detail: 'No source or final asset is registered.'}]; + return [{name: 'asset', passed: false, detail: 'No final rendered asset is registered.'}]; try { const metadata = probe(asset.path); const video = metadata.streams?.find((stream) => stream.codec_type === 'video'); @@ -555,6 +817,37 @@ const mediaChecks = (variant, asset) => { return checks; }; +const carouselChecks = (variant, assets) => { + const checks = [ + { + name: 'carousel-slide-count', + passed: assets.length === variant.slides.length, + detail: `${assets.length} rendered images, expected ${variant.slides.length}.`, + }, + ]; + const media = assets.map((asset) => { + try { + const metadata = probe(asset.path); + return metadata.streams?.find((stream) => stream.codec_type === 'video'); + } catch { + return null; + } + }); + checks.push({ + name: 'decoding', + passed: media.length > 0 && media.every(Boolean), + detail: 'Every carousel image must decode.', + }); + checks.push({ + name: 'dimensions', + passed: media.every( + (entry) => Number(entry?.width) === variant.width && Number(entry?.height) === variant.height, + ), + detail: `Every carousel image must be ${variant.width}x${variant.height}.`, + }); + return checks; +}; + const detectSignal = (asset, filter, pattern) => { if (!asset) return {passed: false, detail: 'No asset to analyze.'}; const result = spawnSync( @@ -596,18 +889,24 @@ 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/), - }); + const finalAssets = state.assets.filter( + (entry) => entry.type === 'final' && entry.provenance.variantId === variant.id, + ); + const asset = finalAssets.at(-1); + const checks = + variant.format === 'carousel' + ? carouselChecks(variant, finalAssets) + : mediaChecks(variant, asset); + if (variant.format === 'video') { + 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( @@ -621,8 +920,15 @@ const qaCampaign = () => { }); 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.', + passed: Boolean( + variant.cta && + (variant.format === 'carousel' || + variant.timeline.some((entry) => entry.type === 'end-card')), + ), + detail: + variant.format === 'carousel' + ? 'Carousel CTA metadata is present.' + : 'CTA and end-card metadata are required.', }); const captureAssets = state.assets.filter( (entry) => @@ -635,6 +941,25 @@ const qaCampaign = () => { ), detail: 'Capture artifacts must be newer than 30 days.', }); + const stockIntents = variant.intents.filter( + (intent) => intent.provenance?.sourceType === 'stock', + ); + checks.push({ + name: 'stock-provenance', + passed: stockIntents.every( + (intent) => + intent.provenance.provider && + intent.provenance.creator && + intent.provenance.sourceUrl && + intent.provenance.licenseUrl && + state.assets.some( + (entry) => + entry.provenance.variantId === variant.id && + entry.provenance.sourceUrl === intent.provenance.sourceUrl, + ), + ), + detail: 'Stock images require registered creator, source, provider, and license metadata.', + }); const mockups = state.assets.filter( (entry) => entry.provenance.variantId === variant.id && entry.provenance.adapter === 'rotato', ); diff --git a/scripts/marketing/schemas.mjs b/scripts/marketing/schemas.mjs index 6a82c89..ce7e276 100644 --- a/scripts/marketing/schemas.mjs +++ b/scripts/marketing/schemas.mjs @@ -29,16 +29,56 @@ const intent = z.object({ argv: z.array(z.string()).default([]), estimateArgv: z.array(z.string()).default([]), output: z.string().optional(), + provenance: z.record(z.string(), z.unknown()).optional(), }); +const attribution = z.object({ + provider: z.string().min(1), + creator: z.string().min(1), + creatorUrl: z.string().url().optional(), + sourceUrl: z.string().url(), + licenseUrl: z.string().url(), +}); + +const slide = z + .object({ + src: z.string().min(1), + eyebrow: z.string().max(40).optional(), + headline: z.string().min(1).max(100), + body: z.string().max(180).optional(), + durationSeconds: z.number().min(1).max(8).default(2.2), + motion: z.enum(['push-in', 'pan-left', 'pan-right']).default('push-in'), + fit: z.enum(['cover', 'contain']).default('cover'), + textPosition: z.enum(['top', 'center', 'bottom']).default('bottom'), + sourceType: z.enum(['owned', 'stock', 'generated']).default('owned'), + attribution: attribution.optional(), + }) + .superRefine((entry, context) => { + if (entry.sourceType === 'stock' && !entry.attribution) + context.addIssue({ + code: 'custom', + path: ['attribution'], + message: 'Stock slides require creator, source, and license attribution metadata.', + }); + }); + const timelineEntry = z .object({ - type: z.enum(['video', 'image', 'text', 'end-card']), + type: z.enum(['video', 'image', 'text', 'slide-text', 'end-card']), startSeconds: z.number().nonnegative(), durationSeconds: z.number().positive(), src: z.string().optional(), text: z.string().optional(), + eyebrow: z.string().optional(), + headline: z.string().optional(), + body: z.string().optional(), transition: z.enum(['cut', 'fade']).default('cut'), + sourceStartSeconds: z.number().nonnegative().default(0), + muted: z.boolean().default(false), + volume: z.number().min(0).max(4).default(1), + fit: z.enum(['cover', 'contain']).default('cover'), + motion: z.enum(['none', 'push-in', 'pan-left', 'pan-right']).default('none'), + textPosition: z.enum(['top', 'center', 'bottom']).default('bottom'), }) .superRefine((entry, context) => { if (['video', 'image'].includes(entry.type) && !entry.src) @@ -53,28 +93,61 @@ const timelineEntry = z path: ['text'], message: `${entry.type} entries require text.`, }); + if (entry.type === 'slide-text' && !entry.headline) + context.addIssue({ + code: 'custom', + path: ['headline'], + message: 'slide-text entries require a headline.', + }); }); -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), +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), + format: z.enum(['video', 'carousel']).default('video'), + durationSeconds: z.number().positive().default(15), + cta: z.string().min(1), + intents: z.array(intent).default([]), + timeline: z.array(timelineEntry).default([]), + slides: z.array(slide).default([]), + endCardDurationSeconds: z.number().min(1).max(5).default(1.8), + captions: z + .array( + z.object({ + text: z.string(), + startSeconds: z.number(), + endSeconds: z.number(), + yPercent: z.number().min(0).max(100).default(82), + }), + ) + .default([]), + music: z.string().optional(), + voice: z.string().optional(), + musicVolume: z.number().min(0).max(1).default(0.08), + audioTargetLufs: z.number().min(-24).max(-10).optional(), + theme: z + .object({ + backgroundColor: z.string(), + foregroundColor: z.string(), + accentColor: z.string(), + }) + .default({ + backgroundColor: '#080b12', + foregroundColor: '#ffffff', + accentColor: '#3b82f6', }), - ) - .default([]), -}); + }) + .superRefine((entry, context) => { + if (entry.slides.length > 0 && entry.timeline.length > 0) + context.addIssue({ + code: 'custom', + path: ['slides'], + message: 'Use slides or timeline, not both.', + }); + }); export const CampaignBrief = z.object({ id: z.string().min(1), diff --git a/scripts/rotato-cli.mjs b/scripts/rotato-cli.mjs index 38b1711..40d69a9 100644 --- a/scripts/rotato-cli.mjs +++ b/scripts/rotato-cli.mjs @@ -14,6 +14,7 @@ const help = `ClipCaptionAI Rotato adapter Usage: clipcaptionai rotato doctor + clipcaptionai rotato templates clipcaptionai rotato inspect [--json] clipcaptionai rotato render --output [Rotato flags] clipcaptionai rotato render --template --screen-slot --output @@ -24,6 +25,34 @@ 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 templatesRoot = () => + path.resolve( + process.env.CCA_ROTATO_TEMPLATES_ROOT || path.join(projectRoot, 'templates', 'rotato'), + ); + +const listTemplates = () => { + const root = templatesRoot(); + if (!fs.existsSync(root)) return {root, templates: []}; + const templates = fs + .readdirSync(root, {withFileTypes: true}) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + const directory = path.join(root, entry.name); + const scene = path.join(directory, 'scene.rotato'); + const metadata = path.join(directory, 'template.json'); + if (!fs.existsSync(scene) || !fs.existsSync(metadata)) return []; + const template = JSON.parse(fs.readFileSync(metadata, 'utf8')); + return [ + { + id: entry.name, + name: template.name || entry.name, + deviceSlots: template.deviceSlots || {}, + }, + ]; + }) + .sort((a, b) => a.id.localeCompare(b.id)); + return {root, templates}; +}; const invoke = (command, args, options = {}) => { const result = spawnSync(command, args, { @@ -79,12 +108,7 @@ const compileRender = (items) => { 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, - ); + const directory = path.join(templatesRoot(), templateId); template = JSON.parse(fs.readFileSync(path.join(directory, 'template.json'), 'utf8')); scene = path.join(directory, 'scene.rotato'); } @@ -111,6 +135,7 @@ const compileRender = (items) => { const forward = ['render', scene]; for (let index = positionalScene ? 1 : 0; index < items.length; index += 1) { const flag = items[index]; + if (flag === '--json') continue; if (flag === '--template') { index += 1; continue; @@ -162,7 +187,7 @@ const compileRender = (items) => { } forward.push(flag); } - return {capability, inspected, forward}; + return {capability, inspected, forward, wantsJson: items.includes('--json')}; }; const main = () => { @@ -179,6 +204,7 @@ const main = () => { }), ); } + if (action === 'templates') return console.log(JSON.stringify(listTemplates())); const {executable} = capabilities(); if (action === 'raw') return process.exit(invoke(executable, items, {stdio: 'inherit'}).status || 0); @@ -194,7 +220,7 @@ const main = () => { 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 wantsJson = compiled.wantsJson; const result = invoke( compiled.capability.executable, compiled.forward, diff --git a/scripts/stock-cli.mjs b/scripts/stock-cli.mjs new file mode 100644 index 0000000..866eb26 --- /dev/null +++ b/scripts/stock-cli.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import {slugify} from './clipkit-lib.mjs'; +import {ensureDir, loadEnv, parseArgs, requireArg} from './lib.mjs'; +import {writeJsonAtomic} from './platform/jobs.mjs'; + +loadEnv(); +const action = process.argv[2]; +const args = parseArgs(process.argv.slice(3)); +const licenseUrl = 'https://www.pexels.com/license/'; +const docsUrl = 'https://www.pexels.com/api/documentation/'; +const providerUrl = 'https://www.pexels.com/'; +const baseUrl = String(process.env.CCA_PEXELS_API_BASE_URL || 'https://api.pexels.com/v1').replace( + /\/$/, + '', +); +const print = (value) => console.log(JSON.stringify(value)); + +const positiveInteger = (value, fallback, maximum) => { + const parsed = value === undefined ? fallback : Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) + throw new Error(`Expected an integer from 1 to ${maximum}.`); + return parsed; +}; + +const requireKey = () => { + const key = process.env.PEXELS_API_KEY; + if (!key) throw new Error(`PEXELS_API_KEY is required. Request one at ${docsUrl}`); + return key; +}; + +const search = async () => { + const query = String(requireArg(args, 'query')).trim(); + if (!query || query.length > 200) + throw new Error('Stock query must contain 1 to 200 characters.'); + const count = positiveInteger(args.count, 10, 20); + const minWidth = positiveInteger(args['min-width'], 1080, 20_000); + const minHeight = positiveInteger(args['min-height'], 1920, 20_000); + const url = new URL(`${baseUrl}/search`); + url.searchParams.set('query', query); + url.searchParams.set('orientation', 'portrait'); + url.searchParams.set('size', 'large'); + url.searchParams.set('per_page', String(Math.min(80, Math.max(count, 15)))); + const response = await fetch(url, {headers: {Authorization: requireKey()}}); + if (!response.ok) throw new Error(`Pexels search failed with HTTP ${response.status}.`); + const payload = await response.json(); + const photos = Array.isArray(payload.photos) ? payload.photos : []; + const results = photos + .filter( + (photo) => + Number(photo.width) >= minWidth && + Number(photo.height) >= minHeight && + photo.url && + photo.photographer, + ) + .slice(0, count) + .map((photo) => ({ + id: String(photo.id), + width: Number(photo.width), + height: Number(photo.height), + alt: String(photo.alt || ''), + creator: String(photo.photographer || 'Unknown'), + creatorUrl: photo.photographer_url ? String(photo.photographer_url) : undefined, + sourceUrl: String(photo.url), + licenseUrl, + imageUrl: String(photo.src?.original || photo.src?.large2x || photo.src?.portrait || ''), + })) + .filter((photo) => photo.imageUrl); + if (results.length === 0) + throw new Error(`No portrait Pexels images met ${minWidth}x${minHeight} for: ${query}`); + return {provider: 'pexels', providerUrl, query, orientation: 'portrait', size: 'large', results}; +}; + +const assertDownloadUrl = (value) => { + const url = new URL(value); + const local = ['127.0.0.1', 'localhost', '::1'].includes(url.hostname); + if (url.protocol !== 'https:' && !(local && url.protocol === 'http:')) + throw new Error('Stock image downloads require HTTPS.'); + return url; +}; + +const download = async () => { + const found = await search(); + const output = path.resolve(requireArg(args, 'out')); + ensureDir(output); + const files = []; + for (const [index, photo] of found.results.entries()) { + const response = await fetch(assertDownloadUrl(photo.imageUrl)); + if (!response.ok) throw new Error(`Stock image download failed with HTTP ${response.status}.`); + const contentType = response.headers.get('content-type'); + if (contentType && !contentType.startsWith('image/')) + throw new Error(`Stock download returned ${contentType} instead of an image.`); + const declaredSize = Number(response.headers.get('content-length') || 0); + if (declaredSize > 50_000_000) throw new Error('Stock image exceeds the 50 MB safety limit.'); + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length === 0 || bytes.length > 50_000_000) + throw new Error('Stock image is empty or exceeds the 50 MB safety limit.'); + const target = path.join( + output, + `${String(index + 1).padStart(2, '0')}-${slugify(found.query, 'stock')}-${photo.id}.jpg`, + ); + const temporary = `${target}.tmp-${process.pid}`; + fs.writeFileSync(temporary, bytes); + fs.renameSync(temporary, target); + files.push({ + ...photo, + path: target, + hash: crypto.createHash('sha256').update(bytes).digest('hex'), + }); + } + const manifest = path.join(output, 'stock-manifest.json'); + writeJsonAtomic(manifest, { + schemaVersion: 1, + provider: found.provider, + providerUrl, + query: found.query, + downloadedAt: new Date().toISOString(), + docsUrl, + licenseUrl, + files, + }); + return {manifest, files}; +}; + +if (action === 'doctor') + print({ + ok: true, + provider: 'pexels', + providerUrl, + configured: Boolean(process.env.PEXELS_API_KEY), + defaults: {orientation: 'portrait', size: 'large', minWidth: 1080, minHeight: 1920}, + docsUrl, + licenseUrl, + }); +else if (action === 'search') print(await search()); +else if (action === 'download') print(await download()); +else throw new Error(`Unknown stock action: ${action || ''}`); diff --git a/src/captioned-clip.tsx b/src/captioned-clip.tsx index 7fb6940..e6d48b5 100644 --- a/src/captioned-clip.tsx +++ b/src/captioned-clip.tsx @@ -21,7 +21,7 @@ import type { export const captionedClipDefaultProps: CaptionedClipProps = { videoSrc: '', foregroundSrc: null, - captions: [], + captions: [{text: 'Sample caption', startMs: 0, endMs: 1000, timestampMs: 0, confidence: null}], width: 1080, height: 1920, fps: 30, @@ -46,8 +46,7 @@ export const captionedClipDefaultProps: CaptionedClipProps = { effectTextOpacityMultiplier: 0.55, effectHighlightTextOpacityMultiplier: 0.62, shadowColor: 'rgba(0, 0, 0, 0.55)', - normalFontFamily: - '"Arial Rounded MT Bold", "Avenir Next", "Arial Black", sans-serif', + normalFontFamily: '"Arial Rounded MT Bold", "Avenir Next", "Arial Black", sans-serif', highlightFontFamily: '"Snell Roundhand", "Apple Chancery", "Savoye LET", "Brush Script MT", "Bodoni 72", "Didot", cursive', normalFontWeight: 950, @@ -271,9 +270,7 @@ const sortedMotionKeyframes = (style: CaptionStyle) => { Number.isFinite(Number(keyframe.at)), ); const keyframes = - configured && configured.length > 0 - ? configured - : motionPresetKeyframes(style.motionPreset); + configured && configured.length > 0 ? configured : motionPresetKeyframes(style.motionPreset); return keyframes .map((keyframe) => ({...keyframe, at: clampProgress(Number(keyframe.at))})) @@ -286,9 +283,7 @@ const interpolateMotionValue = ( field: keyof CaptionMotionKeyframe, fallback: number, ) => { - const firstWithField = keyframes.find( - (keyframe) => keyframe[field] !== undefined, - ); + const firstWithField = keyframes.find((keyframe) => keyframe[field] !== undefined); if (!firstWithField) { return fallback; @@ -346,10 +341,7 @@ const TOKEN_OVERLAP_MS = 90; // Page segmentation is time-independent: build it once per captions input // and only pick the active page per frame. -const buildCaptionPages = ( - captions: Caption[], - combineTokensWithinMilliseconds: number, -) => +const buildCaptionPages = (captions: Caption[], combineTokensWithinMilliseconds: number) => createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds, @@ -421,10 +413,7 @@ const parseDimensionToPx = ( return Number.isFinite(numeric) ? numeric : fallback; }; -const parseLetterSpacingToPx = ( - value: string | number | undefined, - fontSize: number, -) => { +const parseLetterSpacingToPx = (value: string | number | undefined, fontSize: number) => { if (typeof value === 'number' && Number.isFinite(value)) { return value; } @@ -606,8 +595,7 @@ export const CaptionedClip: React.FC = ({ const {fps, width, height} = useVideoConfig(); const currentMs = (frame / fps) * 1000; const normalFontFamily = - style.normalFontFamily ?? - '"Arial Rounded MT Bold", "Avenir Next", "Arial Black", sans-serif'; + style.normalFontFamily ?? '"Arial Rounded MT Bold", "Avenir Next", "Arial Black", sans-serif'; const highlightFontFamily = style.highlightFontFamily ?? '"Snell Roundhand", "Apple Chancery", "Savoye LET", "Brush Script MT", "Bodoni 72", "Didot", cursive'; @@ -615,9 +603,7 @@ export const CaptionedClip: React.FC = ({ const highlightFontWeight = style.highlightFontWeight ?? 400; const normalFontStyle = style.normalFontStyle ?? 'normal'; const highlightFontStyle = style.highlightFontStyle ?? 'italic'; - const videoSource = /^https?:\/\//.test(videoSrc) - ? videoSrc - : staticFile(videoSrc); + const videoSource = /^https?:\/\//.test(videoSrc) ? videoSrc : staticFile(videoSrc); const foregroundSource = foregroundSrc && /^https?:\/\//.test(foregroundSrc) ? foregroundSrc @@ -668,22 +654,15 @@ export const CaptionedClip: React.FC = ({ const visibleTokensBefore = clampTokenCount(style.visibleTokensBefore, 1); const visibleTokensAfter = clampTokenCount(style.visibleTokensAfter, 0); const visibleStartIndex = - page && activeTokenIndex >= 0 - ? Math.max(activeTokenIndex - visibleTokensBefore, 0) - : 0; + page && activeTokenIndex >= 0 ? Math.max(activeTokenIndex - visibleTokensBefore, 0) : 0; const visibleTokens = page && activeTokenIndex >= 0 - ? page.tokens.slice( - visibleStartIndex, - activeTokenIndex + visibleTokensAfter + 1, - ) + ? page.tokens.slice(visibleStartIndex, activeTokenIndex + visibleTokensAfter + 1) : []; const visibleHasExplicitKeyword = visibleTokens.some((token) => highlightSet.has(tokenKey(token.text)), ); - const automaticKeywordKey = visibleHasExplicitKeyword - ? '' - : getStrongestTokenKey(visibleTokens); + const automaticKeywordKey = visibleHasExplicitKeyword ? '' : getStrongestTokenKey(visibleTokens); const baseFontSize = Math.max( style.minFontSize ?? 42, @@ -695,9 +674,7 @@ export const CaptionedClip: React.FC = ({ }); const captionLayout = style.captionLayout ?? 'stacked'; const pageProgress = - page && page.durationMs > 0 - ? clampProgress((currentMs - page.startMs) / page.durationMs) - : 0; + page && page.durationMs > 0 ? clampProgress((currentMs - page.startMs) / page.durationMs) : 0; // Stable identity: this object feeds the layout memo, so it must not be // recreated per frame (that would defeat memoization every frame). const containerPositionStyle = useMemo( @@ -710,10 +687,8 @@ export const CaptionedClip: React.FC = ({ const motionValues = useMemo(() => { const keyframes = sortedMotionKeyframes(style); return { - xPx: - (interpolateMotionValue(keyframes, pageProgress, 'xPercent', 0) / 100) * width, - yPx: - (interpolateMotionValue(keyframes, pageProgress, 'yPercent', 0) / 100) * height, + xPx: (interpolateMotionValue(keyframes, pageProgress, 'xPercent', 0) / 100) * width, + yPx: (interpolateMotionValue(keyframes, pageProgress, 'yPercent', 0) / 100) * height, scale: interpolateMotionValue(keyframes, pageProgress, 'scale', 1), opacity: interpolateMotionValue(keyframes, pageProgress, 'opacity', 1), rotateDeg: interpolateMotionValue(keyframes, pageProgress, 'rotateDeg', 0), @@ -759,12 +734,9 @@ export const CaptionedClip: React.FC = ({ const isExplicitHighlight = highlightSet.has(key); const isAutomaticHighlight = key === automaticKeywordKey; const isHighlighted = isExplicitHighlight || isAutomaticHighlight; - const tokenFrame = Math.max( - 0, - frame - Math.round((token.fromMs / 1000) * fps), - ); + const tokenFrame = Math.max(0, frame - Math.round((token.fromMs / 1000) * fps)); const tokenPop = Math.min(tokenFrame / 5, 1); - const highlightScale = isHighlighted ? style.highlightScale ?? 1.62 : 1; + const highlightScale = isHighlighted ? (style.highlightScale ?? 1.62) : 1; const scale = (isActive ? style.activeScale : style.inactiveScale) * highlightScale * @@ -774,8 +746,8 @@ export const CaptionedClip: React.FC = ({ }) * entrance; const strokeRatio = isHighlighted - ? style.highlightStrokeRatio ?? 0.012 - : style.normalStrokeRatio ?? 0.045; + ? (style.highlightStrokeRatio ?? 0.012) + : (style.normalStrokeRatio ?? 0.045); const defaultTextShadow = isHighlighted ? `0 ${baseFontSize * 0.07}px ${baseFontSize * 0.08}px ${style.shadowColor}, 0 0 ${ baseFontSize * 0.18 @@ -790,17 +762,17 @@ export const CaptionedClip: React.FC = ({ `drop-shadow(0 ${baseFontSize * 0.04}px ${baseFontSize * 0.02}px rgba(0,0,0,0.35))`; const textColor = colorWithOpacity( isHighlighted - ? style.highlightTextColor ?? style.textColor - : style.normalTextColor ?? style.textColor, + ? (style.highlightTextColor ?? style.textColor) + : (style.normalTextColor ?? style.textColor), style.textOpacity * (isHighlighted - ? style.highlightTextOpacityMultiplier ?? 1 - : style.normalTextOpacityMultiplier ?? 1), + ? (style.highlightTextOpacityMultiplier ?? 1) + : (style.normalTextOpacityMultiplier ?? 1)), ); const blendMode = mixBlendModeOrUndefined( isHighlighted - ? style.highlightTextBlendMode ?? style.textBlendMode - : style.normalTextBlendMode ?? style.textBlendMode, + ? (style.highlightTextBlendMode ?? style.textBlendMode) + : (style.normalTextBlendMode ?? style.textBlendMode), ); const filterCss = stringOrUndefined( isHighlighted ? style.highlightTextFilterCss : style.normalTextFilterCss, @@ -837,8 +809,8 @@ export const CaptionedClip: React.FC = ({ fontWeight, strokePx: Math.max(style.minStrokePx ?? 1, baseFontSize * strokeRatio), strokeColor: isHighlighted - ? style.highlightStrokeColor ?? style.normalStrokeColor ?? style.shadowColor - : style.normalStrokeColor ?? style.shadowColor, + ? (style.highlightStrokeColor ?? style.normalStrokeColor ?? style.shadowColor) + : (style.normalStrokeColor ?? style.shadowColor), textShadow: configuredTextShadow ?? defaultTextShadow, dropShadow, blendMode, @@ -874,9 +846,7 @@ export const CaptionedClip: React.FC = ({ for (const token of tokenModels) { const nextWidth = - currentRow.tokens.length === 0 - ? token.width - : currentRow.width + gapPx + token.width; + currentRow.tokens.length === 0 ? token.width : currentRow.width + gapPx + token.width; const shouldWrap = captionLayout === 'inline-wrap' && currentRow.tokens.length > 0 && @@ -906,18 +876,13 @@ export const CaptionedClip: React.FC = ({ const rowGap = captionLayout === 'stacked' ? gapPx : baseFontSize * 0.14; const totalHeight = - rows.reduce((sum, row) => sum + row.height, 0) + - Math.max(0, rows.length - 1) * rowGap; + rows.reduce((sum, row) => sum + row.height, 0) + Math.max(0, rows.length - 1) * rowGap; const topPxValue = containerPositionStyle.top as string | number | undefined; const bottomPxValue = containerPositionStyle.bottom as string | number | undefined; const topPx = - topPxValue === undefined - ? null - : parseDimensionToPx(topPxValue, height, height * 0.5); + topPxValue === undefined ? null : parseDimensionToPx(topPxValue, height, height * 0.5); const bottomPx = - bottomPxValue === undefined - ? null - : parseDimensionToPx(bottomPxValue, height, height * 0.12); + bottomPxValue === undefined ? null : parseDimensionToPx(bottomPxValue, height, height * 0.12); const hasCenterTranslateY = typeof containerPositionStyle.transform === 'string' && containerPositionStyle.transform.includes('translateY(-50%)'); @@ -959,8 +924,7 @@ export const CaptionedClip: React.FC = ({ const maxX = Math.max(...laidOutTokens.map((token) => token.x + token.width)); const minY = Math.min(...laidOutTokens.map((token) => token.y)); const maxY = Math.max(...laidOutTokens.map((token) => token.y + token.height)); - const originX = - align === 'center' ? (minX + maxX) / 2 : align === 'right' ? maxX : minX; + const originX = align === 'center' ? (minX + maxX) / 2 : align === 'right' ? maxX : minX; const originY = (minY + maxY) / 2; const motionMatrix = buildMotionMatrix({ originX, @@ -1022,9 +986,7 @@ export const CaptionedClip: React.FC = ({ Boolean(style.effectLayerEnabled) && Boolean(style.effectMaskedVideoEnabled ?? true); const effectNormalOpacity = style.textOpacity * - (style.effectMaskedVideoOpacityMultiplier ?? - style.effectTextOpacityMultiplier ?? - 0.72); + (style.effectMaskedVideoOpacityMultiplier ?? style.effectTextOpacityMultiplier ?? 0.72); const effectHighlightOpacity = style.textOpacity * (style.effectMaskedHighlightVideoOpacityMultiplier ?? diff --git a/src/marketing-timeline.tsx b/src/marketing-timeline.tsx index a53427b..56fd710 100644 --- a/src/marketing-timeline.tsx +++ b/src/marketing-timeline.tsx @@ -16,16 +16,31 @@ export type MarketingTimelineProps = { fps: number; durationSeconds: number; timeline: Array<{ - type: 'video' | 'image' | 'text' | 'end-card'; + type: 'video' | 'image' | 'text' | 'slide-text' | 'end-card'; startSeconds: number; durationSeconds: number; src?: string; text?: string; + eyebrow?: string; + headline?: string; + body?: string; transition?: 'cut' | 'fade'; + sourceStartSeconds?: number; + muted?: boolean; + volume?: number; + fit?: 'cover' | 'contain'; + motion?: 'none' | 'push-in' | 'pan-left' | 'pan-right'; + textPosition?: 'top' | 'center' | 'bottom'; }>; captions: Array<{text: string; startSeconds: number; endSeconds: number; yPercent?: number}>; voice?: string; music?: string; + musicVolume?: number; + theme?: { + backgroundColor: string; + foregroundColor: string; + accentColor: string; + }; overlays?: Array<{src: string; startSeconds: number; durationSeconds: number}>; }; @@ -47,7 +62,10 @@ export const scheduledFrames = ( return {start, end, duration: end - start}; }; -const TimelineEntry: React.FC<{entry: MarketingTimelineProps['timeline'][number]}> = ({entry}) => { +const TimelineEntry: React.FC<{ + entry: MarketingTimelineProps['timeline'][number]; + theme: NonNullable; +}> = ({entry, theme}) => { const frame = useCurrentFrame(); const {fps} = useVideoConfig(); const fadeFrames = Math.min(fps / 3, (entry.durationSeconds * fps) / 2); @@ -60,29 +78,129 @@ const TimelineEntry: React.FC<{entry: MarketingTimelineProps['timeline'][number] {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}, ) : 1; + const progress = interpolate(frame, [0, Math.max(1, entry.durationSeconds * fps - 1)], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const transform = + entry.motion === 'push-in' + ? `scale(${1.02 + progress * 0.1})` + : entry.motion === 'pan-left' + ? `translateX(${3 - progress * 6}%) scale(1.1)` + : entry.motion === 'pan-right' + ? `translateX(${-3 + progress * 6}%) scale(1.1)` + : undefined; if (entry.type === 'video' && entry.src) return ( ); if (entry.type === 'image' && entry.src) return ( - + + ); + if (entry.type === 'slide-text') { + const position = entry.textPosition ?? 'bottom'; + return ( + + {entry.eyebrow ? ( +
+ {entry.eyebrow.toUpperCase()} +
+ ) : null} +
+ {entry.headline} +
+ {entry.body ? ( +
+ {entry.body} +
+ ) : null} +
); + } return ( @@ -96,6 +214,12 @@ export const MarketingTimeline: React.FC = ({ captions, voice, music, + musicVolume = 0.08, + theme = { + backgroundColor: '#080b12', + foregroundColor: '#ffffff', + accentColor: '#3b82f6', + }, overlays = [], }) => { const {fps} = useVideoConfig(); @@ -104,14 +228,14 @@ export const MarketingTimeline: React.FC = ({ (caption) => frame >= caption.startSeconds * fps && frame < caption.endSeconds * fps, ); return ( - + {timeline.map((entry, index) => ( - + ))} {overlays.map((entry, index) => ( @@ -124,7 +248,7 @@ export const MarketingTimeline: React.FC = ({ ))} {voice ?