@koda-sl/baker-cli 0.71.2 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -8
- package/canvas/end-card-composition/index.html +66 -0
- package/canvas/end-card-composition/meta.json +19 -0
- package/canvas/feature-reveal-composition/index.html +83 -0
- package/canvas/feature-reveal-composition/meta.json +18 -0
- package/canvas/lower-third-composition/index.html +75 -0
- package/canvas/lower-third-composition/meta.json +18 -0
- package/canvas/stat-counter-composition/index.html +73 -0
- package/canvas/stat-counter-composition/meta.json +20 -0
- package/canvas/title-card-composition/index.html +90 -0
- package/canvas/title-card-composition/meta.json +20 -0
- package/dist/{chunk-JIDZ37KG.js → chunk-CCO34ACK.js} +507 -307
- package/dist/chunk-CCO34ACK.js.map +1 -0
- package/dist/cli.js +986 -113
- package/dist/cli.js.map +1 -1
- package/dist/engine/index.d.ts +6 -0
- package/dist/engine/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-JIDZ37KG.js.map +0 -1
package/README.md
CHANGED
|
@@ -1661,7 +1661,7 @@ baker images find "office" --sources library,magnific --fallback --threshold 0.4
|
|
|
1661
1661
|
baker images find "celebration" --sources library,giphy --auto-ingest 3
|
|
1662
1662
|
```
|
|
1663
1663
|
|
|
1664
|
-
Providers: `library`, `magnific`, `google`, `iconify`, `giphy`. Brandfetch is not part of the fanout — it takes a domain, not a query, so it lives at `baker images logo <domain>`. Response shape: `{ groups: { library, external }, ingested, meta: { counts, errors } }`. When `--auto-ingest` is used, `ingested[]` preserves successful external ingest order and matching external hits are enriched with the Baker-owned URL plus source provenance. Partial failures (one provider throws) return the successful providers plus a `meta.errors` array; the whole call never fails on a single provider error.
|
|
1664
|
+
Providers: `library`, `magnific`, `google`, `iconify`, `giphy`, `pinterest`. Brandfetch is not part of the fanout — it takes a domain, not a query, so it lives at `baker images logo <domain>`. (`pinterest` is photo-real/candid imagery — best for sourcing fresh people and sets, e.g. a recast creator or a pinned location in a video reproduction.) Response shape: `{ groups: { library, external }, ingested, meta: { counts, errors } }`. When `--auto-ingest` is used, `ingested[]` preserves successful external ingest order and matching external hits are enriched with the Baker-owned URL plus source provenance. Partial failures (one provider throws) return the successful providers plus a `meta.errors` array; the whole call never fails on a single provider error.
|
|
1665
1665
|
|
|
1666
1666
|
**Flags:**
|
|
1667
1667
|
|
|
@@ -2219,6 +2219,60 @@ baker testimonials list --source google --sentiment positive --limit 20
|
|
|
2219
2219
|
|
|
2220
2220
|
---
|
|
2221
2221
|
|
|
2222
|
+
### Winning Ads (`baker winning-ads`)
|
|
2223
|
+
|
|
2224
|
+
Search the **ad-dna** corpus of scored "winning" competitor ads for reference creatives to reproduce (e.g. with `baker canvas`). Each result carries a presigned media URL (~1h TTL), the ad's DNA summary, and scores. The CLI authenticates with the normal `BAKER_API_KEY`; the Baker backend proxies the request to the ad-dna service with a server-held token — no extra credential in the sandbox.
|
|
2225
|
+
|
|
2226
|
+
> Backend env: the Convex deployment must have `AD_DNA_API_TOKEN` set (`npx convex env set AD_DNA_API_TOKEN …`). `AD_DNA_API_URL` is optional and defaults to `https://ads.withbaker.com`.
|
|
2227
|
+
|
|
2228
|
+
### `baker winning-ads search <query>`
|
|
2229
|
+
|
|
2230
|
+
Semantic search (dense recall + BM25 + rerank). The CLI projects each result to a **lean, decision-focused** shape so the agent's context stays small — default fields: `advertiser`, `advertiser_id`, `platform`, `format`, `relevance`, `winner_score`, `summary` (what the ad is about), `media_url`; plus top-level `pool_size` and `match_confidence`. `--full` adds DNA detail (`angle`, `target_persona`, `hook_archetype`, `awareness_stage`, `industry`) + longevity (`days_active`, `reach`, `active`, `winner_category`, `media_kind`). `--output json` (default) returns the lean objects; `--output md` prints a table.
|
|
2231
|
+
|
|
2232
|
+
> `media_url` is the creative itself: for `static` it's the image, for `video` it's the video file. ad-dna stores **no separate poster** for videos, so a video result has only the video URL.
|
|
2233
|
+
|
|
2234
|
+
> Results are already de-junked and winner-first: the corpus auto-drops measured failures (duds, thrash) and ranks `final = relevance^0.25 × winner_score^0.75`. So `--winner-category winner` is usually unnecessary — it's a stricter filter that also removes `untested` (unproven-but-maybe-fresh) ads. Rely on the default + `--min-relevance`; only add `--winner-category` to force-exclude unproven creatives. (Restricting to a single `--advertiser-id` turns the failure-exclusion off — you then see that brand's duds too.)
|
|
2235
|
+
|
|
2236
|
+
```bash
|
|
2237
|
+
# Only LinkedIn video ads:
|
|
2238
|
+
baker winning-ads search "B2B lead gen demo" --platform linkedin --format video --output md
|
|
2239
|
+
# Cross-platform, exclude our own brand + an advertiser we already mined:
|
|
2240
|
+
baker winning-ads search "fintech onboarding" --exclude-advertiser adv_ourbrand,adv_usedbefore --output md
|
|
2241
|
+
# Similar-to a reference ad, recent only:
|
|
2242
|
+
baker winning-ads search --ref-ad-id a_12345 --first-seen-after 2026-01-01T00:00:00Z --limit 5 --output md
|
|
2243
|
+
```
|
|
2244
|
+
|
|
2245
|
+
| Flag | Purpose |
|
|
2246
|
+
|---|---|
|
|
2247
|
+
| `query` (positional) | Free-text natural-language query |
|
|
2248
|
+
| `--ref-ad-id <id>` | Find ads similar to this ad (instead of free text) |
|
|
2249
|
+
| `--limit <n>` | Max results 1–100 (**default 10** — shortlist size) |
|
|
2250
|
+
| `--max-per-advertiser <n>` | Cap results per advertiser 1–50 (default 3) |
|
|
2251
|
+
| `--min-relevance <0-1>` | Relevance floor; trims weak matches |
|
|
2252
|
+
| `--platform <list>` | One or many of `meta,tiktok,linkedin,google_search,google_display,youtube,reddit,x,pinterest,snapchat` — pass a single value to search **only** that platform |
|
|
2253
|
+
| `--format <list>` | `video,static,carousel` |
|
|
2254
|
+
| `--winner-category <list>` | `winner,scaled_winner,evergreen,rising,untested,dud,…` (default: all) |
|
|
2255
|
+
| `--awareness <list>` | `unaware,problem_aware,solution_aware,product_aware,most_aware` |
|
|
2256
|
+
| `--advertiser-id <list>` | **Restrict to** these advertiser ids (browse one brand's winners) |
|
|
2257
|
+
| `--exclude-advertiser <list>` | **Drop** these advertiser ids — your own brand + already-used references |
|
|
2258
|
+
| `--country <list>` / `--language <list>` | Filter by country / language codes |
|
|
2259
|
+
| `--first-seen-after` / `--first-seen-before` | ISO datetime bounds (recency) |
|
|
2260
|
+
| `--output json\|md\|files` | Output format (default json) |
|
|
2261
|
+
| `--full` | Include DNA detail + longevity |
|
|
2262
|
+
|
|
2263
|
+
Reading the scores: **`relevance`** (0–1) = match of the creative to your query; **`winner_score`** = how proven the ad is in-market. Pick references that are both relevant *and* proven.
|
|
2264
|
+
|
|
2265
|
+
### `baker winning-ads advertisers <brand>`
|
|
2266
|
+
|
|
2267
|
+
Resolve a brand name → `advertiser_id`(s) in the corpus. Use it to find **your own** advertiser (to `--exclude-advertiser`) or a **competitor** (to `--advertiser-id`). Returns `advertiser_id`, `label`, `active_ads`, `total_ads`.
|
|
2268
|
+
|
|
2269
|
+
```bash
|
|
2270
|
+
baker winning-ads advertisers "Acme" --output md # find our own advertiser id
|
|
2271
|
+
baker winning-ads advertisers "Deel" --platform meta --output md
|
|
2272
|
+
```
|
|
2273
|
+
|
|
2274
|
+
---
|
|
2275
|
+
|
|
2222
2276
|
### Scheduled Actions (`baker scheduled-actions`)
|
|
2223
2277
|
|
|
2224
2278
|
Manage company-scoped scheduled recipes that spawn Work Actions later or on a cadence. `create`, `update`, and `delete` stage draft ops on `BAKER_CHAT_ID` and apply when the chat is published. `list` and `get` include draft state when `BAKER_CHAT_ID` is set. `trigger` runs immediately on a published scheduled action and does not require a chat id. Use a regular Work Action instead when there is no date or cadence.
|
|
@@ -2347,7 +2401,7 @@ A re-run with no changes hits the cache for every node — total runtime drops t
|
|
|
2347
2401
|
|
|
2348
2402
|
1. **You write a JSON file** describing nodes and how they wire together (`$ref:other_node.output`).
|
|
2349
2403
|
2. **The validator** checks the schema, every node's params against the model registry, and that every `{{slot}}` in a prompt has a wired input.
|
|
2350
|
-
3. **The engine** runs nodes in topological order. Local nodes (text, ffmpeg, imagemagick, hyperframe_*, font_specimen) execute in-process. Remote nodes (text_generate, image_generate, image_describe, image_search, video_generate, tts, music…) POST to the Convex backend.
|
|
2404
|
+
3. **The engine** runs nodes in topological order. Local nodes (text, ffmpeg, imagemagick, hyperframe_*, font_specimen) execute in-process. Remote nodes (text_generate, image_generate, image_describe, image_search, video_generate, tts, audio_voice_convert, music…) POST to the Convex backend. (`audio_voice_convert` is ElevenLabs Voice Changer / speech-to-speech — re-voices a clip's audio in one target voice while preserving timing; used to give Seedance native-audio talking heads a consistent brand voice.)
|
|
2351
2405
|
4. **Outputs land on disk** the moment each node finishes — assets are downloaded from S3 and named `<node_id>__<slot>[__<index>].<ext>` inside the run dir.
|
|
2352
2406
|
5. **The cache** is content-addressed by node params + input hashes. Edit a prompt, only the dependent nodes re-run.
|
|
2353
2407
|
|
|
@@ -2446,7 +2500,9 @@ Local ImageMagick passthrough. Identical schema and safety contract to `ffmpeg`;
|
|
|
2446
2500
|
|
|
2447
2501
|
##### `hyperframe_render`
|
|
2448
2502
|
|
|
2449
|
-
Render an HTML/CSS/GSAP composition to **mp4
|
|
2503
|
+
Render an HTML/CSS/GSAP composition to **mp4** (default), or to a transparent **webm**/**mov** alpha overlay. Quality and worker count are fixed by the engine for ad-creative delivery; the canvas owns the composition path, the output format, and the composition vars.
|
|
2504
|
+
|
|
2505
|
+
Before each render the engine runs a **pre-render quality gate** on the *substituted* composition — Hyperframes' own `lint` + `inspect`. Layout **overflow** (clipped text) and structural lint **errors block** the render; **low-contrast** text and missing brand fonts/assets only **warn** (logged as `hyperframe check warning …`). If the `hyperframes` binary or its subcommands are unavailable the gate degrades gracefully and the render proceeds.
|
|
2450
2506
|
|
|
2451
2507
|
**Inputs**
|
|
2452
2508
|
|
|
@@ -2457,6 +2513,7 @@ Open record keyed by the composition's `meta.json` inputs (declared per composit
|
|
|
2457
2513
|
| Name | Type | Required | Default | Notes |
|
|
2458
2514
|
|---|---|---|---|---|
|
|
2459
2515
|
| `composition` | string | yes | — | path to composition dir |
|
|
2516
|
+
| `format` | `mp4 \| webm \| mov` | no | `mp4` | `webm`/`mov` render WITH transparency (alpha) when the composition background is transparent — for overlays you composite elsewhere |
|
|
2460
2517
|
| `timeout_ms` | number | no | 600000 | positive |
|
|
2461
2518
|
| **composition vars** | per `meta.json` | per `meta.json` | per `meta.json` | substituted into `{{var}}` |
|
|
2462
2519
|
|
|
@@ -2464,7 +2521,7 @@ Open record keyed by the composition's `meta.json` inputs (declared per composit
|
|
|
2464
2521
|
|
|
2465
2522
|
| Slot | Kind | MIME |
|
|
2466
2523
|
|---|---|---|
|
|
2467
|
-
| `video` | video | `video/mp4` |
|
|
2524
|
+
| `video` | video | `video/mp4`, `video/webm`, or `video/quicktime` (by `format`) |
|
|
2468
2525
|
|
|
2469
2526
|
See [authoring compositions](#authoring-compositions) for the composition file format.
|
|
2470
2527
|
|
|
@@ -2472,7 +2529,7 @@ See [authoring compositions](#authoring-compositions) for the composition file f
|
|
|
2472
2529
|
|
|
2473
2530
|
##### `hyperframe_snapshot`
|
|
2474
2531
|
|
|
2475
|
-
Render the same composition format to a **PNG** still at **2× device-scale** (retina). Output size = composition `meta.json` `width × height × 2`.
|
|
2532
|
+
Render the same composition format to a **PNG** still at **2× device-scale** (retina). Output size = composition `meta.json` `width × height × 2`. Runs the same pre-render `lint`/`inspect` gate as `hyperframe_render` (overflow/structural errors block; contrast/asset advisories warn).
|
|
2476
2533
|
|
|
2477
2534
|
**Inputs**
|
|
2478
2535
|
|
|
@@ -3560,15 +3617,21 @@ Turn a reference video into a **runnable, self-validated reproduction canvas** i
|
|
|
3560
3617
|
|
|
3561
3618
|
It then scaffolds the full pipeline: per scene, two **static-ad-grade frames** (`image_generate` with its **own self-contained `params.prompt`** — edit a frame node to change only that frame; `prompt.json` is wired as a demoted shared-style `target_blueprint`, a per-element reference legend, the real extracted frame as a composition anchor) → `video_generate` (Seedance first/last-frame, fed an ultra-detailed motion brief composed from the scene's action, camera, dialogue, and transcript; duration snapped to the nearest allowed clip length).
|
|
3562
3619
|
|
|
3563
|
-
**Sequenced audio.** Dialogue is a back-and-forth on one absolute timeline, so each **contiguous same-speaker turn** becomes its own `tts` placed at its real `start_s` — turns alternate and never stack (the earlier design concatenated each speaker's whole monologue at their earliest timestamp, so two voices played in parallel for the entire video). Each speaker is locked to one shared `voice_select` voice; a `sound_effect` per SFX and a `music` bed (styled after the AudD-identified track when available, ducked under the voices) round out the mix (`audio_timeline`). The final mux normalizes the soundtrack to **−14 LUFS (stereo)** so the output plays loud in every player — the raw mix is quiet mono, which reads as "no sound."
|
|
3620
|
+
**Sequenced audio.** Dialogue is a back-and-forth on one absolute timeline, so each **contiguous same-speaker turn** becomes its own `tts` placed at its real `start_s` — turns alternate and never stack (the earlier design concatenated each speaker's whole monologue at their earliest timestamp, so two voices played in parallel for the entire video). Each speaker is locked to one shared `voice_select` voice; a `sound_effect` per SFX and a `music` bed (styled after the AudD-identified track when available, ducked under the voices, and started at the reference's `music.starts_at_s` rather than always at 0) round out the mix (`audio_timeline`). The final mux normalizes the soundtrack to **−14 LUFS (stereo)** so the output plays loud in every player — the raw mix is quiet mono, which reads as "no sound."
|
|
3621
|
+
|
|
3622
|
+
**Native talking heads (no post-hoc lip-sync).** Seedance 2.0 generates lip-synced speech **natively** — a scene with a **single on-camera speaker** (a `dialogue.speaker` that maps to a `global.cast` member, when `global.voiceover.mode` isn't pure `voiceover`/`none`) puts the line in the clip's prompt with `generate_audio`, so lips and voice are generated together (no `video_lipsync`/veed). Each native clip's audio is then re-voiced through `audio_voice_convert` (ElevenLabs Voice Changer) to **one brand voice** so every scene sounds like the same person, timing preserved so the lips stay matched. Off-camera narration keeps a sequenced `tts` per turn; scenes with two on-camera speakers stay native-per-clip and are flagged for you to split or pick a primary.
|
|
3564
3623
|
|
|
3565
|
-
**
|
|
3624
|
+
**Native-audio drift guard.** Seedance paces a spoken line to fill the clip, so each native clip is generated **long enough for the estimated speech** (≈150 wpm) — not just the visual scene length — and its audio is extracted at the **full line length** rather than hard-trimmed to the scene, so a line that runs a beat long isn't cut mid-word (it continues over the next scene as natural VO). `metadata.video.talking_scenes` records each scene's `scene_s` vs `est_speech_s` so you can spot a line that overruns its cut.
|
|
3566
3625
|
|
|
3567
3626
|
**Timeline-accurate picture.** Seedance can't render under 4s, so each clip is generated at the smallest allowed duration ≥ the scene length and then **trimmed back to the exact scene duration** before concat. This keeps the concatenated picture on the same timeline as the absolute-timed audio — without it, short scenes balloon to 4s, the spine runs far longer than the soundtrack, and every line plays over the wrong (slowed) scene so the lips never match. Frames are also prompted as **clean text-free plates** (no baked captions/lower-thirds/tickers/logos-as-text) so the overlay layer is the single source of on-screen text.
|
|
3568
3627
|
|
|
3628
|
+
**Scene transitions.** When the deconstruct flags a boundary as `fade`/`whip`/`zoom`/`dissolve`/`swipe` (`scene.transition_out`), the spine reproduces it as an ffmpeg **`xfade`** instead of a hard cut; plain `cut`/`match_cut` stay hard cuts. The overlap is consumed from **extra generated footage** (each transitioning clip is trimmed to `scene_s + transition` and the xfade `offset` lands on the cumulative scene start), so the total length still equals the sum of the scene lengths — the picture stays exactly on the audio timeline.
|
|
3629
|
+
|
|
3630
|
+
**One person, multiple looks.** If a single individual plays multiple personas/wardrobes (e.g. a creator as a skeptic then a believer), the selection pass emits **one element per look** linked via `same_as` — each outfit gets its own reference slot, but the frame legend and the `el_*` TODO tell the generator they are the **same person** (keep the face identical, change only wardrobe). The always-on `metadata.todo.completeness_check` reminds you to split a person collapsed into a single slot.
|
|
3631
|
+
|
|
3569
3632
|
**Overlays are agent-painted HTML, not props.** The clips are concatenated, then the `video-overlay` composition (copied next to the canvas) composites the overlay layer. The scaffold **bakes the reference's overlays into that composition's `index.html` as real, editable HTML** (each overlay is a plain element with its text, a `.pos-*` position class, and `data-start`/`data-dur` timing); a tiny generic runtime only shows/hides each element at its timestamp (with an optional `data-anim` entrance). It makes **no styling decisions** — bars, tickers, colors, fonts, and a real logo `<img>` you drop into the dir all live in the HTML/CSS you edit. Floating elements (logo bugs) are seeded as commented `<img>` stubs so an un-edited render stays clean. Drop `brand-bold.otf`/`brand-regular.otf` for on-brand type.
|
|
3570
3633
|
|
|
3571
|
-
The emitted canvas is validated (`validateCanvasDeep`) before it's written, so it always runs. It also carries a **`metadata.video`** timing plan that `baker canvas validate` proves **statically, before any billed render**: no two voiceover turns overlap, the audio length ≈ the video length, and every single-on-camera-speaker scene
|
|
3634
|
+
The emitted canvas is validated (`validateCanvasDeep`) before it's written, so it always runs. It also carries a **`metadata.video`** timing plan that `baker canvas validate` proves **statically, before any billed render**: no two voiceover turns overlap, the audio length ≈ the video length, and every single-on-camera-speaker scene is a native talking head (its clip carries `generate_audio` and is wired to an `audio_voice_convert` node). The full editable checklist is embedded as **`metadata.todo`** (with a step-by-step guide in `metadata.description`). stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
|
|
3572
3635
|
|
|
3573
3636
|
```bash
|
|
3574
3637
|
baker canvas scaffold-video ./reference-ad.mp4 --focus "competitor UGC ad for <brand>"
|
|
@@ -3582,6 +3645,8 @@ baker canvas run ./reference-ad.video.canvas.json
|
|
|
3582
3645
|
|---|---|---|
|
|
3583
3646
|
| `--out <path>` | `<video-dir>/<name>.video.canvas.json` | Where to write the canvas (composition is copied alongside). |
|
|
3584
3647
|
| `--frames <mode>` | `generate` | `generate` regenerates frames anchored on the originals; `reuse` wires the real extracted frames straight into the clips (faithful, cheaper). |
|
|
3648
|
+
| `--ambient` | off | Give silent **b-roll** scenes native diegetic ambient (Seedance `generate_audio`), mixed deep under the music bed. Talking scenes already carry voice; check levels don't muddy the mix before keeping it. |
|
|
3649
|
+
| `--actor-sheets` | off | Lock a recast **person/animal that recurs across ≥2 scenes** to ONE multi-view turnaround (`image_reference_sheet`) that every frame grounds on — the strongest cross-scene identity lock. Costs extra credits per sheet; a fused sheet can over-polish, so eyeball it. |
|
|
3585
3650
|
| `--max-scenes <n>` | all source scenes | **Cost lever that reduces fidelity** — caps the deconstruct, MERGING away every scene beyond the cap (fewer cuts, lost beats). Prints a warning when set; omit it to reproduce every scene. |
|
|
3586
3651
|
| `--language <code>` | auto | Transcript/dialogue language hint (e.g. `fr`, `en`). |
|
|
3587
3652
|
| `--focus <text>` | — | Known provenance/emphasis to ground the deconstruct. |
|
|
@@ -3590,6 +3655,8 @@ baker canvas run ./reference-ad.video.canvas.json
|
|
|
3590
3655
|
| `--image-model <id>` | `openai/gpt-5.4-image-2` | Override the per-frame `image_generate` model (defaults to the strongest, matching `scaffold-static-ad`). |
|
|
3591
3656
|
| `--video-model <id>` | `bytedance/seedance-2.0` | Override the `video_generate` model. |
|
|
3592
3657
|
|
|
3658
|
+
Each scene is captured in a **shoot mode** — `ugc_selfie` (talking heads, the default look), `ugc_broll`, `studio_product` (pack shot), `lifestyle_cinematic`, or `screen_ui`. The scaffold derives one per scene (UGC by default; the cinematic and screen lanes are opt-in) and bakes its capture block into the frame and a camera default into the clip; override per scene with a `shoot_mode` field in `prompt.json`. Capture aesthetic + depth-of-field follow the mode (UGC stays flat; studio/lifestyle allow shallow DoF). Clips also carry **diegetic native audio** — the scene's own ambience described in the Seedance prompt, never music (the music bed is a separate, ducked track); set a scene's `ambient` field to steer it.
|
|
3659
|
+
|
|
3593
3660
|
The two scaffold passes are billed (the full `video_deconstruct` is the heavy one); **running** the result then generates many image/video/audio assets and is not free. Defaults to vertical 1080×1920 overlays — copy + edit the composition for other aspect ratios. For on-brand overlay type, drop `brand-bold.otf`/`brand-regular.otf` into the copied `video-overlay-composition/` dir (wired via `@font-face`, with a system fallback). Richer transcription (punctuated words + paragraphs) is available via the deconstruct's `transcriber: "deepgram"` param when `DEEPGRAM_API_KEY` is set.
|
|
3594
3661
|
|
|
3595
3662
|
#### `baker canvas scaffold-static-ad <image> [flags]`
|
|
@@ -4026,6 +4093,7 @@ For video rendering, use `hyperframe_render` instead — same composition format
|
|
|
4026
4093
|
- **GSAP animations.** Use `gsap.set()` for visibility transitions — `fromTo(opacity)` silently fails under Hyperframes' screenshot capture.
|
|
4027
4094
|
- **Single root timeline.** `window.__timelines["main"]` only. Sub-composition timelines aren't scrubbed.
|
|
4028
4095
|
- **Cache invalidation.** The composition directory is hashed recursively; editing any file invalidates the cache automatically.
|
|
4096
|
+
- **Pre-render gate.** Both nodes run `hyperframes lint` + `inspect` on the substituted composition before rendering. Keep content inside its container (text/container **overflow blocks** the render — shrink the font, widen the container, or mark intentional bleed with `data-layout-allow-overflow`). Use auto-resolved fonts (generic families, `Arial`) or declare `@font-face` for named/brand fonts — an undeclared named font (`Impact`, `system-ui`, `Roboto`, …) raises a font warning. Low contrast and missing assets only warn.
|
|
4029
4097
|
|
|
4030
4098
|
---
|
|
4031
4099
|
|
|
@@ -4045,6 +4113,18 @@ Non-linear vertical scroll of a tall app screenshot, driven by a GSAP timeline.
|
|
|
4045
4113
|
|
|
4046
4114
|
Output is a 720×1280 MP4 (modern phone aspect) intended to feed straight into an `ffmpeg` chromakey filter graph as the `app` overlay.
|
|
4047
4115
|
|
|
4116
|
+
#### Reusable motion-graphic "moves"
|
|
4117
|
+
|
|
4118
|
+
A library of proven, parameterized motion graphics ships under `packages/cli/canvas/` so agents reuse them instead of authoring HTML/GSAP from scratch. Each is 1080×1920, `lint`/`inspect`-clean, and exposes a `@font-face` hook for `brand-bold.otf` / `brand-regular.otf`:
|
|
4119
|
+
|
|
4120
|
+
- **`title-card-composition`** — kinetic kicker + headline (word-by-word rise) + subhead on a gradient.
|
|
4121
|
+
- **`lower-third-composition`** — animated name/title bar that wipes in, holds, slides out (transparent bg).
|
|
4122
|
+
- **`stat-counter-composition`** — a number that counts up to a value with prefix/suffix + label (transparent bg).
|
|
4123
|
+
- **`feature-reveal-composition`** — a check-marked feature list that staggers in (transparent bg).
|
|
4124
|
+
- **`end-card-composition`** — logo (inline SVG) + closing line + CTA pill.
|
|
4125
|
+
|
|
4126
|
+
Render a still with `hyperframe_snapshot` or an animated clip with `hyperframe_render`; the transparent-bg moves render to an alpha overlay with `format: "webm"`.
|
|
4127
|
+
|
|
4048
4128
|
---
|
|
4049
4129
|
|
|
4050
4130
|
### Programmatic use
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=1080, height=1920" />
|
|
6
|
+
<script src="./gsap.min.js"></script>
|
|
7
|
+
<style>
|
|
8
|
+
@font-face {
|
|
9
|
+
font-family: 'BrandFont';
|
|
10
|
+
src: url('./brand-bold.otf') format('opentype'), url('./brand-bold.ttf') format('truetype');
|
|
11
|
+
font-weight: 700 900;
|
|
12
|
+
font-display: swap;
|
|
13
|
+
}
|
|
14
|
+
@font-face {
|
|
15
|
+
font-family: 'BrandFont';
|
|
16
|
+
src: url('./brand-regular.otf') format('opentype'), url('./brand-regular.ttf') format('truetype');
|
|
17
|
+
font-weight: 100 600;
|
|
18
|
+
font-display: swap;
|
|
19
|
+
}
|
|
20
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
21
|
+
html, body { width: 1080px; height: 1920px; overflow: hidden; }
|
|
22
|
+
#root {
|
|
23
|
+
position: absolute; inset: 0; background: {{bg_color}};
|
|
24
|
+
font-family: 'BrandFont', Arial, sans-serif;
|
|
25
|
+
color: {{text_color}};
|
|
26
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 56px; padding: 120px;
|
|
27
|
+
text-align: center;
|
|
28
|
+
}
|
|
29
|
+
.logo { width: 360px; max-height: 200px; opacity: 0; transform: translateY(20px); }
|
|
30
|
+
.logo svg { width: 100%; height: auto; display: block; }
|
|
31
|
+
.headline { font-size: 84px; font-weight: 900; line-height: 1.08; letter-spacing: -1px; max-width: 860px; text-wrap: balance; opacity: 0; transform: translateY(24px); }
|
|
32
|
+
.cta {
|
|
33
|
+
font-size: 46px; font-weight: 700; padding: 32px 64px; border-radius: 999px;
|
|
34
|
+
background: {{cta_bg}}; color: {{cta_text_color}}; opacity: 0; transform: scale(0.8);
|
|
35
|
+
}
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<div id="root" data-composition-id="main" data-start="0" data-duration="{{duration}}" data-width="1080" data-height="1920">
|
|
40
|
+
<div class="logo" id="logo">{{logo_svg}}</div>
|
|
41
|
+
<h1 class="headline" id="headline">{{headline}}</h1>
|
|
42
|
+
<div class="cta" id="cta">{{cta_text}}</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<script>
|
|
46
|
+
(() => {
|
|
47
|
+
var DURATION = parseFloat('{{duration}}') || 3;
|
|
48
|
+
var logoEl = document.getElementById('logo');
|
|
49
|
+
var ctaEl = document.getElementById('cta');
|
|
50
|
+
var hasLogo = (logoEl.innerHTML || '').trim().length > 0;
|
|
51
|
+
var hasCta = ((ctaEl.textContent) || '').trim().length > 0;
|
|
52
|
+
if (!hasLogo) logoEl.style.display = 'none';
|
|
53
|
+
if (!hasCta) ctaEl.style.display = 'none';
|
|
54
|
+
|
|
55
|
+
var tl = gsap.timeline({ paused: true });
|
|
56
|
+
if (hasLogo) tl.to('#logo', { opacity: 1, y: 0, duration: 0.5, ease: 'power3.out' }, 0.1);
|
|
57
|
+
tl.to('#headline', { opacity: 1, y: 0, duration: 0.5, ease: 'power3.out' }, 0.3);
|
|
58
|
+
if (hasCta) tl.to('#cta', { opacity: 1, scale: 1, duration: 0.45, ease: 'back.out(1.7)' }, 0.6);
|
|
59
|
+
tl.to({}, { duration: 0.01 }, Math.max(0.02, DURATION - 0.01));
|
|
60
|
+
|
|
61
|
+
window.__timelines = window.__timelines || {};
|
|
62
|
+
window.__timelines.main = tl;
|
|
63
|
+
})();
|
|
64
|
+
</script>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "end-card",
|
|
3
|
+
"title": "End card — logo, closing line, CTA pill",
|
|
4
|
+
"description": "A closing/pack-shot card: optional inline-SVG wordmark, a closing headline, and a call-to-action pill that pops in. Renders a solid background, so it works as a final clip (mp4) or, with format=webm/mov, a transparent end band over a product shot. Drop brand-bold.otf / brand-regular.otf in this dir for on-brand type.",
|
|
5
|
+
"width": 1080,
|
|
6
|
+
"height": 1920,
|
|
7
|
+
"fps": 30,
|
|
8
|
+
"default_duration": 3,
|
|
9
|
+
"inputs": {},
|
|
10
|
+
"params": {
|
|
11
|
+
"logo_svg": { "kind": "string", "default": "", "description": "Raw inline <svg>…</svg> wordmark with fill set to text_color (optional)." },
|
|
12
|
+
"headline": { "kind": "string", "required": true, "description": "Closing line." },
|
|
13
|
+
"cta_text": { "kind": "string", "default": "", "description": "Call-to-action pill label (optional)." },
|
|
14
|
+
"bg_color": { "kind": "color", "default": "#0b1020" },
|
|
15
|
+
"text_color": { "kind": "color", "default": "#ffffff" },
|
|
16
|
+
"cta_bg": { "kind": "color", "default": "#5b8cff" },
|
|
17
|
+
"cta_text_color": { "kind": "color", "default": "#0b1020" }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=1080, height=1920" />
|
|
6
|
+
<script src="./gsap.min.js"></script>
|
|
7
|
+
<style>
|
|
8
|
+
@font-face {
|
|
9
|
+
font-family: 'BrandFont';
|
|
10
|
+
src: url('./brand-bold.otf') format('opentype'), url('./brand-bold.ttf') format('truetype');
|
|
11
|
+
font-weight: 700 900;
|
|
12
|
+
font-display: swap;
|
|
13
|
+
}
|
|
14
|
+
@font-face {
|
|
15
|
+
font-family: 'BrandFont';
|
|
16
|
+
src: url('./brand-regular.otf') format('opentype'), url('./brand-regular.ttf') format('truetype');
|
|
17
|
+
font-weight: 100 600;
|
|
18
|
+
font-display: swap;
|
|
19
|
+
}
|
|
20
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
21
|
+
html, body { width: 1080px; height: 1920px; overflow: hidden; background: transparent; }
|
|
22
|
+
#root {
|
|
23
|
+
position: absolute; inset: 0; background: transparent;
|
|
24
|
+
font-family: 'BrandFont', Arial, sans-serif;
|
|
25
|
+
color: {{text_color}};
|
|
26
|
+
display: flex; flex-direction: column; justify-content: center; padding: 130px 110px;
|
|
27
|
+
}
|
|
28
|
+
.title { font-size: 72px; font-weight: 900; line-height: 1.08; margin-bottom: 56px; text-wrap: balance; opacity: 0; transform: translateY(24px); }
|
|
29
|
+
.list { display: flex; flex-direction: column; gap: 36px; }
|
|
30
|
+
.item { display: flex; align-items: center; gap: 32px; opacity: 0; transform: translateX(-40px); }
|
|
31
|
+
.circle {
|
|
32
|
+
flex: 0 0 auto; width: 72px; height: 72px; border-radius: 50%;
|
|
33
|
+
background: {{accent_color}}; display: flex; align-items: center; justify-content: center;
|
|
34
|
+
}
|
|
35
|
+
.circle svg { width: 38px; height: 38px; }
|
|
36
|
+
.item .text { font-size: 52px; font-weight: 600; line-height: 1.15; }
|
|
37
|
+
</style>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<div id="root" data-composition-id="main" data-start="0" data-duration="{{duration}}" data-width="1080" data-height="1920">
|
|
41
|
+
<h2 class="title" id="title">{{title}}</h2>
|
|
42
|
+
<div class="list" id="list"></div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<script>
|
|
46
|
+
(() => {
|
|
47
|
+
var DURATION = parseFloat('{{duration}}') || 5;
|
|
48
|
+
var STAGGER = parseFloat('{{stagger_s}}') || 0.18;
|
|
49
|
+
var features = {{features}};
|
|
50
|
+
var CHECK = '{{check_color}}';
|
|
51
|
+
|
|
52
|
+
var hasTitle = ((document.getElementById('title').textContent) || '').trim().length > 0;
|
|
53
|
+
if (!hasTitle) document.getElementById('title').style.display = 'none';
|
|
54
|
+
|
|
55
|
+
var list = document.getElementById('list');
|
|
56
|
+
(features || []).forEach(function (label) {
|
|
57
|
+
var item = document.createElement('div');
|
|
58
|
+
item.className = 'item';
|
|
59
|
+
var circle = document.createElement('div');
|
|
60
|
+
circle.className = 'circle';
|
|
61
|
+
circle.innerHTML = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 12.5l4.5 4.5L19 7" stroke="' + CHECK + '" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
62
|
+
var text = document.createElement('div');
|
|
63
|
+
text.className = 'text';
|
|
64
|
+
text.textContent = String(label);
|
|
65
|
+
item.appendChild(circle);
|
|
66
|
+
item.appendChild(text);
|
|
67
|
+
list.appendChild(item);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
var items = Array.prototype.slice.call(list.querySelectorAll('.item'));
|
|
71
|
+
var tl = gsap.timeline({ paused: true });
|
|
72
|
+
if (hasTitle) tl.to('#title', { opacity: 1, y: 0, duration: 0.45, ease: 'power3.out' }, 0.1);
|
|
73
|
+
if (items.length) {
|
|
74
|
+
tl.to(items, { opacity: 1, x: 0, duration: 0.45, stagger: STAGGER, ease: 'power3.out' }, hasTitle ? 0.4 : 0.15);
|
|
75
|
+
}
|
|
76
|
+
tl.to({}, { duration: 0.01 }, Math.max(0.02, DURATION - 0.01));
|
|
77
|
+
|
|
78
|
+
window.__timelines = window.__timelines || {};
|
|
79
|
+
window.__timelines.main = tl;
|
|
80
|
+
})();
|
|
81
|
+
</script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "feature-reveal",
|
|
3
|
+
"title": "Feature reveal — checklist items animate in staggered",
|
|
4
|
+
"description": "An optional title plus a list of check-marked feature lines that stagger in one by one, then hold. Transparent background — render with format=webm/mov for an alpha overlay over footage, or format=mp4 over a color. Drop brand-bold.otf / brand-regular.otf in this dir for on-brand type.",
|
|
5
|
+
"width": 1080,
|
|
6
|
+
"height": 1920,
|
|
7
|
+
"fps": 30,
|
|
8
|
+
"default_duration": 5,
|
|
9
|
+
"inputs": {},
|
|
10
|
+
"params": {
|
|
11
|
+
"title": { "kind": "string", "default": "", "description": "Optional heading above the list." },
|
|
12
|
+
"features": { "kind": "json", "required": true, "description": "Array of 1–6 short labels, e.g. [\"No setup fees\", \"Cancel anytime\"]." },
|
|
13
|
+
"text_color": { "kind": "color", "default": "#0b1020" },
|
|
14
|
+
"accent_color": { "kind": "color", "default": "#1f9d57", "description": "Check-circle fill." },
|
|
15
|
+
"check_color": { "kind": "color", "default": "#ffffff", "description": "The check mark inside the circle." },
|
|
16
|
+
"stagger_s": { "kind": "number", "default": 0.18, "min": 0.02, "max": 1, "description": "Seconds between each item." }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=1080, height=1920" />
|
|
6
|
+
<script src="./gsap.min.js"></script>
|
|
7
|
+
<style>
|
|
8
|
+
@font-face {
|
|
9
|
+
font-family: 'BrandFont';
|
|
10
|
+
src: url('./brand-bold.otf') format('opentype'), url('./brand-bold.ttf') format('truetype');
|
|
11
|
+
font-weight: 700 900;
|
|
12
|
+
font-display: swap;
|
|
13
|
+
}
|
|
14
|
+
@font-face {
|
|
15
|
+
font-family: 'BrandFont';
|
|
16
|
+
src: url('./brand-regular.otf') format('opentype'), url('./brand-regular.ttf') format('truetype');
|
|
17
|
+
font-weight: 100 600;
|
|
18
|
+
font-display: swap;
|
|
19
|
+
}
|
|
20
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
21
|
+
html, body { width: 1080px; height: 1920px; overflow: hidden; background: transparent; }
|
|
22
|
+
#root {
|
|
23
|
+
position: absolute; inset: 0; background: transparent;
|
|
24
|
+
font-family: 'BrandFont', Arial, sans-serif;
|
|
25
|
+
}
|
|
26
|
+
.l3 {
|
|
27
|
+
position: absolute; left: 64px; bottom: {{from_bottom_px}}px;
|
|
28
|
+
display: flex; align-items: stretch; max-width: 900px;
|
|
29
|
+
filter: drop-shadow(0 12px 30px rgba(0,0,0,0.35));
|
|
30
|
+
}
|
|
31
|
+
.accent { width: 16px; background: {{accent_color}}; border-radius: 4px 0 0 4px; transform: scaleY(0); transform-origin: bottom; }
|
|
32
|
+
.panel {
|
|
33
|
+
background: {{bar_color}}; color: {{text_color}};
|
|
34
|
+
padding: 30px 44px; border-radius: 0 8px 8px 0; overflow: hidden;
|
|
35
|
+
clip-path: inset(0 100% 0 0);
|
|
36
|
+
}
|
|
37
|
+
.name { font-size: 60px; font-weight: 800; line-height: 1.05; white-space: nowrap; }
|
|
38
|
+
.title { font-size: 34px; font-weight: 500; opacity: 0.85; margin-top: 8px; white-space: nowrap; }
|
|
39
|
+
.name, .title { transform: translateY(20px); opacity: 0; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<div id="root" data-composition-id="main" data-start="0" data-duration="{{duration}}" data-width="1080" data-height="1920">
|
|
44
|
+
<div class="l3" id="l3">
|
|
45
|
+
<div class="accent" id="accent"></div>
|
|
46
|
+
<div class="panel" id="panel">
|
|
47
|
+
<div class="name" id="name">{{name}}</div>
|
|
48
|
+
<div class="title" id="title">{{title}}</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<script>
|
|
54
|
+
(() => {
|
|
55
|
+
var DURATION = parseFloat('{{duration}}') || 4;
|
|
56
|
+
var hasTitle = ((document.getElementById('title').textContent) || '').trim().length > 0;
|
|
57
|
+
if (!hasTitle) document.getElementById('title').style.display = 'none';
|
|
58
|
+
|
|
59
|
+
var tl = gsap.timeline({ paused: true });
|
|
60
|
+
// IN
|
|
61
|
+
tl.to('#accent', { scaleY: 1, duration: 0.35, ease: 'power3.out' }, 0.1);
|
|
62
|
+
tl.to('#panel', { clipPath: 'inset(0 0% 0 0)', duration: 0.45, ease: 'power3.out' }, 0.25);
|
|
63
|
+
tl.to('#name', { y: 0, opacity: 1, duration: 0.4, ease: 'power2.out' }, 0.45);
|
|
64
|
+
if (hasTitle) tl.to('#title', { y: 0, opacity: 0.85, duration: 0.4, ease: 'power2.out' }, 0.55);
|
|
65
|
+
// OUT — slide down + fade in the last 0.5s
|
|
66
|
+
var outAt = Math.max(1.0, DURATION - 0.5);
|
|
67
|
+
tl.to('#l3', { y: 60, opacity: 0, duration: 0.4, ease: 'power2.in' }, outAt);
|
|
68
|
+
tl.to({}, { duration: 0.01 }, Math.max(0.02, DURATION - 0.01));
|
|
69
|
+
|
|
70
|
+
window.__timelines = window.__timelines || {};
|
|
71
|
+
window.__timelines.main = tl;
|
|
72
|
+
})();
|
|
73
|
+
</script>
|
|
74
|
+
</body>
|
|
75
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "lower-third",
|
|
3
|
+
"title": "Lower-third name bar — animated name + title",
|
|
4
|
+
"description": "A transparent name/title bar that wipes in, holds, and slides out. Renders on a transparent background, so render with format=webm (or mov) to get an alpha overlay you composite over footage in the ffmpeg node or an editor, or paste it into another composition. Drop brand-bold.otf / brand-regular.otf in this dir for on-brand type.",
|
|
5
|
+
"width": 1080,
|
|
6
|
+
"height": 1920,
|
|
7
|
+
"fps": 30,
|
|
8
|
+
"default_duration": 4,
|
|
9
|
+
"inputs": {},
|
|
10
|
+
"params": {
|
|
11
|
+
"name": { "kind": "string", "required": true, "description": "Primary line (person/brand name)." },
|
|
12
|
+
"title": { "kind": "string", "default": "", "description": "Secondary line (role/handle)." },
|
|
13
|
+
"bar_color": { "kind": "color", "default": "#0b1020" },
|
|
14
|
+
"text_color": { "kind": "color", "default": "#ffffff" },
|
|
15
|
+
"accent_color": { "kind": "color", "default": "#5b8cff", "description": "The leading accent block." },
|
|
16
|
+
"from_bottom_px": { "kind": "integer", "default": 220, "min": 0, "max": 1600, "description": "Distance of the bar from the bottom edge." }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=1080, height=1920" />
|
|
6
|
+
<script src="./gsap.min.js"></script>
|
|
7
|
+
<style>
|
|
8
|
+
@font-face {
|
|
9
|
+
font-family: 'BrandFont';
|
|
10
|
+
src: url('./brand-bold.otf') format('opentype'), url('./brand-bold.ttf') format('truetype');
|
|
11
|
+
font-weight: 700 900;
|
|
12
|
+
font-display: swap;
|
|
13
|
+
}
|
|
14
|
+
@font-face {
|
|
15
|
+
font-family: 'BrandFont';
|
|
16
|
+
src: url('./brand-regular.otf') format('opentype'), url('./brand-regular.ttf') format('truetype');
|
|
17
|
+
font-weight: 100 600;
|
|
18
|
+
font-display: swap;
|
|
19
|
+
}
|
|
20
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
21
|
+
html, body { width: 1080px; height: 1920px; overflow: hidden; background: transparent; }
|
|
22
|
+
#root {
|
|
23
|
+
position: absolute; inset: 0; background: transparent;
|
|
24
|
+
font-family: 'BrandFont', Arial, sans-serif;
|
|
25
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80px;
|
|
26
|
+
}
|
|
27
|
+
.value {
|
|
28
|
+
font-size: 240px; font-weight: 900; line-height: 1; letter-spacing: -4px; color: {{value_color}};
|
|
29
|
+
font-variant-numeric: tabular-nums; text-align: center; transform: scale(0.9); opacity: 0;
|
|
30
|
+
}
|
|
31
|
+
.label { font-size: 48px; font-weight: 500; color: {{label_color}}; margin-top: 36px; text-align: center; max-width: 820px; text-wrap: balance; opacity: 0; }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<div id="root" data-composition-id="main" data-start="0" data-duration="{{duration}}" data-width="1080" data-height="1920">
|
|
36
|
+
<div class="value" id="value">{{prefix}}0{{suffix}}</div>
|
|
37
|
+
<div class="label" id="label">{{label}}</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<script>
|
|
41
|
+
(() => {
|
|
42
|
+
var DURATION = parseFloat('{{duration}}') || 3;
|
|
43
|
+
var TARGET = parseFloat('{{value}}') || 0;
|
|
44
|
+
var DECIMALS = parseInt('{{decimals}}', 10) || 0;
|
|
45
|
+
var COUNT_S = parseFloat('{{count_s}}') || 1.2;
|
|
46
|
+
var PREFIX = '{{prefix}}';
|
|
47
|
+
var SUFFIX = '{{suffix}}';
|
|
48
|
+
var valueEl = document.getElementById('value');
|
|
49
|
+
var hasLabel = ((document.getElementById('label').textContent) || '').trim().length > 0;
|
|
50
|
+
if (!hasLabel) document.getElementById('label').style.display = 'none';
|
|
51
|
+
|
|
52
|
+
function render(n) {
|
|
53
|
+
var fixed = Number(n).toFixed(DECIMALS);
|
|
54
|
+
// group thousands for readability
|
|
55
|
+
var parts = fixed.split('.');
|
|
56
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
57
|
+
valueEl.textContent = PREFIX + parts.join('.') + SUFFIX;
|
|
58
|
+
}
|
|
59
|
+
render(0);
|
|
60
|
+
|
|
61
|
+
var counter = { n: 0 };
|
|
62
|
+
var tl = gsap.timeline({ paused: true });
|
|
63
|
+
tl.to('#value', { scale: 1, opacity: 1, duration: 0.4, ease: 'back.out(1.6)' }, 0.1);
|
|
64
|
+
tl.to(counter, { n: TARGET, duration: COUNT_S, ease: 'power1.out', onUpdate: function () { render(counter.n); } }, 0.15);
|
|
65
|
+
if (hasLabel) tl.to('#label', { opacity: 1, duration: 0.4, ease: 'power2.out' }, 0.2 + COUNT_S * 0.5);
|
|
66
|
+
tl.to({}, { duration: 0.01 }, Math.max(0.02, DURATION - 0.01));
|
|
67
|
+
|
|
68
|
+
window.__timelines = window.__timelines || {};
|
|
69
|
+
window.__timelines.main = tl;
|
|
70
|
+
})();
|
|
71
|
+
</script>
|
|
72
|
+
</body>
|
|
73
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "stat-counter",
|
|
3
|
+
"title": "Animated stat counter — number counts up to a value",
|
|
4
|
+
"description": "A big number that counts up from zero to a target, with a prefix/suffix and a label underneath. Transparent background — render with format=webm/mov for an alpha overlay, or format=mp4 over a color. Good for data beats (\"$2.4M raised\", \"98% retention\"). Drop brand-bold.otf / brand-regular.otf in this dir for on-brand type.",
|
|
5
|
+
"width": 1080,
|
|
6
|
+
"height": 1920,
|
|
7
|
+
"fps": 30,
|
|
8
|
+
"default_duration": 3,
|
|
9
|
+
"inputs": {},
|
|
10
|
+
"params": {
|
|
11
|
+
"value": { "kind": "number", "required": true, "description": "Target number to count up to." },
|
|
12
|
+
"decimals": { "kind": "integer", "default": 0, "min": 0, "max": 4, "description": "Decimal places to display." },
|
|
13
|
+
"prefix": { "kind": "string", "default": "", "description": "e.g. \"$\"." },
|
|
14
|
+
"suffix": { "kind": "string", "default": "", "description": "e.g. \"%\", \"M\", \"x\"." },
|
|
15
|
+
"label": { "kind": "string", "default": "", "description": "Caption under the number." },
|
|
16
|
+
"value_color": { "kind": "color", "default": "#ffffff" },
|
|
17
|
+
"label_color": { "kind": "color", "default": "#9fb3d1" },
|
|
18
|
+
"count_s": { "kind": "number", "default": 1.2, "min": 0.2, "max": 6, "description": "Seconds spent counting up." }
|
|
19
|
+
}
|
|
20
|
+
}
|