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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 097b0a1..2390325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,6 @@ permissions: jobs: verify: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node-version: [20, 22] steps: - name: Check out repository @@ -23,11 +19,6 @@ jobs: with: persist-credentials: false - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: ${{ matrix.node-version }} - - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -36,6 +27,13 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Enforce Bun runtime commands + run: | + if rg "#!/usr/bin/env node|['\"]node['\"]" scripts tests; then + echo "Node runtime command found; use Bun or process.execPath." >&2 + exit 1 + fi + - name: Install ffmpeg (smoke tests render real MP4s) run: sudo apt-get update && sudo apt-get install -y ffmpeg imagemagick @@ -59,11 +57,6 @@ jobs: with: persist-credentials: false - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -79,12 +72,29 @@ jobs: tar -tzf .pack/clipcaptionai-*.tgz > pack-report.txt bun - <<'EOF' const files = require('node:fs').readFileSync('pack-report.txt', 'utf8').trim().split('\n'); - const required = ['scripts/ebay/', 'scripts/logo/', 'bin/clipcaptionai.js', 'docs/WORKFLOWS.md']; + const required = [ + 'scripts/ebay/', + 'scripts/logo/', + 'scripts/adapters/stock.adapter.mjs', + 'scripts/marketing/', + 'templates/rotato/', + 'bin/clipcaptionai.js', + 'docs/MARKETING_PLATFORM.md', + 'docs/VOICE_LIBRARY.md', + 'docs/WORKFLOWS.md', + 'desktop/worker/progress.mjs', + ]; const missing = required.filter((p) => !files.some((f) => f.includes(p))); if (missing.length > 0) { console.error('Tarball is missing advertised files:', missing); process.exit(1); } + const tarball = require('node:fs').readdirSync('.pack').find((name) => name.endsWith('.tgz')); + const bin = require('node:child_process').execFileSync('tar', ['-xOf', `.pack/${tarball}`, 'package/bin/clipcaptionai.js'], {encoding: 'utf8'}); + if (!bin.startsWith('#!/usr/bin/env bun\n')) { + console.error('Published CLI entrypoint must run with Bun.'); + process.exit(1); + } console.log('Tarball OK:', files.length, 'entries packed'); EOF @@ -96,11 +106,6 @@ jobs: with: persist-credentials: false - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: 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/CHANGELOG.md b/CHANGELOG.md index cb981f4..38cf951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to ClipCaptionAI are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] — 2026-08-21 + +### Added +- Automatic repo-owned adapter discovery shared by the CLI and Electron desktop app +- Filesystem-backed detached jobs with logs, cancellation, recovery, and resource locks +- Remotion, Ollama, yt-dlp, workflow, Rotato, Higgsfield, capture, marketing, and Pexels stock adapters +- Generic campaign planning, cost estimation, approval, execution, inspection, technical QA, and export +- Native value-first carousels that render one provenance-tracked PNG per declared slide +- Reusable ElevenLabs phrase-library workflow and subject-aware portrait framing plans + +### Changed +- Standardized development, commands, CI, and packaging on Bun/Bunx +- Desktop now observes the same adapter catalog and job store used by agents +- Marketing QA keeps technical, claims, visual-review, and publication states separate + +### Security +- Paid generation requires matching plan approval, capability fingerprints, explicit live execution, and budget compliance +- Stock assets retain provider, creator, source, license, and content-hash provenance + ## [0.1.1] — 2026-08-20 — production-readiness pass ### Added diff --git a/README.md b/README.md index d05c328..3882ddd 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ On macOS with Homebrew: brew install oven-sh/bun/bun ffmpeg ``` -On Windows or Linux, install Node.js 20+ and FFmpeg using your normal package manager or the official installers, then continue with `bun run doctor` below. +On Windows or Linux, install Bun 1.3+ and FFmpeg using your normal package manager or the official installers, then continue with `bun run doctor` below. Optional extras: @@ -67,7 +67,7 @@ bun run clipkit -- video run \ --run-id first-video ``` -When installed from the v0.1.0 release tarball, the equivalent clean first-run command is: +When installed from the v0.2.0 release tarball, the equivalent clean first-run command is: ```bash clipcaptionai video run --example --run-id first-video @@ -160,7 +160,7 @@ The menu is convenient for interactive editing. The direct `bun run clipkit -- . `bun run desktop` starts an Electron shell that loads the same adapter catalog and observes the same persisted jobs as the CLI. Work continues if the window closes. -- Required: Node.js + `ffmpeg` + `ffprobe` + project CLI/runtime files +- Required: Bun + `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). @@ -210,6 +210,7 @@ Detailed walkthroughs for every workflow live in [docs/WORKFLOWS.md](docs/WORKFL | `bun run transcribe:benchmark` | Compare local vs reference transcription providers | [Transcription Notes](docs/WORKFLOWS.md#transcription-notes) | | `bun run smart:clips` | AI clip selection on one local video | [AI Clip Selection](docs/WORKFLOWS.md#ai-clip-selection) | | `bun run render:clip` | Render one clip from captions JSON | [Single Clip Commands](docs/WORKFLOWS.md#single-clip-commands) | +| `bun run portrait:analyze` | Plan subject-aware 9:16 framing | [Subject-aware portrait framing](docs/WORKFLOWS.md#subject-aware-portrait-framing) | | `bun run render:batch` | Batch render clips from a captions folder | — | | `bun run rerender:clip` | Rerender after caption/fix edits | [Rerender](docs/WORKFLOWS.md#rerender) | | `bun run moments:review` | Viral scorecard report for a moments run | [Find Important Moments Only](docs/WORKFLOWS.md#find-important-moments-only) | @@ -226,6 +227,7 @@ Detailed walkthroughs for every workflow live in [docs/WORKFLOWS.md](docs/WORKFL | `bun run ebay:render-blueprint-ad` / `ebay:render-blueprint-batch` | Product-safe preview ads from blueprints | [Competitive eBay Creative Blueprints](docs/WORKFLOWS.md#competitive-ebay-creative-blueprints) | | `bun run ebay:competitive-*` | Post-blueprint pipeline: `competitive-loop`, `competitive-qa`, `prep-premium-renders`, `competitive-handoff`, `competitive-higgsfield-render`, `competitive-packets`, `competitive-research-queue/import/loop/process/rerun`, `collect-premium-renders`, `finalize-premium-ads`, `competitive-status`, `competitive-review` | [Competitive eBay Creative Blueprints](docs/WORKFLOWS.md#competitive-ebay-creative-blueprints) | | `bun run voiceover:elevenlabs` | ElevenLabs narration file | [Demo Capture And Reviewed AI Assets](docs/WORKFLOWS.md#demo-capture-and-reviewed-ai-assets) | +| `bun run voiceover:library` | Build or resume a reusable ElevenLabs phrase library | [Voice Library](docs/VOICE_LIBRARY.md) | | `bun run fal:image-edit` / `fal:reference-video` | fal.ai asset generation (opt-in, human-reviewed) | [Demo Capture And Reviewed AI Assets](docs/WORKFLOWS.md#demo-capture-and-reviewed-ai-assets) | | `bun run sample:props` | Write Remotion Studio sample props | [Preview In Remotion Studio](docs/WORKFLOWS.md#preview-in-remotion-studio) | | `bun run cleanup` | Clean temp files / old outputs | [Clean Up Generated Files](docs/WORKFLOWS.md#clean-up-generated-files) | @@ -238,6 +240,7 @@ See `package.json` scripts for the full list (including `rotato`, `interview:qa` - [docs/WORKFLOWS.md](docs/WORKFLOWS.md) — every workflow walkthrough, in depth - [docs/AI_PROVIDERS.md](docs/AI_PROVIDERS.md) — provider keys, review gates, live-provider evidence +- [docs/VOICE_LIBRARY.md](docs/VOICE_LIBRARY.md) — restore or extend the released ElevenLabs phrase library - [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — automation guide for coding agents - [docs/PRODUCTION_SUPPORT.md](docs/PRODUCTION_SUPPORT.md) — production support matrix - [docs/GITHUB.md](docs/GITHUB.md) — GitHub-specific setup @@ -286,7 +289,7 @@ bun test tests/ai-provider.test.mjs bun test tests/clipkit-lib.test.mjs ``` -Tests use Node's built-in test runner. Integration tests create temp dirs, run the actual CLI, generate real MP4s, and clean up. They skip gracefully when ffmpeg/ImageMagick are absent. `bun run check` runs typechecking plus the full test suite. +Tests use Bun's test runner. Integration tests create temp dirs, run the actual CLI, generate real MP4s, and clean up. They skip gracefully when ffmpeg/ImageMagick are absent. `bun run check` runs typechecking plus the full test suite. ## Related Projects diff --git a/bin/clipcaptionai.js b/bin/clipcaptionai.js index fc72754..067ef06 100755 --- a/bin/clipcaptionai.js +++ b/bin/clipcaptionai.js @@ -1,3 +1,3 @@ -#!/usr/bin/env node +#!/usr/bin/env bun process.env.CCA_WORKSPACE_ROOT ||= process.cwd(); await import('../scripts/clipkit.mjs'); 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/AI_PROVIDERS.md b/docs/AI_PROVIDERS.md index 88df4ce..e54ec7f 100644 --- a/docs/AI_PROVIDERS.md +++ b/docs/AI_PROVIDERS.md @@ -64,6 +64,14 @@ bun run voiceover:elevenlabs -- \ Writes MP3 audio and a generation manifest with voice/model IDs, text hash, and request ID. Never writes the key or narration text into the manifest. +Build or resume the reusable phrase library within an explicit character budget: + +```bash +bun run voiceover:library -- --budget 36000 --resume +``` + +The command writes one MP3 and non-secret manifest per phrase, does not retry ambiguous paid generation requests, checks the live subscription balance, and preserves a safety reserve. Use `--dry-run` before spending credits. Generated audio still requires human review for pronunciation, tone, and licensing suitability; see [the voice library guide](VOICE_LIBRARY.md). + ### fal reviewed marketing assets ```bash diff --git a/docs/ASSET_RECOVERY.md b/docs/ASSET_RECOVERY.md new file mode 100644 index 0000000..edd29c4 --- /dev/null +++ b/docs/ASSET_RECOVERY.md @@ -0,0 +1,36 @@ +# Asset Recovery Bundle + +The `asset-recovery-2026-07-23` GitHub Release preserves the verified self-created ClipCaptionAI B-roll cards and the local public-source SFX library. + +The B-roll bundle contains: + +- `plan.svg` +- `render.svg` +- `qa.svg` + +The separate `clipcaptionai-sfx-library.tar.gz` release asset contains all 256 local SFX files plus `sfx-library/index.json`. + +Restore them from a fresh checkout with: + +```bash +mkdir -p /tmp/clipcaptionai-asset-recovery +gh release download asset-recovery-2026-07-23 \ + --repo jongan69/ClipCaptionAI \ + --pattern 'clipcaptionai-cleared-assets.tar.gz' \ + --dir /tmp/clipcaptionai-asset-recovery +tar -xzf /tmp/clipcaptionai-asset-recovery/clipcaptionai-cleared-assets.tar.gz \ + -C . +``` + +To restore the SFX library as well: + +```bash +gh release download asset-recovery-2026-07-23 \ + --repo jongan69/ClipCaptionAI \ + --pattern 'clipcaptionai-sfx-library.tar.gz' \ + --dir /tmp/clipcaptionai-asset-recovery +tar -xzf /tmp/clipcaptionai-asset-recovery/clipcaptionai-sfx-library.tar.gz \ + -C . +``` + +The SFX files are preserved because they are publicly available local assets, but public availability is not the same as verified commercial-use clearance. Review source terms before publishing a video commercially. The `music-library/` manifest explicitly marks its tracks `review_before_commercial_use`, so music remains excluded. The downloaded YouTube/movie `scene-library/` is intentionally excluded until its rights are reviewed. 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/RELEASE.md b/docs/RELEASE.md index 264f7fd..9327c6a 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,13 +1,13 @@ -# ClipCaptionAI v0.1.0 Beta +# ClipCaptionAI v0.2.0 -ClipCaptionAI v0.1.0 is a CLI-first public beta. The supported first-run path is local and deterministic: it does not require a paid AI provider or an API key. +ClipCaptionAI v0.2.0 is a CLI-first video-production toolkit with an optional Electron desktop observer. Its supported first-run path is local and deterministic; provider-backed workflows are opt-in. ## Install from the release tarball -Prerequisites are Node.js 20 or newer plus `ffmpeg` and `ffprobe` on `PATH`. +Prerequisites are Bun 1.3 or newer plus `ffmpeg` and `ffprobe` on `PATH`. ```bash -bun install --global ./clipcaptionai-0.1.0.tgz +bun install --global ./clipcaptionai-0.2.0.tgz clipcaptionai doctor clipcaptionai video run --example --run-id first-video clipcaptionai video qa --run outputs/video-runs/first-video @@ -19,10 +19,10 @@ The finished MP4 and its versioned QA manifest are written below the current dir | Platform | CLI beta | Desktop beta | | --- | --- | --- | -| macOS 13+ on Apple silicon | Supported and release-smoked | Best effort; attached only when signed installation passes | -| macOS 13+ on Intel | Expected with Node.js and FFmpeg; not release-smoked | Not included in v0.1.0 | -| Windows 10/11 x64 | Expected with Node.js and FFmpeg; not release-smoked | Not included in v0.1.0 | -| Current x64 Linux | Expected with Node.js and FFmpeg; not release-smoked | Not included in v0.1.0 | +| macOS 13+ on Apple silicon | Supported and release-smoked | Attached when signing and packaging pass | +| macOS 13+ on Intel | Expected with Bun and FFmpeg; not release-smoked | Not included in v0.2.0 | +| Windows 10/11 x64 | Expected with Bun and FFmpeg; not release-smoked | Not included in v0.2.0 | +| Current x64 Linux | CI verified with Bun and FFmpeg | Build verified; installer not included in v0.2.0 | ## Optional providers 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/docs/VOICE_LIBRARY.md b/docs/VOICE_LIBRARY.md new file mode 100644 index 0000000..fbc93cc --- /dev/null +++ b/docs/VOICE_LIBRARY.md @@ -0,0 +1,29 @@ +# ElevenLabs Voice Library + +The repository contains the generator and manifests for the local ElevenLabs phrase library. The generated MP3 files are distributed as a GitHub Release asset so normal clones stay small. + +## Restore the generated library + +From a fresh checkout: + +```bash +mkdir -p /tmp/clipcaptionai-voice-library +gh release download voice-library-2026-07-23 \ + --repo jongan69/ClipCaptionAI \ + --pattern 'clipcaptionai-elevenlabs-library.tar.gz' \ + --dir /tmp/clipcaptionai-voice-library +tar -xzf /tmp/clipcaptionai-voice-library/clipcaptionai-elevenlabs-library.tar.gz \ + -C outputs/voiceover +``` + +The archive restores `outputs/voiceover/elevenlabs-library/`, including 672 MP3 clips, per-clip generation manifests, and `library.json`. Verify the download before extracting it: + +```text +SHA-256: 3fb5e58e7a6acde17ac81c4c78ddb5e7294dd5a80fc89d4a80b142adc81f3d29 +``` + +The generated audio is reusable production material, but still requires human review for pronunciation, tone, and suitability. The source generator is resumable and checks the live ElevenLabs balance: + +```bash +bun run voiceover:library -- --resume --budget 36000 +``` diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 30717c8..e6a6056 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -111,7 +111,7 @@ Use this when the project folder is getting too heavy. bun run cleanup ``` -Cleanup can remove temporary render staging from `outputs/work/` and `public/media/`, or prune old folders in `outputs/` while keeping the newest 5. It asks for confirmation before deleting. +Cleanup can remove temporary render staging from `outputs/work/` and `outputs/.public/media/`, or prune old folders in `outputs/` while keeping the newest 5. It asks for confirmation before deleting. Useful direct commands: @@ -1748,3 +1748,23 @@ For the "letters react to the footage underneath" look, use blend modes: "highlightTextFilterCss": "contrast(1.12) saturate(0.96)" } ``` + +## Subject-aware portrait framing + +Create a reviewable framing plan before rendering landscape footage into 9:16: + +```bash +clipcaptionai workflow run portrait-analyze --wait -- \ + --video /absolute/path/input.mp4 \ + --out outputs/input.framing.json \ + --center-x 0.32 + +clipcaptionai workflow run render-clip --wait -- \ + --video /absolute/path/input.mp4 \ + --captions outputs/input.captions.json \ + --out outputs/input.vertical.mp4 \ + --vertical \ + --framing outputs/input.framing.json +``` + +Omit `--center-x` for centered framing. Framing plans are non-destructive JSON inputs. diff --git a/examples/clipcaptionai-self-ad-brief.txt b/examples/clipcaptionai-self-ad-brief.txt new file mode 100644 index 0000000..fe35d59 --- /dev/null +++ b/examples/clipcaptionai-self-ad-brief.txt @@ -0,0 +1,8 @@ +Meet ClipCaptionAI. +Turn a creative brief into a real video run. +Use your footage, images, captions, B-roll, narration, and AI providers. +Let your coding model direct the workflow through a reproducible CLI. +Plan the shots and inspect the run before spending provider credits. +Render a polished cut from the command line. +Every render gets a manifest, hashes, output metadata, and technical QA. +ClipCaptionAI. Prompt it. Render it. Ship the cut. diff --git a/examples/clipcaptionai-self-ad-voiceover.txt b/examples/clipcaptionai-self-ad-voiceover.txt new file mode 100644 index 0000000..520d403 --- /dev/null +++ b/examples/clipcaptionai-self-ad-voiceover.txt @@ -0,0 +1 @@ +Meet ClipCaptionAI. It turns a creative brief into a real video run. Use your own footage and images, add captions, B-roll, narration, music, or AI-generated assets. Your coding model can direct the workflow through a reproducible command line. Every render records its plan, input hashes, output metadata, and technical quality checks. ClipCaptionAI: prompt it, render it, and ship the cut. 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/package.json b/package.json index 2d15ce6..21ca378 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipcaptionai", - "version": "0.1.1", + "version": "0.2.0", "description": "CLI-first local AI video editor and model harness for planning, rendering, captioning, and QA of production video assets.", "author": { "name": "Jonathan Gan", @@ -35,7 +35,6 @@ "reels" ], "engines": { - "node": ">=20", "bun": ">=1.3" }, "publishConfig": { @@ -50,11 +49,14 @@ "CHANGELOG.md", "docs/AGENT_GUIDE.md", "docs/AI_PROVIDERS.md", + "docs/ASSET_RECOVERY.md", "docs/TOOL_ADAPTERS.md", + "docs/VOICE_LIBRARY.md", "docs/MARKETING_PLATFORM.md", "docs/PRODUCTION_SUPPORT.md", "docs/RELEASE.md", "docs/WORKFLOWS.md", + "desktop/worker/progress.mjs", "examples/", "remotion.config.ts", "scripts/*.mjs", @@ -94,6 +96,7 @@ "logo:verify": "bunx tsx scripts/logo/verify-variant.tsx", "logo:render": "bun scripts/logo/render-all.mjs", "render:clip": "bun scripts/render-clip.mjs", + "portrait:analyze": "bun scripts/portrait-framing.mjs", "render:batch": "bun scripts/render-batch.mjs", "rerender:clip": "bun scripts/rerender-clip.mjs", "smart:clips": "bun scripts/smart-clips.mjs", @@ -150,6 +153,7 @@ "sample:props": "bun scripts/make-sample-props.mjs", "video": "bun scripts/video.mjs", "voiceover:elevenlabs": "bun scripts/generate-elevenlabs-voiceover.mjs", + "voiceover:library": "bun scripts/generate-elevenlabs-library.mjs", "fal:image-edit": "bun scripts/fal-image-edit.mjs", "fal:reference-video": "bun scripts/fal-reference-video.mjs", "interview:qa": "bun scripts/interview-qa.mjs", 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/adapters/workflow.adapter.mjs b/scripts/adapters/workflow.adapter.mjs index 64c4e85..c041ea9 100644 --- a/scripts/adapters/workflow.adapter.mjs +++ b/scripts/adapters/workflow.adapter.mjs @@ -54,6 +54,21 @@ const workflows = [ 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('voiceover', 'Voiceover', 'Generate ElevenLabs narration.', ['elevenlabs']), + workflow('voice-library', 'Voice Library', 'Build or resume the ElevenLabs phrase library.'), + workflow( + 'portrait-analyze', + 'Portrait Framing', + 'Plan subject-aware 9:16 framing for a video.', + ['portrait'], + videoArg, + ), + workflow( + 'render-clip', + 'Render Clip', + 'Render a captioned clip with optional framing.', + [], + videoArg, + ), workflow('fal-image-edit', 'fal Image Edit', 'Create a reviewed image edit.'), workflow('fal-reference-video', 'fal Reference Video', 'Create a reviewed reference video.'), workflow('rerender', 'Rerender Clip', 'Rerender an existing generated clip.'), @@ -131,6 +146,23 @@ export default { args: [path.join(projectRoot, 'scripts', 'interview-qa.mjs'), ...args], cwd: projectRoot, }; + if (name === 'voice-library' || name === 'portrait-analyze' || name === 'render-clip') + return { + command: 'bun', + args: [ + path.join( + projectRoot, + 'scripts', + name === 'voice-library' + ? 'generate-elevenlabs-library.mjs' + : name === 'render-clip' + ? 'render-clip.mjs' + : 'portrait-framing.mjs', + ), + ...args, + ], + cwd: projectRoot, + }; return { command: process.execPath, args: [path.join(projectRoot, 'scripts', 'clipkit.mjs'), name, ...args], diff --git a/scripts/assemble-context-scenes.mjs b/scripts/assemble-context-scenes.mjs index defe3bd..4f9d564 100644 --- a/scripts/assemble-context-scenes.mjs +++ b/scripts/assemble-context-scenes.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; diff --git a/scripts/benchmark-transcription.mjs b/scripts/benchmark-transcription.mjs index aa154a4..3806966 100644 --- a/scripts/benchmark-transcription.mjs +++ b/scripts/benchmark-transcription.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; @@ -123,7 +123,7 @@ const runTranscribe = ({provider, outputPath, inputVideoPath}) => { } try { - execFileSync('node', scriptArgs, { + execFileSync(process.execPath, scriptArgs, { cwd: projectRoot, stdio: 'inherit', }); diff --git a/scripts/caption-video.mjs b/scripts/caption-video.mjs index 9c9f8ad..370cfaa 100644 --- a/scripts/caption-video.mjs +++ b/scripts/caption-video.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import { diff --git a/scripts/capture-cli.mjs b/scripts/capture-cli.mjs index dcabd46..e2c5462 100644 --- a/scripts/capture-cli.mjs +++ b/scripts/capture-cli.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import {spawnSync} from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; diff --git a/scripts/chapter-video.mjs b/scripts/chapter-video.mjs index 0737aad..a7d2e4f 100644 --- a/scripts/chapter-video.mjs +++ b/scripts/chapter-video.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import { diff --git a/scripts/cleanup.mjs b/scripts/cleanup.mjs index 889bd5b..d50363d 100644 --- a/scripts/cleanup.mjs +++ b/scripts/cleanup.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline/promises'; @@ -20,7 +20,7 @@ Usage: bun run cleanup -- --all --yes Options: - --temp Clean temporary render staging: outputs/work/ and public/media/. + --temp Clean temporary render staging: outputs/work/ and outputs/.public/media/. --outputs Clean old output folders, keeping the newest folders. --keep-latest N Number of output folders to keep with --outputs. Default: 5. --all Clean temp files and all output folders. diff --git a/scripts/clipkit.mjs b/scripts/clipkit.mjs index 09ee6f4..1c8ba65 100644 --- a/scripts/clipkit.mjs +++ b/scripts/clipkit.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {createRequire} from 'node:module'; diff --git a/scripts/compress-video.mjs b/scripts/compress-video.mjs index 0975d94..db20afd 100644 --- a/scripts/compress-video.mjs +++ b/scripts/compress-video.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * compress-video.mjs — Compress a video with minimal quality loss using CRF encoding. * diff --git a/scripts/download-and-split.mjs b/scripts/download-and-split.mjs index e96ddc8..bc1aad0 100644 --- a/scripts/download-and-split.mjs +++ b/scripts/download-and-split.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/download-youtube.mjs b/scripts/download-youtube.mjs index 97c6350..681389b 100644 --- a/scripts/download-youtube.mjs +++ b/scripts/download-youtube.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {ensureDir, parseArgs, outputsRoot, projectRoot} from './lib.mjs'; diff --git a/scripts/ebay/audit-competitive-video-pipeline.mjs b/scripts/ebay/audit-competitive-video-pipeline.mjs index 98f926c..cd52047 100644 --- a/scripts/ebay/audit-competitive-video-pipeline.mjs +++ b/scripts/ebay/audit-competitive-video-pipeline.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/ebay/build-competitive-review-board.mjs b/scripts/ebay/build-competitive-review-board.mjs index db7c699..1c33f8a 100644 --- a/scripts/ebay/build-competitive-review-board.mjs +++ b/scripts/ebay/build-competitive-review-board.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {ensureDir, parseArgs} from '../lib.mjs'; diff --git a/scripts/ebay/collect-competitive-premium-renders.mjs b/scripts/ebay/collect-competitive-premium-renders.mjs index 70ee9df..e3b068a 100644 --- a/scripts/ebay/collect-competitive-premium-renders.mjs +++ b/scripts/ebay/collect-competitive-premium-renders.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/ebay/competitive-listing-video-architect.mjs b/scripts/ebay/competitive-listing-video-architect.mjs index f6142f2..6ae6644 100644 --- a/scripts/ebay/competitive-listing-video-architect.mjs +++ b/scripts/ebay/competitive-listing-video-architect.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync, spawnSync} from 'node:child_process'; diff --git a/scripts/ebay/ebay-cinematic-ads.mjs b/scripts/ebay/ebay-cinematic-ads.mjs index d217c2d..d206642 100644 --- a/scripts/ebay/ebay-cinematic-ads.mjs +++ b/scripts/ebay/ebay-cinematic-ads.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/ebay/export-competitive-creative-packets.mjs b/scripts/ebay/export-competitive-creative-packets.mjs index 849c639..9501063 100644 --- a/scripts/ebay/export-competitive-creative-packets.mjs +++ b/scripts/ebay/export-competitive-creative-packets.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/export-competitive-render-handoff.mjs b/scripts/ebay/export-competitive-render-handoff.mjs index da13203..603eb7e 100644 --- a/scripts/ebay/export-competitive-render-handoff.mjs +++ b/scripts/ebay/export-competitive-render-handoff.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/export-competitive-research-queue.mjs b/scripts/ebay/export-competitive-research-queue.mjs index a28a30f..625d551 100644 --- a/scripts/ebay/export-competitive-research-queue.mjs +++ b/scripts/ebay/export-competitive-research-queue.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/export-competitive-voiceover-plan.mjs b/scripts/ebay/export-competitive-voiceover-plan.mjs index bf8e48c..b71763d 100644 --- a/scripts/ebay/export-competitive-voiceover-plan.mjs +++ b/scripts/ebay/export-competitive-voiceover-plan.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/export-ebay-main-photo-apply-bundle.mjs b/scripts/ebay/export-ebay-main-photo-apply-bundle.mjs index a5bbb05..0a77b57 100644 --- a/scripts/ebay/export-ebay-main-photo-apply-bundle.mjs +++ b/scripts/ebay/export-ebay-main-photo-apply-bundle.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/finalize-competitive-premium-ads.mjs b/scripts/ebay/finalize-competitive-premium-ads.mjs index c67f1a5..8c57116 100644 --- a/scripts/ebay/finalize-competitive-premium-ads.mjs +++ b/scripts/ebay/finalize-competitive-premium-ads.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {spawnSync, execFileSync} from 'node:child_process'; @@ -153,7 +153,7 @@ const main = async () => { } const assembleArgs = buildAssembleArgs(packet); - const result = spawnSync('node', assembleArgs, { + const result = spawnSync(process.execPath, assembleArgs, { cwd: projectRoot, encoding: 'utf8', maxBuffer: 1024 * 1024 * 20, diff --git a/scripts/ebay/generate-ebay-main-photo-candidates.mjs b/scripts/ebay/generate-ebay-main-photo-candidates.mjs index 34d883a..c4d6900 100644 --- a/scripts/ebay/generate-ebay-main-photo-candidates.mjs +++ b/scripts/ebay/generate-ebay-main-photo-candidates.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/import-competitive-research-results.mjs b/scripts/ebay/import-competitive-research-results.mjs index c4920e6..fb061bf 100644 --- a/scripts/ebay/import-competitive-research-results.mjs +++ b/scripts/ebay/import-competitive-research-results.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/optimize-ebay-traffic-report.mjs b/scripts/ebay/optimize-ebay-traffic-report.mjs index 982c846..c64907f 100644 --- a/scripts/ebay/optimize-ebay-traffic-report.mjs +++ b/scripts/ebay/optimize-ebay-traffic-report.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/prepare-competitive-premium-renders.mjs b/scripts/ebay/prepare-competitive-premium-renders.mjs index de89502..d672bda 100644 --- a/scripts/ebay/prepare-competitive-premium-renders.mjs +++ b/scripts/ebay/prepare-competitive-premium-renders.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/ebay/process-competitive-research-queue.mjs b/scripts/ebay/process-competitive-research-queue.mjs index 9313446..f0bf3c2 100644 --- a/scripts/ebay/process-competitive-research-queue.mjs +++ b/scripts/ebay/process-competitive-research-queue.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; @@ -284,7 +284,7 @@ const runRerun = ({listing, commandArgs}) => { title: listing.title, packet_dir: listing.packet_dir, competitors: listing.competitor_import_template, - command: ['node', ...commandArgs].join(' '), + command: ['bun', ...commandArgs].join(' '), status: args['dry-run'] === true ? 'planned' : 'running', started_at: null, finished_at: null, @@ -294,7 +294,7 @@ const runRerun = ({listing, commandArgs}) => { }; if (args['dry-run'] === true) return entry; entry.started_at = new Date().toISOString(); - const result = spawnSync('node', commandArgs, { + const result = spawnSync(process.execPath, commandArgs, { cwd: projectRoot, encoding: 'utf8', maxBuffer: 1024 * 1024 * 50, diff --git a/scripts/ebay/qa-competitive-videos.mjs b/scripts/ebay/qa-competitive-videos.mjs index e121c63..381e8bd 100644 --- a/scripts/ebay/qa-competitive-videos.mjs +++ b/scripts/ebay/qa-competitive-videos.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync, spawnSync} from 'node:child_process'; diff --git a/scripts/ebay/render-competitive-blueprint-ad.mjs b/scripts/ebay/render-competitive-blueprint-ad.mjs index 5a7463f..48351eb 100644 --- a/scripts/ebay/render-competitive-blueprint-ad.mjs +++ b/scripts/ebay/render-competitive-blueprint-ad.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/ebay/render-competitive-blueprint-batch.mjs b/scripts/ebay/render-competitive-blueprint-batch.mjs index baa70a0..a483a35 100644 --- a/scripts/ebay/render-competitive-blueprint-batch.mjs +++ b/scripts/ebay/render-competitive-blueprint-batch.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; @@ -170,7 +170,7 @@ const main = async () => { pushFlag(renderArgs, 'no-music'); pushFlag(renderArgs, 'no-sfx'); - const result = spawnSync('node', renderArgs, { + const result = spawnSync(process.execPath, renderArgs, { cwd: projectRoot, encoding: 'utf8', maxBuffer: 1024 * 1024 * 20, diff --git a/scripts/ebay/rerun-competitive-research-packet.mjs b/scripts/ebay/rerun-competitive-research-packet.mjs index 63f3b07..83e81e9 100644 --- a/scripts/ebay/rerun-competitive-research-packet.mjs +++ b/scripts/ebay/rerun-competitive-research-packet.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; @@ -63,7 +63,7 @@ const tail = (value, max = 5000) => { const runStep = ({name, commandArgs, expectedFiles = []}) => { const entry = { name, - command: ['node', ...commandArgs].join(' '), + command: ['bun', ...commandArgs].join(' '), status: args['dry-run'] === true ? 'planned' : 'running', expected_files: expectedFiles, started_at: null, @@ -74,7 +74,7 @@ const runStep = ({name, commandArgs, expectedFiles = []}) => { }; if (args['dry-run'] === true) return entry; entry.started_at = new Date().toISOString(); - const result = spawnSync('node', commandArgs, { + const result = spawnSync(process.execPath, commandArgs, { cwd: projectRoot, encoding: 'utf8', maxBuffer: 1024 * 1024 * 40, diff --git a/scripts/ebay/run-competitive-higgsfield-renders.mjs b/scripts/ebay/run-competitive-higgsfield-renders.mjs index 1719139..9e9c7fa 100644 --- a/scripts/ebay/run-competitive-higgsfield-renders.mjs +++ b/scripts/ebay/run-competitive-higgsfield-renders.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; diff --git a/scripts/ebay/run-competitive-research-import-loop.mjs b/scripts/ebay/run-competitive-research-import-loop.mjs index a65accf..796787b 100644 --- a/scripts/ebay/run-competitive-research-import-loop.mjs +++ b/scripts/ebay/run-competitive-research-import-loop.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; @@ -81,14 +81,14 @@ const fileHref = (file, outDir) => { const runStep = ({name, commandArgs}) => { const startedAt = new Date().toISOString(); - const result = spawnSync('node', commandArgs, { + const result = spawnSync(process.execPath, commandArgs, { cwd: projectRoot, encoding: 'utf8', maxBuffer: 1024 * 1024 * 50, }); return { name, - command: ['node', ...commandArgs].join(' '), + command: ['bun', ...commandArgs].join(' '), status: result.status === 0 ? 'ok' : 'failed', exit_code: result.status, started_at: startedAt, diff --git a/scripts/ebay/run-competitive-video-control-loop.mjs b/scripts/ebay/run-competitive-video-control-loop.mjs index 669afc9..0a1c9a7 100644 --- a/scripts/ebay/run-competitive-video-control-loop.mjs +++ b/scripts/ebay/run-competitive-video-control-loop.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; @@ -76,7 +76,7 @@ const pushOption = (cmdArgs, key) => { const runStep = ({name, commandArgs, expectedFiles = []}) => { const entry = { name, - command: ['node', ...commandArgs].join(' '), + command: ['bun', ...commandArgs].join(' '), status: 'planned', expected_files: expectedFiles, started_at: null, @@ -90,7 +90,7 @@ const runStep = ({name, commandArgs, expectedFiles = []}) => { entry.status = 'running'; entry.started_at = new Date().toISOString(); - const result = spawnSync('node', commandArgs, { + const result = spawnSync(process.execPath, commandArgs, { cwd: projectRoot, encoding: 'utf8', maxBuffer: 1024 * 1024 * 30, diff --git a/scripts/enhance-video-with-broll.mjs b/scripts/enhance-video-with-broll.mjs index c0f32d0..7faa75b 100644 --- a/scripts/enhance-video-with-broll.mjs +++ b/scripts/enhance-video-with-broll.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/fal-image-edit.mjs b/scripts/fal-image-edit.mjs index 35c4bf6..59cb455 100644 --- a/scripts/fal-image-edit.mjs +++ b/scripts/fal-image-edit.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -24,7 +24,7 @@ Options: not an eBay source-of-truth or main listing image. --dry-run Validate local inputs and print the provider request without calling fal. -Requires FAL_KEY in .env or the environment. This local CLI only runs in Node; +Requires FAL_KEY in .env or the environment. This local CLI only runs in Bun; the key is not sent to Electron's renderer or saved in its generation manifest. `; diff --git a/scripts/fal-reference-video.mjs b/scripts/fal-reference-video.mjs index 896ef2c..ab2fa44 100644 --- a/scripts/fal-reference-video.mjs +++ b/scripts/fal-reference-video.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/scripts/find-broll-from-text.mjs b/scripts/find-broll-from-text.mjs index 32c1573..1af8368 100644 --- a/scripts/find-broll-from-text.mjs +++ b/scripts/find-broll-from-text.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import { diff --git a/scripts/generate-elevenlabs-library.mjs b/scripts/generate-elevenlabs-library.mjs new file mode 100644 index 0000000..fc43f9a --- /dev/null +++ b/scripts/generate-elevenlabs-library.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env bun +import {createHash} from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {ensureDir, loadEnv, outputsRoot, parseArgs} from './lib.mjs'; + +const scriptName = path.basename(fileURLToPath(import.meta.url)); +const args = parseArgs(process.argv.slice(2)); +loadEnv(); +const usage = ` +Usage: + bun run voiceover:library -- --budget 36000 + bun run voiceover:library -- --resume --budget 36000 + +Options: + --budget N Maximum planned characters. Default: 36000. + --reserve N Safety reserve below the account limit. Default: 2000. + --out-dir DIR Default: outputs/voiceover/elevenlabs-library. + --model ID Default: eleven_multilingual_v2. + --output-format FORMAT Default: mp3_44100_128. + --resume Skip clips whose audio and manifest already exist. + --dry-run Show the planned library and estimated character cost. + --max-clips N Generate at most N clips after planning. + +Requires ELEVENLABS_API_KEY in .env or the environment. The key is never +written to output files or printed. +`; + +if (args.help || args.h) { + console.log(usage); + process.exit(0); +} + +const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const numeric = (value, fallback) => { + const parsed = Number(value ?? fallback); + if (!Number.isFinite(parsed) || parsed < 0) throw new Error(`Invalid numeric value: ${value}`); + return parsed; +}; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const voices = [ + ['jon', process.env.ELEVENLABS_VOICE_ID, 'configured cloned voice'], + ['bella', 'hpp4J3VqNfWAUOO0d1Us', 'professional bright warm'], + ['roger', 'CwhRBWXzGAHq8TQ4Fs17', 'laid-back casual resonant'], + ['sarah', 'EXAVITQu4vr4xnSDxMaL', 'mature reassuring confident'], + ['laura', 'FGY2WhTYpPnrIDTdsKH5', 'enthusiastic social creator'], + ['charlie', 'IKne3meq5aSn9XLyUdCD', 'deep confident energetic'], + ['liam', 'TX3LPaxmHKxFdv7VOQHJ', 'energetic social creator'], + ['alice', 'Xb7hH8MSUJpSbSDYk0k2', 'clear engaging educator'], + ['eric', 'cjVigY5qzO86Huf0OWal', 'smooth trustworthy'], + ['george', 'JBFqnCBsd6RMkjVDRZzb', 'warm captivating storyteller'], + ['callum', 'N2lVS1w4EtoT3dr4eOWO', 'husky character voice'], + ['river', 'SAz9YHcvj6GT2YYXdXww', 'relaxed neutral informative'], + ['harry', 'SOYHLrjzK2X1ezoPC6cr', 'fierce character voice'], + ['matilda', 'XrExE9yKIg1WjnnlVkGX', 'knowledgeable professional'], + ['will', 'bIHbv24MWmeRgasZH58o', 'relaxed optimist'], + ['jessica', 'cgSgspJ2msm6clMCkdW9', 'playful bright warm'], + ['brian', 'nPczCjzI2devNBz1zQrb', 'deep resonant comforting'], + ['daniel', 'onwK4e9ZLuTAKqWW03F9', 'steady broadcaster'], + ['lily', 'pFZP5JQG7iQjIQuC4Bku', 'velvety actress'], + ['adam', 'pNInz6obpgDQGcFmaJgB', 'dominant firm'], + ['bill', 'pqHfZKP75CvOlQylNhV4', 'wise mature balanced'], +].filter(([, voiceId]) => clean(voiceId)); + +// These are intentionally complete, reusable spoken assets rather than one long ad. +// Each phrase can be cut into a hook, explainer, transition, or CTA in a future edit. +const phrases = [ + ['hook', 'Meet ClipCaptionAI, the command-line video editor built for creative teams and AI models.'], + ['hook', 'Start with a brief, a folder of approved assets, and a clear outcome. ClipCaptionAI turns that direction into a video run.'], + ['hook', 'Your next product video should not begin with a blank timeline. It should begin with a plan you can inspect.'], + ['hook', 'From idea to finished cut, ClipCaptionAI keeps the creative brief, assets, render, and quality checks connected.'], + ['workflow', 'Plan a run before spending provider credits. Review the shots, sources, prompts, framing, and export settings first.'], + ['workflow', 'The model can direct the workflow, while the CLI keeps every decision explicit, reproducible, and easy to resume.'], + ['workflow', 'A run manifest records what was requested, what was rendered, which files were used, and what passed technical QA.'], + ['workflow', 'Use dry-run mode to validate paths, providers, and output settings before an external generation call begins.'], + ['workflow', 'Resume an existing run instead of starting over. The manifest is the handoff point between planning, rendering, and review.'], + ['feature', 'Bring your own footage, product photos, logos, captions, music, sound effects, and approved B-roll into one composition.'], + ['feature', 'Generate clean vertical, horizontal, or contained layouts from versioned configuration instead of editing source code.'], + ['feature', 'Choose shot recipes, caption styles, audio presets, and export settings that match the channel you are publishing to.'], + ['feature', 'Add narration as a real audio input, mix it above a music bed, and verify that the final file is not silently broken.'], + ['feature', 'Use local assets for reliable demos, then add OpenAI, ElevenLabs, or fal generation when the brief calls for it.'], + ['feature', 'The renderer stays deterministic, so a model can make creative choices without losing control of the final export.'], + ['feature', 'Every successful run produces a final artifact, a manifest, hashes, media metadata, and a machine-readable QA result.'], + ['feature', 'The desktop app is optional and thin. The CLI is the production surface that works for people, scripts, and coding agents.'], + ['captions', 'Captions are part of the composition, not an afterthought. Keep the message readable, paced, and safe inside the frame.'], + ['captions', 'Use a clear headline for the hook, supporting copy for the proof, and a concise call to action at the end.'], + ['captions', 'A good caption survives muted playback. A good voiceover adds rhythm, context, and confidence without fighting the visuals.'], + ['broll', 'Show the work as it happens: the brief becomes a plan, the plan becomes a render, and the render becomes a checked deliverable.'], + ['broll', 'Use interface captures, product details, source footage, and workflow cards to make the benefit visible in seconds.'], + ['broll', 'B-roll should prove the product promise. Show inputs, decisions, transformations, and the final result instead of decorative noise.'], + ['quality', 'Before you ship, check that the file exists, the duration is valid, the dimensions are correct, the codec is supported, and the audio is present.'], + ['quality', 'Technical QA catches black screens, missing audio, wrong framing, broken paths, and incomplete renders before your audience does.'], + ['quality', 'A passing manifest is evidence about this artifact and this run. It does not pretend that an unverified provider completed work remotely.'], + ['quality', 'Keep secrets in the environment. Keep prompts, model IDs, request IDs, hashes, and QA state in the non-secret manifest.'], + ['cta', 'ClipCaptionAI. Prompt it, render it, inspect it, and ship the cut.'], + ['cta', 'Turn the next creative brief into a video you can actually review. Try ClipCaptionAI today.'], + ['cta', 'Stop losing the story between the prompt and the export. Keep the whole run in one place with ClipCaptionAI.'], + ['cta', 'Build once, review clearly, and reuse the assets that work. ClipCaptionAI is your model-facing video production CLI.'], + ['cta', 'When the brief is ready, the next step is simple: plan the run, render the cut, and let QA tell you what shipped.'], +]; + +const apiKey = clean(process.env.ELEVENLABS_API_KEY); +const budget = numeric(args.budget, 36000); +const reserve = numeric(args.reserve, 2000); +const modelId = clean(args.model ?? 'eleven_multilingual_v2'); +const outputFormat = clean(args['output-format'] ?? 'mp3_44100_128'); +const outDir = path.resolve(args['out-dir'] ?? path.join(outputsRoot, 'voiceover', 'elevenlabs-library')); +const indexPath = path.join(outDir, 'library.json'); +const resume = args.resume === true; + +if (!voices.length) throw new Error('No ElevenLabs voices configured. Set ELEVENLABS_VOICE_ID in .env.'); +const planned = []; +for (const [voiceKey, voiceId, voiceDescription] of voices) { + for (const [index, [category, text]] of phrases.entries()) { + const id = `${String(index + 1).padStart(2, '0')}-${category}-${voiceKey}`; + const audio = path.join(outDir, voiceKey, `${id}.mp3`); + planned.push({id, category, voice_key: voiceKey, voice_id: voiceId, voice_description: voiceDescription, text, text_characters: text.length, audio}); + } +} +const maxClips = args['max-clips'] === undefined ? planned.length : Math.floor(numeric(args['max-clips'], planned.length)); +const selected = planned.slice(0, maxClips); +const pending = resume + ? selected.filter((item) => { + const manifestPath = item.audio.replace(/\.mp3$/i, '.generation.json'); + return !(fs.existsSync(item.audio) && fs.existsSync(manifestPath)); + }) + : selected; +const estimated = pending.reduce((sum, item) => sum + item.text_characters, 0); +if (estimated > budget) throw new Error(`Planned text costs ${estimated} characters, above --budget ${budget}. Reduce --max-clips or increase the budget.`); + +if (args['dry-run'] === true) { + console.log(JSON.stringify({provider: 'elevenlabs', model_id: modelId, output_format: outputFormat, voices: voices.map(([key, id, description]) => ({key, voice_id: id, description})), clips: selected.length, pending_clips: pending.length, estimated_characters: estimated, budget, reserve, output_directory: outDir, dry_run: true}, null, 2)); + process.exit(0); +} +if (!apiKey) throw new Error('ELEVENLABS_API_KEY is required in .env or the environment.'); +ensureDir(outDir); + +const subscriptionResponse = await fetch('https://api.elevenlabs.io/v1/user/subscription', {headers: {'xi-api-key': apiKey}}); +if (!subscriptionResponse.ok) throw new Error(`Could not read ElevenLabs subscription (${subscriptionResponse.status}).`); +const subscription = await subscriptionResponse.json(); +const remaining = Math.max(0, Number(subscription.character_limit ?? 0) - Number(subscription.character_count ?? 0)); +if (estimated > Math.max(0, remaining - reserve)) { + throw new Error(`Planned text costs ${estimated} characters, but only ${remaining} remain after the ${reserve}-character safety reserve.`); +} + +const existing = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, 'utf8')) : null; +const entries = new Map((existing?.entries ?? []).map((entry) => [entry.id, entry])); +const failures = []; +for (const [position, item] of selected.entries()) { + const manifestPath = item.audio.replace(/\.mp3$/i, '.generation.json'); + if (resume && fs.existsSync(item.audio) && fs.existsSync(manifestPath)) continue; + const endpoint = `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(item.voice_id)}?output_format=${encodeURIComponent(outputFormat)}`; + const response = await fetch(endpoint, {method: 'POST', headers: {'content-type': 'application/json', 'xi-api-key': apiKey}, body: JSON.stringify({text: item.text, model_id: modelId})}); + if (!response.ok) { + const detail = (await response.text()).slice(0, 500); + failures.push({id: item.id, status: response.status, detail}); + console.error(`Failed ${position + 1}/${selected.length}: ${item.id} (${response.status})`); + continue; + } + const audioBuffer = Buffer.from(await response.arrayBuffer()); + if (!audioBuffer.length) throw new Error(`ElevenLabs returned empty audio for ${item.id}.`); + ensureDir(path.dirname(item.audio)); + fs.writeFileSync(item.audio, audioBuffer); + const entry = {...item, model_id: modelId, output_format: outputFormat, text_sha256: sha256(item.text), audio_sha256: sha256(audioBuffer), audio_bytes: audioBuffer.length, character_cost: Number(response.headers.get('character-cost') ?? item.text_characters), request_id: response.headers.get('request-id'), created_at: new Date().toISOString(), manifest: manifestPath}; + fs.writeFileSync(manifestPath, `${JSON.stringify({provider: 'elevenlabs', script: scriptName, ...entry}, null, 2)}\n`); + entries.set(item.id, entry); + fs.writeFileSync(indexPath, `${JSON.stringify({provider: 'elevenlabs', model_id: modelId, output_format: outputFormat, generated_at: new Date().toISOString(), entries: [...entries.values()], failures}, null, 2)}\n`); + console.error(`Generated ${position + 1}/${selected.length}: ${item.id}`); + await sleep(250); +} + +const finalEntries = [...entries.values()]; +const totalTextCharacters = selected.reduce((sum, item) => sum + item.text_characters, 0); +const totalBilledCharacters = finalEntries.reduce((sum, entry) => sum + Number(entry.character_cost ?? entry.text_characters), 0); +fs.writeFileSync(indexPath, `${JSON.stringify({provider: 'elevenlabs', model_id: modelId, output_format: outputFormat, generated_at: new Date().toISOString(), planned_clips: selected.length, planned_text_characters: totalTextCharacters, estimated_billable_characters: estimated, generated_clips: finalEntries.length, generated_billable_characters: totalBilledCharacters, failures, entries: finalEntries}, null, 2)}\n`); +console.log(JSON.stringify({provider: 'elevenlabs', output_directory: outDir, index: indexPath, planned_clips: selected.length, generated_clips: finalEntries.length, planned_text_characters: totalTextCharacters, generated_billable_characters: totalBilledCharacters, failures: failures.length, remaining_before_run: remaining}, null, 2)); diff --git a/scripts/generate-elevenlabs-voiceover.mjs b/scripts/generate-elevenlabs-voiceover.mjs index 7866c9b..ac132cb 100644 --- a/scripts/generate-elevenlabs-voiceover.mjs +++ b/scripts/generate-elevenlabs-voiceover.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import {createHash} from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/scripts/higgsfield-cli.mjs b/scripts/higgsfield-cli.mjs index 42f47b5..995f1e1 100644 --- a/scripts/higgsfield-cli.mjs +++ b/scripts/higgsfield-cli.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import {spawnSync} from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; diff --git a/scripts/index-scene-library.mjs b/scripts/index-scene-library.mjs index 266546c..482e711 100644 --- a/scripts/index-scene-library.mjs +++ b/scripts/index-scene-library.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import { diff --git a/scripts/ingest-youtube-cc-scenes.mjs b/scripts/ingest-youtube-cc-scenes.mjs index 9efcc32..0e892b1 100644 --- a/scripts/ingest-youtube-cc-scenes.mjs +++ b/scripts/ingest-youtube-cc-scenes.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import path from 'node:path'; import {ensureDir, loadEnv, parseArgs, projectRoot} from './lib.mjs'; import {ingestYouTubeScenes} from './lib-youtube-scenes.mjs'; diff --git a/scripts/interview-qa.mjs b/scripts/interview-qa.mjs index 58e919a..72254ae 100644 --- a/scripts/interview-qa.mjs +++ b/scripts/interview-qa.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * Interview Q&A Detection * diff --git a/scripts/logo/build-spec.mjs b/scripts/logo/build-spec.mjs index ebc698d..3020a29 100644 --- a/scripts/logo/build-spec.mjs +++ b/scripts/logo/build-spec.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * build-spec.mjs — Step 2 of the logo animation pipeline. This is the piece that * solves the "the model can't accept images" problem. @@ -10,8 +10,8 @@ * Paste that file into Codex Spark (or any text-only model) and it can animate a * logo it has never seen, because the spec tells it everything a picture would. * - * node scripts/logo/build-spec.mjs --slug listingos - * node scripts/logo/build-spec.mjs --slug listingos --variants 12 + * bun scripts/logo/build-spec.mjs --slug listingos + * bun scripts/logo/build-spec.mjs --slug listingos --variants 12 * * Zero dependencies. */ @@ -29,7 +29,7 @@ const arg = (flag, fallback = null) => { const slug = arg('--slug'); const variantCount = Number(arg('--variants', '10')); if (!slug) { - console.error('Usage: node scripts/logo/build-spec.mjs --slug [--variants 10]'); + console.error('Usage: bun scripts/logo/build-spec.mjs --slug [--variants 10]'); process.exit(1); } diff --git a/scripts/logo/render-all.mjs b/scripts/logo/render-all.mjs index 4e934e8..877c64e 100644 --- a/scripts/logo/render-all.mjs +++ b/scripts/logo/render-all.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * render-all.mjs — Step 5: batch-render every brand x variant composition. * diff --git a/scripts/logo/vectorize.mjs b/scripts/logo/vectorize.mjs index a6ecb57..77fab3c 100644 --- a/scripts/logo/vectorize.mjs +++ b/scripts/logo/vectorize.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun /** * vectorize.mjs — Step 1 of the logo animation pipeline. * @@ -7,7 +7,7 @@ * exactly the granularity a text-only code model needs in order to animate parts * of a logo independently. * - * node scripts/logo/vectorize.mjs --in assets/logos/acme/source.png --slug acme + * bun scripts/logo/vectorize.mjs --in assets/logos/acme/source.png --slug acme * * Output: assets/logos//logo.traced.svg (+ a colour report on stdout) * @@ -35,7 +35,7 @@ const scale = Number(arg('--scale', '1')); if (!inPath || !slug) { console.error(` -Usage: node scripts/logo/vectorize.mjs --in --slug [--colors 8] [--scale 1] +Usage: bun scripts/logo/vectorize.mjs --in --slug [--colors 8] [--scale 1] --in Path to the source PNG (flat-colour logos trace best). --slug Brand folder name under assets/logos/. @@ -152,4 +152,4 @@ for (const [i, l] of layers.entries()) { console.log(` layer-${i} ${l.fill} ${l.ds.length} path(s)`); } console.log(`\nNext: rename ids, save as ${path.join(outDir, 'logo.svg')}, then run:`); -console.log(` node scripts/logo/build-spec.mjs --slug ${slug}\n`); +console.log(` bun scripts/logo/build-spec.mjs --slug ${slug}\n`); diff --git a/scripts/make-sample-props.mjs b/scripts/make-sample-props.mjs index 5cfa63e..d87f885 100644 --- a/scripts/make-sample-props.mjs +++ b/scripts/make-sample-props.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {ensureDir, outputsRoot, probeVideo, videoToSrc} from './lib.mjs'; diff --git a/scripts/marketing.mjs b/scripts/marketing.mjs index e2154ef..0e4d6f7 100644 --- a/scripts/marketing.mjs +++ b/scripts/marketing.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import {execFileSync, spawnSync} from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; @@ -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({ @@ -290,7 +373,7 @@ const copyAsset = (state, variant, intent, source, type, extra = {}) => { state.directory, 'artifacts', type, - `${variant.id}-${hash.slice(0, 12)}${extension}`, + `${variant.id}${extra.slideIndex ? `-slide-${String(extra.slideIndex).padStart(2, '0')}` : ''}-${hash.slice(0, 12)}${extension}`, ); if (!fs.existsSync(target)) fs.copyFileSync(source, target); const adapter = @@ -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); @@ -344,6 +593,7 @@ const executeCampaign = async () => { throw new Error('No paid generation intents exist in this plan.'); const assets = [...state.assets]; const providerJobs = {...state.run.providerJobs}; + const completedProviderKeys = new Set(); const spentKeys = new Set(Object.keys(providerJobs)); const spentCredits = () => [...spentKeys].reduce((sum, key) => sum + (state.run.estimates[key] ?? 0), 0); @@ -404,12 +654,14 @@ const executeCampaign = async () => { spentKeys.add(key); const generatedPath = intent.output || providerJobs[key].output || providerJobs[key].result_path; - if (generatedPath && fs.existsSync(generatedPath)) + if (generatedPath && fs.existsSync(generatedPath)) { assets.push( copyAsset(state, variant, intent, generatedPath, 'generated', { providerJob: providerJobs[key], }), ); + completedProviderKeys.add(key); + } continue; } if (intent.type === 'capture') { @@ -468,16 +720,23 @@ 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(), ]); const run = saveRun(state.directory, state.run, { status: - live && Object.keys(providerJobs).length > 0 && assets.length === 0 + live && Object.keys(providerJobs).some((key) => !completedProviderKeys.has(key)) ? 'awaiting-assets' : live ? 'executed' @@ -495,7 +754,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 +781,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 +820,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 +892,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 +923,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 +944,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/mix-sfx.mjs b/scripts/mix-sfx.mjs index bb709ab..bdb79cc 100644 --- a/scripts/mix-sfx.mjs +++ b/scripts/mix-sfx.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/portrait-framing.mjs b/scripts/portrait-framing.mjs new file mode 100644 index 0000000..e37d2ee --- /dev/null +++ b/scripts/portrait-framing.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env bun +import fs from 'node:fs'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {ensureDir, parseArgs, probeVideo, requireArg} from './lib.mjs'; + +const usage = ` +Usage: + bun run portrait:analyze -- --video input.mp4 --out framing.json [options] + +Options: + --center-x N Manual subject center from 0 to 1. Default: 0.5. +`; + +const args = parseArgs(process.argv.slice(2)); +if (args.help || args.h) { + console.log(usage); + process.exit(0); +} + +const video = path.resolve(requireArg(args, 'video', usage)); +const out = path.resolve(requireArg(args, 'out', usage)); +const metadata = probeVideo(video); +const manualCenter = Number(args['center-x'] ?? 0.5); +if (!Number.isFinite(manualCenter) || manualCenter < 0 || manualCenter > 1) { + throw new Error('--center-x must be a number between 0 and 1.'); +} + +const fallback = (source = 'fallback', reason = 'Centered framing.') => ({ + schemaVersion: 1, + generator: 'clipcaptionai/portrait-framing', + generatorVersion: '1', + createdAt: new Date().toISOString(), + video, + source, + strategy: metadata.width / metadata.height > 1 ? 'track' : 'contain', + confidence: source === 'manual' ? 1 : 0, + reason, + keyframes: [ + {at: 0, centerX: manualCenter, confidence: source === 'manual' ? 1 : 0}, + {at: 1, centerX: manualCenter, confidence: source === 'manual' ? 1 : 0}, + ], +}); + +if (args.auto || args.model) + throw new Error('Automatic portrait detection is not supported; use --center-x.'); +const plan = + args['center-x'] !== undefined ? fallback('manual', 'Manual subject center.') : fallback(); +const hasher = createHash('sha256'); +for await (const chunk of fs.createReadStream(video)) hasher.update(chunk); +plan.inputSha256 = hasher.digest('hex'); +ensureDir(path.dirname(out)); +fs.writeFileSync(out, `${JSON.stringify(plan, null, 2)}\n`); +console.log( + JSON.stringify({ + ok: true, + out, + strategy: plan.strategy, + source: plan.source, + confidence: plan.confidence, + }), +); diff --git a/scripts/process-links.mjs b/scripts/process-links.mjs index 6d30c56..e871d86 100644 --- a/scripts/process-links.mjs +++ b/scripts/process-links.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import { diff --git a/scripts/render-batch.mjs b/scripts/render-batch.mjs index 285b149..9e470bf 100644 --- a/scripts/render-batch.mjs +++ b/scripts/render-batch.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {ensureDir, parseArgs, requireArg, run} from './lib.mjs'; diff --git a/scripts/render-clip.mjs b/scripts/render-clip.mjs index a5a8b54..3fb9af5 100644 --- a/scripts/render-clip.mjs +++ b/scripts/render-clip.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -34,6 +34,8 @@ Options: --text-opacity N Caption fill opacity. Default: 0.92. --frames START-END Optional Remotion frame range for proof renders. --uppercase Render caption text uppercase. + --framing FILE JSON framing plan from portrait:analyze. + --center-x N Manual horizontal subject center from 0 to 1. `; const args = parseArgs(process.argv.slice(2)); @@ -71,6 +73,28 @@ const highlightedWords = args['highlight-words'] ? styleConfig.highlightedWords : []; +const readFraming = () => { + if (args.framing) { + const framingPath = path.resolve(String(args.framing)); + return JSON.parse(fs.readFileSync(framingPath, 'utf8')); + } + if (args['center-x'] !== undefined) { + const centerX = Number(args['center-x']); + if (!Number.isFinite(centerX) || centerX < 0 || centerX > 1) { + throw new Error('--center-x must be a number between 0 and 1.'); + } + return { + strategy: 'track', + source: 'manual', + keyframes: [ + {at: 0, centerX}, + {at: 1, centerX}, + ], + }; + } + return null; +}; + const props = { videoSrc: videoToSrc(video), foregroundSrc: foregroundVideo ? videoToSrc(foregroundVideo) : null, @@ -79,6 +103,7 @@ const props = { height, fps, durationInFrames: Math.max(1, Math.ceil(metadata.durationSeconds * fps)), + framing: readFraming(), style: { ...styleConfig, position: String(args.position ?? styleConfig.position ?? 'left-hook'), diff --git a/scripts/render-ebay-photo-ad.mjs b/scripts/render-ebay-photo-ad.mjs index b9c11c7..34d729a 100644 --- a/scripts/render-ebay-photo-ad.mjs +++ b/scripts/render-ebay-photo-ad.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/render-frame-from-links.mjs b/scripts/render-frame-from-links.mjs index ad695b8..7339486 100644 --- a/scripts/render-frame-from-links.mjs +++ b/scripts/render-frame-from-links.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/rerender-clip.mjs b/scripts/rerender-clip.mjs index b03cfc4..95cbee3 100644 --- a/scripts/rerender-clip.mjs +++ b/scripts/rerender-clip.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import { diff --git a/scripts/research-pop-culture-scenes.mjs b/scripts/research-pop-culture-scenes.mjs index 37e5c88..b0ae6f0 100644 --- a/scripts/research-pop-culture-scenes.mjs +++ b/scripts/research-pop-culture-scenes.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {resolveProvider, prepareProvider, createClient, resolveModel} from './ai-provider.mjs'; diff --git a/scripts/review-moments.mjs b/scripts/review-moments.mjs index 45f7285..ca6eae4 100644 --- a/scripts/review-moments.mjs +++ b/scripts/review-moments.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {buildViralScorecard, slugify} from './clipkit-lib.mjs'; diff --git a/scripts/rotato-cli.mjs b/scripts/rotato-cli.mjs index 38b1711..69df714 100644 --- a/scripts/rotato-cli.mjs +++ b/scripts/rotato-cli.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import {spawnSync} from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; @@ -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/smart-clips.mjs b/scripts/smart-clips.mjs index 2a5f6ae..76af958 100644 --- a/scripts/smart-clips.mjs +++ b/scripts/smart-clips.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/split-local-video.mjs b/scripts/split-local-video.mjs index c4beaf0..bf81041 100644 --- a/scripts/split-local-video.mjs +++ b/scripts/split-local-video.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/standardize-sfx-library.mjs b/scripts/standardize-sfx-library.mjs index 08c9a9c..1053d15 100644 --- a/scripts/standardize-sfx-library.mjs +++ b/scripts/standardize-sfx-library.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/stock-cli.mjs b/scripts/stock-cli.mjs new file mode 100644 index 0000000..af81ae4 --- /dev/null +++ b/scripts/stock-cli.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env bun +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 usage = `Usage: + clipcaptionai stock doctor + clipcaptionai stock search --query [--count N] + clipcaptionai stock download --query --out [--count N]`; +if (action === '--help' || action === '-h' || args.help || args.h) { + console.log(usage); + process.exit(0); +} +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/scripts/sync-scene-blacklist.mjs b/scripts/sync-scene-blacklist.mjs index 51959b9..12750ef 100644 --- a/scripts/sync-scene-blacklist.mjs +++ b/scripts/sync-scene-blacklist.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {ensureDir, parseArgs, projectRoot} from './lib.mjs'; diff --git a/scripts/tighten-video.mjs b/scripts/tighten-video.mjs index bbfcbd6..93f8e96 100644 --- a/scripts/tighten-video.mjs +++ b/scripts/tighten-video.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; diff --git a/scripts/transcribe-openai.mjs b/scripts/transcribe-openai.mjs index a909a19..dd01678 100644 --- a/scripts/transcribe-openai.mjs +++ b/scripts/transcribe-openai.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import {execFileSync} from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; diff --git a/scripts/video.mjs b/scripts/video.mjs index 3dff4e0..0b8e0ff 100644 --- a/scripts/video.mjs +++ b/scripts/video.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; diff --git a/src/captioned-clip.tsx b/src/captioned-clip.tsx index 7fb6940..80b5611 100644 --- a/src/captioned-clip.tsx +++ b/src/captioned-clip.tsx @@ -16,16 +16,18 @@ import type { CaptionMotionPreset, CaptionPosition, CaptionStyle, + PortraitFraming, } from './types'; 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, durationInFrames: 450, + framing: null, style: { position: 'left-hook', fit: 'cover', @@ -46,8 +48,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 +272,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 +285,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 +343,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 +415,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; } @@ -544,6 +535,33 @@ const matrixToCss = (matrix: MotionMatrix) => const matrixToSvg = (matrix: MotionMatrix) => `matrix(${matrix.a} ${matrix.b} ${matrix.c} ${matrix.d} ${matrix.e} ${matrix.f})`; +const framingCenterX = ( + framing: PortraitFraming | null | undefined, + frame: number, + durationInFrames: number, +) => { + if (!framing || framing.strategy !== 'track' || framing.keyframes.length === 0) return 50; + const progress = + durationInFrames <= 1 ? 0 : Math.max(0, Math.min(1, frame / (durationInFrames - 1))); + const keyframes = [...framing.keyframes] + .filter((keyframe) => Number.isFinite(keyframe.at) && Number.isFinite(keyframe.centerX)) + .sort((left, right) => left.at - right.at); + if (keyframes.length === 0) return 50; + if (progress <= keyframes[0].at) return Math.max(0, Math.min(100, keyframes[0].centerX * 100)); + for (let index = 1; index < keyframes.length; index += 1) { + const previous = keyframes[index - 1]; + const next = keyframes[index]; + if (progress <= next.at) { + const local = (progress - previous.at) / Math.max(0.0001, next.at - previous.at); + return Math.max( + 0, + Math.min(100, (previous.centerX + (next.centerX - previous.centerX) * local) * 100), + ); + } + } + return Math.max(0, Math.min(100, keyframes.at(-1)!.centerX * 100)); +}; + const escapeXml = (value: string) => value .replaceAll('&', '&') @@ -600,14 +618,14 @@ export const CaptionedClip: React.FC = ({ videoSrc, foregroundSrc, captions, + framing, style, }) => { const frame = useCurrentFrame(); - const {fps, width, height} = useVideoConfig(); + const {fps, width, height, durationInFrames} = 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 +633,8 @@ 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 objectPosition = `${framingCenterX(framing, frame, durationInFrames)}% 50%`; const foregroundSource = foregroundSrc && /^https?:\/\//.test(foregroundSrc) ? foregroundSrc @@ -668,22 +685,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 +705,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 +718,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 +765,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 +777,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 +793,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 +840,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 +877,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 +907,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 +955,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 +1017,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 ?? @@ -1057,6 +1050,7 @@ export const CaptionedClip: React.FC = ({ width: '100%', height: '100%', objectFit: style.fit, + objectPosition, borderRadius: style.videoBorderRadius ?? 0, }; const captionMotionLayerStyle: CSSProperties = layout.motionMatrix @@ -1089,6 +1083,7 @@ export const CaptionedClip: React.FC = ({ width: '100%', height: '100%', objectFit: style.fit, + objectPosition, borderRadius: style.videoBorderRadius ?? 0, filter: style.videoFilter ?? 'none', }} @@ -1204,6 +1199,7 @@ export const CaptionedClip: React.FC = ({ width: '100%', height: '100%', objectFit: style.fit, + objectPosition, borderRadius: style.videoBorderRadius ?? 0, pointerEvents: 'none', }} 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 ?