@scenerok/sdk 0.26.0 → 0.28.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.
@@ -0,0 +1,111 @@
1
+ # Choosing tools for a beat
2
+
3
+ SceneRok's blocks, motion, transitions, filters and media are a repertoire,
4
+ not a checklist. Nothing is required, nothing is decoration by default: each
5
+ beat gets what its job calls for. `repertoire.md` says what every tool looks
6
+ like, what it is for, and what it fights. This file is how to use that.
7
+
8
+ ## The procedure
9
+
10
+ For each beat:
11
+
12
+ 1. **Name its job** with one word from the job list in `repertoire.md`
13
+ (announce, count, quote, code, prove, demo, establish, cta, ...), and the one
14
+ thing the viewer must take away.
15
+ 2. **Put the material on the table.** What do you actually have for this beat:
16
+ the source's photos, screenshots, footage, logo, the exact numbers and words?
17
+ You can only choose a picture you have looked at.
18
+ 3. **List the candidates** under that job in `repertoire.md` → Find tools by job.
19
+ Plain `text` and "nothing extra" are always candidates.
20
+ 4. **Drop what fights.** Read each candidate's *Fights* against your content
21
+ (length of the words, busyness of the picture), its neighbours (the beat
22
+ before and after, anything else moving) and the frame (9:16 or 16:9: read
23
+ *Fit*).
24
+ 5. **Choose by the look.** Read *Looks* for the survivors and pick the frame you
25
+ want the viewer to see. When two would do, take the simpler.
26
+ 6. **Write one line**: `<beat>: <tool> because <reason>`. If you can't finish
27
+ the sentence, choose something plainer.
28
+ 7. **Verify by looking.** After rendering, open a frame of that beat. If it
29
+ doesn't look like the *Looks* you chose for, the description or your
30
+ placement is wrong. Fix the placement, and report the description.
31
+
32
+ ## When plain text on a still frame is right, and when it is not
33
+
34
+ Right, when:
35
+
36
+ - the words are the content: a quote, a definition, a price, a command, a
37
+ legal line;
38
+ - the viewer needs reading time, and anything moving would compete;
39
+ - the design is typographic on purpose (a brand that speaks in type);
40
+ - there is no honest picture: an abstract claim, an internal change with no
41
+ UI. A stock or generated image would only pretend.
42
+
43
+ Wrong, when:
44
+
45
+ - the source has pictures that show the thing (a product page, an app, a repo
46
+ with a UI) and the beat's job is demo, prove or establish;
47
+ - several beats in a row are text on the same flat colour, so the video turns
48
+ into a slideshow;
49
+ - the text says what a picture could show ("sleek design", "easy to use").
50
+
51
+ The same goes for motion: a still frame is right when the eye should rest.
52
+ Motion is for arrival, emphasis and time passing, and one moving thing at a
53
+ time is easier to follow than three.
54
+
55
+ ## Worked choices
56
+
57
+ **Product promo, hook (9:16).** Job: establish. Material: three lifestyle
58
+ photos from the product page, a 500 px hero shot on white. Candidates: a page
59
+ photo, `xai.imagine`, `animations.liquid_glass_notification`, `text`.
60
+ Dropped: `xai.imagine` fights facts (it would invent a different robot); the
61
+ liquid-glass block is fixed artwork in rainbow colours. Chosen: the lifestyle
62
+ photo as a band over a blurred copy of itself, with a 3 s push, because a still
63
+ held for 3 s reads as a slide and the push gives it time. Headline: `text`
64
+ with `riseIn`.
65
+
66
+ **Customer review (any aspect).** Job: quote. Material: one two-sentence review
67
+ from the page. Candidates: `text_typewriter_quote`, `x_post`, `reddit_post`,
68
+ `text`. Dropped: `x_post` and `reddit_post` would dress the review as a post on
69
+ a platform it never appeared on; the typewriter takes three seconds to type
70
+ what a reader takes two to read. Chosen: plain `text` on a flat dark frame with
71
+ a muted attribution, no motion, held 5 s.
72
+
73
+ **Breaking change in release notes.** Job: announce. Material: one sentence
74
+ from the changelog. Chosen: plain `text`, no entrance, no grade change, held
75
+ 4 s. Gravity is the point; any flourish would make a warning read like
76
+ marketing. Nothing else was considered necessary, and that is a valid outcome
77
+ of step 3.
78
+
79
+ **A stat (9:16).** Job: count. Material: "12 bug fixes" counted from the
80
+ release body. Candidates: `text_counter_stat`, `apple_money_count`, `text` with
81
+ `popIn`. Dropped: `apple_money_count` is money on a white slab. Chosen:
82
+ `text_counter_stat` with `suffix: ""` (its default `+` would turn 12 into
83
+ "12+", which is false).
84
+
85
+ **Code in 9:16.** Job: code. Material: a five-line before/after. Candidates:
86
+ `code_typing`, `code_diff`, a `code_snippet_*` theme, `text` lines. Dropped:
87
+ `code_typing` (in 9:16 its code shrinks to about 17 px). Chosen: `code_diff`,
88
+ which has its own 9:16 layout and keeps the removed line on screen with
89
+ `keep_removed: true`, so the change reads as a before/after. Its *Looks* says it
90
+ sits in the upper half, so the beat's headline goes in the empty lower half
91
+ instead of on top of the code.
92
+
93
+ **Cut between two lifestyle shots in a calm promo.** Job: transition.
94
+ Candidates: every `tr.*`, and a hard cut. Dropped: everything whose *Fights*
95
+ says "calm or premium edits"; `flash` (flicker). Chosen: a hard cut, because
96
+ the shots differ enough to read as a new beat. `dissolve` only if the two shots
97
+ share a subject. A transition is a candidate, not a default.
98
+
99
+ **Talking head, key line.** Job: emphasize. Material: a 16:9 interview, the
100
+ line "it paid for itself". Candidates: a punch-in (scale the clip),
101
+ `text_kinetic_words`, `blend_caption`, `text_glitch_title`. Dropped: kinetic
102
+ words fight a sentence that must be read whole; glitch fights the tone.
103
+ Chosen: a punch-in on the speaker with a two-word caption card in the accent
104
+ colour.
105
+
106
+ ## Before you trust a description
107
+
108
+ The records come from the block source and the catalog previews. Some previews
109
+ predate a block's 9:16 layout, and a few are blank (see each record's *Fit*).
110
+ When a choice rests on a block you have never seen rendered, render a short
111
+ test or look at its preview before building the video around it.
@@ -462,8 +462,10 @@ input a = "log-footage.mp4"
462
462
  [0s .. 4s] = filter "cdl", slope: "1.1 1 0.9", offset: "0 0 0", power: "1 1 1", saturation: 1
463
463
  ```
464
464
 
465
- - `filter "lut"` applies a 3D `.cube` file next to the script (512 KB at
466
- most, up to 33 points per axis). Use the `filter` line, not `Filter("lut")`.
465
+ - `filter "lut"` applies a 3D `.cube` file (512 KB at most, up to 33 points
466
+ per axis). Use the `filter` line, not `Filter("lut")`. A hosted compile (CLI
467
+ render, web, MCP) can't read a local path: give an HTTPS URL or a `data:`
468
+ URI, or use `cdl`.
467
469
  - `filter "cdl"` takes ASC CDL `slope`, `offset`, `power` (three numbers each)
468
470
  and `saturation`.
469
471
 
@@ -29,13 +29,13 @@ Defaults unless the user says otherwise:
29
29
 
30
30
  | Workflow | Load these slugs | Primary APIs (VidScript · SDK) |
31
31
  | --- | --- | --- |
32
- | **Product promo** | `ai-models.md`, `text-styles.md`, `music.md`, `workflow.md`, `brand-setup.md` (+ `sdk-guide.md` if TS) | `xai.imagine` / `await xai.genVideo(...)`, `motion.popIn` / `fadeIn`, `eleven.music` |
32
+ | **Product promo** | `recipes.md`, `look-and-feel.md`, `brand-setup.md` (+ `ai-models.md` when the page has no usable pictures, `sdk-guide.md` if TS) | page images as `input` with a scale push, `motion.riseIn` / `popIn`, `solid` CTA button, `filter "cdl"`, `eleven.music` |
33
33
  | **Motion graphic** | `animations-picker.md` (+ `animations-catalog.md` if needed) | `animations.*` (VidScript HyperFrames; prefer VidScript for catalog blocks) |
34
34
  | **Social proof card** | `animations-picker.md` | `animations.x_post`, `reddit_post`, … (VidScript) |
35
35
  | **Edit existing footage** | `editing-actions.md` (+ `media-stdlib.md` to probe the clips, `transitions-catalog.md` for cuts) | `Trim` / `Speed` / `Loop` / `Crop` / `Mask` / `Overlay` / `Composite`, `blend:`, `duck_under:`, `filter "lut"` (VidScript only) |
36
36
  | **Scene montage** | `transitions-catalog.md`, `ai-models.md` (+ `sdk-guide.md` if TS loops) | `tr.dissolve` · SDK: `import tr from '@scenerok/transitions'` + `t[start][end] = tr.dissolve()` |
37
37
  | **Narrated explainer** | `tts.md`, `text-styles.md`, `music.md`, `workflow.md` (+ `sdk-guide.md` if TS) | `xai.tts` · `await xai.tts(...)` + `t.push(vo)` |
38
- | **Code demo / PR** | `animations-picker.md` (code rows) | `code_typing`, `code_diff` (VidScript) |
38
+ | **Code demo / PR / release notes** | `recipes.md` (release notes), `animations-picker.md` (code rows) | 16:9: `code_typing`, `code_diff`; 9:16: a code card of `text` lines with `motion.typewriter` (VidScript) |
39
39
  | **Text animation / headline / stat** | `animations-picker.md` (text-anim rows) | `animations.text_*` (VidScript) · SDK kinetic: `text` + `fadeIn` / `popIn` |
40
40
  | **Kinetic title only** | `text-styles.md` (+ `sdk-guide.md` if TS) | `text` + `motion.*` · SDK: `text(..., { area })` + `.animate(fadeIn(...))` |
41
41
  | **Custom-authored animation (no block fits)** | `animations-catalog.md` → "Custom HTML" | `animations.custom_html` (VidScript) |
@@ -43,7 +43,7 @@ Defaults unless the user says otherwise:
43
43
 
44
44
  ## Staged routing
45
45
 
46
- 1. **Classify** — workflow from table above
46
+ 1. **Classify** — workflow from table above, then the matching script in `recipes.md`
47
47
  2. **Choose authoring** — VidScript by default and for every editing action (masks, blends, crops, ducking, LUTs, timecode); `@scenerok/sdk` (`sdk-guide.md`) only for loops or computed timing (it has none of the editing actions)
48
48
  3. **Load catalogs** — only what that workflow needs (conditional loading)
49
49
  4. **Name block or API** — VidScript: pick `animations.<fn>` from `animations-picker.md`. SDK: factories (`video` / `loadVideo`, `text`, `fadeIn`) + AI imports (`@scenerok/xai`, …)
@@ -0,0 +1,146 @@
1
+ # Look and feel
2
+
3
+ Decide the look before you write a block. Declare it as `let` values at the top
4
+ of the script and use only those values below. `recipes.md` shows the whole
5
+ system in use.
6
+
7
+ ## Palette
8
+
9
+ Four colours, declared once:
10
+
11
+ | Name | Use | Default (dark) |
12
+ | --- | --- | --- |
13
+ | `bg` | Canvas and plates behind cards | `#0A0C10` |
14
+ | `ink` | Headlines and body copy | `#F4F6FA` |
15
+ | `muted` | Secondary lines: ratings, URLs, captions | `#A3ADBD` |
16
+ | `accent` | One brand colour: kicker, price, bullets, CTA button, highlight bar | sampled from the brand |
17
+
18
+ Sample the accent from the brand's own pixels (a button or logo on the page)
19
+ or from `brand-setup.md`. Don't invent one. Use at most three non-neutral text
20
+ colours (`validate` warns above that).
21
+
22
+ ## Type
23
+
24
+ Use two families: a display face and a body face. `font:` is one Google
25
+ Fonts family, loaded at weight 400. A weight in the name (`"Inter Bold"`,
26
+ `"Inter Black"`) is not a family: the render falls back to a default face.
27
+ For heavy type, use a family that is heavy at 400.
28
+
29
+ Rendered and checked: `Archivo Black` (display), `Inter` (body),
30
+ `JetBrains Mono` (code; turns `=>` into a ligature). Other single-weight
31
+ display families: `Anton`, `Bebas Neue`.
32
+
33
+ Sizes in output pixels (`size:`). `validate` flags text under 2.2 % of the
34
+ canvas height:
35
+
36
+ | Role | 1080x1920 (9:16) | 1920x1080 (16:9) |
37
+ | --- | --- | --- |
38
+ | Hero / price | 120-150 | 120-140 |
39
+ | Headline | 84-104 | 72-88 |
40
+ | Bullet / caption card | 64-72 | 48-56 |
41
+ | Body / kicker / URL (the floor) | 44-48 | 32-36 |
42
+
43
+ Kickers: body face, accent colour, `letter_spacing: 6`-`10`, all caps.
44
+ Validate's text geometry is an estimate. A display face can run 15-20 % wider
45
+ than it predicts, so check the rendered frame before trusting a line to fit.
46
+
47
+ ## Layout and safe areas
48
+
49
+ - Text `x`/`y` place the text's centre (`anchor: "left"` places its
50
+ left-centre). Prefer `area:` when a named region fits. Use explicit pixels
51
+ for designed layouts like the recipes.
52
+ - 9:16: keep copy between y 220 and y 1500 and x 72 to 1008. Feed UI covers
53
+ the top ~8 %, the bottom ~20 % and a right-hand icon rail.
54
+ - 16:9: keep copy inside a 96 px margin. Put the picture on one side and the
55
+ copy on the other.
56
+ - Margins: 48 px at the edge of a card; cards use `radius: 32`-`48`.
57
+ - Show at most 3 text overlays at once, except the lines of a code card.
58
+
59
+ ## Pictures
60
+
61
+ Whether a beat gets a picture is a choice (`choosing.md`). When it does, the
62
+ sources rank like this:
63
+
64
+ 1. **Real**: product photos, logos, app screenshots, footage, screen
65
+ recordings. They prove things; nothing else does.
66
+ 2. **Generated**: `xai.image`, `xai.imageToVideo` from a real photo,
67
+ `xai.imagine` (`ai-models.md`; prompts forbid text). They set a mood and
68
+ invent everything else.
69
+ 3. **Made**: a brand-colour glow or gradient rendered with ffmpeg (recipes →
70
+ Release notes). Atmosphere without a subject.
71
+
72
+ Placing stills in a frame of a different shape:
73
+
74
+ - **Card**: the still at its own aspect, `radius:` and `blend: "normal"`, on
75
+ a plain or blurred plate (a product on white in a 900 px card).
76
+ - **Band + blurred fill**: a wide crop as a full-width band, over a blurred,
77
+ darkened 9:16 copy of the same shot. This works for small or landscape stills.
78
+ - **Cover**: oversize the clip to fill, e.g. 16:9 into 9:16 at
79
+ `width: 3413, height: 1920, x: -1166`. This keeps transitions legal, which
80
+ `Crop` doesn't.
81
+
82
+ Prepare stills with ffmpeg before you place them. Crop out baked-in
83
+ captions, make the blurred fill, and probe the result:
84
+
85
+ ```bash
86
+ ffmpeg -i in.jpg -vf "crop=W:H:X:Y,scale=1080:-2" band.jpg
87
+ ffmpeg -i in.jpg -vf "crop=W:H:X:Y,scale=1080:1920,boxblur=40:2,eq=brightness=-0.18" fill.jpg
88
+ sips -g pixelWidth -g pixelHeight band.jpg
89
+ ```
90
+
91
+ Use `.Crop()` for video footage. A `.Crop()` on a remote still rendered as
92
+ nothing in testing, so crop stills with ffmpeg.
93
+
94
+ ## Sourcing imagery
95
+
96
+ Collect pictures before you write copy:
97
+
98
+ | Source | How |
99
+ | --- | --- |
100
+ | Product page | Image URLs from `og:image`, the JSON-LD `Product.image`, Shopify `/products/<handle>.json` → `images`, Amazon `#landingImage` `data-old-hires` and the gallery `m.media-amazon.com/images/I/…` URLs |
101
+ | Page screenshot | Browser tool at 1440x900 (desktop) and 390x844 (mobile). Crop to the hero, the price card, or the feature you name; never use a full page as a background |
102
+ | Logo | The site's header SVG/PNG, `apple-touch-icon`, or the GitHub org avatar (`https://avatars.githubusercontent.com/u/<id>`) |
103
+ | App or site in motion | A screen recording (QuickTime, `ffmpeg -f avfoundation`, a browser recorder) as an `input` clip |
104
+ | Nothing usable | `xai.image` / `xai.imageToVideo` (`ai-models.md`) |
105
+
106
+ Local `input` files upload with `scenerok render`. Probe every file
107
+ (`sips`, `ffprobe`) and size placements from the measured aspect.
108
+
109
+ ## Files the server must read
110
+
111
+ `filter "lut", file:` and `captions("…srt")` are read by the compiler. On a
112
+ hosted compile (CLI, web, MCP) a local path fails with `local asset not
113
+ found`. Pass an HTTPS URL or a `data:` URI, or grade with `filter "cdl"`,
114
+ which needs no file.
115
+
116
+ ## Motion
117
+
118
+ When you do use motion, these timings read well. What each primitive looks
119
+ like is in `repertoire.md`.
120
+
121
+ | Phase | Typical choice | Timing |
122
+ | --- | --- | --- |
123
+ | Entrance | `motion.riseIn(0.5s, 40-80)` for copy and cards, `motion.popIn(0.4-0.6s)` for prices, buttons, logos, `motion.typewriter` for code, `motion.fadeIn(0.4s)` for secondary lines | Stagger items 0.3-0.6 s apart |
124
+ | Hold | Nothing (rest the eye), or a push on a still `{ type: "scale", from: { scale: 1 }, to: { scale: 1.08 }, easing: "linear" }`, or `motion.float(3s, 10-14)` on a logo or product | A still hold beyond ~3 s should be one you meant: reading time, gravity |
125
+ | Exit | A cut, or `motion.fadeOut(0.3s)` when the element should leave softly | The last 0.3 s of the block |
126
+
127
+ A push on a soft gradient is invisible, so it adds nothing. Fast moves can take
128
+ `motion_blur:` (`editing-actions.md`).
129
+
130
+ ## Pacing
131
+
132
+ - First visible picture and words by 0.3 s; the hook reads in under 1 s.
133
+ - Beats last 2.5-4.5 s each. A 15 s promo is 4 beats; a 30 s video is 6-8.
134
+ - One idea per beat: the hook, the product, three features, the price, and the call to action.
135
+ - Close on the call to action for at least 2.5 s.
136
+
137
+ ## What premium tends to mean
138
+
139
+ Tendencies, not requirements; each is a choice you can argue against.
140
+
141
+ - Real pictures where the subject has them; type alone where the words are the point.
142
+ - One accent, used sparingly; everything else is ink and muted on dark (or the reverse).
143
+ - Big type with tight copy: 3-6 words a line, rarely a paragraph.
144
+ - Restraint: one thing moving at a time, consistent entrances, stillness where the eye should rest.
145
+ - A grade only if the pictures need tying together: `filter "cdl"` warmth or coolness, a `vignette` of about 0.3.
146
+ - Sound that fits the use: many feeds play muted, so the picture must work without it.
@@ -7,7 +7,11 @@ cheaper than hand-composing. **Never assume which plugins exist** — discover
7
7
  them at run time; the catalog grows and changes.
8
8
 
9
9
  Plugins are the reuse-first path: check `intent-router.md` for the workflow
10
- match, and prefer a plugin whenever the user pasted structured source data.
10
+ match. Review a plugin's video with the same questions as your own (`SKILL.md` →
11
+ Review). Draft first and read the filled script. Today the first-party
12
+ `listing-promo` and `release-video` templates set every beat as text over a
13
+ flat colour and leave the product images unused. When the request calls for
14
+ more, change those beats before `render-draft`.
11
15
 
12
16
  ## Workflow (MCP)
13
17
 
@@ -0,0 +1,223 @@
1
+ # Recipes
2
+
3
+ Three complete scripts, each rendered and reviewed. They show one set of
4
+ choices for one brief; the comments say why each tool is there, so you can tell
5
+ which choices carry over to yours and which don't. Choose for your own beats
6
+ with `choosing.md`.
7
+
8
+ ## Product promo (9:16, 15 s)
9
+
10
+ Four beats: hook, hero, features, then price and CTA. The images are the
11
+ product page's own (Amazon `landingImage`, `og:image`, or JSON-LD `image`;
12
+ see `look-and-feel.md` → Sourcing imagery). Prepare them before you write the
13
+ script. Amazon stills are 500 px squares with baked-in captions, so crop to a
14
+ clean region and make a blurred 9:16 backdrop from the same shot:
15
+
16
+ ```bash
17
+ ffmpeg -i room.jpg -vf "crop=500:185:0:95,scale=1080:-2" room-band.jpg
18
+ ffmpeg -i room.jpg -vf "crop=104:185:140:95,scale=1080:1920,boxblur=40:2,eq=brightness=-0.18" room-bg.jpg
19
+ sips -g pixelWidth -g pixelHeight room-band.jpg # 1080x398: place at 984x363
20
+ ```
21
+
22
+ ```vidscript
23
+ import motion from "@scenerok/basic-animations"
24
+ import eleven from "@elevenlabs/music"
25
+
26
+ # Look: one palette, two fonts, one accent.
27
+ let bg = "#0A0C10"
28
+ let ink = "#F4F6FA"
29
+ let muted = "#A3ADBD"
30
+ let accent = "#3D8BFF"
31
+ let display = "Archivo Black"
32
+ let body = "Inter"
33
+
34
+ # Assets: the product page's own images, cropped and blurred with ffmpeg first.
35
+ input hero = "hero.jpg" # 483x472, product on white
36
+ input roomBand = "room-band.jpg" # 1080x398, crop of a lifestyle shot
37
+ input roomBg = "room-bg.jpg" # 1080x1920, same shot, blurred + darkened
38
+ input closeBand = "closeup-band.jpg" # 1080x368
39
+ input closeBg = "closeup-bg.jpg" # 1080x1920
40
+
41
+ # A slow push on the stills: these photos hold 3-4 s each, and a push keeps a held
42
+ # photo from reading as a slide.
43
+ let push = { type: "scale", from: { scale: 1 }, to: { scale: 1.08 }, easing: "linear" }
44
+
45
+ # 1. Hook (0-3 s): lifestyle shot; the headline lands in the first second.
46
+ [0s .. 3s] = roomBg, width: 1080, height: 1920, x: 0, y: 0, animate: push
47
+ [0s .. 3s] = roomBand, width: 984, height: 363, x: 48, y: 820, radius: 36, blend: "normal", animate: [motion.riseIn(0.5s, 80), push, motion.fadeOut(0.3s)]
48
+ [0.2s .. 3s] = text "Vacuums. Mops.\nEmpties itself.", font: display, size: 104, color: ink, line_height: 1.05, x: "50%", y: "27%", max_width: "86%", align: center, shadow_color: "#000000", shadow_blur: 24, animate: [motion.riseIn(0.6s, 50), motion.fadeOut(0.3s)]
49
+
50
+ # 2. Hero (3-7 s): the product on its own white card.
51
+ [3s .. 7s] = solid(bg)
52
+ [3s .. 7s] = hero, width: 900, height: 880, x: 90, y: 300, radius: 48, blend: "normal", animate: [motion.fadeIn(0.4s), push, motion.fadeOut(0.3s)]
53
+ [3.3s .. 7s] = text "ECOVACS", font: body, size: 44, color: accent, letter_spacing: 10, x: "50%", y: "64.5%", align: center, animate: [motion.fadeIn(0.4s), motion.fadeOut(0.3s)]
54
+ [3.5s .. 7s] = text "DEEBOT T30e OMNI", font: display, size: 72, color: ink, x: "50%", y: "71%", max_width: "88%", align: center, animate: [motion.riseIn(0.5s, 40), motion.fadeOut(0.3s)]
55
+
56
+ # 3. Features (7-11 s): a close-up band, then one bullet every 0.6 s.
57
+ [7s .. 11s] = closeBg, width: 1080, height: 1920, x: 0, y: 0, animate: push
58
+ [7s .. 11s] = closeBand, width: 984, height: 335, x: 48, y: 300, radius: 36, blend: "normal", animate: [motion.fadeIn(0.4s), push, motion.fadeOut(0.3s)]
59
+ [7.4s .. 11s] = solid(accent, width: 24, height: 24, x: 96, y: 868, radius: 12), animate: [motion.popIn(0.4s), motion.fadeOut(0.3s)]
60
+ [7.4s .. 11s] = text "25,000 Pa suction", font: display, size: 64, color: ink, x: 150, y: 880, anchor: "left", align: left, animate: [motion.riseIn(0.5s, 40), motion.fadeOut(0.3s)]
61
+ [8.0s .. 11s] = solid(accent, width: 24, height: 24, x: 96, y: 1028, radius: 12), animate: [motion.popIn(0.4s), motion.fadeOut(0.3s)]
62
+ [8.0s .. 11s] = text "ZeroTangle 3.0 brush", font: display, size: 64, color: ink, x: 150, y: 1040, anchor: "left", align: left, animate: [motion.riseIn(0.5s, 40), motion.fadeOut(0.3s)]
63
+ [8.6s .. 11s] = solid(accent, width: 24, height: 24, x: 96, y: 1188, radius: 12), animate: [motion.popIn(0.4s), motion.fadeOut(0.3s)]
64
+ [8.6s .. 11s] = text "Self-emptying dock", font: display, size: 64, color: ink, x: 150, y: 1200, max_width: "80%", anchor: "left", align: left, animate: [motion.riseIn(0.5s, 40), motion.fadeOut(0.3s)]
65
+
66
+ # 4. Price and CTA (11-15 s). The button is a solid with a radius under the label.
67
+ [11s .. 15s] = solid(bg)
68
+ [11s .. 15s] = hero, width: 560, height: 548, x: 260, y: 260, radius: 40, blend: "normal", animate: [motion.popIn(0.5s), motion.float(3s, 10)]
69
+ [11.3s .. 15s] = text "₹44,999", font: display, size: 150, color: ink, x: "50%", y: "50%", align: center, animate: motion.popIn(0.5s)
70
+ [11.6s .. 15s] = text "★ 4.4 · 166 ratings", font: body, size: 48, color: muted, x: "50%", y: "58.5%", align: center, animate: motion.fadeIn(0.4s)
71
+ [12s .. 15s] = solid(accent, width: 660, height: 132, x: 210, y: 1210, radius: 66), animate: motion.popIn(0.5s)
72
+ [12s .. 15s] = text "Shop on Amazon", font: display, size: 56, color: "#FFFFFF", x: "50%", y: 1276, align: center, animate: motion.fadeIn(0.4s)
73
+
74
+ # Grade: warm the whole frame, pull the edges down.
75
+ [0s .. 15s] = filter "cdl", slope: "1.04 1.0 0.96", offset: "0 0 0", power: "1 1 1", saturation: 1.08
76
+ [0s .. 15s] = filter "vignette", intensity: 0.3
77
+
78
+ # Sound: a bed under everything.
79
+ let bed = eleven.music("Bright, modern electronic product-ad bed, confident pulse, clean ending", duration: 15, instrumental: true)
80
+ [0s .. 15s] = audio bed, volume: 0.5, fade_out: 1s
81
+
82
+ output to "promo.mp4", resolution: "1080x1920", fps: 30, background: "#0A0C10"
83
+ ```
84
+
85
+ `validate` warns `too-many-animations` at the bullets: the three bullets
86
+ leave together on one 0.3 s fade, and the linter counts each exit as motion.
87
+ Accept that warning here and fix any other.
88
+
89
+ Variations: replace the hook band with
90
+ `xai.imageToVideo(hero, "slow orbit around the product, …", aspect_ratio: "9:16", duration: 3)`
91
+ for a moving hero shot (`ai-models.md`). For 16:9, use the 16:9 type scale
92
+ and put the product on the right and the copy on the left.
93
+
94
+ ## Release notes / changelog (9:16, 18 s)
95
+
96
+ Four beats: version hook, the one new feature as typed code with a
97
+ highlight, a stat, and an outro. Facts come from the release body
98
+ (`gh api repos/<owner>/<repo>/releases/latest --jq .body`); count the
99
+ `fix`/`feat` lines rather than estimating. The logo is the project's own
100
+ (`https://avatars.githubusercontent.com/u/<id>`, or the README image). The
101
+ background is a brand glow made with ffmpeg:
102
+
103
+ ```bash
104
+ ffmpeg -f lavfi -i "color=c=black:s=1080x1920,format=rgb24" -frames:v 1 \
105
+ -vf "geq=r='11+90*exp(-((X-760)^2+(Y-520)^2)/180000)':g='11+30*exp(-((X-760)^2+(Y-520)^2)/180000)':b='15+4*exp(-((X-760)^2+(Y-520)^2)/180000)'" glow.png
106
+ ```
107
+
108
+ ```vidscript
109
+ import motion from "@scenerok/basic-animations"
110
+ import animations from "@scenerok/animations"
111
+ import eleven from "@elevenlabs/music"
112
+
113
+ let ink = "#F5F5F7"
114
+ let muted = "#9CA3AF"
115
+ let accent = "#FF5B11"
116
+ let display = "Archivo Black"
117
+ let mono = "JetBrains Mono"
118
+
119
+ input glow = "glow.png" # 1080x1920, made with ffmpeg: brand-colour glow on near-black
120
+ input flame = "flame.png" # 460x460 RGBA, the project's logo
121
+
122
+ # Ambient layer: the background drifts for the whole video.
123
+ [0s .. 18s] = glow, width: 1080, height: 1920, x: 0, y: 0, animate: { type: "scale", from: { scale: 1 }, to: { scale: 1.15 }, easing: "linear" }
124
+
125
+ # 1. Hook (0-3.5 s)
126
+ [0s .. 3.5s] = flame, width: 460, height: 460, x: 310, y: 340, blend: "normal", animate: [motion.popIn(0.6s), motion.float(2.9s, 14), motion.fadeOut(0.3s)]
127
+ [0.3s .. 3.5s] = text "RELEASE NOTES", font: mono, size: 44, color: accent, letter_spacing: 8, x: "50%", y: "50%", align: center, animate: [motion.fadeIn(0.4s), motion.fadeOut(0.3s)]
128
+ [0.5s .. 3.5s] = text "hono v4.13.14", font: display, size: 120, color: ink, x: "50%", y: "57%", max_width: "88%", align: center, animate: [motion.riseIn(0.6s, 60), motion.fadeOut(0.3s)]
129
+
130
+ # 2. The new feature as code (3.5-10 s): headline, card, then the code types itself.
131
+ [3.5s .. 10s] = text "NEW IN BEARER-AUTH", font: mono, size: 44, color: accent, letter_spacing: 6, x: 96, y: 300, anchor: "left", align: left, animate: [motion.fadeIn(0.4s), motion.fadeOut(0.3s)]
132
+ [3.6s .. 10s] = text "Error messages can\nreturn a Response", font: display, size: 84, color: ink, line_height: 1.1, x: 96, y: 440, anchor: "left", align: left, animate: [motion.riseIn(0.5s, 40), motion.fadeOut(0.3s)]
133
+ [4s .. 10s] = solid("#16161D", width: 960, height: 700, x: 60, y: 620, radius: 32), animate: [motion.riseIn(0.5s, 60), motion.fadeOut(0.3s)]
134
+ # One text layer per code line: a tall multi-line block can clip. Indent with x, not spaces.
135
+ [4.4s .. 10s] = text "bearerAuth({ token,", font: mono, size: 44, color: ink, x: 108, y: 760, anchor: "left", align: left, animate: [motion.typewriter(0.5s), motion.fadeOut(0.3s)]
136
+ [4.9s .. 10s] = text "invalidToken: {", font: mono, size: 44, color: ink, x: 164, y: 836, anchor: "left", align: left, animate: [motion.typewriter(0.5s), motion.fadeOut(0.3s)]
137
+ [5.4s .. 10s] = text "message: (c) =>", font: mono, size: 44, color: accent, x: 220, y: 912, anchor: "left", align: left, animate: [motion.typewriter(0.5s), motion.fadeOut(0.3s)]
138
+ [5.9s .. 10s] = text "c.json(problem, 401),", font: mono, size: 44, color: accent, x: 276, y: 988, anchor: "left", align: left, animate: [motion.typewriter(0.5s), motion.fadeOut(0.3s)]
139
+ [6.4s .. 10s] = text "}})", font: mono, size: 44, color: ink, x: 108, y: 1064, anchor: "left", align: left, animate: [motion.typewriter(0.3s), motion.fadeOut(0.3s)]
140
+ # Highlight beat: a bar sweeps under the changed lines once the code is typed.
141
+ [7.2s .. 10s] = solid(accent, width: 900, height: 158, x: 90, y: 872, radius: 16, opacity: 0.16), animate: [{ type: "slideX", from: { x: -1000 }, to: { x: 0 }, start: 0, end: 0.5, easing: "easeOutCubic" }, motion.fadeOut(0.3s)]
142
+ [7.4s .. 10s] = text "Any content type, e.g. problem+json", font: mono, size: 44, color: muted, x: 108, y: 1220, anchor: "left", align: left, animate: [motion.fadeIn(0.4s), motion.fadeOut(0.3s)]
143
+
144
+ # 3. Highlight (10-14 s): one number, then three rows. suffix: "" because the block defaults to "+".
145
+ [10s .. 14s] = animations.text_counter_stat("12", "BUG FIXES", suffix: "", accent: accent)
146
+ [11s .. 14s] = text "serve-static: GET and HEAD only", font: mono, size: 44, color: ink, x: "50%", y: "70%", align: center, animate: [motion.riseIn(0.4s, 30), motion.fadeOut(0.3s)]
147
+ [11.4s .. 14s] = text "css: safer url() minifying", font: mono, size: 44, color: ink, x: "50%", y: "74.5%", align: center, animate: [motion.riseIn(0.4s, 30), motion.fadeOut(0.3s)]
148
+ [11.8s .. 14s] = text "trie-router: keep first param", font: mono, size: 44, color: ink, x: "50%", y: "79%", align: center, animate: [motion.riseIn(0.4s, 30), motion.fadeOut(0.3s)]
149
+
150
+ # 4. Outro (14-18 s)
151
+ [14s .. 18s] = flame, width: 320, height: 320, x: 380, y: 520, blend: "normal", animate: [motion.popIn(0.5s), motion.float(3s, 12)]
152
+ [14.3s .. 18s] = solid("#1C1C24", width: 860, height: 120, x: 110, y: 950, radius: 60), animate: motion.popIn(0.5s)
153
+ [14.3s .. 18s] = text "npm i hono@4.13.14", font: mono, size: 48, color: ink, x: "50%", y: 1010, align: center, animate: motion.fadeIn(0.4s)
154
+ [14.8s .. 18s] = text "github.com/honojs/hono", font: mono, size: 44, color: muted, x: "50%", y: 1150, align: center, animate: motion.fadeIn(0.4s)
155
+
156
+ # Grade
157
+ [0s .. 18s] = filter "cdl", slope: "1.03 1.0 0.97", offset: "0 0 0", power: "1 1 1", saturation: 1.05
158
+ [0s .. 18s] = filter "vignette", intensity: 0.35
159
+
160
+ let bed = eleven.music("Minimal driving synth pulse for a developer product update, clean and modern", duration: 18, instrumental: true)
161
+ [0s .. 18s] = audio bed, volume: 0.5, fade_out: 1.5s
162
+
163
+ output to "release.mp4", resolution: "1080x1920", fps: 30, background: "#0B0B0F"
164
+ ```
165
+
166
+ `validate` warns `crowded-text-overlays` and `too-many-animations` on the
167
+ code card, because each code line counts as an overlay. Accept those two
168
+ there; fix any other warning. This recipe builds its own code card so the
169
+ headline, card and caption can share one frame. The `code_snippet_*` themes and
170
+ `code_diff` also have 9:16 layouts of their own (see their records in
171
+ `repertoire.md`): reach for them when the code is the whole beat.
172
+
173
+ ## Talking head with captions (9:16, 14 s)
174
+
175
+ A 16:9 interview cut for a vertical feed: fill the frame by oversizing the
176
+ clip (no `Crop`, so transitions stay legal), punch in on the key line, cut
177
+ away to b-roll with a whip pan, and burn in short, timed caption cards.
178
+
179
+ ```vidscript
180
+ import motion from "@scenerok/basic-animations"
181
+ import animations from "@scenerok/animations"
182
+ import tr from "@scenerok/transitions"
183
+
184
+ let ink = "#FFFFFF"
185
+ let accent = "#FFD43B"
186
+ let display = "Archivo Black"
187
+
188
+ input talk = "https://cdn.example.com/interview-16x9.mp4" # 1920x1080, probed
189
+ input broll = "https://cdn.example.com/broll-16x9.mp4" # 1920x1080, probed
190
+ input bed = "https://cdn.example.com/music.mp3"
191
+
192
+ # Fill 9:16 from 16:9: scale to 1920 tall (3413 wide) and centre it, x = (1080 - 3413) / 2.
193
+ # Cut 1: wide, with a slow push. Cut 2: punch in on the key line. Cut 3: b-roll. Cut 4: back to the speaker.
194
+ [0s .. 4s] = talk.Trim(12s, 16s), width: 3413, height: 1920, x: -1166, y: 0, animate: { type: "scale", from: { scale: 1 }, to: { scale: 1.05 }, easing: "linear" }
195
+ [4s .. 7s] = talk.Trim(16s, 19s), width: 3413, height: 1920, x: -1166, y: 0, animate: { type: "scale", from: { scale: 1.18 }, to: { scale: 1.22 }, easing: "linear" }
196
+ [6.8s .. 7.2s] = tr.whip_pan()
197
+ [7.2s .. 10s] = broll.Trim(0s, 2.8s), width: 3413, height: 1920, x: -1166, y: 0, animate: { type: "scale", from: { scale: 1.1 }, to: { scale: 1 }, easing: "linear" }
198
+ [9.8s .. 10.2s] = tr.dissolve()
199
+ [10.2s .. 14s] = talk.Trim(22.2s, 26s), width: 3413, height: 1920, x: -1166, y: 0, animate: { type: "scale", from: { scale: 1 }, to: { scale: 1.05 }, easing: "linear" }
200
+
201
+ # Name card from the catalog (it has a native 9:16 layout).
202
+ [0.6s .. 4s] = animations.lt_kicker_name("FOUNDER", "Asha Rao", accent: accent)
203
+
204
+ # Captions: 2-4 words per card, timed to the transcript, the key words in the accent colour.
205
+ [0.2s .. 1.4s] = text "We shipped it", font: display, size: 72, color: ink, stroke: "#000000", stroke_width: 6, x: "50%", y: "70%", max_width: "84%", align: center, animate: motion.popIn(0.25s)
206
+ [1.4s .. 2.8s] = text "in nine days", font: display, size: 72, color: accent, stroke: "#000000", stroke_width: 6, x: "50%", y: "70%", max_width: "84%", align: center, animate: motion.popIn(0.25s)
207
+ [4s .. 5.6s] = text "and it paid", font: display, size: 72, color: ink, stroke: "#000000", stroke_width: 6, x: "50%", y: "70%", max_width: "84%", align: center, animate: motion.popIn(0.25s)
208
+ [5.6s .. 7s] = text "for itself", font: display, size: 72, color: accent, stroke: "#000000", stroke_width: 6, x: "50%", y: "70%", max_width: "84%", align: center, animate: motion.popIn(0.25s)
209
+
210
+ # Sound: the speaker's own audio, with music ducked under it.
211
+ [0s .. 7s] = audio talk.Trim(12s, 19s)
212
+ [10.2s .. 14s] = audio talk.Trim(22.2s, 26s)
213
+ [0s .. 14s] = audio bed, volume: 0.5, duck_under: talk, fade_out: 1s
214
+
215
+ [0s .. 14s] = filter "cdl", slope: "1.02 1.0 0.98", offset: "0 0 0", power: "1 1 1", saturation: 1.05
216
+
217
+ output to "talk.mp4", resolution: "1080x1920", fps: 30
218
+ ```
219
+
220
+ With a transcript in an SRT file, `captions("talk.srt", font: display, size: 72, area: "caption")`
221
+ replaces the caption lines (`editing-actions.md` → Text and titles). On a
222
+ hosted render the file must be an HTTPS URL; see `look-and-feel.md` → Files the
223
+ server must read.