storyshot 0.9.0 → 0.11.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 CHANGED
@@ -1,19 +1,94 @@
1
1
  # Storyshot
2
2
 
3
- Ask your coding agent for a video of your web app, and get a polished 1080p
4
- MP4: a promo clip, a narrated tutorial, a lesson, or step-by-step GIFs for your
5
- docs.
3
+ Ask your coding agent for a video, and get a polished 1080p MP4. Two kinds of
4
+ film come out of one tool:
6
5
 
6
+ - **An explainer of an idea** — no web app, no footage, nothing recorded. Every
7
+ scene is an animation the agent writes and times to the narration: a
8
+ mechanism, a diagram that builds, a number that lands. Chinese or English.
9
+ *This is what most people come for.*
10
+ - **A video of your web app** — a promo, a narrated tutorial, a lesson, or
11
+ step-by-step GIFs for your docs. A real browser clicks through your site, and
12
+ the recording gets a smooth cursor, automatic zoom, captions, an optional
13
+ voiceover, music and an end card.
14
+
15
+ The agent plans, writes and fixes the storyboard. Storyshot does the rendering,
16
+ and the same storyboard always renders the same video.
17
+
18
+ ## Install it into your coding agent
19
+
20
+ **The skill is the front door.** You do not learn a CLI — your agent does.
21
+
22
+ Open your project in your coding agent (Claude Code, Cursor, Codex, Gemini CLI,
23
+ Copilot in VS Code, Windsurf, Cline) and paste this one line into its chat, with
24
+ your agent's name in place of `claude`:
25
+
26
+ > Set up Storyshot in this project: run `npx -y storyshot setup claude` and do everything it says.
27
+
28
+ Agent names: `claude` · `cursor` · `codex` · `gemini` · `vscode` · `windsurf` · `cline`.
29
+
30
+ That one command installs **the Storyshot skill** into your agent, writes the MCP
31
+ server config, adds `.storyshot/` to `.gitignore`, and downloads the browser it
32
+ needs. The skill is what teaches your agent the whole workflow — how to plan a
33
+ film, write the scenes, read the render back and fix it — so from the next chat
34
+ on you can just ask for a video in plain words.
35
+
36
+ Restart or reload your agent when `setup` says to. Then:
37
+
38
+ > *「做一个视频,讲清楚热泵为什么能把一份电变成三份热」*
39
+ >
40
+ > *"make an explainer about how a heat pump moves three units of heat per unit of electricity"*
41
+ >
7
42
  > *"make a 15 s promo video of https://myapp.com"*
43
+ >
44
+ > *"make a narrated tutorial showing how to export a report"*
8
45
 
9
- Storyshot gives coding agents (Cursor, Claude Code, Codex, Gemini CLI, Copilot in VS Code, Windsurf, Cline) what they need to
10
- do this well. A real browser clicks through your site, and the recording gets
11
- a smooth cursor, automatic zoom, captions, an optional AI voiceover, music
12
- and an end card. Loading time is squeezed out, so a 30-second AI job takes a
13
- second or two on screen.
46
+ or type `/storyshot`.
14
47
 
15
- The agent plans, writes and fixes the storyboard. Storyshot does the
16
- recording and editing, and the same storyboard always renders the same video.
48
+ `npx storyshot doctor` re-checks everything at any time. Storyshot runs on
49
+ macOS, Linux and Windows. Needs Node 18+ and ffmpeg (setup prints the exact
50
+ install command for your OS if it is missing).
51
+
52
+ ### Explainers, in one paragraph
53
+
54
+ An explainer has no `url` and records nothing. You ask for a subject; the agent
55
+ writes a storyboard of **scenes**, and each scene is an HTML animation it draws
56
+ and times to the voice, phrase by phrase. It renders a still of every scene,
57
+ looks at it, and fixes what is wrong before it ever renders video. Free local
58
+ voices in English and Chinese, synth music, subtitles and chapters — no API key
59
+ and no per-video cost. Start here:
60
+ [the explainers in the gallery](https://boheling.github.io/storyshot-gallery/#explainer),
61
+ and the ["Scenes and explainers" playbook](#for-agents-tools-and-playbook).
62
+
63
+ ## What `setup` writes, per agent
64
+
65
+ | agent | what `setup` writes |
66
+ |---|---|
67
+ | Cursor | `.cursor/mcp.json` and the skill in `.cursor/skills/storyshot/` |
68
+ | Claude Code | `.mcp.json` and the skill in `.claude/skills/storyshot/` |
69
+ | Codex | the skill in `.agents/skills/storyshot/`, a short section in `AGENTS.md` pointing to it, and the MCP server through `codex mcp add` (in `~/.codex/config.toml`, for your user). Storyshot launches a browser, which Codex's sandbox blocks: approve it when Codex asks, or run Codex with `--sandbox danger-full-access` |
70
+ | Gemini CLI | `.gemini/settings.json`, a section in `GEMINI.md`, the skill in `.agents/skills/storyshot/` (trust the folder when Gemini asks, or it ignores project settings) |
71
+ | Copilot in VS Code (`vscode`) | `.vscode/mcp.json`, a section in `.github/copilot-instructions.md`, the skill in `.agents/skills/storyshot/` |
72
+ | Windsurf | `.windsurf/rules/storyshot.md`, the skill in `.agents/skills/storyshot/`, and the MCP server in `~/.codeium/windsurf/mcp_config.json` (for your user) |
73
+ | Cline | `.clinerules/storyshot.md`, the skill in `.agents/skills/storyshot/`, and the MCP server in Cline's own settings in each editor it is installed in (for your user) |
74
+
75
+ Existing files are merged, not replaced: other MCP servers stay, and instruction files get one marked
76
+ Storyshot section that a second `setup` updates instead of duplicating.
77
+
78
+ **Tested end to end** (setup, then an agent making a real video from a plain request): **Claude Code,
79
+ Cursor and Codex**. Gemini CLI, Copilot in VS Code, Windsurf and Cline get the configuration their docs
80
+ describe, but haven't been tested with a real session yet; reports are welcome.
81
+
82
+ The agent does the rest:
83
+ - `setup` writes the MCP server config and the Storyshot skill, adds
84
+ `.storyshot/` to `.gitignore`, and downloads the browser it needs.
85
+ - If ffmpeg is missing, setup prints the exact install command for your OS
86
+ (`brew install ffmpeg`, `winget install --id Gyan.FFmpeg -e`,
87
+ `sudo apt-get install -y ffmpeg`). The agent runs it after you approve.
88
+ - The agent reads the skill and starts on your video in the same chat, using
89
+ the CLI.
90
+
91
+ From the next chat on, the agent uses the Storyshot MCP tools automatically.
17
92
 
18
93
  https://github.com/user-attachments/assets/05dd9a73-2edc-46af-9b2b-a8e33c7005b4
19
94
 
@@ -57,51 +132,6 @@ https://github.com/user-attachments/assets/8d3370ca-7a90-48af-b1c9-88189763e95d
57
132
  How each one was made, what we saw, and the caveats:
58
133
  [docs/comparison](docs/comparison/README.md).
59
134
 
60
- ## Quick start
61
-
62
- Open your project in your coding agent and paste this into its chat, with your
63
- agent's name (`cursor`, `claude`, `codex`, `gemini`, `vscode`, `windsurf` or `cline`):
64
-
65
- > Set up Storyshot in this project: run `npx -y storyshot setup cursor` and do
66
- > everything it says. Then make a short promo video of https://myapp.com
67
-
68
- | agent | what `setup` writes |
69
- |---|---|
70
- | Cursor | `.cursor/mcp.json` and the skill in `.cursor/skills/storyshot/` |
71
- | Claude Code | `.mcp.json` and the skill in `.claude/skills/storyshot/` |
72
- | Codex | the skill in `.agents/skills/storyshot/`, a short section in `AGENTS.md` pointing to it, and the MCP server through `codex mcp add` (in `~/.codex/config.toml`, for your user). Storyshot launches a browser, which Codex's sandbox blocks: approve it when Codex asks, or run Codex with `--sandbox danger-full-access` |
73
- | Gemini CLI | `.gemini/settings.json`, a section in `GEMINI.md`, the skill in `.agents/skills/storyshot/` (trust the folder when Gemini asks, or it ignores project settings) |
74
- | Copilot in VS Code (`vscode`) | `.vscode/mcp.json`, a section in `.github/copilot-instructions.md`, the skill in `.agents/skills/storyshot/` |
75
- | Windsurf | `.windsurf/rules/storyshot.md`, the skill in `.agents/skills/storyshot/`, and the MCP server in `~/.codeium/windsurf/mcp_config.json` (for your user) |
76
- | Cline | `.clinerules/storyshot.md`, the skill in `.agents/skills/storyshot/`, and the MCP server in Cline's own settings in each editor it is installed in (for your user) |
77
-
78
- Existing files are merged, not replaced: other MCP servers stay, and instruction files get one marked
79
- Storyshot section that a second `setup` updates instead of duplicating.
80
-
81
- **Tested end to end** (setup, then an agent making a real video from a plain request): **Claude Code,
82
- Cursor and Codex**. Gemini CLI, Copilot in VS Code, Windsurf and Cline get the configuration their docs
83
- describe, but haven't been tested with a real session yet; reports are welcome.
84
-
85
- The agent does the rest:
86
- - `setup` writes the MCP server config and the Storyshot skill, adds
87
- `.storyshot/` to `.gitignore`, and downloads the browser it needs.
88
- - If ffmpeg is missing, setup prints the exact install command for your OS
89
- (`brew install ffmpeg`, `winget install --id Gyan.FFmpeg -e`,
90
- `sudo apt-get install -y ffmpeg`). The agent runs it after you approve.
91
- - The agent reads the skill and starts on your video in the same chat, using
92
- the CLI.
93
-
94
- Restart or reload your agent when convenient (`setup` says how for each one). From the next chat on,
95
- the agent uses the Storyshot MCP tools automatically, and you can just ask:
96
-
97
- - *"make a 15 s promo video of https://myapp.com"*, or type `/storyshot`
98
- - *"make a narrated tutorial showing how to export a report"*
99
- - *"make a lesson video explaining how the AI editor works"*
100
- - *"make docs GIFs for the onboarding flow"*
101
-
102
- `npx storyshot doctor` re-checks everything at any time. Storyshot runs on
103
- macOS, Linux and Windows.
104
-
105
135
  ## What happens when you ask
106
136
 
107
137
  1. **The agent looks at your site** (about 5 s). It reads the page, and checks
@@ -142,8 +172,8 @@ After your next release, re-render it and you have an up-to-date video.
142
172
  | ask for | type (`purpose`) | you get |
143
173
  |---|---|---|
144
174
  | a promo / launch / landing-page clip | `pitch` | short and tightly cut: intro card, the app in a browser frame, music, captions |
145
- | a how-to or onboarding video | `tutorial` | paced to follow along: one narrated action per step, the camera following each click, part cards, a second example, a recap; `.srt` + chapters |
146
- | a lesson | `course` | explains the *why*: concept cards, the idea shown in the app, question cards, a recap, no music; `.srt` + chapters |
175
+ | a how-to or onboarding video | `tutorial` | paced to follow along: one narrated action per step, the camera following each click, part cards, a second example, a recap; `.srt` + chapters. `check` holds the narration to a teaching pace too — one job per step, a budget per line, no bolded payoffs — so a slow film doesn't still read as a pitch |
176
+ | a lesson | `course` | explains the *why*: concept cards, the idea shown in the app, question cards, a recap, no music; `.srt` + chapters. `check` holds it to the tutorial's teaching pace, and to a lesson's own shape: a card names one idea, a question waits for an answer and its step marks it |
147
177
  | help-center snippets | `docs` | silent per-step GIFs and PNGs cropped to the action, plus a ready-to-paste Markdown article |
148
178
  | an animated explainer of how something works | `explainer` | no web app: every step is a **scene**, an animation the agent writes and times to the narration (a mechanism, a diagram that grows, a number); calm music, subtitles, chapters |
149
179
 
@@ -259,9 +289,11 @@ by hand.
259
289
  ```yaml
260
290
  url: https://example.com
261
291
  out: acme-promo.mp4 # relative to this file; default: <file name>.mp4
262
- viewport: { width: 1600, height: 900 } # CSS px the site is laid out at
263
- output: { width: 1920, height: 1080, fps: 30 } # same aspect ratio; use 1080x1920 with a 900x1600 viewport for vertical
264
- theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter }
292
+ aspect: landscape # landscape (16:9, default) | portrait (9:16, 1080x1920: a phone upright, and most short-video feeds) | square (1:1). 16:9, 9:16 and 1:1 work too,
293
+ # and so does any other ratio: "3:4" is 1080x1440 (what Xiaohongshu fills), "4:5" is 1080x1350. The short side is always 1080
294
+ viewport: { width: 1600, height: 900 } # CSS px the site is laid out at; leave it out and it follows the output's shape
295
+ output: { width: 1920, height: 1080, fps: 30 } # an exact size, when `aspect` is not the shape you want; the viewport must share its aspect ratio
296
+ theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter, paper: "#e9ddc4" } # paper: the film's own background. The intro, section and end cards are built on it instead of the default near-white, which is cold against a warm film. Leave it out and the cards follow `ground:`
265
297
  music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or false
266
298
  sfx: true # click / whoosh / riser sounds; clicks = no whooshes on cards and page changes, and no rising sound under a riser step, only its chime (tutorial and course default); false = none
267
299
  voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
@@ -529,8 +561,24 @@ Every scene of a film shares one look, and the look belongs to the subject and
529
561
  the audience: a dark instrument panel suits a mechanism explained to specialists,
530
562
  a warm paper poster suits civics explained to nine-year-olds. Say which ground
531
563
  you chose with `ground: light | dark`, and the burned-in subtitles and the cards
532
- follow it. The playbook's "The look follows the subject" has the styles and what
533
- each ground costs.
564
+ follow it. When the film is drawn on a *colour* rather than on plain light or
565
+ dark, give it as `theme: { paper: "#e9ddc4" }` and the title, section and end
566
+ cards are built on that ground instead of the default near-white, which is a
567
+ cold blue-grey and reads as a different film. The playbook's "The look follows
568
+ the subject" has the styles and what each ground costs.
569
+
570
+ `aspect: portrait` makes the video 1080x1920 — a phone held upright, and what
571
+ most short-video feeds want (`square` is 1:1, `landscape` 16:9 is the default,
572
+ and `output:` still takes an exact size). The feeds do not all want the same
573
+ tall frame, so any ratio works written out: `aspect: "3:4"` is 1080x1440, which
574
+ is the shape Xiaohongshu's feed fills and the one it crops a 9:16 video down
575
+ to, and `aspect: "4:5"` is 1080x1350 for an Instagram feed. The short side is
576
+ always 1080 and both sides come out even. A portrait film is not a landscape
577
+ film resized: a tall frame reads top to bottom, so a scene that puts an object
578
+ beside its labels stacks them instead, and a side-by-side comparison becomes an
579
+ upper and a lower half. Each scene's root carries `data-width="1080"
580
+ data-height="1920"`, and `storyshot check` says so when it does not match. The
581
+ playbook's "Portrait" has the type scale and the margins a feed covers.
534
582
 
535
583
  `lead` is how long the scene plays before the voice starts (default 0.5 s), and
536
584
  `tail` is how long it holds after the voice ends (default 0.8 s). Consecutive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Ask your coding agent for a video of your web app: promo clips, narrated tutorials, lessons and docs GIFs from a YAML storyboard.",
5
5
  "author": "boheling <boheling@gmail.com>",
6
6
  "repository": {
@@ -175,7 +175,9 @@ or scientific tool they have never seen. Storyshot paces `tutorial` and
175
175
  `course` slowly by default (`pace: slow`: slower cursor and typing, a beat
176
176
  after every click, a hold after every narration line, slower voice, real
177
177
  progress shown for waits) and the camera follows the action (`follow`), so
178
- small controls are readable. Your job is the structure:
178
+ small controls are readable. What the tool cannot set for you is the structure
179
+ and the voice. The structure first; "The teaching voice" below is the half that
180
+ decides whether a slow film still sounds like a sales pitch:
179
181
 
180
182
  - **Start with the goal**: an `intro` with `bullets` saying what the viewer
181
183
  will be able to do ("Run a BLAST search", "Read the hits table", "Judge a
@@ -245,6 +247,86 @@ small controls are readable. Your job is the structure:
245
247
  don't pad: if a part is long, split it into more parts rather than
246
248
  speeding it up.
247
249
 
250
+ #### The teaching voice
251
+
252
+ `pace: slow` buys time on screen: slower cursor and typing, a beat after every
253
+ click, a hold after every line. It cannot buy time *inside* a sentence. A
254
+ tutorial that still sounds like an ad read slowly is nearly always a writing
255
+ problem, not a timing one — and the usual cause is density, not length.
256
+ `check` holds a `tutorial` or a `course` to the rules below. A promo is held to
257
+ none of them, which is the point: they are different jobs.
258
+
259
+ 1. **One job per step.** A step either names an idea, does an action, or reads
260
+ a result. Not two of those, and never three. The line that packs all three
261
+ is the commonest failure, and it reads fine on the page:
262
+
263
+ > "The E value is how many hits this good you would expect by pure chance in
264
+ > a database this size. Smaller is better, and anything below 1e-5 usually
265
+ > means a real relationship. Here it is 6e-106, essentially zero."
266
+
267
+ A definition, a rule of thumb and a reading, in eleven seconds. The viewer
268
+ is still working out what "expect by chance" means when the number arrives,
269
+ so they keep none of it. As three steps they keep all three.
270
+ 2. **A step's whole `say:` fits in about 35 words (55 Chinese characters).**
271
+ Past that, split the step.
272
+ 3. **A sentence is about 15 words (25 characters).** `check` speaks up past 18
273
+ (30), so 15 is what to write to, not a line to trip over. Promos are allowed
274
+ 25; a lesson is not.
275
+ 4. **At most three figures in one line.** Nobody holds four numbers by ear. Say
276
+ the one that carries the point, round it out loud ("about eighty percent"),
277
+ and let the screen carry the exact value — which it can, because you marked
278
+ the cell.
279
+ 5. **Say the number, then what it means, then the rule again.** Teaching
280
+ repeats. A script that states each fact exactly once reads as a list of
281
+ facts, however plainly each one is put. The recap at the end repeats them in
282
+ the same words as the first time.
283
+ 6. **Silence is part of the lesson.** After a result appears, the viewer needs a
284
+ beat to find it on their own screen. Hold the step (`pause: 2000`) and say
285
+ nothing. Compose's dead-air warning knows the difference: 3 s is too long in
286
+ a promo, a `tutorial` or `course` is allowed 6 s, and 8 s on a step that
287
+ asked to hold. Resolve a dead-air warning in a lesson by saying what to look
288
+ at, not by cutting the beat.
289
+ 7. **A new term gets a step of its own**, the first time it appears, before any
290
+ number is attached to it.
291
+
292
+ **A tutorial's caption is a label, not a payoff.** A promo bolds the payoff
293
+ because a promo is selling the payoff. Here the caption says where the viewer
294
+ is — "The E value column", "Part 2: read the results" — and the voice does the
295
+ explaining. `Shorter protein, **larger** E value` reaches the conclusion before
296
+ the narration gets there, which is an ad's grammar; `check` warns on `**` in a
297
+ tutorial caption. A `note` works the same way: it labels the control it points
298
+ at in a few words ("FASTA or accession"), it does not recommend it ("Curated,
299
+ fast: good for learning").
300
+
301
+ #### What a lesson adds
302
+
303
+ A tutorial teaches the steps; a lesson (`course`) teaches the idea behind them,
304
+ so the clicking is usually done in `hidden` steps and the film is about reading
305
+ what is on the page. It is held to the whole teaching voice above, and to three
306
+ things of its own. `check` warns on each.
307
+
308
+ 1. **A card names one idea.** A concept card's `say:` gets the same budget as a
309
+ step (about 35 words, 55 Chinese characters), and it is the easiest place to
310
+ break it, because a card feels like the place for the whole explanation:
311
+ definition, threshold and example. Put the name and the plain-words meaning
312
+ on the card. The rule and the number go in the steps after it, where the
313
+ page can show them and you can `mark` them.
314
+ 2. **A question card waits, and its step shows the answer.** A check has three
315
+ parts: the question, time to answer it (`think: 3`, at least 2), and the
316
+ answer marked on the page. Without the wait it is a rhetorical question,
317
+ which a promo asks and a lesson does not. Without the mark, a viewer who
318
+ guessed wrong is told so and never shown where to look. The step a question
319
+ card sits on is the reveal: it points at the answer first, and the reason
320
+ comes in the step after.
321
+ 3. **Ask at least once.** A lesson with no question is a lecture: the viewer
322
+ cannot tell whether they followed it. The best second question is a new case
323
+ the rule has to be applied to (a different search, another input), not the
324
+ first one asked again.
325
+
326
+ The order inside each idea is the one in "Explain in an order the viewer can
327
+ follow" below: a concrete case on screen, then the card that names it, then the
328
+ reading, then the rule, then the question.
329
+
248
330
  ### Writing the narration
249
331
 
250
332
  Viewers notice three things first: lines that run together, phrasing nobody
@@ -254,7 +336,9 @@ They should sound like one person talking a friend through it.
254
336
 
255
337
  **Say it like a person.**
256
338
  - Short sentences, one idea each: about 15 words at most in English, 25
257
- characters in Chinese. If you would run out of breath, split it.
339
+ characters in Chinese. If you would run out of breath, split it. In a
340
+ tutorial or lesson `check` enforces this, along with a budget for the whole
341
+ step — see "The teaching voice".
258
342
  - Spoken words: contractions ("you'll", "it's"), "you", present tense, active
259
343
  voice. In Chinese, 口语 rather than 书面语: "点一下这里", not "进行点击操作".
260
344
  - Use the exact labels on screen, and the same term every time (no synonyms
@@ -314,8 +398,10 @@ Rules of thumb from real renders (promos and tutorials):
314
398
  icon and name, not an address bar.
315
399
  - Other purposes: leave out `duration`. `auto` times each step to its
316
400
  narration and plays motion in real time.
317
- - **Captions ≤ 7 words.** `**bold**` the payoff ("Every text box is
318
- **fully editable**"). Steps with the same caption keep it on screen.
401
+ - **Captions ≤ 7 words.** In a promo, `**bold**` the payoff ("Every text box is
402
+ **fully editable**"); in a tutorial or lesson a caption is a label for where
403
+ the viewer is, with no bold payoff (see "The teaching voice"). Steps with the
404
+ same caption keep it on screen.
319
405
  - **Narration (`say:`) is not a caption.** Write full spoken sentences, one
320
406
  instruction or idea per step. Use normal spelling, and put pronunciations in
321
407
  `voice.pronounce` (e.g. `pxGenius: P X Genius`, `AI: A I`) so subtitles stay
@@ -380,7 +466,9 @@ Notes:
380
466
  automatically (never under 2.5 s); leave out `duration` there.
381
467
  - Compose warns about **dead air**: 3 s or more where nothing moves and
382
468
  nothing is said. Shorten the wait or pause, hide the load in a `hidden`
383
- step, or narrate it. Keep `intro` cards to 6–8 s.
469
+ step, or narrate it. A `tutorial` or `course` is allowed 6 s, and 8 s on a
470
+ step that asked to hold with `pause`, because a learner needs the beat.
471
+ Keep `intro` cards to 6–8 s.
384
472
  - With `subtitles: burn`, the top caption pills can repeat the subtitles;
385
473
  `captions: false` keeps them off the video (chapters and SRT still use them).
386
474
  - `.storyshot/` next to the YAML is a cache of recordings, screenshots and
@@ -681,6 +769,17 @@ much cheaper to show one previewed scene in the chosen style and ask.
681
769
  - A glow that spills out of its box: `data-layout-allow-overflow` on the box.
682
770
  - A label sitting on its object: `data-layout-allow-overlap` on the container.
683
771
  - Small credits or texture: `data-layout-ignore`.
772
+ - But `data-layout-ignore` takes an element out of the check entirely, so two
773
+ ignored elements may overlap and nothing will say so. A source credit
774
+ running under a "schematic" mark in the same corner is the usual way this
775
+ bites. Read that corner off the still.
776
+ - **A CSS rule beats an SVG presentation attribute.** If your scene CSS says
777
+ `.fig .ln { fill: none; stroke-width: 4 }`, then `fill="#c00"` or
778
+ `stroke-width="7"` *as attributes* on a `.ln` element do nothing at all —
779
+ silently, with no warning. Shapes come out unfilled and strokes come out the
780
+ wrong weight. Set them as inline **styles** instead, or give the element its
781
+ own class. This is the single most common reason a drawn scene looks wrong
782
+ while every check passes.
684
783
  - **"The film's last line needs 2 s".** Only the video's last scene must hold
685
784
  its final words. Elsewhere, reveal text on an earlier phrase or raise
686
785
  `tail:`.
@@ -688,6 +787,10 @@ much cheaper to show one previewed scene in the chosen style and ask.
688
787
  so they come out dark when the scenes do; `ground: light | dark` settles the
689
788
  whole film and `cards: light | dark` overrides the cards alone. Keep
690
789
  end-card bullets short: about 10 Chinese characters or 5 English words each.
790
+ `ground` only picks light or dark, so a film drawn on a *colour* — warm
791
+ paper, a blueprint blue — still opens on storyshot's near-white, which reads
792
+ as cold and as a different film. Give that colour as `theme: { paper:
793
+ "#e9ddc4" }` and every card tone is mixed from it and the ink instead.
691
794
 
692
795
  Long narration is fine: a scene's `say:` is spoken in chunks of whole
693
796
  sentences, and in an explainer no pause inside a line lasts longer than
@@ -715,6 +818,47 @@ each invent one. Settle anything they disagree about (a colour that fails
715
818
  contrast, an object drawn two ways) in the kit, once, not scene by scene.
716
819
  Review every scene's stills yourself afterwards.
717
820
 
821
+ ### Portrait
822
+
823
+ `aspect: portrait` makes the video 1080×1920 — a phone held upright, which is
824
+ what most short-video feeds want. `square` is 1:1 and `landscape` (16:9) is
825
+ the default; `output:` still takes an exact size when none of them fits, and
826
+ `viewport:` now follows the output's shape unless it is given.
827
+
828
+ **Ask which feed it is for before you pick 9:16.** The tall frames are not
829
+ interchangeable. Any ratio can be written out — `aspect: "3:4"` is 1080×1440,
830
+ `aspect: "4:5"` is 1080×1350 — the short side is always 1080. 9:16 is right for
831
+ TikTok, Reels, Shorts and Douyin. **Xiaohongshu (RED) fills its feed with 3:4**
832
+ and crops a 9:16 video to it, so a 9:16 film posted there loses the top and
833
+ bottom of every frame — which, in a film that stacks its beats down a column, is
834
+ where the first and last beat live. 3:4 is a different staging problem again:
835
+ the frame is only 1.33× taller than wide while the type still has to grow for a
836
+ 1080-wide frame, so it holds *less* than 16:9 did. Cut harder.
837
+
838
+ A portrait film is **not a 16:9 film resized**. The frame is 1.8× taller than
839
+ it is wide, so rebuild each scene rather than scaling it:
840
+
841
+ - **One column, read top to bottom.** A 16:9 scene puts the object on one side
842
+ and its labels on the other. In portrait that side-by-side has nowhere to go:
843
+ stack it instead — the object, then the label under it, then the next beat
844
+ under that. A scene that is a left-to-right comparison in 16:9 becomes a
845
+ top-and-bottom one.
846
+ - **Fewer things, bigger.** 1080 px of width is half what you had. Carry one
847
+ idea per screenful, and give the subject the middle of the frame, where a
848
+ thumb is not.
849
+ - **Type goes up, not down.** The 28 px floor is measured at 1080p, so in
850
+ portrait it is a *width* floor: 28 px across 1080 px reads like 50 px across
851
+ 1920. Titles 92–120 px, leads 52–64 px, labels 36–40 px.
852
+ - **Keep the ends clear.** Feeds put a caption, a handle and buttons over the
853
+ bottom fifth and a status bar over the top. Hold the content between about
854
+ 12% and 80% of the height. With `subtitles: burn`, `S.safeBottom` is already
855
+ 22% in portrait (16% in landscape) — keep above it.
856
+ - **Burn the subtitles.** A portrait film is usually watched in a feed that
857
+ autoplays muted, which is the one case where `subtitles: burn` earns its
858
+ cost. Say so when you do it.
859
+ - Each scene's root still carries `data-width="1080" data-height="1920"`, and
860
+ `storyshot check` tells you when it does not match the video.
861
+
718
862
  ## 4. Render, look, fix
719
863
 
720
864
  1. `render_video` (use `draft: true` while iterating if the voice is
package/src/check.mjs CHANGED
@@ -9,6 +9,9 @@ import { sentences } from './voice.mjs';
9
9
  import { sceneInfo, filmGround, contrast } from './scenes.mjs';
10
10
  import path from 'node:path';
11
11
 
12
+ const cjkLen = (s) => (s.match(/[぀-ヿ㐀-鿿가-힯]/g) ?? []).length;
13
+ const wordLen = (s) => s.replace(/[぀-ヿ㐀-鿿가-힯]/g, ' ').split(/\s+/).filter((w) => /\w/.test(w)).length;
14
+
12
15
  // Phrases that mark narration as machine-written, and sentences too long to say in one breath.
13
16
  const TELLS = [
14
17
  /\bnot (?:just|only|merely) [^.,;!?]{1,40}?,? but(?: also)?\b/i, // "not just X, but Y"
@@ -28,15 +31,41 @@ export function narrationLint(text) {
28
31
  }
29
32
  let long = null;
30
33
  for (const s of sentences(t)) {
31
- const cjk = (s.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g) ?? []).length;
32
- const words = s.replace(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g, ' ').split(/\s+/).filter((w) => /\w/.test(w)).length;
34
+ const cjk = cjkLen(s), words = wordLen(s);
33
35
  if (cjk > 40 && !long) long = `${cjk}-character`;
34
36
  else if (!cjk && words > 25 && !long) long = `${words}-word`;
35
37
  }
36
38
  return { tells, long };
37
39
  }
38
40
 
39
- const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'ground', 'lang', 'colorScheme',
41
+ // How much one step may carry when the viewer is meant to follow along and do it themselves.
42
+ // A promo packs; a tutorial parcels out. The skill has always asked for 15 words a sentence, but the
43
+ // lint above only caught 25, which is a promo's budget — tutorials and lessons are held to the number
44
+ // the skill actually says, and a step gets a budget of its own. Density is the thing nobody notices
45
+ // while writing: three jobs in one breath (name the idea, give the rule, read the number) is what
46
+ // makes a tutorial sound like an ad read at speed, even when every sentence is short.
47
+ // sentenceWords/Chars is the number the skill asks for; the lint only speaks up past the slack, so
48
+ // "about 15 words" stays a target to write to rather than a line to trip over at 16.
49
+ export const TEACH = { words: 35, chars: 55, sentenceWords: 15, sentenceChars: 25, slackWords: 18, slackChars: 30, numbers: 3 };
50
+ const an = (s) => `${/^(?:8|11|18|8\d|11\d|18\d)/.test(s) ? 'an' : 'a'} ${s}`;
51
+ export function teachingLint(text) {
52
+ const t = String(text).replace(/\*\*/g, '');
53
+ const c = cjkLen(t), w = wordLen(t);
54
+ const heavy = c > TEACH.chars ? `${c}-character` : (!c && w > TEACH.words ? `${w}-word` : null);
55
+ let long = null;
56
+ for (const s of sentences(t)) {
57
+ const sc = cjkLen(s), sw = wordLen(s);
58
+ if (sc > TEACH.slackChars && !long) long = `${sc}-character`;
59
+ else if (!sc && sw > TEACH.slackWords && !long) long = `${sw}-word`;
60
+ }
61
+ // figures the viewer has to hold: a digit that starts a number, so an accession (P68871) or a
62
+ // version inside a word is not one, and the comma in "1,000" is part of it while the one after
63
+ // "105," is not. Chinese numerals are spelled out, where the character budget above is what limits them.
64
+ const numbers = [...new Set(t.match(/(?<![A-Za-z\d])\d(?:,?\d)*(?:\.\d+)?(?:e[-+]?\d+)?%?/gi) ?? [])];
65
+ return { heavy, long, numbers };
66
+ }
67
+
68
+ const TOP =['name', 'url', 'out', 'purpose', 'aspect', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'ground', 'lang', 'colorScheme',
40
69
  'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes', 'scenes'];
41
70
  const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc', 'scene'];
42
71
  const SCENE = ['file', 'title', 'lead', 'tail'];
@@ -91,6 +120,8 @@ export function check(file, { draft = false } = {}) {
91
120
  return { ok: false, errors: [`YAML syntax: ${e.message.split('\n')[0]}`], warnings, summary: '' };
92
121
  }
93
122
  unknown(raw, TOP, 'top level');
123
+ const sold = []; // tutorial captions written as a promo's payoff, reported together
124
+ let questions = 0; // question cards (a card that waits with `think`), which a lesson is expected to have
94
125
  for (const [i, a] of (raw.setup ?? []).entries()) checkAction(a, `setup action ${i + 1}`);
95
126
  for (const [i, s] of (raw.steps ?? []).entries()) {
96
127
  const where = `step ${i + 1}`;
@@ -121,8 +152,42 @@ export function check(file, { draft = false } = {}) {
121
152
  if (s.say && kinds.some((k) => k === 'scroll' || k === 'scrollTo') && !kinds.some((k) => ['mark', 'note', 'click', 'type', 'select', 'move', 'hover'].includes(k)) && !s.zoom) {
122
153
  warnings.push(`${where}: scrolls and talks but points at nothing — on a dense page the viewer can't tell what you mean; \`mark\` or \`note\` the row or value the narration is about (the camera then zooms to it)`);
123
154
  }
155
+ // A promo's caption bolds the payoff, because a promo is selling the payoff. A tutorial's caption
156
+ // says where the viewer is, and the narration does the explaining: bold it and the caption gets
157
+ // to the conclusion before the voice does, which is the grammar of an ad, not of a lesson. It is
158
+ // one decision rather than one mistake per step, so it is reported once.
159
+ if (typeof s.caption === 'string' && s.caption.includes('**')) sold.push([i + 1, s.caption]);
160
+ // A callout points at a control and names it. Longer than that and it is arguing for the app.
161
+ for (const a of (s.actions ?? [])) {
162
+ const n = a?.note;
163
+ const t = typeof n === 'string' ? n : n?.text;
164
+ if (t && (wordLen(t) > 8 || cjkLen(t) > 14)) warnings.push(`${where}: the note reads as a sentence ("${t}") — a callout is a label for the control it points at, a few words at most`);
165
+ }
166
+ // A question card is a check, and a check has three parts: the question, time to answer it, and
167
+ // the answer shown on the page. Without the wait it is a rhetorical question, which a promo asks
168
+ // and a lesson does not; without something marked, the viewer who guessed wrong is told they
169
+ // were wrong and never shown where to look.
170
+ const card = s.card && typeof s.card === 'object' ? s.card : null;
171
+ const asks = card && /question|quiz|check|问题|提问|思考|想一想|pregunta|frage/i.test(String(card.kicker ?? ''));
172
+ if (card && (card.think != null || asks)) {
173
+ questions++;
174
+ if (card.think == null) warnings.push(`${where} card: asks a question but does not wait for an answer — give it \`think: 3\` (seconds), so the viewer answers before the reveal`);
175
+ else if (!(card.think >= 2)) warnings.push(`${where} card: think: ${card.think} is too short to answer in — give the viewer at least 2 seconds, 3 to 4 for anything they have to work out`);
176
+ if (!s.zoom && !kinds.some((k) => ['mark', 'note', 'move', 'hover', 'click', 'slide', 'drag', 'type', 'select'].includes(k))) {
177
+ warnings.push(`${where}: this step answers the question card before it, but points at nothing — \`mark\` the row, value or line that is the answer, so a viewer who guessed wrong sees why`);
178
+ }
179
+ }
124
180
  }
125
181
  }
182
+ // A lesson that never asks is a lecture: the viewer cannot tell whether they followed it.
183
+ if (raw.purpose === 'course' && !questions && (raw.steps ?? []).some((s) => !s.hidden && s.say)) {
184
+ warnings.push('a lesson with no question card — check understanding at least once: `card: { kicker: Question, title: "…?", think: 3 }` on the step that then marks the answer');
185
+ }
186
+ if (sold.length) {
187
+ const ns = sold.map(([n]) => n);
188
+ const which = sold.length === 1 ? `step ${ns[0]}` : sold.length > 6 ? `steps ${ns[0]}–${ns.at(-1)} (${sold.length} captions)` : `steps ${ns.join(', ')}`;
189
+ warnings.push(`${which}: the caption bolds a payoff (${sold.slice(0, 3).map(([, c]) => `"${c}"`).join(', ')}${sold.length > 3 ? ', …' : ''}) — that is a promo's grammar, and it reaches the conclusion before the voice does; in a ${raw.purpose} a caption labels where the viewer is ("The E value column", "Part 2: read the results") and the narration explains`);
190
+ }
126
191
  if (raw.end_card) unknown(raw.end_card, END, 'end_card');
127
192
  if (raw.intro) unknown(raw.intro, END, 'intro');
128
193
  if (raw.hyperframes && typeof raw.hyperframes === 'object') unknown(raw.hyperframes, HYPERFRAMES, 'hyperframes');
@@ -226,12 +291,29 @@ export function check(file, { draft = false } = {}) {
226
291
  }
227
292
  // spoken lines that read as machine-written, or run on without a breath (the skill's "Writing the narration")
228
293
  if (cfg.voice) {
229
- const lines = [...visible.flatMap((s) => [[s.say, `step ${cfg.steps.indexOf(s) + 1}`], [s.card?.say, `step ${cfg.steps.indexOf(s) + 1} card`]]),
230
- [cfg.intro?.say, 'intro'], [cfg.endCard?.say, 'end card']].filter(([t]) => t);
231
- for (const [text, where] of lines) {
294
+ const teach = cfg.purpose === 'tutorial' || cfg.purpose === 'course';
295
+ const lines = [...visible.flatMap((s) => [[s.say, `step ${cfg.steps.indexOf(s) + 1}`, true], [s.card?.say, `step ${cfg.steps.indexOf(s) + 1} card`, 'card']]),
296
+ [cfg.intro?.say, 'intro', false], [cfg.endCard?.say, 'end card', false]].filter(([t]) => t);
297
+ for (const [text, where, isStep] of lines) {
232
298
  const { tells, long } = narrationLint(text);
233
299
  if (tells.length) warnings.push(`${where}: the narration sounds machine-written (${tells.map((x) => `"${x}"`).join(', ')}) — say it plainly, as you would out loud`);
234
- if (long) warnings.push(`${where}: a ${long} sentence — split it, so the voice gets a breath between the parts`);
300
+ // a tutorial's own sentence budget is tighter, and it replaces this one
301
+ if (long && !teach) warnings.push(`${where}: a ${long} sentence — split it, so the voice gets a breath between the parts`);
302
+ if (!teach) continue;
303
+ const t = teachingLint(text);
304
+ if (isStep === 'card' && t.heavy) {
305
+ // a concept card names one idea. The rule that goes with it, and the number that shows it,
306
+ // belong to the steps after the card, where the page can carry them
307
+ warnings.push(`${where}: ${an(t.heavy)} line on one card — a card names one idea; say the rule and read the number in the steps after it, where the page shows them`);
308
+ } else if (isStep && t.heavy) {
309
+ warnings.push(`${where}: ${an(t.heavy)} line for one step — one job per step in a ${cfg.purpose} (name the idea, do the action, or read the result); split it`);
310
+ }
311
+ if (t.long) {
312
+ warnings.push(`${where}: ${an(t.long)} sentence — a ${cfg.purpose} keeps them to about ${TEACH.sentenceWords} words (${TEACH.sentenceChars} Chinese characters), so the viewer holds one idea at a time`);
313
+ }
314
+ if (t.numbers.length > TEACH.numbers) {
315
+ warnings.push(`${where}: ${t.numbers.length} figures in one line (${t.numbers.join(', ')}) — nobody holds that many by ear; say the one that carries the point, round it out loud ("about 80 percent"), and leave the exact values on screen`);
316
+ }
235
317
  }
236
318
  }
237
319
  // the Chinese Kokoro voice needs a Python for its G2P: say so now, not after the recording
package/src/compose.mjs CHANGED
@@ -5,11 +5,31 @@ import { execFileSync } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { renderHyperframesCards } from './hyperframes.mjs';
8
- import { sceneSeconds, filmGround } from './scenes.mjs';
8
+ import { sceneSeconds, filmGround, mixColour, shadeColour } from './scenes.mjs';
9
9
 
10
10
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
11
  const md = (s, accent) => esc(s).replace(/\*\*(.+?)\*\*/g, `<b style="color:${accent};font-weight:700">$1</b>`);
12
12
 
13
+ // The camera path as one ffmpeg expression: a value per keyframe interval, smoothstepped between the
14
+ // two ends. Written as a FLAT SUM of gated terms, not a nested if/else chain — ffmpeg's expression
15
+ // parser guards recursion at 100 levels, and each zoom contributes four keyframes, so a tutorial
16
+ // written to "one job per step" (30-odd zooms, 130-odd intervals) silently exceeded it and the whole
17
+ // compose failed with "Missing ')' or too many args". A `+` chain is parsed iteratively, so the depth
18
+ // is constant however long the film is. The gates partition the timeline, so exactly one term is ever
19
+ // non-zero: the first interval also covers everything before it, and the last keyframe holds to the end.
20
+ export function keyframeExpr(kf, get, fps) {
21
+ const n = kf.length, terms = [];
22
+ for (let i = 0; i < n - 1; i++) {
23
+ const t0 = kf[i][0], t1 = kf[i + 1][0], v0 = get(kf[i]), v1 = get(kf[i + 1]);
24
+ const p = `clip((T-${t0.toFixed(3)})/${Math.max(1e-3, t1 - t0).toFixed(3)},0,1)`;
25
+ const body = v0 === v1 ? `${v0}` : `(${v0}+(${(v1 - v0).toFixed(4)})*${p}*${p}*(3-2*${p}))`;
26
+ const gate = i === 0 ? `lt(T,${t1.toFixed(3)})` : `(gte(T,${t0.toFixed(3)})*lt(T,${t1.toFixed(3)}))`;
27
+ terms.push(`${gate}*${body}`);
28
+ }
29
+ terms.push(`gte(T,${kf[n - 1][0].toFixed(3)})*${get(kf[n - 1])}`);
30
+ return `(${terms.join('+')})`.replaceAll('T', `(on/${fps})`);
31
+ }
32
+
13
33
  export async function compose(cfg, { log = console.log, voiceLens = { steps: {}, endCard: null } } = {}) {
14
34
  const warnings = [];
15
35
  const warn = (m) => { warnings.push(m); log(`warning: ${m}`); };
@@ -192,11 +212,23 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
192
212
  ...cards.map((c) => [c.t0, c.t1]),
193
213
  ...segs.filter((g) => voiceLens.steps[g.i] != null).map((g) => [g.o0 + g.sayAt, g.o0 + g.sayAt + voiceLens.steps[g.i]]),
194
214
  ];
215
+ // A promo must never stall. A tutorial is the opposite: the viewer is doing this too, and needs a
216
+ // beat to read the result, find the control on their own screen, and catch up. So a lesson gets a
217
+ // longer allowance, and a step that asked to hold (`pause`) means it — only a hold long enough to
218
+ // feel broken is worth a word there.
219
+ const teach = cfg.purpose === 'tutorial' || cfg.purpose === 'course';
220
+ const held = (i) => (cfg.steps[i]?.actions ?? []).some((a) => a && (a.pause != null || a.wait != null));
195
221
  let from = null;
196
222
  const report = (to) => {
197
- if (from != null && to - from >= 3) {
223
+ if (from != null) {
198
224
  const ns = segs.filter((x) => x.o1 > from + 1e-6 && x.o0 < to - 1e-6).map((x) => x.i + 1);
199
- warn(`${ns.length > 1 ? `steps ${ns[0]}–${ns.at(-1)}` : `step ${ns[0] ?? (segs.at(-1)?.i ?? cfg.steps.length - 1) + 1}`}: ${(to - from).toFixed(1)}s of dead air at ${from.toFixed(1)}–${to.toFixed(1)}s (nothing moves and nothing is said) — shorten the wait or pause, hide the load, or narrate what is happening`);
225
+ const limit = !teach ? 3 : ns.length && ns.every((k) => held(k - 1)) ? 8 : 6;
226
+ const fix = teach
227
+ ? 'say what is happening or what to look at, or let the step hold on purpose with `pause`'
228
+ : 'shorten the wait or pause, hide the load, or narrate what is happening';
229
+ if (to - from >= limit) {
230
+ warn(`${ns.length > 1 ? `steps ${ns[0]}–${ns.at(-1)}` : `step ${ns[0] ?? (segs.at(-1)?.i ?? cfg.steps.length - 1) + 1}`}: ${(to - from).toFixed(1)}s of dead air at ${from.toFixed(1)}–${to.toFixed(1)}s (nothing moves and nothing is said) — ${fix}`);
231
+ }
200
232
  }
201
233
  from = null;
202
234
  };
@@ -270,16 +302,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
270
302
  }
271
303
  if (kf.at(-1)[0] < TOTAL) kf.push([TOTAL, 1, C0]);
272
304
  const S = 2; // supersample to avoid zoompan's integer-pixel jitter
273
- const piece = (get) => {
274
- let e = String(get(kf.at(-1)));
275
- for (let i = kf.length - 2; i >= 0; i--) {
276
- const t0 = kf[i][0], t1 = kf[i + 1][0], v0 = get(kf[i]), v1 = get(kf[i + 1]);
277
- const p = `clip((T-${t0.toFixed(3)})/${Math.max(1e-3, t1 - t0).toFixed(3)},0,1)`;
278
- const body = v0 === v1 ? `${v0}` : `(${v0}+(${(v1 - v0).toFixed(4)})*${p}*${p}*(3-2*${p}))`;
279
- e = `if(lt(T,${t1.toFixed(3)}),${body},${e})`;
280
- }
281
- return e.replaceAll('T', `(on/${FPS})`);
282
- };
305
+ const piece = (get) => keyframeExpr(kf, get, FPS);
283
306
  const zExpr = piece((k) => k[1]);
284
307
  const cxExpr = piece((k) => +(k[2][0] * S).toFixed(1));
285
308
  const cyExpr = piece((k) => +(k[2][1] * S).toFixed(1));
@@ -388,11 +411,18 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
388
411
  // draw on a dark ground get dark cards, so a dark film doesn't cut to a white card; `ground: light |
389
412
  // dark` says it outright and `cards: light | dark` overrides the cards alone
390
413
  const darkCards = cfg.cards === 'dark' || (cfg.cards !== 'light' && filmGround(cfg) === 'dark');
391
- const CK = darkCards
392
- ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#1e2a44 0%,#111830 60%,#0b1020 100%)`, title: '#f1f5f9', sub: '#cbd5e1', muted: '#94a3b8', item: '#e2e8f0', track: '#26314d',
393
- pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
394
- : { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%)`, title: ink, sub: '#334155', muted: '#64748b', item: '#1e293b', track: '#e2e8f0',
395
- pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
414
+ // `theme.paper`: the film's own ground. Every card tone is then mixed from that paper and the ink,
415
+ // so a film drawn on warm paper does not open on a cold blue-white title card.
416
+ const paper = cfg.theme.paper;
417
+ const CK = paper
418
+ ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,${shadeColour(paper, darkCards ? -0.07 : 0.08)} 0%,${paper} 58%,${shadeColour(paper, darkCards ? 0.05 : -0.07)} 100%)`,
419
+ title: ink, sub: mixColour(ink, paper, 0.24), muted: mixColour(ink, paper, 0.46), item: mixColour(ink, paper, 0.12), track: mixColour(ink, paper, 0.76),
420
+ pillBg: ink, pillFg: paper, pillLine: 'none', ctaBg: ink, ctaFg: paper, shadow: `${mixColour(ink, '#000000', 0.3)}40` }
421
+ : darkCards
422
+ ? { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#1e2a44 0%,#111830 60%,#0b1020 100%)`, title: '#f1f5f9', sub: '#cbd5e1', muted: '#94a3b8', item: '#e2e8f0', track: '#26314d',
423
+ pillBg: '#1a2238', pillFg: '#e2e8f0', pillLine: '1px solid #33405f', ctaBg: accent, ctaFg: ink, shadow: 'rgba(0,0,0,.45)' }
424
+ : { bg: `radial-gradient(${W * 0.62}px ${H * 0.65}px at 50% 40%,#ffffff 0%,#eef2f7 60%,#e2e8f0 100%)`, title: ink, sub: '#334155', muted: '#64748b', item: '#1e293b', track: '#e2e8f0',
425
+ pillBg: ink, pillFg: '#e2e8f0', pillLine: 'none', ctaBg: ink, ctaFg: '#fff', shadow: 'rgba(15,23,42,.25)' };
396
426
  const card = (c, logoPath, { thinkAt = 0 } = {}) => {
397
427
  const image = c.imageFile && fs.existsSync(c.imageFile)
398
428
  ? `<img class="a rise" src="data:image/${path.extname(c.imageFile).slice(1).replace('jpg', 'jpeg').replace('svg', 'svg+xml')};base64,${fs.readFileSync(c.imageFile).toString('base64')}" style="animation-delay:.4s;max-width:${W * 0.6}px;max-height:${H * 0.42}px;border-radius:${12 * H / 1080}px;box-shadow:0 ${12 * H / 1080}px ${36 * H / 1080}px rgba(15,23,42,.18)">` : '';
@@ -557,7 +587,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
557
587
  // doesn't render) the built-in ones. A launch outro is drawn with transparency over the last frame,
558
588
  // which the ffmpeg pass below zooms into the page.
559
589
  const hf = cfg.hyperframes || cards.some((c) => c.scene) ? await renderHyperframesCards(hfJobs(), {
560
- W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work, subs: cfg.subtitles === 'burn',
590
+ W, H, FPS, dark: darkCards, accent, ink, paper, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work, subs: cfg.subtitles === 'burn',
561
591
  frame: { FX, FY, FW, FH, ...Object.fromEntries([['frameBg', 'frame-bg.png'], ['chrome', 'frame-chrome.png'], ['url', 'frame-url0.png']]
562
592
  .map(([k, f]) => [k, cfg.frame && fs.existsSync(path.join(cfg.work, f)) ? path.join(cfg.work, f) : null])) },
563
593
  }, { log, warn }) : {};
package/src/config.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import YAML from 'yaml';
5
5
  import { VOICE_DEFAULTS, kokoroVoiceFor, qwenVoiceFor } from './voice.mjs';
6
6
  import { QWEN_VOICES, qwenSupported, qwenVoice } from './qwen-tts.mjs';
7
- import { SCENE_DEFAULTS } from './scenes.mjs';
7
+ import { SCENE_DEFAULTS, toRgb } from './scenes.mjs';
8
8
 
9
9
  // Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
10
10
  export const LABELS = {
@@ -54,6 +54,27 @@ export const PACES = {
54
54
 
55
55
  export const FRAME_STYLES = ['aurora', 'studio', 'dark', 'pastel', 'blur'];
56
56
 
57
+ // The shape of the video, as one word in the storyboard: `aspect: portrait`. Portrait is a phone held
58
+ // upright and is what every short-video feed wants; square still plays either way round.
59
+ export const ASPECTS = { landscape: [1920, 1080], portrait: [1080, 1920], square: [1080, 1080] };
60
+ export const ASPECT_ALIASES = {
61
+ '16:9': 'landscape', wide: 'landscape', horizontal: 'landscape',
62
+ '9:16': 'portrait', vertical: 'portrait', tall: 'portrait',
63
+ '1:1': 'square',
64
+ };
65
+ // Any other shape is written as the ratio itself — `aspect: "3:4"` → 1080x1440, `aspect: "4:5"` →
66
+ // 1080x1350 — because the feeds do not all want the same tall frame: a 9:16 video is cropped in
67
+ // Xiaohongshu's feed, where 3:4 is what fills it. The short side is always 1080, and both sides come
68
+ // out even, which h.264 requires.
69
+ export function aspectFrame(ratio) {
70
+ const m = String(ratio).match(/^\s*(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)\s*$/);
71
+ const [w, h] = [Number(m?.[1]), Number(m?.[2])];
72
+ if (!m || !(w > 0) || !(h > 0)) return null;
73
+ const even = (n) => 2 * Math.round(n / 2);
74
+ return w >= h ? [even((1080 * w) / h), 1080] : [1080, even((1080 * h) / w)];
75
+ }
76
+ export const orientationOf = (w, h) => (h > w ? 'portrait' : h === w ? 'square' : 'landscape');
77
+
57
78
  export const VIDEO_DIR = 'videos';
58
79
  export const KINDS = { pitch: 'promo', tutorial: 'tutorial', course: 'lesson', docs: 'docs', explainer: 'explainer' };
59
80
  // pxgenius.ai → pxgenius; an IP or a port is kept whole so two internal apps don't share a name
@@ -94,11 +115,29 @@ export function loadConfig(file, { draft = false } = {}) {
94
115
  // only a storyboard made entirely of scenes has nothing to record
95
116
  if (!raw.url && !raw.steps.every((s) => s?.scene || s?.hidden)) throw new Error('demo.yaml: `url` is required (only a storyboard whose steps are all scenes can leave it out)');
96
117
 
97
- const viewport = { width: 1600, height: 900, ...raw.viewport };
98
- const output = { width: 1920, height: 1080, fps: 30, ...raw.output };
118
+ // aspect: the shape of the video in one word — landscape (16:9, the default), portrait (9:16: a
119
+ // phone held upright, and most short-video feeds) or square (1:1) — or as a ratio, `3:4` for the
120
+ // shape Xiaohongshu's feed fills. `output` still wins when it is given outright, so an unusual
121
+ // size stays possible.
122
+ const aspectName = raw.aspect == null ? null : (ASPECT_ALIASES[String(raw.aspect).toLowerCase()] ?? String(raw.aspect).toLowerCase());
123
+ const aspectSize = aspectName == null ? null : (ASPECTS[aspectName] ?? aspectFrame(aspectName));
124
+ if (aspectName && !aspectSize) throw new Error(`aspect must be one of ${Object.keys(ASPECTS).join(', ')}, or a ratio like "3:4" (16:9, 9:16 and 1:1 also work)`);
125
+ const [AW, AH] = aspectSize ?? ASPECTS.landscape;
126
+ const output = { width: AW, height: AH, fps: 30, ...raw.output };
127
+ // The browser viewport is only what a recording is captured at, and it has to share the output's
128
+ // aspect ratio. Unless it is given, it follows the output — so a portrait or square video never
129
+ // asks for that arithmetic, and a storyboard of scenes (which records nothing) never mentions it.
130
+ const long = 1600;
131
+ const viewport = {
132
+ ...(output.width >= output.height
133
+ ? { width: long, height: Math.round((long * output.height) / output.width) }
134
+ : { width: Math.round((long * output.width) / output.height), height: long }),
135
+ ...raw.viewport,
136
+ };
99
137
  const scale = output.width / viewport.width;
100
138
  if (Math.abs(viewport.height * scale - output.height) > 1) {
101
- throw new Error(`viewport ${viewport.width}x${viewport.height} and output ${output.width}x${output.height} must have the same aspect ratio`);
139
+ throw new Error(`viewport ${viewport.width}x${viewport.height} and output ${output.width}x${output.height} must have the same aspect ratio`
140
+ + ' (leave `viewport` out and it follows the output)');
102
141
  }
103
142
 
104
143
  const purpose = raw.purpose ?? 'pitch';
@@ -199,6 +238,7 @@ export function loadConfig(file, { draft = false } = {}) {
199
238
  for (const c of clips) if (!['gif', 'webm', 'mp4', 'png'].includes(c)) throw new Error('clips must be gif, webm, mp4 or png (or a list of them)');
200
239
  if (music?.file) music.file = path.resolve(dir, music.file);
201
240
  if (music?.style && !['calm'].includes(music.style)) throw new Error('music.style must be calm (or leave it out for the upbeat synth)');
241
+ if (raw.theme?.paper != null && !toRgb(raw.theme.paper)) throw new Error('theme.paper is the film\'s background colour, as a hex or rgb() value (e.g. "#e9ddc4")');
202
242
 
203
243
  // scenes: { css: [files], js: [files] } shared by every scene (a palette, drawing helpers), loaded before each one
204
244
  const sk = raw.scenes ?? {};
@@ -231,7 +271,13 @@ export function loadConfig(file, { draft = false } = {}) {
231
271
  scrollOffset: raw.scrollOffset ?? 40,
232
272
  // burned-in subtitles sit at the bottom, so captions move to the top
233
273
  captionPosition: raw.captionPosition ?? (pick('subtitles') === 'burn' ? 'top' : 'bottom'),
234
- theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', ...raw.theme },
274
+ // theme.paper: the film's own background colour. The title, section and end cards are built on it
275
+ // instead of storyshot's default near-white, which is a cold blue-grey and cuts badly against a
276
+ // film drawn on warm paper. Leave it out and the cards keep the default ground for `ground:`.
277
+ theme: { accent: '#d9f75b', ink: '#0f172a', font: 'Inter', paper: null, ...raw.theme },
278
+ // landscape | portrait | square, for anything that lays out by the shape rather than the pixels
279
+ // which way up the finished video is, whatever got it there: a named aspect, a ratio or `output`
280
+ aspect: orientationOf(output.width, output.height),
235
281
  setup: raw.setup ?? [],
236
282
  steps,
237
283
  endCard,
@@ -16,7 +16,7 @@ import path from 'node:path';
16
16
  import { createRequire } from 'node:module';
17
17
  import os from 'node:os';
18
18
  import { fileURLToPath } from 'node:url';
19
- import { writeSceneProject, sceneAssets, fileHash } from './scenes.mjs';
19
+ import { writeSceneProject, sceneAssets, fileHash, mixColour, shadeColour } from './scenes.mjs';
20
20
 
21
21
  export const HF_VERSION = '0.8.217';
22
22
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -152,6 +152,9 @@ const isCmd = (cta) => /^\$?\s*(npx|npm|pnpm|yarn|bun|pip|pipx|uv|brew|curl|git|
152
152
  const lum = (hex) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? ''); if (!m) return 0.5; const n = parseInt(m[1], 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; };
153
153
  // a light accent (a highlighter colour) made dark enough to draw lines and text with on white: same
154
154
  // hue, lightness capped (lower for yellows and greens, which read light at any lightness)
155
+ // Fine noise, laid over a flat or near-flat card ground so an 8-bit gradient does not contour.
156
+ const GRAIN = `url('data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .055 0"/></filter><rect width="200" height="200" filter="url(#n)"/></svg>')}')`;
157
+
155
158
  const deepen = (hex) => {
156
159
  const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? ''); if (!m) return '#4f46e5';
157
160
  const n = parseInt(m[1], 16), [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map((c) => c / 255);
@@ -301,9 +304,16 @@ function lesson(job, ctx) {
301
304
  const { W, H, dark, font, opts } = ctx, u = Math.min(H, W * 1.15) / 1080, D = job.seconds, c = job.card, accent = ctx.accent, ink = ctx.ink;
302
305
  // portrait with burned-in subtitles: the subtitles sit above the bottom fifth, so the card keeps above them
303
306
  const lift = ctx.subs && H > W ? H * 0.22 : 0;
304
- const P = dark
305
- ? { ground: '#0c1222', ground2: '#070b16', glow: '#1b2744', text: '#eef2f8', muted: '#9aa8bd', line: '#2a3550', chip: '#131c31', chipLine: '#26324d', band: 0.14, shadow: 'rgba(0,0,0,.35)' }
306
- : { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
307
+ // `theme.paper`: the film's own ground, which every card tone is then mixed from, so a film drawn on
308
+ // warm paper opens on that paper instead of storyshot's default cold blue-white.
309
+ const paper = ctx.paper;
310
+ const P = paper
311
+ ? { ground: paper, ground2: shadeColour(paper, dark ? 0.035 : -0.045), glow: shadeColour(paper, dark ? -0.045 : 0.05), text: ink,
312
+ muted: mixColour(ink, paper, 0.44), line: mixColour(ink, paper, 0.7), chip: shadeColour(paper, dark ? 0.07 : 0.06),
313
+ chipLine: mixColour(ink, paper, 0.74), band: dark ? 0.14 : 0.17, shadow: `${mixColour(ink, '#000000', 0.35)}1f` }
314
+ : dark
315
+ ? { ground: '#0c1222', ground2: '#070b16', glow: '#1b2744', text: '#eef2f8', muted: '#9aa8bd', line: '#2a3550', chip: '#131c31', chipLine: '#26324d', band: 0.14, shadow: 'rgba(0,0,0,.35)' }
316
+ : { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
307
317
  const strong = dark ? accent : deepen(accent); // accent as text/line on this ground
308
318
  const bandText = opts.band ? String(opts.band).replace(/\s+/g, '') : null;
309
319
  const colorOf = (ch) => (opts.bandColors === 'aminoacid' ? RES[ch] : opts.bandColors === 'nucleotide' ? NUC[ch] : null) ?? strong;
@@ -346,7 +356,9 @@ function lesson(job, ctx) {
346
356
 
347
357
  const css = `${fontFaces(font)}
348
358
  #stage{font-family:'${font}','StoryshotCJK',sans-serif;color:${P.text}}
349
- .bg{background:radial-gradient(${W * 0.73}px ${H * 0.88}px at 50% 40%, ${P.glow} 0%, ${P.ground} 52%, ${P.ground2} 100%)}
359
+ /* the grain is what keeps a wide, low-saturation gradient (warm paper above all) from banding into
360
+ visible rings once the frames are written as JPEG */
361
+ .bg{background:${paper ? `${GRAIN}, ` : ''}radial-gradient(${W * 0.73}px ${H * 0.88}px at 50% 40%, ${P.glow} 0%, ${P.ground} 52%, ${P.ground2} 100%)}
350
362
  .backdrop{object-fit:cover;opacity:${dark ? 0.16 : 0.1};-webkit-mask-image:radial-gradient(ellipse 60% 60% at 50% 50%,#000 35%,transparent 100%)}
351
363
  .band-wrap{position:absolute;left:0;right:0;height:${80 * u}px;overflow:hidden;opacity:${P.band};-webkit-mask-image:linear-gradient(90deg,transparent,#000 18%,#000 82%,transparent)}
352
364
  .band{position:absolute;left:0;top:0;white-space:nowrap;font:500 ${54 * u}px 'IBM Plex Mono',monospace;letter-spacing:.3em}
package/src/init.mjs CHANGED
@@ -120,7 +120,7 @@ name: ${name}
120
120
  purpose: explainer
121
121
  out: ${name}.mp4
122
122
  ${zh ? 'lang: zh\nvoice: { provider: qwen, voice: dylan } # free, local (Apple Silicon); elsewhere kokoro zf_001' : 'voice: { provider: kokoro } # free, local'}
123
- ${portrait ? `output: { width: ${W}, height: ${H} }\nviewport: { width: 900, height: 1600 }\n` : ''}subtitles: true # an .srt beside the video, which is the right one for an explainer: the
123
+ ${portrait ? 'aspect: portrait # 9:16, 1080x1920: a phone held upright, and every short-video feed.\n # Write every scene as one column: `storyshot guide` ("Portrait").\n' : ''}subtitles: true # an .srt beside the video, which is the right one for an explainer: the
124
124
  # scenes already carry text, and \`burn\` lights the words up at the bottom
125
125
  # of the picture, pulling the eye off the drawing for the whole film.
126
126
  # Burn them for social video or a feed that autoplays muted, and say so.
package/src/record.mjs CHANGED
@@ -423,7 +423,10 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
423
423
  }
424
424
  const { width: VW, height: VH } = cfg.viewport;
425
425
 
426
- const browser = await chromium.launch();
426
+ // STORYSHOT_GPU=1 renders WebGL on the GPU (Metal on macOS) instead of SwiftShader;
427
+ // 3D pages otherwise record at ~10 fps and every step comes out rushed.
428
+ const gpu = /^(1|true|yes)$/i.test(process.env.STORYSHOT_GPU || '');
429
+ const browser = await chromium.launch(gpu ? { args: ['--enable-gpu', '--ignore-gpu-blocklist', ...(process.platform === 'darwin' ? ['--use-angle=metal'] : [])] } : {});
427
430
  const context = await sessionContext(browser, {
428
431
  viewport: cfg.viewport, deviceScaleFactor: cfg.scale, colorScheme: cfg.colorScheme, storageState: cfg.storageState, ignoreHTTPSErrors: cfg.ignoreHTTPSErrors,
429
432
  permissions: ['clipboard-read', 'clipboard-write'], // for `paste`
package/src/scenes.mjs CHANGED
@@ -144,7 +144,12 @@ ${(job.kit?.css ?? []).map((f, k) => `<link rel="stylesheet" href="kit/${k}-${pa
144
144
  ${(job.kit?.js ?? []).map((f, k) => `<script src="kit/${k}-${path.basename(f)}"></script>`).join('\n')}
145
145
  </head><body>
146
146
  <div id="root" data-composition-id="main" data-start="0" data-duration="${D}" data-width="${W}" data-height="${H}">
147
- <div id="${info.id}" class="clip" data-composition-id="${info.id}" data-composition-src="compositions/scene.html" data-start="0" data-duration="${D}" data-track-index="1" data-width="${W}" data-height="${H}"></div>
147
+ <!-- The host the scene mounts into carries no \`id\`, only \`data-composition-id\`, which is what
148
+ HyperFrames matches on. It used to carry the scene's id as well, and since the scene's own root
149
+ element must have that same id, every scene then had TWO elements with it in one document:
150
+ document.getElementById('<id>') returned this empty host, and a scene that appended to it put
151
+ everything outside its own styled root and rendered a blank frame. -->
152
+ <div class="clip" data-composition-id="${info.id}" data-composition-src="compositions/scene.html" data-start="0" data-duration="${D}" data-track-index="1" data-width="${W}" data-height="${H}"></div>
148
153
  ${job.preview && ctx.subs ? `<div id="storyshot-subtitle-band" data-layout-ignore style="position:absolute;left:0;right:0;bottom:0;height:${Math.round(H > W ? H * 0.22 : H * 0.16)}px;z-index:9999;background:repeating-linear-gradient(135deg,rgba(255,64,64,.18) 0 14px,rgba(255,64,64,.08) 14px 28px);border-top:3px dashed rgba(255,64,64,.9);pointer-events:none"><span style="position:absolute;right:24px;top:10px;font:600 26px/1 sans-serif;color:#ff4040">burned-in subtitles</span></div>` : ''}
149
154
  </div>
150
155
  <script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
@@ -195,11 +200,29 @@ export function formatTimings(list) {
195
200
  ].join('\n')).join('\n\n');
196
201
  }
197
202
 
203
+ // The red, green and blue of a hex or rgb() colour, 0–255. null for anything else (a variable, an
204
+ // image, a colour function).
205
+ export function toRgb(colour) {
206
+ const hex = String(colour).match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i)?.[1], rgb = String(colour).match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i);
207
+ return hex ? (hex.length === 3 ? [...hex].map((h) => parseInt(h + h, 16)) : [0, 2, 4].map((k) => parseInt(hex.slice(k, k + 2), 16))) : rgb ? rgb.slice(1, 4).map(Number) : null;
208
+ }
209
+ const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
210
+
211
+ // Mix two colours: t = 0 is all `a`, t = 1 is all `b`. Returns a hex colour, or null if either side
212
+ // can't be read. This is how a film's own `theme.paper` becomes a whole set of card tones instead of
213
+ // storyshot's default near-white ones, which are cold against warm paper.
214
+ export function mixColour(a, b, t) {
215
+ const [x, y] = [toRgb(a), toRgb(b)];
216
+ if (!x || !y) return null;
217
+ return `#${x.map((v, i) => hex2(v + (y[i] - v) * t)).join('')}`;
218
+ }
219
+ // Lighten (amount > 0) or darken (amount < 0) a colour, 0–1.
220
+ export const shadeColour = (c, amount) => mixColour(c, amount >= 0 ? '#ffffff' : '#000000', Math.abs(amount));
221
+
198
222
  // How light a hex or rgb() colour is, 0 (black) to 1 (white). null for anything else (a variable, an
199
223
  // image, a colour function).
200
224
  export function luminance(colour) {
201
- const hex = String(colour).match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i)?.[1], rgb = String(colour).match(/rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i);
202
- const c = hex ? (hex.length === 3 ? [...hex].map((h) => parseInt(h + h, 16)) : [0, 2, 4].map((k) => parseInt(hex.slice(k, k + 2), 16))) : rgb ? rgb.slice(1, 4).map(Number) : null;
225
+ const c = toRgb(colour);
203
226
  if (!c) return null;
204
227
  const lin = c.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4));
205
228
  return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];