storyshot 0.7.2 → 0.8.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 +65 -3
- package/bin/storyshot.mjs +27 -2
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +261 -3
- package/src/audio.mjs +27 -2
- package/src/build.mjs +15 -1
- package/src/check.mjs +42 -5
- package/src/compose.mjs +34 -10
- package/src/config.mjs +34 -4
- package/src/hyperframes.mjs +63 -17
- package/src/init.mjs +70 -0
- package/src/mcp.mjs +43 -5
- package/src/outputs.mjs +22 -12
- package/src/record.mjs +1 -0
- package/src/scene-tools.mjs +123 -0
- package/src/scenes.mjs +234 -0
- package/src/voice.mjs +114 -5
package/src/mcp.mjs
CHANGED
|
@@ -13,7 +13,8 @@ import { build } from './build.mjs';
|
|
|
13
13
|
import { record } from './record.mjs';
|
|
14
14
|
import { guide } from './guide.mjs';
|
|
15
15
|
import { check, formatCheck } from './check.mjs';
|
|
16
|
-
import { initStoryboard } from './init.mjs';
|
|
16
|
+
import { initStoryboard, initExplainer } from './init.mjs';
|
|
17
|
+
import { sceneCues, previewScene, formatPreview } from './scene-tools.mjs';
|
|
17
18
|
import { openLoginWindow, finishLogin, authFileFor } from './auth.mjs';
|
|
18
19
|
import { ensureBrowser, ffmpegInstallCommand } from './doctor.mjs';
|
|
19
20
|
import { execFileSync } from 'node:child_process';
|
|
@@ -29,7 +30,8 @@ const abs = (p) => path.resolve(p);
|
|
|
29
30
|
|
|
30
31
|
const server = new McpServer({ name: 'storyshot', version: pkg.version }, {
|
|
31
32
|
instructions: [
|
|
32
|
-
'Storyshot makes product demo, tutorial, course and docs videos of web apps from a YAML storyboard
|
|
33
|
+
'Storyshot makes product demo, tutorial, course and docs videos of web apps from a YAML storyboard,',
|
|
34
|
+
'and explainers: a concept told with animated scenes you write (scene steps; also usable inside tutorials and lessons).',
|
|
33
35
|
'Before writing anything, inspect_page the start url (it reports login walls), then confirm the brief with the user in ONE message:',
|
|
34
36
|
'purpose (pitch/tutorial/course/docs), length, flow, voice, music, subtitles, language, and whether a login is needed (then call login).',
|
|
35
37
|
'Workflow: storyboard_guide (once) → init_storyboard (or inspect_page) → edit the YAML → check_storyboard → render_video',
|
|
@@ -49,15 +51,22 @@ server.registerTool('init_storyboard', {
|
|
|
49
51
|
title: 'Create a starter storyboard',
|
|
50
52
|
description: 'Inspect a URL and write a starter storyboard YAML that already uses verified click targets from that page (listed as comments at the bottom). Start here, then edit the file.',
|
|
51
53
|
inputSchema: {
|
|
52
|
-
url: z.string().describe('Start page of the web app'),
|
|
54
|
+
url: z.string().optional().describe('Start page of the web app (not needed for purpose explainer)'),
|
|
53
55
|
path: z.string().optional().describe('Absolute path for the YAML (must not exist yet). Default: <cwd>/videos/<site>-<kind>[-<topic>].yaml, kind = promo/tutorial/lesson/docs from purpose'),
|
|
54
|
-
purpose: z.enum(['pitch', 'tutorial', 'course', 'docs']).optional().describe('pitch = short promo (default); tutorial = narrated how-to, paced to follow along; course = narrated lesson that explains the why; docs = per-step GIFs/PNGs + a Markdown article'),
|
|
56
|
+
purpose: z.enum(['pitch', 'tutorial', 'course', 'docs', 'explainer']).optional().describe('pitch = short promo (default); tutorial = narrated how-to, paced to follow along; course = narrated lesson that explains the why; docs = per-step GIFs/PNGs + a Markdown article; explainer = a concept told with animated scenes you write (no url: give topic)'),
|
|
57
|
+
lang: z.string().optional().describe('explainer: language of the narration, e.g. zh (Mandarin, Qwen3-TTS Dylan) or en'),
|
|
58
|
+
portrait: z.boolean().optional().describe('explainer: a vertical 1080x1920 video'),
|
|
55
59
|
topic: z.string().optional().describe('Short topic for the file name, e.g. "export-pdf" → videos/<site>-tutorial-export-pdf.yaml. Use it for tutorials/lessons, since a site usually gets several'),
|
|
56
60
|
storage_state: z.string().optional().describe('Absolute path to a saved login (from the login tool) for apps behind a login'),
|
|
57
61
|
ignore_https_errors: z.boolean().optional().describe('Accept a self-signed HTTPS certificate (internal sites only); also writes ignoreHTTPSErrors: true into the storyboard'),
|
|
58
62
|
},
|
|
59
|
-
}, async ({ url, path: file, purpose = 'pitch', topic, storage_state, ignore_https_errors = false }) => {
|
|
63
|
+
}, async ({ url, path: file, purpose = 'pitch', topic, storage_state, ignore_https_errors = false, lang, portrait = false }) => {
|
|
60
64
|
try {
|
|
65
|
+
if (purpose === 'explainer') {
|
|
66
|
+
const r = initExplainer({ topic, file: file ? abs(file) : undefined, lang, portrait });
|
|
67
|
+
return { content: [text(`Wrote ${r.file} and the starter scenes\n${r.scenes.join('\n')}\n\n${r.yaml}\nNext: write the say: lines, call scene_cues, write the scenes with S.at(k), then preview_scene each one.`)] };
|
|
68
|
+
}
|
|
69
|
+
if (!url) return fail('give the url of the web app (only purpose: explainer starts without one)');
|
|
61
70
|
await ensureBrowser(() => {});
|
|
62
71
|
const r = await initStoryboard(url, { purpose, topic, file: file ? abs(file) : undefined, storageState: storage_state ? abs(storage_state) : undefined, ignoreHTTPSErrors: ignore_https_errors });
|
|
63
72
|
return { content: [text(`${r.login?.hint ? `ℹ Login: ${r.login.hint}. If the flow needs it, call login first.\n\n` : ''}Wrote ${r.file}:\n\n${r.yaml}`), image(r.screenshot)] };
|
|
@@ -143,6 +152,35 @@ server.registerTool('check_storyboard', {
|
|
|
143
152
|
return { isError: !r.ok, content: [text(formatCheck(r))] };
|
|
144
153
|
});
|
|
145
154
|
|
|
155
|
+
server.registerTool('scene_cues', {
|
|
156
|
+
title: 'Scene cues',
|
|
157
|
+
description: 'For storyboards with scene steps: synthesizes the narration and returns, for every scene, its length and when each phrase of its say: line is spoken (scene seconds = what S.at(k) returns inside the scene). Call it after writing the say: lines and before timing the animations; again after changing a line.',
|
|
158
|
+
inputSchema: { storyboard: z.string().describe('Absolute path to the storyboard YAML'), draft: z.boolean().optional().describe('use the free local voice') },
|
|
159
|
+
}, async ({ storyboard, draft = false }) => {
|
|
160
|
+
try {
|
|
161
|
+
const r = await sceneCues(loadConfig(abs(storyboard), { draft }), { log: () => {} });
|
|
162
|
+
return { content: [text(`${r.text || 'no scene steps in this storyboard'}\n\n(also in ${r.file})`)] };
|
|
163
|
+
} catch (e) { return fail(`scene_cues failed: ${e.message}`); }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
server.registerTool('preview_scene', {
|
|
167
|
+
title: 'Preview a scene',
|
|
168
|
+
description: 'Render stills of one scene step as HyperFrames will (default: a beat into each phrase of its narration, and the last frame), plus HyperFrames lint and its layout/contrast check, in about 20–40 s — without rendering the video. Returns the contact sheet: look at every still (does each part appear when the voice names it? overlaps, crops, text under the subtitles?), fix the scene, preview again.',
|
|
169
|
+
inputSchema: {
|
|
170
|
+
storyboard: z.string().describe('Absolute path to the storyboard YAML'),
|
|
171
|
+
step: z.number().int().min(1).describe('The scene\'s step number (from 1)'),
|
|
172
|
+
at: z.array(z.number().min(0)).optional().describe('Scene seconds to capture instead of the defaults'),
|
|
173
|
+
check: z.boolean().optional().describe('Run the layout/contrast check too (default true; ~20 s)'),
|
|
174
|
+
draft: z.boolean().optional(),
|
|
175
|
+
},
|
|
176
|
+
}, async ({ storyboard, step, at, check: chk = true, draft = false }) => {
|
|
177
|
+
try {
|
|
178
|
+
const r = await previewScene(loadConfig(abs(storyboard), { draft }), step, { at, check: chk, log: () => {} });
|
|
179
|
+
const pics = r.sheets?.length ? r.sheets.map(image) : r.images.slice(0, 6).map(image);
|
|
180
|
+
return { isError: !!r.lint.errors.length, content: [text(formatPreview(r, step)), ...pics] };
|
|
181
|
+
} catch (e) { return fail(`preview_scene failed: ${e.message}`); }
|
|
182
|
+
});
|
|
183
|
+
|
|
146
184
|
// --- background render jobs -------------------------------------------------------------------
|
|
147
185
|
const jobs = new Map();
|
|
148
186
|
let seq = 0;
|
package/src/outputs.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { chromium } from 'playwright';
|
|
6
6
|
import { hasFilter } from './ffmpeg.mjs';
|
|
7
|
+
import { filmGround, isDark } from './scenes.mjs';
|
|
7
8
|
|
|
8
9
|
const plain = (s) => String(s).replace(/\*\*/g, '');
|
|
9
10
|
const srtTime = (t) => {
|
|
@@ -90,7 +91,11 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
90
91
|
// Chinese, Japanese, Korean: no spaces between words, so lines are cut by characters (one line of
|
|
91
92
|
// at most ~18), in a lighter weight and a font that has the glyphs
|
|
92
93
|
const cjk = cfg.lang === 'zh' || narration.some((l) => /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(l.text));
|
|
93
|
-
|
|
94
|
+
// portrait (9:16): size by the width, and keep the lines above the bottom fifth, which short-video
|
|
95
|
+
// apps cover with the title, the account name and their buttons
|
|
96
|
+
const portrait = H > W;
|
|
97
|
+
const size = Math.round(portrait ? W * (cjk ? 0.068 : 0.062) : H * (cjk ? 0.042 : 0.048)), edge = Math.max(2, Math.round(size * (cjk ? 0.07 : 0.09)));
|
|
98
|
+
const lift = Math.round(H * (portrait ? 0.2 : 0.06)), most = portrait ? Math.floor((W * 0.84) / size) : 18;
|
|
94
99
|
const fontName = cjk && cfg.theme.font === 'Inter' ? (process.platform === 'darwin' ? 'PingFang SC' : 'Noto Sans CJK SC') : cfg.theme.font;
|
|
95
100
|
const cues = []; // { words: [{ w, start, d }] }: one on-screen line each
|
|
96
101
|
for (const line of narration) {
|
|
@@ -102,7 +107,7 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
102
107
|
const total = words.reduce((a, w) => a + weight(w), 0), span = line.end - line.start;
|
|
103
108
|
let at = line.start;
|
|
104
109
|
const timed = words.map((w) => { const d = (span * weight(w)) / total; const r = { w, start: at, d }; at += d; return r; });
|
|
105
|
-
// chunks of up to 7 words, broken after sentence punctuation
|
|
110
|
+
// chunks of up to 7 words (5 in portrait), broken after sentence punctuation
|
|
106
111
|
let chunk = [];
|
|
107
112
|
const flush = () => {
|
|
108
113
|
if (!chunk.length) return;
|
|
@@ -111,16 +116,21 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
111
116
|
};
|
|
112
117
|
const len = () => chunk.reduce((a, x) => a + x.w.length, 0);
|
|
113
118
|
for (const x of timed) {
|
|
114
|
-
// a CJK line ends at a sentence break, or at a comma once it is half full, or
|
|
115
|
-
if (cjk && chunk.length && len() + x.w.length >
|
|
119
|
+
// a CJK line ends at a sentence break, or at a comma once it is half full, or when full (18 characters; about 12 in portrait)
|
|
120
|
+
if (cjk && chunk.length && len() + x.w.length > most) flush();
|
|
116
121
|
chunk.push(x);
|
|
117
|
-
if (cjk ? /[。!?;!?;]$/.test(x.w) || (/[,、:,:]$/.test(x.w) && len() >=
|
|
122
|
+
if (cjk ? /[。!?;!?;]$/.test(x.w) || (/[,、:,:]$/.test(x.w) && len() >= most / 2) : chunk.length >= (portrait ? 5 : 7) || /[.!?;:]$/.test(x.w)) flush();
|
|
118
123
|
}
|
|
119
124
|
flush();
|
|
120
125
|
}
|
|
121
126
|
const tmp = cfg.out.replace(/(\.[^.]+)$/, '.burn$1');
|
|
122
127
|
const encode = ['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'copy', '-movflags', '+faststart', tmp];
|
|
123
|
-
|
|
128
|
+
// The words follow the film's ground (`ground:`, else read from the scenes or the frame): white
|
|
129
|
+
// letters with an ink outline on a dark film, ink letters with a white outline on a light one, where
|
|
130
|
+
// white letters would need a dark bar under them. The word being spoken is the accent either way.
|
|
131
|
+
const light = filmGround(cfg) === 'light' && isDark(cfg.theme.ink);
|
|
132
|
+
const restCol = light ? cfg.theme.ink : '#ffffff', edgeCol = light ? '#ffffff' : cfg.theme.ink, sweep = cfg.theme.accent;
|
|
133
|
+
const shade = light ? [255, 255, 255] : [0, 0, 0];
|
|
124
134
|
if (renderer === 'png') {
|
|
125
135
|
// no libass in this ffmpeg: draw each state of each line (the words spoken so far in the accent
|
|
126
136
|
// colour) in Chromium, string them into one transparent track and overlay it
|
|
@@ -156,10 +166,10 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
156
166
|
if (cue.words[0].start > at) push('blank.png', cue.words[0].start);
|
|
157
167
|
for (const [j, x] of cue.words.entries()) {
|
|
158
168
|
const file = `s${String(n++).padStart(5, '0')}.png`;
|
|
159
|
-
const text = cue.words.map((y, k) => `<span style="color:${k <= j ? sweep :
|
|
160
|
-
await shot(`<div style="position:absolute;left:${Math.round(W * 0.08)}px;right:${Math.round(W * 0.08)}px;bottom:${
|
|
169
|
+
const text = cue.words.map((y, k) => `<span style="color:${k <= j ? sweep : restCol}">${esc(y.w)}</span>`).join(cjk ? '' : ' ');
|
|
170
|
+
await shot(`<div style="position:absolute;left:${Math.round(W * 0.08)}px;right:${Math.round(W * 0.08)}px;bottom:${lift}px;text-align:center;
|
|
161
171
|
font-family:${font};font-size:${px}px;font-weight:${cjk ? 400 : 700};line-height:1.2;
|
|
162
|
-
-webkit-text-stroke:${edge * 2}px ${edgeCol};paint-order:stroke fill;text-shadow:${Math.round(edge * 0.8)}px ${Math.round(edge * 0.8)}px 0 rgba(
|
|
172
|
+
-webkit-text-stroke:${edge * 2}px ${edgeCol};paint-order:stroke fill;text-shadow:${Math.round(edge * 0.8)}px ${Math.round(edge * 0.8)}px 0 rgba(${shade.join(',')},.5)">${text}</div>`, path.join(dir, file));
|
|
163
173
|
push(file, j + 1 < cue.words.length ? cue.words[j + 1].start : end);
|
|
164
174
|
}
|
|
165
175
|
}
|
|
@@ -170,7 +180,7 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
170
180
|
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-f', 'concat', '-safe', '0', '-i', path.join(dir, 'list.txt'),
|
|
171
181
|
'-filter_complex', '[1:v]format=rgba[s];[0:v][s]overlay=0:0:eof_action=pass,format=yuv420p[v]', '-map', '[v]', '-map', '0:a?', ...encode]);
|
|
172
182
|
fs.renameSync(tmp, cfg.out);
|
|
173
|
-
return log(`subtitles: burned in ${cues.length} lines with word-by-word highlight (drawn by Storyshot: this ffmpeg has no libass)`);
|
|
183
|
+
return log(`subtitles: burned in ${cues.length} lines with word-by-word highlight${light ? ', in ink (the film is light)' : ''} (drawn by Storyshot: this ffmpeg has no libass)`);
|
|
174
184
|
}
|
|
175
185
|
const events = cues.map(({ words }) => {
|
|
176
186
|
const s0 = words[0].start, e0 = words.at(-1).start + words.at(-1).d;
|
|
@@ -184,7 +194,7 @@ WrapStyle: 0
|
|
|
184
194
|
|
|
185
195
|
[V4+ Styles]
|
|
186
196
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
187
|
-
Style: Sub,${fontName},${size},${bgr(sweep)}
|
|
197
|
+
Style: Sub,${fontName},${size},${bgr(sweep)},${bgr(restCol)},${bgr(edgeCol)},${bgr(light ? '#ffffff' : '#000000', '80')},${cjk ? 0 : 1},0,0,0,100,100,0,0,1,${edge},${Math.round(edge * 0.8)},2,${Math.round(W * 0.08)},${Math.round(W * 0.08)},${lift},1
|
|
188
198
|
|
|
189
199
|
[Events]
|
|
190
200
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
@@ -196,5 +206,5 @@ ${events.join('\n')}
|
|
|
196
206
|
const arg = file.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
|
|
197
207
|
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', cfg.out, '-vf', `ass='${arg}'`, ...encode]);
|
|
198
208
|
fs.renameSync(tmp, cfg.out);
|
|
199
|
-
log(`subtitles: burned in ${events.length} lines with word-by-word highlight`);
|
|
209
|
+
log(`subtitles: burned in ${events.length} lines with word-by-word highlight${light ? ', in ink (the film is light)' : ''}`);
|
|
200
210
|
}
|
package/src/record.mjs
CHANGED
|
@@ -413,6 +413,7 @@ const isPoint = (v) => v && typeof v === 'object' && typeof v.x === 'number' &&
|
|
|
413
413
|
// inspectAfter: N → don't record; run setup + steps 1..N quickly, then describe the page as
|
|
414
414
|
// it is at that moment (for states only reachable through the flow, e.g. an editor after upload).
|
|
415
415
|
export async function record(cfg, { log = console.log, inspectAfter = null } = {}) {
|
|
416
|
+
if (!cfg.url) throw new Error('this storyboard has no url: its steps are all scenes, so there is no web app to record or inspect — preview a scene with `storyshot scene <storyboard> --step N` (MCP: preview_scene)');
|
|
416
417
|
const fast = inspectAfter != null;
|
|
417
418
|
const RAW = path.join(cfg.work, 'raw');
|
|
418
419
|
fs.mkdirSync(cfg.work, { recursive: true });
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The two tools for writing scenes: the cue table (when each phrase of each scene's narration is
|
|
2
|
+
// spoken, so the animation can land on the words) and a preview of one scene (stills at chosen
|
|
3
|
+
// moments, plus HyperFrames' own lint and layout check), without rendering the whole video.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { measureVoice, speak, phraseTimes } from './voice.mjs';
|
|
7
|
+
import { sceneTimings, sceneSeconds, writeSceneProject, formatTimings } from './scenes.mjs';
|
|
8
|
+
import { hyperframesRun, sceneFontFaces } from './hyperframes.mjs';
|
|
9
|
+
|
|
10
|
+
const SR = 48000;
|
|
11
|
+
|
|
12
|
+
// Every scene step's timing and cues; also written to .storyshot/<name>/cues.json.
|
|
13
|
+
export async function sceneCues(cfg, { log = console.log } = {}) {
|
|
14
|
+
const lens = await measureVoice(cfg, { log });
|
|
15
|
+
const list = sceneTimings(cfg, lens);
|
|
16
|
+
fs.mkdirSync(cfg.work, { recursive: true });
|
|
17
|
+
const file = path.join(cfg.work, 'cues.json');
|
|
18
|
+
fs.writeFileSync(file, JSON.stringify(list.map((s) => ({ ...s, file: path.relative(path.dirname(cfg.work), s.file) })), null, 1));
|
|
19
|
+
return { list, file, text: formatTimings(list) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// One scene as HyperFrames will render it: stills at `at` (scene seconds; default: as each phrase is
|
|
23
|
+
// spoken, and the last frame), HyperFrames lint, and (check: true) its layout/contrast check.
|
|
24
|
+
export async function previewScene(cfg, stepNo, { at, check = true, log = console.log } = {}) {
|
|
25
|
+
const i = stepNo - 1, s = cfg.steps[i];
|
|
26
|
+
if (!s) throw new Error(`the storyboard has ${cfg.steps.length} steps, not ${stepNo}`);
|
|
27
|
+
if (!s.scene) throw new Error(`step ${stepNo} is not a scene (it records the web app: preview it with render --draft, or inspect after_step)`);
|
|
28
|
+
let len = null, cues = [];
|
|
29
|
+
if (s.say && cfg.voice) {
|
|
30
|
+
const sp = await speak(cfg, s.say, { log, chunk: true });
|
|
31
|
+
len = sp.pcm.length / 2 / SR;
|
|
32
|
+
cues = phraseTimes(sp.pcm, sp.parts);
|
|
33
|
+
}
|
|
34
|
+
const seconds = sceneSeconds(s, len);
|
|
35
|
+
const { width: W, height: H, fps: FPS } = cfg.output;
|
|
36
|
+
const { accent, ink, font } = cfg.theme;
|
|
37
|
+
const ctx = { W, H, FPS, accent, ink, font, lang: cfg.lang, subs: cfg.subtitles === 'burn', fontFaces: sceneFontFaces(font) };
|
|
38
|
+
const job = { name: `step${stepNo}`, kind: 'scene', seconds, scene: s.scene, say: s.say, lead: s.scene.lead, cues, step: i, preview: true, kit: cfg.sceneKit };
|
|
39
|
+
const dir = path.join(cfg.work, 'scene-preview', `step-${stepNo}`);
|
|
40
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
writeSceneProject(job, ctx, dir);
|
|
42
|
+
const local = cues.map((c) => ({ text: c.text, t: +(c.t + s.scene.lead).toFixed(2), end: +(c.end + s.scene.lead).toFixed(2) }));
|
|
43
|
+
|
|
44
|
+
// lint: the HyperFrames contract (fast, no browser)
|
|
45
|
+
const lint = { errors: [], warnings: [] };
|
|
46
|
+
{
|
|
47
|
+
const r = await hyperframesRun(['lint', dir, '--json'], { log });
|
|
48
|
+
try {
|
|
49
|
+
const j = JSON.parse(r.out.slice(r.out.indexOf('{')));
|
|
50
|
+
for (const f of j.findings ?? []) {
|
|
51
|
+
// the wrapper is ours: only what is about the scene file counts
|
|
52
|
+
if (f.file && !/scene\.html$/.test(f.file)) continue;
|
|
53
|
+
(f.severity === 'error' ? lint.errors : f.severity === 'warning' ? lint.warnings : []).push(`${f.code}: ${f.message}${f.fixHint ? ` — ${f.fixHint}` : ''}`);
|
|
54
|
+
}
|
|
55
|
+
} catch { if (r.code !== 0) lint.errors.push((r.err || r.out).trim().split('\n').slice(-3).join(' | ')); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// stills: as each phrase is spoken (a beat into it), and the end
|
|
59
|
+
let times = at?.length ? at : [...local.map((c) => c.t + Math.min(0.9, (c.end - c.t) * 0.6)), seconds - 0.05];
|
|
60
|
+
times = [...new Set(times.map((t) => +Math.max(0, Math.min(seconds - 0.02, t)).toFixed(2)))].sort((a, b) => a - b);
|
|
61
|
+
if (times.length > 12) times = Array.from({ length: 12 }, (_, k) => times[Math.round(k * (times.length - 1) / 11)]);
|
|
62
|
+
const shots = path.join(dir, 'shots');
|
|
63
|
+
const r = await hyperframesRun(['snapshot', dir, '--at', times.join(','), '--no-end', '--output', shots], { log });
|
|
64
|
+
if (r.code !== 0) throw new Error(`the scene did not load in HyperFrames: ${(r.err || r.out).trim().split('\n').filter(Boolean).slice(-4).join(' | ')}`);
|
|
65
|
+
const files = fs.existsSync(shots) ? fs.readdirSync(shots).sort() : [];
|
|
66
|
+
const images = files.filter((f) => /^frame-.*\.png$/.test(f)).map((f) => path.join(shots, f));
|
|
67
|
+
const sheets = files.filter((f) => /^contact-sheet/.test(f)).map((f) => path.join(shots, f)), sheet = sheets[0] ?? null;
|
|
68
|
+
|
|
69
|
+
// check: overlaps, text off the frame, contrast, text too small (needs a browser: ~20 s)
|
|
70
|
+
const found = { errors: [], warnings: [] };
|
|
71
|
+
if (check) {
|
|
72
|
+
const c = await hyperframesRun(['check', dir, '--json', '--samples', '6'], { log });
|
|
73
|
+
try {
|
|
74
|
+
const j = JSON.parse(c.out.slice(c.out.indexOf('{')));
|
|
75
|
+
for (const k of ['runtime', 'layout', 'contrast', 'craft']) {
|
|
76
|
+
for (const f of j[k]?.findings ?? []) {
|
|
77
|
+
if (f.severity === 'info') continue;
|
|
78
|
+
// only the video's own first frame is its thumbnail, and only its last line needs the long hold
|
|
79
|
+
const visible = cfg.steps.map((x, k) => (x.hidden ? -1 : k)).filter((k) => k >= 0);
|
|
80
|
+
if (f.code === 'first_frame_empty' && visible[0] !== i) continue;
|
|
81
|
+
if (f.code === 'line_unread' && /last line/i.test(f.message ?? '') && (visible.at(-1) !== i || cfg.endCard)) continue;
|
|
82
|
+
if (f.selector && /storyshot-subtitle-band/.test(f.selector)) continue;
|
|
83
|
+
if (f.code === 'film_silent') continue; // the video's sound comes from Storyshot, not the scene
|
|
84
|
+
const where = [f.selector, f.time != null ? `at ${Number(f.time).toFixed(1)}s` : null].filter(Boolean).join(' ');
|
|
85
|
+
const hint = HINTS[f.code] ? ` → ${HINTS[f.code]}` : '';
|
|
86
|
+
(f.severity === 'error' ? found.errors : found.warnings).push(`${f.code}${where ? ` (${where})` : ''}: ${f.message}${hint}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { found.warnings.push(`the layout check could not run: ${(c.err || c.out).trim().split('\n').slice(-2).join(' | ')}`); }
|
|
90
|
+
// one line for a finding repeated on siblings (a counter's digits, a row of tiles)
|
|
91
|
+
for (const k of ['errors', 'warnings']) {
|
|
92
|
+
const groups = new Map();
|
|
93
|
+
for (const w of found[k]) {
|
|
94
|
+
const key = w.replace(/:nth-of-type\(\d+\)/g, '').replace(/ at [\d.]+s/, '').replace(/"[^"]*"/g, '"…"').replace(/[\d.]+ s \(±[\d.]+ s\)/, '').slice(0, 120);
|
|
95
|
+
groups.set(key, [...(groups.get(key) ?? []), w]);
|
|
96
|
+
}
|
|
97
|
+
found[k] = [...groups.values()].map((ws) => (ws.length > 1 ? `${ws[0]} (and ${ws.length - 1} more like it)` : ws[0]));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { dir, seconds, cues: local, times, images, sheet, sheets, lint, check: check ? found : null, subs: ctx.subs };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What to do about the findings agents meet most in scenes (HyperFrames' own messages point at its docs).
|
|
104
|
+
const HINTS = {
|
|
105
|
+
text_below_floor: 'text meant to be read: at least 28 px at 1080p; a source credit or a label that is only texture may stay small with data-layout-ignore on it',
|
|
106
|
+
escaped_container: 'a glow or a part meant to spill out of its box: add data-layout-allow-overflow to the box',
|
|
107
|
+
content_overlap: 'parts meant to overlap (a label on its object): add data-layout-allow-overlap to their container',
|
|
108
|
+
line_unread: 'hold the text longer: reveal it on an earlier phrase (S.at), or give the scene more tail: (seconds after the voice)',
|
|
109
|
+
text_occluded: 'text under another element: move one of them, or add data-layout-allow-occlusion if that is the point',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function formatPreview(r, stepNo) {
|
|
113
|
+
const list = (xs) => xs.map((x) => ` ${x}`).join('\n');
|
|
114
|
+
return [
|
|
115
|
+
`step ${stepNo} scene: ${r.seconds.toFixed(2)}s`,
|
|
116
|
+
...(r.cues.length ? [' cues (scene seconds):', ...r.cues.map((c, k) => ` S.at(${k}) = ${c.t.toFixed(2)} → ${c.end.toFixed(2)} ${c.text}`)] : [' (no narration: S.at(k) = lead + k seconds)']),
|
|
117
|
+
` stills at ${r.times.join(', ')}s:`, ...r.images.map((f) => ` ${f}`), ...(r.sheets ?? []).map((f) => ` contact sheet: ${f}`),
|
|
118
|
+
...(r.lint.errors.length ? [' ✖ lint errors:', list(r.lint.errors)] : []), ...(r.lint.warnings.length ? [' ⚠ lint warnings:', list(r.lint.warnings)] : []),
|
|
119
|
+
...(r.check ? [r.check.errors.length || r.check.warnings.length ? ' layout check:' : ' ✔ layout check: nothing found', ...r.check.errors.map((e) => ` ✖ ${e}`), ...r.check.warnings.map((w) => ` ⚠ ${w}`)] : []),
|
|
120
|
+
` look at every still: does each part appear when the voice names it, and does anything overlap, crop or sit under the subtitles${r.subs ? ' (the striped red band)' : ''}?`,
|
|
121
|
+
...(r.lint.errors.length || r.lint.warnings.length || r.check?.errors.length || r.check?.warnings.length ? [' (references/….md in these messages are HyperFrames docs: https://hyperframes.heygen.com)'] : []),
|
|
122
|
+
].join('\n');
|
|
123
|
+
}
|
package/src/scenes.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Scenes: a step whose picture is an animation the agent writes (a HyperFrames composition) instead
|
|
2
|
+
// of a recording of the web app: a concept, a mechanism, a diagram that grows while the voice explains
|
|
3
|
+
// it. `purpose: explainer` storyboards are made of scenes only (no url, nothing recorded); tutorials
|
|
4
|
+
// and lessons can put a scene between recorded steps.
|
|
5
|
+
//
|
|
6
|
+
// A scene file is one HyperFrames sub-composition: a <template> with a root element whose id and
|
|
7
|
+
// data-composition-id are the same, and a script that registers one paused GSAP timeline under that
|
|
8
|
+
// id. Storyshot wraps it in a project of its own, as long as the step's narration needs, and gives it
|
|
9
|
+
// `window.__storyshot`: the duration, the frame size, the theme, and `cues`, when each phrase of the
|
|
10
|
+
// step's `say:` line is spoken, so the animation lands on the words (`S.at(2)` = when the third phrase
|
|
11
|
+
// starts). Media the scene uses sit in an `assets/` folder next to the scene file.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
export const SCENE_DEFAULTS = { lead: 0.5, tail: 0.8 };
|
|
17
|
+
export const GSAP_URL = 'https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js';
|
|
18
|
+
|
|
19
|
+
// How long a scene plays: a fixed `duration`, else its narration plus a lead-in and a tail.
|
|
20
|
+
export function sceneSeconds(step, voiceLen) {
|
|
21
|
+
if (typeof step.duration === 'number') return step.duration;
|
|
22
|
+
const { lead, tail } = step.scene;
|
|
23
|
+
return voiceLen == null ? 4 : Math.max(1.5, lead + voiceLen + tail);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// What a scene file declares, and what is wrong with it (errors stop the render, warnings don't).
|
|
27
|
+
export function sceneInfo(file, { width, height } = {}) {
|
|
28
|
+
const errors = [], warnings = [];
|
|
29
|
+
if (!fs.existsSync(file)) return { errors: [`the scene ${file} does not exist — write it (\`storyshot init --purpose explainer\` writes a starter)`], warnings };
|
|
30
|
+
const html = fs.readFileSync(file, 'utf8');
|
|
31
|
+
const name = path.basename(file);
|
|
32
|
+
if (!/<template[\s>]/i.test(html)) errors.push(`${name}: a scene puts its styles, markup and script inside one <template> … </template>`);
|
|
33
|
+
const id = html.match(/data-composition-id\s*=\s*["']([^"']+)["']/)?.[1];
|
|
34
|
+
if (!id) errors.push(`${name}: the scene's root element needs data-composition-id="<id>" (and the same id)`);
|
|
35
|
+
else {
|
|
36
|
+
const tag = html.match(new RegExp(`<[a-z][^>]*data-composition-id\\s*=\\s*["']${id}["'][^>]*>`, 'i'))?.[0] ?? '';
|
|
37
|
+
const elId = tag.match(/\sid\s*=\s*["']([^"']+)["']/)?.[1];
|
|
38
|
+
if (elId !== id) errors.push(`${name}: the root element's id must equal its data-composition-id ("${id}"), or HyperFrames shows a still`);
|
|
39
|
+
const re = new RegExp(`__timelines\\s*\\[\\s*["'\`]${id}["'\`]\\s*\\]\\s*=`);
|
|
40
|
+
if (!re.test(html)) errors.push(`${name}: register the scene's timeline: window.__timelines['${id}'] = tl;`);
|
|
41
|
+
const w = Number(tag.match(/data-width\s*=\s*["'](\d+)/)?.[1]), h = Number(tag.match(/data-height\s*=\s*["'](\d+)/)?.[1]);
|
|
42
|
+
if (width && height && w && h && (w !== width || h !== height)) errors.push(`${name}: the scene is ${w}x${h} but the video is ${width}x${height} — set data-width/data-height to the video's size`);
|
|
43
|
+
}
|
|
44
|
+
if (/\bDate\.now\(|\bnew Date\(|setTimeout\(|setInterval\(|requestAnimationFrame\(/.test(html)) errors.push(`${name}: no clocks or timers in a scene (Date, setTimeout, requestAnimationFrame): everything on screen must follow the timeline, which is seeked frame by frame`);
|
|
45
|
+
if (/Math\.random\(/.test(html)) warnings.push(`${name}: Math.random() gives a different picture on every render — use a seeded generator`);
|
|
46
|
+
if (/repeat\s*:\s*-1/.test(html)) errors.push(`${name}: repeat: -1 never ends; repeat a fixed number of times`);
|
|
47
|
+
if (!/__storyshot/.test(html)) warnings.push(`${name}: times nothing from window.__storyshot — read them from its cues (S.at(k)) so the animation stays on the words when the narration changes`);
|
|
48
|
+
if (/["'(]\/assets\//.test(html)) errors.push(`${name}: asset paths are relative: "assets/…", not "/assets/…"`);
|
|
49
|
+
return { id, html, errors, warnings };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Every file in the scene's assets/ folder: { 'sub/x.png': '/abs/…/assets/sub/x.png' }
|
|
53
|
+
export function sceneAssets(file) {
|
|
54
|
+
const root = path.join(path.dirname(file), 'assets'), out = {};
|
|
55
|
+
const walk = (d) => {
|
|
56
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
57
|
+
const f = path.join(d, e.name);
|
|
58
|
+
if (e.isDirectory()) walk(f);
|
|
59
|
+
else out[path.relative(root, f).split(path.sep).join('/')] = f;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
if (fs.existsSync(root)) walk(root);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The runtime a scene reads: window.__storyshot.
|
|
67
|
+
// duration, width, height, lang, lead (when the voice starts), say (the line), safeBottom (px the
|
|
68
|
+
// burned-in subtitles take at the bottom, 0 without them), theme { accent, ink, font }
|
|
69
|
+
// cues: [{ text, t, end }] in scene seconds
|
|
70
|
+
// at(k, offset = 0): when phrase k starts (0-based; -1 = the last; or a piece of its text)
|
|
71
|
+
// end(k, offset = 0): when it ends
|
|
72
|
+
export function sceneRuntime(job, ctx) {
|
|
73
|
+
const data = {
|
|
74
|
+
duration: +job.seconds.toFixed(3), width: ctx.W, height: ctx.H, lang: ctx.lang, lead: job.lead, say: job.say ?? '',
|
|
75
|
+
safeBottom: ctx.subs ? Math.round(ctx.H > ctx.W ? ctx.H * 0.22 : ctx.H * 0.16) : 0,
|
|
76
|
+
theme: { accent: ctx.accent, ink: ctx.ink, font: ctx.font },
|
|
77
|
+
cues: (job.cues ?? []).map((c) => ({ text: c.text, t: +(c.t + job.lead).toFixed(3), end: +(c.end + job.lead).toFixed(3) })),
|
|
78
|
+
};
|
|
79
|
+
return `window.__storyshot = Object.assign(${JSON.stringify(data)}, {
|
|
80
|
+
_find(k) {
|
|
81
|
+
const c = this.cues;
|
|
82
|
+
if (!c.length) return null;
|
|
83
|
+
if (typeof k === 'string') { const i = c.findIndex((x) => x.text.includes(k)); if (i < 0) throw new Error('no cue contains "' + k + '"'); return c[i]; }
|
|
84
|
+
return c[Math.max(0, Math.min(c.length - 1, k < 0 ? c.length + k : k))];
|
|
85
|
+
},
|
|
86
|
+
at(k, offset = 0) { const c = this._find(k); return Math.max(0, (c ? c.t : this.lead + (typeof k === 'number' ? k : 0)) + offset); },
|
|
87
|
+
end(k, offset = 0) { const c = this._find(k); return Math.max(0, (c ? c.end : this.lead + (typeof k === 'number' ? k + 1 : 1)) + offset); },
|
|
88
|
+
});`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// GSAP renders a fromTo's start state as soon as it is created. When a later fromTo animates the same
|
|
92
|
+
// property of the same element (a label that fades in, then out), that start state would show from the
|
|
93
|
+
// first frame. A fromTo placed after another on the same element and property gets immediateRender: false
|
|
94
|
+
// unless it says otherwise, so each one takes over when it starts.
|
|
95
|
+
export const GSAP_GUARD = `(function () {
|
|
96
|
+
if (!window.gsap) return;
|
|
97
|
+
const P = gsap.core.Timeline.prototype, fromTo = P.fromTo;
|
|
98
|
+
const NOT = { duration: 1, ease: 1, stagger: 1, delay: 1, immediateRender: 1, overwrite: 1, repeat: 1, yoyo: 1, repeatDelay: 1, id: 1, paused: 1, data: 1,
|
|
99
|
+
callbackScope: 1, onStart: 1, onUpdate: 1, onComplete: 1, transformOrigin: 1, svgOrigin: 1, autoRound: 1, keyframes: 1 };
|
|
100
|
+
P.fromTo = function (targets, from, to, position) {
|
|
101
|
+
if (to && typeof to === 'object' && to.immediateRender === undefined && typeof position === 'number') {
|
|
102
|
+
let els = [];
|
|
103
|
+
try { els = gsap.utils.toArray(targets); } catch (e) {}
|
|
104
|
+
const props = Object.keys(to).filter((k) => !NOT[k]);
|
|
105
|
+
const seen = this.__storyshotSeen || (this.__storyshotSeen = new Map());
|
|
106
|
+
if (els.some((el) => props.some((p) => (seen.get(el) || {})[p] < position))) to = Object.assign({}, to, { immediateRender: false });
|
|
107
|
+
for (const el of els) { const m = seen.get(el) || {}; for (const p of props) m[p] = Math.min(m[p] == null ? Infinity : m[p], position); seen.set(el, m); }
|
|
108
|
+
}
|
|
109
|
+
return fromTo.call(this, targets, from, to, position);
|
|
110
|
+
};
|
|
111
|
+
})();`;
|
|
112
|
+
|
|
113
|
+
// The project HyperFrames renders for one scene: a root as long as the scene, mounting the scene file.
|
|
114
|
+
// preview: also draw the band the burned-in subtitles will cover (never in a render).
|
|
115
|
+
export function sceneDoc(job, ctx, info) {
|
|
116
|
+
const { W, H } = ctx, D = job.seconds.toFixed(3);
|
|
117
|
+
return `<!doctype html><html lang="${ctx.lang ?? 'en'}"><head><meta charset="UTF-8"><meta name="viewport" content="width=${W}, height=${H}">
|
|
118
|
+
<script src="${GSAP_URL}"></script>
|
|
119
|
+
<style>
|
|
120
|
+
${ctx.fontFaces}
|
|
121
|
+
:root{--accent:${ctx.accent};--ink:${ctx.ink};--font:'${ctx.font}','StoryshotCJK',sans-serif;--safe-bottom:${ctx.subs ? Math.round(H > W ? H * 0.22 : H * 0.16) : 0}px}
|
|
122
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
123
|
+
html,body{width:${W}px;height:${H}px;overflow:hidden;background:#000}
|
|
124
|
+
#root{position:relative;width:100%;height:100%;font-family:var(--font)}
|
|
125
|
+
</style>
|
|
126
|
+
<script>${sceneRuntime(job, ctx)}
|
|
127
|
+
${GSAP_GUARD}</script>
|
|
128
|
+
${(job.kit?.css ?? []).map((f, k) => `<link rel="stylesheet" href="kit/${k}-${path.basename(f)}">`).join('\n')}
|
|
129
|
+
${(job.kit?.js ?? []).map((f, k) => `<script src="kit/${k}-${path.basename(f)}"></script>`).join('\n')}
|
|
130
|
+
</head><body>
|
|
131
|
+
<div id="root" data-composition-id="main" data-start="0" data-duration="${D}" data-width="${W}" data-height="${H}">
|
|
132
|
+
<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>
|
|
133
|
+
${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>` : ''}
|
|
134
|
+
</div>
|
|
135
|
+
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
|
136
|
+
</body></html>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const fileHash = (f) => crypto.createHash('sha1').update(fs.readFileSync(f)).digest('hex');
|
|
140
|
+
|
|
141
|
+
// Write a scene's project into `dir` (index.html, the scene, its assets). Returns the scene info.
|
|
142
|
+
export function writeSceneProject(job, ctx, dir) {
|
|
143
|
+
const info = sceneInfo(job.scene.file, { width: ctx.W, height: ctx.H });
|
|
144
|
+
if (info.errors.length) throw new Error(info.errors.join('; '));
|
|
145
|
+
fs.mkdirSync(path.join(dir, 'compositions'), { recursive: true });
|
|
146
|
+
fs.writeFileSync(path.join(dir, 'index.html'), sceneDoc(job, ctx, info));
|
|
147
|
+
fs.writeFileSync(path.join(dir, 'compositions', 'scene.html'), info.html);
|
|
148
|
+
// the storyboard's scene kit, shared by every scene
|
|
149
|
+
for (const [kind, list] of Object.entries(job.kit ?? {})) for (const [k, f] of list.entries()) {
|
|
150
|
+
fs.mkdirSync(path.join(dir, 'kit'), { recursive: true });
|
|
151
|
+
fs.copyFileSync(f, path.join(dir, 'kit', `${k}-${path.basename(f)}`));
|
|
152
|
+
}
|
|
153
|
+
for (const [k, f] of Object.entries(sceneAssets(job.scene.file))) {
|
|
154
|
+
fs.mkdirSync(path.dirname(path.join(dir, 'assets', k)), { recursive: true });
|
|
155
|
+
fs.copyFileSync(f, path.join(dir, 'assets', k));
|
|
156
|
+
}
|
|
157
|
+
fs.writeFileSync(path.join(dir, 'hyperframes.json'), JSON.stringify({ paths: { blocks: 'compositions', components: 'compositions/components', assets: 'assets' } }));
|
|
158
|
+
fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify({ id: job.name, name: job.name }));
|
|
159
|
+
return info;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The scene steps of a storyboard with their timing: [{ i, file, seconds, lead, cues }] (cues in scene seconds).
|
|
163
|
+
export function sceneTimings(cfg, voiceLens) {
|
|
164
|
+
return cfg.steps.flatMap((s, i) => (!s.scene || s.hidden ? [] : [{
|
|
165
|
+
i, file: s.scene.file, title: s.scene.title ?? null, seconds: +sceneSeconds(s, voiceLens.steps[i]).toFixed(3), lead: s.scene.lead,
|
|
166
|
+
voice: voiceLens.steps[i] != null ? +voiceLens.steps[i].toFixed(3) : null,
|
|
167
|
+
cues: (voiceLens.cues?.[i] ?? []).map((c) => ({ text: c.text, t: +(c.t + s.scene.lead).toFixed(2), end: +(c.end + s.scene.lead).toFixed(2) })),
|
|
168
|
+
}]));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function formatTimings(list) {
|
|
172
|
+
return list.map((s) => [
|
|
173
|
+
`step ${s.i + 1} · ${path.basename(s.file)} · ${s.seconds.toFixed(2)}s${s.voice != null ? ` (voice ${s.voice.toFixed(2)}s from ${s.lead}s)` : ' (no say: line)'}${s.title ? ` · ${s.title}` : ''}`,
|
|
174
|
+
...s.cues.map((c, k) => ` S.at(${k}) = ${c.t.toFixed(2)} → ${c.end.toFixed(2)} ${c.text}`),
|
|
175
|
+
].join('\n')).join('\n\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// How light a hex or rgb() colour is, 0 (black) to 1 (white). null for anything else (a variable, an
|
|
179
|
+
// image, a colour function).
|
|
180
|
+
export function luminance(colour) {
|
|
181
|
+
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);
|
|
182
|
+
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;
|
|
183
|
+
if (!c) return null;
|
|
184
|
+
const lin = c.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4));
|
|
185
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// How much two colours differ to the eye, 1:1 to 21:1 (WCAG: large text wants 3, normal text 4.5).
|
|
189
|
+
export function contrast(a, b) {
|
|
190
|
+
const [la, lb] = [luminance(a), luminance(b)];
|
|
191
|
+
return la == null || lb == null ? null : +((Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)).toFixed(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Darker than mid-grey? null for a colour this can't read (a variable, an image, a colour function).
|
|
195
|
+
export function isDark(colour) {
|
|
196
|
+
const l = luminance(colour);
|
|
197
|
+
return l == null ? null : l < 0.18;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Whether a scene draws on a dark ground: the background of the rule for its root (a colour, or the
|
|
201
|
+
// first colour of a gradient). A scene whose ground comes from the shared kit (`<div id="x"
|
|
202
|
+
// class="world">` + `.world { background: … }` in scenes.css) has none of its own, so the root's
|
|
203
|
+
// classes are looked up in the kit's CSS too. null when it can't tell (a variable, an image).
|
|
204
|
+
export function sceneIsDark(file, kitCss = '') {
|
|
205
|
+
const info = fs.existsSync(file) ? sceneInfo(file) : null;
|
|
206
|
+
if (!info?.id) return null;
|
|
207
|
+
const tag = info.html.match(new RegExp(`<[a-z][^>]*data-composition-id\\s*=\\s*["']${info.id}["'][^>]*>`, 'i'))?.[0] ?? '';
|
|
208
|
+
const selectors = [`#${info.id}`, ...(tag.match(/\sclass\s*=\s*["']([^"']*)["']/)?.[1] ?? '').split(/\s+/).filter(Boolean).map((c) => `.${c}`)];
|
|
209
|
+
for (const css of [info.html, kitCss]) {
|
|
210
|
+
for (const sel of selectors) {
|
|
211
|
+
const rule = css.match(new RegExp(`(?<![\\w#.-])${sel.replace('.', '\\.')}\\s*\\{([^}]*)\\}`))?.[1] ?? '';
|
|
212
|
+
const bg = rule.match(/background(?:-color|-image)?\s*:\s*([^;]+)/)?.[1];
|
|
213
|
+
const dark = bg ? isDark(bg) : null; // a rule with no background of its own says nothing: try the next one
|
|
214
|
+
if (dark != null) return dark;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Is the film light or dark overall? That decides what reads on top of it: the colour of the burned-in
|
|
221
|
+
// subtitles, and the ground of the intro, section and end cards. `ground: light | dark` in the
|
|
222
|
+
// storyboard says so outright; otherwise a film of scenes is read from the scenes themselves (a
|
|
223
|
+
// majority vote) and a recording from its frame's backdrop. null when nothing is sure — a recording of
|
|
224
|
+
// a page whose colours we have not measured — and then the subtitles stay white, which carries an
|
|
225
|
+
// outline and works over most screens.
|
|
226
|
+
export function filmGround(cfg) {
|
|
227
|
+
if (cfg.ground === 'light' || cfg.ground === 'dark') return cfg.ground;
|
|
228
|
+
if (!cfg.frame) {
|
|
229
|
+
const kitCss = (cfg.sceneKit?.css ?? []).filter((f) => fs.existsSync(f)).map((f) => fs.readFileSync(f, 'utf8')).join('\n');
|
|
230
|
+
const grounds = cfg.steps.filter((s) => s.scene && !s.hidden).map((s) => sceneIsDark(s.scene.file, kitCss)).filter((d) => d != null);
|
|
231
|
+
if (grounds.length) return grounds.filter(Boolean).length * 2 > grounds.length ? 'dark' : 'light';
|
|
232
|
+
}
|
|
233
|
+
return ['aurora', 'dark', 'blur'].includes(cfg.frame?.style ?? '') ? 'dark' : null;
|
|
234
|
+
}
|