reelson 0.7.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/CHANGELOG.md +99 -0
- package/LICENSE +21 -0
- package/NOTICE.md +15 -0
- package/README.md +180 -0
- package/bin/reelson.js +649 -0
- package/bin/reelson.ts +676 -0
- package/bin/run.js +5 -0
- package/demo.config.example.json +51 -0
- package/docs/demo.webp +0 -0
- package/docs/prompting.md +72 -0
- package/docs/style-guide.md +117 -0
- package/examples/demo.config.json +18 -0
- package/examples/todo-add-item/scenario.ts +45 -0
- package/examples/todo-add-item/video.json +35 -0
- package/music/README.md +7 -0
- package/package.json +68 -0
- package/skills/reelson-compose/SKILL.md +226 -0
- package/skills/reelson-compose/schemas/video.schema.json +172 -0
- package/skills/reelson-compose/scripts/build.js +424 -0
- package/skills/reelson-compose/scripts/build.ts +532 -0
- package/skills/reelson-compose/scripts/captions.js +24 -0
- package/skills/reelson-compose/scripts/captions.ts +40 -0
- package/skills/reelson-compose/scripts/check.js +80 -0
- package/skills/reelson-compose/scripts/check.ts +93 -0
- package/skills/reelson-compose/scripts/composition.js +163 -0
- package/skills/reelson-compose/scripts/composition.ts +241 -0
- package/skills/reelson-compose/scripts/hyperframes.js +138 -0
- package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
- package/skills/reelson-compose/scripts/portrait.js +150 -0
- package/skills/reelson-compose/scripts/portrait.ts +218 -0
- package/skills/reelson-compose/scripts/project.js +238 -0
- package/skills/reelson-compose/scripts/project.ts +324 -0
- package/skills/reelson-compose/scripts/studio.js +330 -0
- package/skills/reelson-compose/scripts/studio.ts +394 -0
- package/skills/reelson-compose/scripts/timeline.js +271 -0
- package/skills/reelson-compose/scripts/timeline.ts +514 -0
- package/skills/reelson-compose/scripts/tts.js +303 -0
- package/skills/reelson-compose/scripts/tts.ts +371 -0
- package/skills/reelson-compose/scripts/verify.js +190 -0
- package/skills/reelson-compose/scripts/verify.ts +209 -0
- package/skills/reelson-compose/scripts/voice.js +178 -0
- package/skills/reelson-compose/scripts/voice.ts +224 -0
- package/skills/reelson-compose/scripts/zooms.js +259 -0
- package/skills/reelson-compose/scripts/zooms.ts +333 -0
- package/skills/reelson-compose/sections/README.md +5 -0
- package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
- package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
- package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
- package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
- package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
- package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
- package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
- package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
- package/skills/reelson-compose/sections/intro/split/section.css +30 -0
- package/skills/reelson-compose/sections/intro/split/section.html +12 -0
- package/skills/reelson-compose/sections/intro/split/section.js +19 -0
- package/skills/reelson-compose/sections/intro/split/section.json +9 -0
- package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
- package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
- package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
- package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
- package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
- package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
- package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
- package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
- package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
- package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
- package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
- package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
- package/skills/reelson-compose/studio/index.html +81 -0
- package/skills/reelson-compose/studio/studio.css +142 -0
- package/skills/reelson-compose/studio/studio.js +1031 -0
- package/skills/reelson-compose/templates/README.md +193 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/stage.html +447 -0
- package/skills/reelson-compose/templates/classic/template.json +5 -0
- package/skills/reelson-record/SKILL.md +220 -0
- package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
- package/skills/reelson-record/scripts/capture.js +142 -0
- package/skills/reelson-record/scripts/capture.ts +190 -0
- package/skills/reelson-record/scripts/config.js +148 -0
- package/skills/reelson-record/scripts/config.ts +264 -0
- package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
- package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
- package/skills/reelson-record/scripts/doctor.js +128 -0
- package/skills/reelson-record/scripts/doctor.ts +147 -0
- package/skills/reelson-record/scripts/languages.js +29 -0
- package/skills/reelson-record/scripts/languages.ts +41 -0
- package/skills/reelson-record/scripts/record.js +365 -0
- package/skills/reelson-record/scripts/record.ts +451 -0
- package/skills/reelson-record/scripts/scenario.js +356 -0
- package/skills/reelson-record/scripts/scenario.ts +570 -0
- package/skills/reelson-record/scripts/validate.js +143 -0
- package/skills/reelson-record/scripts/validate.ts +179 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reelson verify`: does each demo still record, and does its video.json still fit?
|
|
3
|
+
*
|
|
4
|
+
* Apps change under their demos: a column moves, a URL changes, a step is added. verify
|
|
5
|
+
* re-records every scenario headless into a scratch folder — the committed recording and
|
|
6
|
+
* markers.json stay untouched — and checks the new take against video.json: the scenario
|
|
7
|
+
* ran to the end, every marker a callout or trim uses still exists, every click a zoom or
|
|
8
|
+
* trim refers to by number is still the same kind of click in the same place, and the plan
|
|
9
|
+
* (timeline, zoom checks) still holds. `--update` puts a take that passes in place. A demo
|
|
10
|
+
* with a phone or square take (markers.mobile.json, markers.square.json) gets those
|
|
11
|
+
* re-recorded and checked too.
|
|
12
|
+
*
|
|
13
|
+
* reelson verify <slug...> | --all [--update]
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { basename, join, resolve } from 'node:path';
|
|
19
|
+
import { planSpec } from "./build.js";
|
|
20
|
+
import { readMarkers, RECORD_SCRIPT, readVideoSpec, ReelsonError } from "./project.js";
|
|
21
|
+
import { checkZoom, zoomOverlaps } from "./zooms.js";
|
|
22
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
23
|
+
/** A click that moved more than this (CSS px) is probably a different target. */
|
|
24
|
+
const MOVED_PX = 80;
|
|
25
|
+
/** What a re-record changed that video.json relies on. Pure. */
|
|
26
|
+
export function compareRecordings(before, after, spec) {
|
|
27
|
+
const problems = [];
|
|
28
|
+
const notes = [];
|
|
29
|
+
const labels = new Set(after.markers.map((m) => m.label));
|
|
30
|
+
const trimPoints = ['start', 'end']
|
|
31
|
+
.map((edge) => [edge, spec.trim?.[edge]])
|
|
32
|
+
.filter(([, p]) => p !== undefined && typeof p === 'object');
|
|
33
|
+
// Markers: the ones video.json uses must still be set.
|
|
34
|
+
for (const c of spec.callouts ?? []) {
|
|
35
|
+
if (c.marker !== undefined && !labels.has(c.marker)) {
|
|
36
|
+
problems.push(`callout "${c.text}" is timed from marker "${c.marker}", which the scenario no longer sets`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const [edge, p] of trimPoints) {
|
|
40
|
+
if ('marker' in p && !labels.has(p.marker)) {
|
|
41
|
+
problems.push(`trim.${edge} is tied to marker "${p.marker}", which the scenario no longer sets`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (before) {
|
|
45
|
+
const old = new Set(before.markers.map((m) => m.label));
|
|
46
|
+
const added = after.markers.filter((m) => !old.has(m.label)).map((m) => `"${m.label}"`);
|
|
47
|
+
const removed = before.markers.filter((m) => !labels.has(m.label)).map((m) => `"${m.label}"`);
|
|
48
|
+
if (added.length)
|
|
49
|
+
notes.push(`new marker(s) ${added.join(', ')} — no callout uses them yet`);
|
|
50
|
+
if (removed.length)
|
|
51
|
+
notes.push(`marker(s) ${removed.join(', ')} are gone`);
|
|
52
|
+
}
|
|
53
|
+
// Clicks are referred to by number: the same number must still be the same click.
|
|
54
|
+
const clicks = after.clicks ?? [];
|
|
55
|
+
const used = new Map();
|
|
56
|
+
(spec.zooms ?? []).forEach((z, i) => {
|
|
57
|
+
if (!z.clicks)
|
|
58
|
+
return;
|
|
59
|
+
const [a, b = a] = z.clicks;
|
|
60
|
+
for (let n = a; n <= b; n++)
|
|
61
|
+
used.set(n, `zoom ${i + 1}`);
|
|
62
|
+
});
|
|
63
|
+
for (const [edge, p] of trimPoints) {
|
|
64
|
+
if ('click' in p)
|
|
65
|
+
used.set(p.click, `trim.${edge}`);
|
|
66
|
+
}
|
|
67
|
+
for (const [n, user] of [...used].sort((x, y) => x[0] - y[0])) {
|
|
68
|
+
const now = clicks[n - 1];
|
|
69
|
+
if (!now) {
|
|
70
|
+
problems.push(`${user} uses click #${n}, but the new take has ${clicks.length} click(s)`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const then = before?.clicks?.[n - 1];
|
|
74
|
+
if (then) {
|
|
75
|
+
const moved = Math.hypot(now.x - then.x, now.y - then.y);
|
|
76
|
+
if (now.kind !== then.kind || moved > MOVED_PX) {
|
|
77
|
+
problems.push(`${user} uses click #${n}: it was a ${then.kind} at ${then.x},${then.y}, now a ${now.kind} at ${now.x},${now.y} — ` +
|
|
78
|
+
'a step was probably added or removed before it; renumber the clicks in video.json');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (before?.clicks && before.clicks.length !== clicks.length) {
|
|
83
|
+
notes.push(`${before.clicks.length} → ${clicks.length} click(s)`);
|
|
84
|
+
}
|
|
85
|
+
if (before && (before.transitions?.length ?? 0) !== (after.transitions?.length ?? 0)) {
|
|
86
|
+
notes.push(`${before.transitions?.length ?? 0} → ${after.transitions?.length ?? 0} hand-off card(s)`);
|
|
87
|
+
}
|
|
88
|
+
if (before && Math.abs(after.durationSeconds - before.durationSeconds) > Math.max(3, before.durationSeconds * 0.2)) {
|
|
89
|
+
notes.push(`length ${before.durationSeconds.toFixed(1)}s → ${after.durationSeconds.toFixed(1)}s`);
|
|
90
|
+
}
|
|
91
|
+
return { problems, notes };
|
|
92
|
+
}
|
|
93
|
+
/** The recordings a demo can have: the desktop one, and the takes behind portrait and square. */
|
|
94
|
+
export const TAKES = [
|
|
95
|
+
{ name: 'desktop', suffix: '', flags: [] },
|
|
96
|
+
{ name: 'phone', suffix: '.mobile', flags: ['--mobile'] },
|
|
97
|
+
{ name: 'square', suffix: '.square', flags: ['--square'] },
|
|
98
|
+
];
|
|
99
|
+
/** The takes this demo has (its markers are there): desktop always, phone and square if recorded. */
|
|
100
|
+
export function takesOf(demoDir) {
|
|
101
|
+
return TAKES.filter((take) => take.suffix === '' || existsSync(resolve(demoDir, `markers${take.suffix}.json`)));
|
|
102
|
+
}
|
|
103
|
+
/** Re-records each take of one demo and checks it; returns the number of problems. */
|
|
104
|
+
export function verify(demoDir, config, options = {}) {
|
|
105
|
+
const log = options.log ?? console.log;
|
|
106
|
+
const scenario = resolve(demoDir, 'scenario.ts');
|
|
107
|
+
if (!existsSync(scenario)) {
|
|
108
|
+
throw new ReelsonError(`${demoDir} has no scenario.ts`);
|
|
109
|
+
}
|
|
110
|
+
let problems = 0;
|
|
111
|
+
for (const take of takesOf(demoDir)) {
|
|
112
|
+
problems += verifyTake(demoDir, scenario, take, config, options, log);
|
|
113
|
+
}
|
|
114
|
+
return problems;
|
|
115
|
+
}
|
|
116
|
+
function verifyTake(demoDir, scenario, take, config, options, log) {
|
|
117
|
+
const slug = take.suffix ? `${basename(demoDir)} (${take.name})` : basename(demoDir);
|
|
118
|
+
const recording = `recording${take.suffix}.mp4`;
|
|
119
|
+
const markersFile = `markers${take.suffix}.json`;
|
|
120
|
+
const dir = mkdtempSync(join(tmpdir(), `reelson-verify-${basename(demoDir)}${take.suffix}-`));
|
|
121
|
+
log(`${slug}: recording…`);
|
|
122
|
+
const run = spawnSync(process.execPath, [RECORD_SCRIPT, scenario, '--out', dir, ...take.flags], {
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
});
|
|
125
|
+
if (run.status !== 0) {
|
|
126
|
+
const output = `${run.stdout}${run.stderr}`.trim().split('\n').filter((l) => !/^\s+at /.test(l));
|
|
127
|
+
log(`✗ ${slug}: the scenario failed`);
|
|
128
|
+
output.slice(-8).forEach((line) => log(` ${line}`));
|
|
129
|
+
const failed = resolve(dir, `recording${take.suffix}.failed.mp4`);
|
|
130
|
+
if (existsSync(failed))
|
|
131
|
+
log(` partial take: ${failed}`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const after = JSON.parse(readFileSync(resolve(dir, markersFile), 'utf8'));
|
|
135
|
+
const before = existsSync(resolve(demoDir, markersFile)) ? JSON.parse(readFileSync(resolve(demoDir, markersFile), 'utf8')) : null;
|
|
136
|
+
const video = readVideoSpec(demoDir);
|
|
137
|
+
const problems = [];
|
|
138
|
+
const notes = [];
|
|
139
|
+
if (!video) {
|
|
140
|
+
notes.push('no video.json yet — nothing to check against (run `reelson build`)');
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// As the build uses it: the phone take never zooms; the square one zooms while its clicks
|
|
144
|
+
// match the desktop take's (video.json numbers them by the desktop clicks).
|
|
145
|
+
let spec = video;
|
|
146
|
+
if (take.name === 'phone') {
|
|
147
|
+
spec = { ...video, zooms: [] };
|
|
148
|
+
}
|
|
149
|
+
else if (take.name === 'square' && video.zooms?.length) {
|
|
150
|
+
const desktop = existsSync(resolve(demoDir, 'markers.json')) ? readMarkers(demoDir) : null;
|
|
151
|
+
if (desktop && (desktop.clicks?.length ?? 0) !== (after.clicks?.length ?? 0)) {
|
|
152
|
+
notes.push(`${count(after.clicks?.length ?? 0, 'click')}, the desktop take ${desktop.clicks?.length ?? 0} — the square video leaves out the zooms`);
|
|
153
|
+
spec = { ...video, zooms: [] };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const drift = compareRecordings(before, after, spec);
|
|
157
|
+
problems.push(...drift.problems);
|
|
158
|
+
notes.push(...drift.notes);
|
|
159
|
+
try {
|
|
160
|
+
const plan = planSpec(demoDir, spec, after, config);
|
|
161
|
+
notes.push(...plan.warnings);
|
|
162
|
+
const overlaps = zoomOverlaps(plan.zooms);
|
|
163
|
+
plan.zooms.forEach((z, i) => {
|
|
164
|
+
for (const p of [...checkZoom(z, plan.clicks, plan.timeline).problems, ...overlaps.filter((o) => o.index === i).map((o) => o.message)]) {
|
|
165
|
+
problems.push(`zoom ${i + 1}: ${p}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
problems.push(error.message);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const p of problems)
|
|
174
|
+
log(`✗ ${slug}: ${p}`);
|
|
175
|
+
for (const n of notes)
|
|
176
|
+
log(`! ${slug}: ${n}`);
|
|
177
|
+
if (!problems.length) {
|
|
178
|
+
log(`✓ ${slug}: records fine and video.json still fits (${after.durationSeconds.toFixed(1)}s, ${count(after.markers.length, 'marker')}, ${count(after.clicks?.length ?? 0, 'click')})`);
|
|
179
|
+
if (options.update) {
|
|
180
|
+
copyFileSync(resolve(dir, recording), resolve(demoDir, recording));
|
|
181
|
+
copyFileSync(resolve(dir, markersFile), resolve(demoDir, markersFile));
|
|
182
|
+
log(` updated ${basename(demoDir)}/${recording} and ${markersFile} — rebuild to use them`);
|
|
183
|
+
}
|
|
184
|
+
rmSync(dir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
log(` new take kept for a look: ${dir}`);
|
|
188
|
+
}
|
|
189
|
+
return problems.length;
|
|
190
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reelson verify`: does each demo still record, and does its video.json still fit?
|
|
3
|
+
*
|
|
4
|
+
* Apps change under their demos: a column moves, a URL changes, a step is added. verify
|
|
5
|
+
* re-records every scenario headless into a scratch folder — the committed recording and
|
|
6
|
+
* markers.json stay untouched — and checks the new take against video.json: the scenario
|
|
7
|
+
* ran to the end, every marker a callout or trim uses still exists, every click a zoom or
|
|
8
|
+
* trim refers to by number is still the same kind of click in the same place, and the plan
|
|
9
|
+
* (timeline, zoom checks) still holds. `--update` puts a take that passes in place. A demo
|
|
10
|
+
* with a phone or square take (markers.mobile.json, markers.square.json) gets those
|
|
11
|
+
* re-recorded and checked too.
|
|
12
|
+
*
|
|
13
|
+
* reelson verify <slug...> | --all [--update]
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { basename, join, resolve } from 'node:path'
|
|
19
|
+
import type { LoadedConfig } from '../../reelson-record/scripts/config.ts'
|
|
20
|
+
import { planSpec } from './build.ts'
|
|
21
|
+
import { readMarkers, RECORD_SCRIPT, readVideoSpec, ReelsonError } from './project.ts'
|
|
22
|
+
import type { Markers, TrimPoint, VideoSpec } from './timeline.ts'
|
|
23
|
+
import { checkZoom, zoomOverlaps } from './zooms.ts'
|
|
24
|
+
|
|
25
|
+
export interface Drift {
|
|
26
|
+
/** video.json no longer fits the new take. */
|
|
27
|
+
problems: string[]
|
|
28
|
+
/** Worth knowing, nothing broken. */
|
|
29
|
+
notes: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
|
|
33
|
+
|
|
34
|
+
/** A click that moved more than this (CSS px) is probably a different target. */
|
|
35
|
+
const MOVED_PX = 80
|
|
36
|
+
|
|
37
|
+
/** What a re-record changed that video.json relies on. Pure. */
|
|
38
|
+
export function compareRecordings(before: Markers | null, after: Markers, spec: VideoSpec): Drift {
|
|
39
|
+
const problems: string[] = []
|
|
40
|
+
const notes: string[] = []
|
|
41
|
+
const labels = new Set(after.markers.map((m) => m.label))
|
|
42
|
+
const trimPoints = (['start', 'end'] as const)
|
|
43
|
+
.map((edge) => [edge, spec.trim?.[edge]] as [string, TrimPoint | undefined])
|
|
44
|
+
.filter(([, p]) => p !== undefined && typeof p === 'object') as [string, Exclude<TrimPoint, number | 'auto'>][]
|
|
45
|
+
|
|
46
|
+
// Markers: the ones video.json uses must still be set.
|
|
47
|
+
for (const c of spec.callouts ?? []) {
|
|
48
|
+
if (c.marker !== undefined && !labels.has(c.marker)) {
|
|
49
|
+
problems.push(`callout "${c.text}" is timed from marker "${c.marker}", which the scenario no longer sets`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const [edge, p] of trimPoints) {
|
|
53
|
+
if ('marker' in p && !labels.has(p.marker)) {
|
|
54
|
+
problems.push(`trim.${edge} is tied to marker "${p.marker}", which the scenario no longer sets`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (before) {
|
|
58
|
+
const old = new Set(before.markers.map((m) => m.label))
|
|
59
|
+
const added = after.markers.filter((m) => !old.has(m.label)).map((m) => `"${m.label}"`)
|
|
60
|
+
const removed = before.markers.filter((m) => !labels.has(m.label)).map((m) => `"${m.label}"`)
|
|
61
|
+
if (added.length) notes.push(`new marker(s) ${added.join(', ')} — no callout uses them yet`)
|
|
62
|
+
if (removed.length) notes.push(`marker(s) ${removed.join(', ')} are gone`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Clicks are referred to by number: the same number must still be the same click.
|
|
66
|
+
const clicks = after.clicks ?? []
|
|
67
|
+
const used = new Map<number, string>()
|
|
68
|
+
;(spec.zooms ?? []).forEach((z, i) => {
|
|
69
|
+
if (!z.clicks) return
|
|
70
|
+
const [a, b = a] = z.clicks
|
|
71
|
+
for (let n = a; n <= b; n++) used.set(n, `zoom ${i + 1}`)
|
|
72
|
+
})
|
|
73
|
+
for (const [edge, p] of trimPoints) {
|
|
74
|
+
if ('click' in p) used.set(p.click, `trim.${edge}`)
|
|
75
|
+
}
|
|
76
|
+
for (const [n, user] of [...used].sort((x, y) => x[0] - y[0])) {
|
|
77
|
+
const now = clicks[n - 1]
|
|
78
|
+
if (!now) {
|
|
79
|
+
problems.push(`${user} uses click #${n}, but the new take has ${clicks.length} click(s)`)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
const then = before?.clicks?.[n - 1]
|
|
83
|
+
if (then) {
|
|
84
|
+
const moved = Math.hypot(now.x - then.x, now.y - then.y)
|
|
85
|
+
if (now.kind !== then.kind || moved > MOVED_PX) {
|
|
86
|
+
problems.push(
|
|
87
|
+
`${user} uses click #${n}: it was a ${then.kind} at ${then.x},${then.y}, now a ${now.kind} at ${now.x},${now.y} — ` +
|
|
88
|
+
'a step was probably added or removed before it; renumber the clicks in video.json',
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (before?.clicks && before.clicks.length !== clicks.length) {
|
|
94
|
+
notes.push(`${before.clicks.length} → ${clicks.length} click(s)`)
|
|
95
|
+
}
|
|
96
|
+
if (before && (before.transitions?.length ?? 0) !== (after.transitions?.length ?? 0)) {
|
|
97
|
+
notes.push(`${before.transitions?.length ?? 0} → ${after.transitions?.length ?? 0} hand-off card(s)`)
|
|
98
|
+
}
|
|
99
|
+
if (before && Math.abs(after.durationSeconds - before.durationSeconds) > Math.max(3, before.durationSeconds * 0.2)) {
|
|
100
|
+
notes.push(`length ${before.durationSeconds.toFixed(1)}s → ${after.durationSeconds.toFixed(1)}s`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { problems, notes }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface VerifyOptions {
|
|
107
|
+
/** Put a take that passes in place of the committed recording + markers. */
|
|
108
|
+
update?: boolean
|
|
109
|
+
log?: (line: string) => void
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The recordings a demo can have: the desktop one, and the takes behind portrait and square. */
|
|
113
|
+
export const TAKES = [
|
|
114
|
+
{ name: 'desktop', suffix: '', flags: [] as string[] },
|
|
115
|
+
{ name: 'phone', suffix: '.mobile', flags: ['--mobile'] },
|
|
116
|
+
{ name: 'square', suffix: '.square', flags: ['--square'] },
|
|
117
|
+
] as const
|
|
118
|
+
export type Take = (typeof TAKES)[number]
|
|
119
|
+
|
|
120
|
+
/** The takes this demo has (its markers are there): desktop always, phone and square if recorded. */
|
|
121
|
+
export function takesOf(demoDir: string): Take[] {
|
|
122
|
+
return TAKES.filter((take) => take.suffix === '' || existsSync(resolve(demoDir, `markers${take.suffix}.json`)))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Re-records each take of one demo and checks it; returns the number of problems. */
|
|
126
|
+
export function verify(demoDir: string, config: LoadedConfig, options: VerifyOptions = {}): number {
|
|
127
|
+
const log = options.log ?? console.log
|
|
128
|
+
const scenario = resolve(demoDir, 'scenario.ts')
|
|
129
|
+
if (!existsSync(scenario)) {
|
|
130
|
+
throw new ReelsonError(`${demoDir} has no scenario.ts`)
|
|
131
|
+
}
|
|
132
|
+
let problems = 0
|
|
133
|
+
for (const take of takesOf(demoDir)) {
|
|
134
|
+
problems += verifyTake(demoDir, scenario, take, config, options, log)
|
|
135
|
+
}
|
|
136
|
+
return problems
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function verifyTake(demoDir: string, scenario: string, take: Take, config: LoadedConfig, options: VerifyOptions, log: (line: string) => void): number {
|
|
140
|
+
const slug = take.suffix ? `${basename(demoDir)} (${take.name})` : basename(demoDir)
|
|
141
|
+
const recording = `recording${take.suffix}.mp4`
|
|
142
|
+
const markersFile = `markers${take.suffix}.json`
|
|
143
|
+
const dir = mkdtempSync(join(tmpdir(), `reelson-verify-${basename(demoDir)}${take.suffix}-`))
|
|
144
|
+
log(`${slug}: recording…`)
|
|
145
|
+
const run = spawnSync(process.execPath, [RECORD_SCRIPT, scenario, '--out', dir, ...take.flags], {
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
})
|
|
148
|
+
if (run.status !== 0) {
|
|
149
|
+
const output = `${run.stdout}${run.stderr}`.trim().split('\n').filter((l) => !/^\s+at /.test(l))
|
|
150
|
+
log(`✗ ${slug}: the scenario failed`)
|
|
151
|
+
output.slice(-8).forEach((line) => log(` ${line}`))
|
|
152
|
+
const failed = resolve(dir, `recording${take.suffix}.failed.mp4`)
|
|
153
|
+
if (existsSync(failed)) log(` partial take: ${failed}`)
|
|
154
|
+
return 1
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const after = JSON.parse(readFileSync(resolve(dir, markersFile), 'utf8')) as Markers
|
|
158
|
+
const before = existsSync(resolve(demoDir, markersFile)) ? (JSON.parse(readFileSync(resolve(demoDir, markersFile), 'utf8')) as Markers) : null
|
|
159
|
+
const video = readVideoSpec(demoDir)
|
|
160
|
+
const problems: string[] = []
|
|
161
|
+
const notes: string[] = []
|
|
162
|
+
if (!video) {
|
|
163
|
+
notes.push('no video.json yet — nothing to check against (run `reelson build`)')
|
|
164
|
+
} else {
|
|
165
|
+
// As the build uses it: the phone take never zooms; the square one zooms while its clicks
|
|
166
|
+
// match the desktop take's (video.json numbers them by the desktop clicks).
|
|
167
|
+
let spec = video
|
|
168
|
+
if (take.name === 'phone') {
|
|
169
|
+
spec = { ...video, zooms: [] }
|
|
170
|
+
} else if (take.name === 'square' && video.zooms?.length) {
|
|
171
|
+
const desktop = existsSync(resolve(demoDir, 'markers.json')) ? readMarkers(demoDir) : null
|
|
172
|
+
if (desktop && (desktop.clicks?.length ?? 0) !== (after.clicks?.length ?? 0)) {
|
|
173
|
+
notes.push(`${count(after.clicks?.length ?? 0, 'click')}, the desktop take ${desktop.clicks?.length ?? 0} — the square video leaves out the zooms`)
|
|
174
|
+
spec = { ...video, zooms: [] }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const drift = compareRecordings(before, after, spec)
|
|
178
|
+
problems.push(...drift.problems)
|
|
179
|
+
notes.push(...drift.notes)
|
|
180
|
+
try {
|
|
181
|
+
const plan = planSpec(demoDir, spec, after, config)
|
|
182
|
+
notes.push(...plan.warnings)
|
|
183
|
+
const overlaps = zoomOverlaps(plan.zooms)
|
|
184
|
+
plan.zooms.forEach((z, i) => {
|
|
185
|
+
for (const p of [...checkZoom(z, plan.clicks, plan.timeline).problems, ...overlaps.filter((o) => o.index === i).map((o) => o.message)]) {
|
|
186
|
+
problems.push(`zoom ${i + 1}: ${p}`)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
} catch (error) {
|
|
190
|
+
problems.push((error as Error).message)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const p of problems) log(`✗ ${slug}: ${p}`)
|
|
195
|
+
for (const n of notes) log(`! ${slug}: ${n}`)
|
|
196
|
+
if (!problems.length) {
|
|
197
|
+
log(`✓ ${slug}: records fine and video.json still fits (${after.durationSeconds.toFixed(1)}s, ${count(after.markers.length, 'marker')}, ${count(after.clicks?.length ?? 0, 'click')})`)
|
|
198
|
+
if (options.update) {
|
|
199
|
+
copyFileSync(resolve(dir, recording), resolve(demoDir, recording))
|
|
200
|
+
copyFileSync(resolve(dir, markersFile), resolve(demoDir, markersFile))
|
|
201
|
+
log(` updated ${basename(demoDir)}/${recording} and ${markersFile} — rebuild to use them`)
|
|
202
|
+
}
|
|
203
|
+
rmSync(dir, { recursive: true, force: true })
|
|
204
|
+
} else {
|
|
205
|
+
log(` new take kept for a look: ${dir}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return problems.length
|
|
209
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice-over: each callout spoken as it appears (video.json "voice": true).
|
|
3
|
+
*
|
|
4
|
+
* Lines are spoken by a text-to-speech provider (demo.config.json `voice.provider`: OpenAI,
|
|
5
|
+
* ElevenLabs, a local Piper voice or any local command — see tts.ts), trimmed of the silence
|
|
6
|
+
* around them and cached in <demo>/voice/<hash>.mp3 — the hash covers the words and every
|
|
7
|
+
* setting that changes the sound, so a line is only made once and a re-render never calls the
|
|
8
|
+
* provider. `reelson voice <slug>` (and `render`) make the missing lines; the build (sync)
|
|
9
|
+
* mixes whatever is cached into one track per version (landscape, portrait, square: each has
|
|
10
|
+
* its own timing), and says what is missing.
|
|
11
|
+
*
|
|
12
|
+
* What is spoken: a callout's `say` (false: nothing), else its text; plus video.json
|
|
13
|
+
* `voice.intro` over the intro, when given.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { ReelsonError } from "./project.js";
|
|
21
|
+
import { round } from "./timeline.js";
|
|
22
|
+
import { missingSetup, providerDefaults, speak } from "./tts.js";
|
|
23
|
+
/** A line starts this long after its callout appears (the pill is read, then heard). */
|
|
24
|
+
const LEAD_IN = 0.25;
|
|
25
|
+
/** The voice settings for a video, or null when it has no voice-over. */
|
|
26
|
+
export function voiceSettings(spec, config) {
|
|
27
|
+
if (!spec.voice) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const own = typeof spec.voice === 'object' ? spec.voice : {};
|
|
31
|
+
const project = config.voice;
|
|
32
|
+
const provider = own.provider ?? project.provider;
|
|
33
|
+
// A video switching provider starts from that provider's defaults, not the project's voice.
|
|
34
|
+
const same = provider === project.provider;
|
|
35
|
+
const defaults = providerDefaults(provider, config.language);
|
|
36
|
+
return {
|
|
37
|
+
provider,
|
|
38
|
+
model: own.model ?? (same ? project.model : undefined) ?? defaults.model,
|
|
39
|
+
voice: own.voice ?? (same ? project.voice : undefined) ?? defaults.voice,
|
|
40
|
+
instructions: own.instructions ?? project.instructions,
|
|
41
|
+
...((own.speed ?? project.speed) !== undefined ? { speed: own.speed ?? project.speed } : {}),
|
|
42
|
+
language: config.language,
|
|
43
|
+
...(same && project.baseURL ? { baseURL: project.baseURL } : {}),
|
|
44
|
+
...(same && project.command ? { command: project.command } : {}),
|
|
45
|
+
...(same && project.options ? { options: project.options } : {}),
|
|
46
|
+
lufs: project.lufs,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Everything spoken in this video, in order, timed to `t`. Pure. */
|
|
50
|
+
export function voiceLines(t, spec, settings) {
|
|
51
|
+
const intro = typeof spec.voice === 'object' ? spec.voice.intro : undefined;
|
|
52
|
+
const spoken = [
|
|
53
|
+
...(intro ? [{ at: round(t.intro.start + 0.4), text: intro, intro: true }] : []),
|
|
54
|
+
...t.callouts.map((c) => ({ at: round(c.at + LEAD_IN), text: lineOf(c) })),
|
|
55
|
+
].filter((l) => l.text);
|
|
56
|
+
return spoken.map((line, i) => ({
|
|
57
|
+
...line,
|
|
58
|
+
file: `${lineHash(line.text, settings)}.mp3`,
|
|
59
|
+
// The intro line runs until the recording comes in; a step's, until the next line.
|
|
60
|
+
until: round(i === 0 && intro ? t.clipStart + 0.6 : (spoken[i + 1]?.at ?? t.clipEnd)),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
/** Bumped when the post-processing (the trim) changes, so cached lines are made again. */
|
|
64
|
+
const CACHE_VERSION = 2;
|
|
65
|
+
/** The cache name of a line: everything that changes how it sounds (not the loudness: the mix sets it). */
|
|
66
|
+
export function lineHash(text, s) {
|
|
67
|
+
const { lufs: _, ...sound } = s;
|
|
68
|
+
return createHash('sha1')
|
|
69
|
+
.update(JSON.stringify([CACHE_VERSION, sound, text]))
|
|
70
|
+
.digest('hex')
|
|
71
|
+
.slice(0, 16);
|
|
72
|
+
}
|
|
73
|
+
/** What a callout says (its `say`, else its text); '' when it is silent. */
|
|
74
|
+
function lineOf(c) {
|
|
75
|
+
return c.say === false ? '' : (c.say || c.text).trim();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* For the timeline: seconds from a callout appearing until its cached line is done (the lead-in
|
|
79
|
+
* plus the line); 0 when it is silent or not spoken yet.
|
|
80
|
+
*/
|
|
81
|
+
export function spokenLength(settings, cacheDir) {
|
|
82
|
+
const lengths = new Map();
|
|
83
|
+
return (c) => {
|
|
84
|
+
const text = lineOf(c);
|
|
85
|
+
const file = resolve(cacheDir, `${lineHash(text, settings)}.mp3`);
|
|
86
|
+
if (!text || !existsSync(file)) {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (!lengths.has(file)) {
|
|
90
|
+
lengths.set(file, lengthOf(file));
|
|
91
|
+
}
|
|
92
|
+
return round(LEAD_IN + lengths.get(file));
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Every line the video would speak, without a timeline (for fetching). */
|
|
96
|
+
export function spokenTexts(spec, callouts) {
|
|
97
|
+
const intro = typeof spec.voice === 'object' ? spec.voice.intro : undefined;
|
|
98
|
+
return [intro, ...callouts.map(lineOf)].filter((t) => !!t);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Speaks the lines missing from `cacheDir` (see tts.ts). Returns how many it made.
|
|
102
|
+
* Throws a ReelsonError when the provider cannot speak here (only when something is missing).
|
|
103
|
+
*/
|
|
104
|
+
export async function fetchLines(texts, settings, cacheDir, log) {
|
|
105
|
+
const missing = [...new Set(texts)].filter((text) => !existsSync(resolve(cacheDir, `${lineHash(text, settings)}.mp3`)));
|
|
106
|
+
if (!missing.length) {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const problem = missingSetup(settings);
|
|
110
|
+
if (problem) {
|
|
111
|
+
throw new ReelsonError(`voice-over: ${missing.length} line(s) to speak with ${settings.provider} and ${problem} (or turn "voice" off in video.json)`);
|
|
112
|
+
}
|
|
113
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
114
|
+
const scratch = mkdtempSync(join(tmpdir(), 'reelson-voice-'));
|
|
115
|
+
try {
|
|
116
|
+
for (const text of missing) {
|
|
117
|
+
const raw = resolve(scratch, settings.provider === 'openai' || settings.provider === 'elevenlabs' ? 'line.mp3' : 'line.wav');
|
|
118
|
+
rmSync(raw, { force: true });
|
|
119
|
+
await speak(text, settings, raw);
|
|
120
|
+
trimInto(raw, resolve(cacheDir, `${lineHash(text, settings)}.mp3`));
|
|
121
|
+
log(` voice: spoke "${text}" (${settings.provider})`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
return missing.length;
|
|
128
|
+
}
|
|
129
|
+
/** The silence around a line, cut (a breath of it kept) and saved as mp3. */
|
|
130
|
+
const TRIM = 'silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.05';
|
|
131
|
+
function trimInto(raw, target) {
|
|
132
|
+
const run = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', raw, '-af', `${TRIM},areverse,${TRIM},areverse`, '-ar', '48000', '-c:a', 'libmp3lame', '-q:a', '2', target], { stdio: 'inherit' });
|
|
133
|
+
if (run.status !== 0 || !existsSync(target)) {
|
|
134
|
+
throw new ReelsonError(`ffmpeg could not read the spoken line ${raw}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Seconds of audio in `file` (ffprobe). */
|
|
138
|
+
function lengthOf(file) {
|
|
139
|
+
const probe = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { encoding: 'utf8' });
|
|
140
|
+
return Number.parseFloat(probe.stdout.trim()) || 0;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Mixes the cached lines into one track as long as the video (`target`, m4a). Returns false
|
|
144
|
+
* when none is cached. Warns about missing lines and an intro line that outlasts the intro.
|
|
145
|
+
*/
|
|
146
|
+
export function renderVoiceTrack(lines, settings, cacheDir, total, target, log) {
|
|
147
|
+
const cached = lines.filter((l) => existsSync(resolve(cacheDir, l.file)));
|
|
148
|
+
const missing = lines.length - cached.length;
|
|
149
|
+
if (missing) {
|
|
150
|
+
log(` warning: voice-over: ${missing} line(s) not spoken yet — run \`reelson voice <slug>\` (render does it for you)`);
|
|
151
|
+
}
|
|
152
|
+
if (!cached.length) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
for (const line of cached.filter((l) => l.intro)) {
|
|
156
|
+
const length = lengthOf(resolve(cacheDir, line.file));
|
|
157
|
+
if (line.at + length > line.until + 0.05) {
|
|
158
|
+
log(` warning: voice-over: "${line.text}" (${length.toFixed(1)}s) runs ${(line.at + length - line.until).toFixed(1)}s into the recording — shorten \`voice.intro\``);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const key = JSON.stringify({ lines: cached.map((l) => [l.at, l.file]), total, lufs: settings.lufs, v: 2 });
|
|
162
|
+
const stamp = `${target}.key`;
|
|
163
|
+
if (existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
const inputs = cached.flatMap((l) => ['-i', resolve(cacheDir, l.file)]);
|
|
167
|
+
const delayed = cached.map((l, i) => `[${i}:a]aresample=48000,adelay=${Math.round(l.at * 1000)}:all=1[v${i}]`).join(';');
|
|
168
|
+
// 0.1 s past the video: AAC's encoder padding would otherwise leave the track a frame or
|
|
169
|
+
// two short of its slot. loudnorm works at 192 kHz, hence the resample back.
|
|
170
|
+
const mix = `${delayed};${cached.map((_, i) => `[v${i}]`).join('')}amix=inputs=${cached.length}:normalize=0,` +
|
|
171
|
+
`loudnorm=I=${settings.lufs}:TP=-1.5:LRA=11,aresample=48000,apad,atrim=0:${round(total + 0.1)}[out]`;
|
|
172
|
+
const render = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', ...inputs, '-filter_complex', mix, '-map', '[out]', '-c:a', 'aac', '-b:a', '160k', target], { stdio: 'inherit' });
|
|
173
|
+
if (render.status !== 0) {
|
|
174
|
+
throw new ReelsonError('ffmpeg could not mix the voice-over');
|
|
175
|
+
}
|
|
176
|
+
writeFileSync(stamp, key);
|
|
177
|
+
return true;
|
|
178
|
+
}
|