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 +110 -62
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +149 -5
- package/src/__pycache__/qwen_tts.cpython-312.pyc +0 -0
- package/src/check.mjs +89 -7
- package/src/compose.mjs +49 -19
- package/src/config.mjs +51 -5
- package/src/hyperframes.mjs +17 -5
- package/src/init.mjs +1 -1
- package/src/record.mjs +4 -1
- package/src/scenes.mjs +26 -3
package/README.md
CHANGED
|
@@ -1,19 +1,94 @@
|
|
|
1
1
|
# Storyshot
|
|
2
2
|
|
|
3
|
-
Ask your coding agent for a video
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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.
|
|
533
|
-
|
|
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.
|
|
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.
|
|
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**")
|
|
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.
|
|
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
|
|
Binary file
|
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
|
|
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
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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:
|
|
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,
|
package/src/hyperframes.mjs
CHANGED
|
@@ -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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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];
|