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,172 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "reelson video (video.json)",
|
|
4
|
+
"description": "The source of truth for one video. `reelson build` regenerates video/index.html from it; every time in here is a RECORDING time (seconds in recording.mp4, as in markers.json), so trims and templates can change without re-timing anything.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["title"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"title": { "type": "string", "description": "Cover title: the task, e.g. \"Find a customer\"." },
|
|
10
|
+
"subtitle": { "type": "string", "description": "Cover subtitle: the benefit or context." },
|
|
11
|
+
"template": { "type": "string", "description": "Overrides demo.config.json `template` for this video." },
|
|
12
|
+
"sections": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"description": "Overrides demo.config.json `sections` per slot, e.g. { \"intro\": \"minimal\", \"recap\": \"none\" }. Run `reelson templates` for the list.",
|
|
16
|
+
"properties": {
|
|
17
|
+
"intro": { "type": "string", "description": "Opening section; frame 0 is the poster. Kit: poster, minimal, split." },
|
|
18
|
+
"recap": { "type": "string", "description": "Steps card after the recording, or \"none\". Kit: steps, compact, none." },
|
|
19
|
+
"outro": { "type": "string", "description": "Closing brand card. Kit: wordmark, compact, endcard." }
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"recapTitle": { "type": "string", "description": "Overrides demo.config.json `strings.recapTitle`." },
|
|
23
|
+
"brand": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"description": "Per-video overrides of demo.config.json `brand` text.",
|
|
27
|
+
"properties": {
|
|
28
|
+
"name": { "type": "string" },
|
|
29
|
+
"tagline": { "type": "string" },
|
|
30
|
+
"eyebrow": { "type": "string" },
|
|
31
|
+
"logo": { "type": ["string", "null"], "description": "Overrides demo.config.json `brand.logo`: an .svg/.png/.webp relative to the project root, or null for the text wordmark." }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"trim": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"description": "The part of the recording the video uses. Prefer \"auto\" or a marker/click anchor: a plain number goes stale when the demo is re-recorded.",
|
|
38
|
+
"properties": {
|
|
39
|
+
"start": {
|
|
40
|
+
"description": "Where the footage starts: \"auto\" (just before the first glide or marker, i.e. after the login), { \"marker\": label, \"offset\"?: s }, { \"click\": n, \"offset\"?: s } (from the glide towards click n), or recording seconds.",
|
|
41
|
+
"anyOf": [
|
|
42
|
+
{ "type": "number", "minimum": 0 },
|
|
43
|
+
{ "enum": ["auto"] },
|
|
44
|
+
{ "$ref": "#/definitions/markerPoint" },
|
|
45
|
+
{ "$ref": "#/definitions/clickPoint" }
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"end": {
|
|
49
|
+
"description": "Where the footage stops: { \"marker\": label, \"offset\"?: s }, { \"click\": n, \"offset\"?: s }, or recording seconds. Default: the end of the recording.",
|
|
50
|
+
"anyOf": [
|
|
51
|
+
{ "type": "number", "exclusiveMinimum": 0 },
|
|
52
|
+
{ "$ref": "#/definitions/markerPoint" },
|
|
53
|
+
{ "$ref": "#/definitions/clickPoint" }
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"music": {
|
|
59
|
+
"type": ["string", "boolean", "null"],
|
|
60
|
+
"description": "Music bed: a file (relative to the project root) for this video only, false for none, null/omitted for demo.config.json `music.file`."
|
|
61
|
+
},
|
|
62
|
+
"callouts": {
|
|
63
|
+
"type": "array",
|
|
64
|
+
"maxItems": 20,
|
|
65
|
+
"description": "Numbered step pills over the recording; they also become the recap. Give each a `marker` (a markers.json label) or an `at`.",
|
|
66
|
+
"items": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"additionalProperties": false,
|
|
69
|
+
"required": ["text"],
|
|
70
|
+
"properties": {
|
|
71
|
+
"text": { "type": "string", "description": "Imperative step in the UI's language, ≤ 6 words." },
|
|
72
|
+
"marker": { "type": "string", "description": "markers.json label this callout is timed from." },
|
|
73
|
+
"anchor": { "enum": ["step", "marker"], "description": "Where a marker callout starts: step (default) — as the step it names begins (the first glide/click after the previous marker; a marker comes right after its action); marker — on the marker itself." },
|
|
74
|
+
"offset": { "type": "number", "description": "Seconds after that start (negative: before). Keeps the callout tied to its marker when you nudge it." },
|
|
75
|
+
"at": { "type": "number", "minimum": 0, "description": "Recording time instead of a marker (manual recordings)." },
|
|
76
|
+
"duration": { "type": "number", "exclusiveMinimum": 0, "description": "Seconds on screen. Default: through its step and 1.2 s past its marker (at least 3 s), ending before the next callout." },
|
|
77
|
+
"group": { "type": "string", "description": "Role tag (two-actor videos). Default: taken from the hand-off cards." },
|
|
78
|
+
"say": { "type": ["string", "boolean"], "not": { "const": true }, "description": "Voice-over (\"voice\": true): what is spoken for this step, when it should differ from the text (e.g. a full sentence); false: nothing." }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"voice": {
|
|
83
|
+
"description": "Voice-over: true speaks each callout (its `say`, else its text) with the demo.config.json `voice` provider (OpenAI by default; ElevenLabs, a local Piper voice or a local command); lines are cached in <demo>/voice/. An object turns it on with this video's own provider / model / voice / instructions / speed and may add an `intro` line spoken over the intro.",
|
|
84
|
+
"oneOf": [
|
|
85
|
+
{ "type": "boolean" },
|
|
86
|
+
{
|
|
87
|
+
"type": "object",
|
|
88
|
+
"additionalProperties": false,
|
|
89
|
+
"properties": {
|
|
90
|
+
"provider": { "enum": ["openai", "elevenlabs", "piper", "command"], "description": "This video's provider, from its defaults (baseURL, command and options stay with the project's provider)." },
|
|
91
|
+
"model": { "type": "string" },
|
|
92
|
+
"voice": { "type": "string" },
|
|
93
|
+
"instructions": { "type": "string" },
|
|
94
|
+
"speed": { "type": "number", "minimum": 0.5, "maximum": 2 },
|
|
95
|
+
"intro": { "type": "string", "description": "Spoken over the intro, e.g. the title as a sentence." }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
"formats": {
|
|
101
|
+
"type": "array",
|
|
102
|
+
"uniqueItems": true,
|
|
103
|
+
"items": { "enum": ["portrait", "square"] },
|
|
104
|
+
"description": "Versions every `reelson render` adds besides the 16:9 one, as with --portrait / --square. A square version needs the square take (`reelson record --square`); without one it is skipped with a note."
|
|
105
|
+
},
|
|
106
|
+
"portrait": {
|
|
107
|
+
"enum": ["auto", "mobile", "desktop"],
|
|
108
|
+
"description": "Source of `reelson render --portrait`: mobile = the phone take (`reelson record --mobile`), desktop = a camera framing each element on the desktop recording, auto (default) = the phone take when there is one."
|
|
109
|
+
},
|
|
110
|
+
"cursor": {
|
|
111
|
+
"description": "The cursor layer (recordings with record.cursor \"layer\", the default since reelson 0.4): false hides it, or tune it. Has no effect when the cursor is filmed in.",
|
|
112
|
+
"anyOf": [
|
|
113
|
+
{ "enum": [false] },
|
|
114
|
+
{
|
|
115
|
+
"type": "object",
|
|
116
|
+
"additionalProperties": false,
|
|
117
|
+
"properties": {
|
|
118
|
+
"size": { "type": "number", "minimum": 16, "maximum": 120, "description": "Arrow size in recording CSS px. Default 44 (the classic macOS-style arrow)." },
|
|
119
|
+
"ripple": { "type": "boolean", "description": "The brand-coloured ring on each press. Default true." },
|
|
120
|
+
"idle": { "type": "number", "minimum": 0, "maximum": 30, "description": "Fade the cursor out after this many seconds without moving or clicking, back in as it moves again. Default 0 (always shown)." }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
"zooms": {
|
|
126
|
+
"type": "array",
|
|
127
|
+
"description": "Zoom-ins on the framed recording. Prefer `clicks`: timing and focus are computed from the cursor so the zoom rides along with the glide.",
|
|
128
|
+
"items": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"additionalProperties": false,
|
|
131
|
+
"required": ["scale"],
|
|
132
|
+
"properties": {
|
|
133
|
+
"clicks": {
|
|
134
|
+
"type": "array",
|
|
135
|
+
"minItems": 1,
|
|
136
|
+
"maxItems": 2,
|
|
137
|
+
"items": { "type": "integer", "minimum": 1 },
|
|
138
|
+
"description": "[first, last] click numbers (1-based, markers.json `clicks`) the zoom frames. Timing and focus are computed."
|
|
139
|
+
},
|
|
140
|
+
"scale": { "type": "number", "minimum": 1.1, "maximum": 4, "description": "1.5–2.0 reads well." },
|
|
141
|
+
"follow": { "type": "boolean", "description": "Pan with the cursor while zoomed, so the clicks need not fit one view (recordings with a logged cursor, record.cursor \"layer\"). Default false." },
|
|
142
|
+
"x": { "type": "number", "minimum": 0, "maximum": 1, "description": "Focus point, fraction of the frame width. Default: centred on the clicks." },
|
|
143
|
+
"y": { "type": "number", "minimum": 0, "maximum": 1, "description": "Focus point, fraction of the frame height." },
|
|
144
|
+
"at": { "type": "number", "minimum": 0, "description": "Manual zoom: recording time the zoom-in starts (instead of `clicks`)." },
|
|
145
|
+
"duration": { "type": "number", "exclusiveMinimum": 0, "description": "Manual zoom: seconds from zoom-in start to zoomed-out." },
|
|
146
|
+
"in": { "type": "number", "minimum": 0.4, "description": "Zoom-in ease seconds (default 0.8)." },
|
|
147
|
+
"out": { "type": "number", "minimum": 0.4, "description": "Zoom-out ease seconds (default 0.8)." }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
"definitions": {
|
|
153
|
+
"markerPoint": {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"additionalProperties": false,
|
|
156
|
+
"required": ["marker"],
|
|
157
|
+
"properties": {
|
|
158
|
+
"marker": { "type": "string", "description": "markers.json label." },
|
|
159
|
+
"offset": { "type": "number", "description": "Seconds after the marker (negative: before)." }
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"clickPoint": {
|
|
163
|
+
"type": "object",
|
|
164
|
+
"additionalProperties": false,
|
|
165
|
+
"required": ["click"],
|
|
166
|
+
"properties": {
|
|
167
|
+
"click": { "type": "integer", "minimum": 1, "description": "1-based click in markers.json `clicks`: the time the cursor starts gliding towards it." },
|
|
168
|
+
"offset": { "type": "number", "description": "Seconds after that (negative: before)." }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a demo's HyperFrames project from its video.json.
|
|
3
|
+
*
|
|
4
|
+
* reelson build <slug|dir> [--title ".."] [--subtitle ".."] [--trim-start s] [--trim-end s]
|
|
5
|
+
* [--template name] [--intro name] [--recap name|none] [--outro name]
|
|
6
|
+
* [--music file | --no-music]
|
|
7
|
+
*
|
|
8
|
+
* video.json is the source of truth; video/ is generated and can be deleted at
|
|
9
|
+
* any time. The first build creates video.json from markers.json (one callout
|
|
10
|
+
* per marker, a suggested trim); the flags above edit video.json in place.
|
|
11
|
+
*
|
|
12
|
+
* Writes <demo>/video/{index.html, hyperframes.json, package.json, assets/*}:
|
|
13
|
+
* the template's stage with the chosen intro/recap/outro sections, its own assets
|
|
14
|
+
* (vendored GSAP + fonts), each section's assets, the brand logo, the recording, narration
|
|
15
|
+
* (when the recording has audio) and the music bed (trimmed, loudness-normalised,
|
|
16
|
+
* faded; cached between builds).
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { basename, extname, resolve } from 'node:path';
|
|
21
|
+
import { countLabel, fromRoot } from "../../reelson-record/scripts/config.js";
|
|
22
|
+
import { renderComposition } from "./composition.js";
|
|
23
|
+
import { phoneLayout, portraitLayout, squareLayout } from "./portrait.js";
|
|
24
|
+
import { renderVoiceTrack, spokenLength, voiceLines, voiceSettings } from "./voice.js";
|
|
25
|
+
import { HYPERFRAMES_VERSION, RENDER_FLAGS } from "./hyperframes.js";
|
|
26
|
+
import { readMarkers, readSection, readVideoSpec, ReelsonError, resolveDesign, videoSchemaRef, } from "./project.js";
|
|
27
|
+
import { computeTimeline, defaultCallouts, round, } from "./timeline.js";
|
|
28
|
+
import { compositionClicks, followPath, planZoom, ZoomError } from "./zooms.js";
|
|
29
|
+
/** Everything a build needs, computed without touching video/ (check uses it too). */
|
|
30
|
+
export function plan(demoDir, config, options = {}) {
|
|
31
|
+
const markers = readMarkers(demoDir);
|
|
32
|
+
const existing = readVideoSpec(demoDir);
|
|
33
|
+
const spec = existing ?? {
|
|
34
|
+
title: options.title ?? humanize(basename(demoDir)),
|
|
35
|
+
// Follows the recording: a re-record never leaves a stale trim behind.
|
|
36
|
+
trim: { start: 'auto' },
|
|
37
|
+
callouts: defaultCallouts(markers),
|
|
38
|
+
zooms: [],
|
|
39
|
+
};
|
|
40
|
+
applyOptions(spec, options);
|
|
41
|
+
return { ...planSpec(demoDir, spec, markers, config), created: existing === null };
|
|
42
|
+
}
|
|
43
|
+
/** The plan for a given video.json (validated elsewhere), without reading it from disk. */
|
|
44
|
+
export function planSpec(demoDir, spec, markers, config) {
|
|
45
|
+
const design = resolveDesign(spec.template ?? config.template, [config.sections, spec.sections], config);
|
|
46
|
+
let computed;
|
|
47
|
+
try {
|
|
48
|
+
// With a voice-over, the callouts wait for their (cached) lines to be said.
|
|
49
|
+
const voice = voiceSettings(spec, config);
|
|
50
|
+
computed = computeTimeline(markers, spec, design.timing, voice ? spokenLength(voice, resolve(demoDir, 'voice')) : undefined);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw new ReelsonError(`${resolve(demoDir, 'video.json')}: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
const { timeline, warnings } = computed;
|
|
56
|
+
const clicks = compositionClicks(markers, timeline);
|
|
57
|
+
const zooms = (spec.zooms ?? []).map((z, i) => {
|
|
58
|
+
try {
|
|
59
|
+
const zoom = planZoom(z, clicks, timeline);
|
|
60
|
+
if (z.follow && timeline.cursor) {
|
|
61
|
+
zoom.path = followPath(zoom, timeline.cursor, timeline.viewport);
|
|
62
|
+
}
|
|
63
|
+
else if (z.follow) {
|
|
64
|
+
warnings.push(`zoom ${i + 1}: "follow" needs the cursor log of a record.cursor "layer" recording — it stays on its focus`);
|
|
65
|
+
}
|
|
66
|
+
return zoom;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error instanceof ZoomError) {
|
|
70
|
+
throw new ReelsonError(`video.json zooms[${i}]: ${error.message}`);
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return { spec, markers, design, timeline, clicks, zooms, warnings };
|
|
76
|
+
}
|
|
77
|
+
export function build(demoDir, config, options = {}) {
|
|
78
|
+
const log = options.log ?? console.log;
|
|
79
|
+
const recording = resolve(demoDir, 'recording.mp4');
|
|
80
|
+
if (!existsSync(recording)) {
|
|
81
|
+
throw new ReelsonError(`missing ${recording} — run \`reelson record\` first (or export one from OpenScreen)`);
|
|
82
|
+
}
|
|
83
|
+
const result = plan(demoDir, config, options);
|
|
84
|
+
const { spec, timeline, design } = result;
|
|
85
|
+
const { template } = design;
|
|
86
|
+
// video.json first: it is the source of truth even if a later step fails.
|
|
87
|
+
const specPath = resolve(demoDir, 'video.json');
|
|
88
|
+
const serialized = serializeVideoSpec(spec, demoDir, config);
|
|
89
|
+
if (!existsSync(specPath) || readFileSync(specPath, 'utf8') !== serialized) {
|
|
90
|
+
writeFileSync(specPath, serialized);
|
|
91
|
+
log(result.created ? `created ${specPath} — word the callouts and add zooms there` : `updated ${specPath}`);
|
|
92
|
+
}
|
|
93
|
+
const videoDir = resolve(demoDir, 'video');
|
|
94
|
+
const assets = resolve(videoDir, 'assets');
|
|
95
|
+
mkdirSync(assets, { recursive: true });
|
|
96
|
+
if (existsSync(resolve(template.dir, 'assets'))) {
|
|
97
|
+
cpSync(resolve(template.dir, 'assets'), assets, { recursive: true, preserveTimestamps: true });
|
|
98
|
+
}
|
|
99
|
+
const sections = Object.values(design.sections)
|
|
100
|
+
.filter((section) => section !== null)
|
|
101
|
+
.map((section) => {
|
|
102
|
+
const source = readSection(section);
|
|
103
|
+
if (source.assets) {
|
|
104
|
+
cpSync(resolve(section.dir, 'assets'), resolve(videoDir, source.assets), { recursive: true, preserveTimestamps: true });
|
|
105
|
+
}
|
|
106
|
+
return source;
|
|
107
|
+
});
|
|
108
|
+
const logo = copyLogo(spec, config, assets, log);
|
|
109
|
+
copyIfChanged(recording, resolve(assets, 'recording.mp4'));
|
|
110
|
+
const narration = extractNarration(recording, resolve(assets, 'narration.m4a'), timeline, log);
|
|
111
|
+
const voice = voiceTrack(demoDir, config, spec, timeline, resolve(assets, 'voice.m4a'), log);
|
|
112
|
+
const music = renderMusicBed(spec, config, timeline, resolve(assets, 'music.m4a'), narration || voice, log);
|
|
113
|
+
const brand = { ...config.brand, ...spec.brand };
|
|
114
|
+
const composition = {
|
|
115
|
+
stage: readFileSync(resolve(template.dir, 'stage.html'), 'utf8'),
|
|
116
|
+
sections,
|
|
117
|
+
timeline,
|
|
118
|
+
zooms: result.zooms,
|
|
119
|
+
narration,
|
|
120
|
+
music,
|
|
121
|
+
voice,
|
|
122
|
+
text: {
|
|
123
|
+
language: config.language,
|
|
124
|
+
brand,
|
|
125
|
+
logo,
|
|
126
|
+
title: spec.title,
|
|
127
|
+
subtitle: spec.subtitle ?? '',
|
|
128
|
+
recapTitle: spec.recapTitle ?? config.strings.recapTitle,
|
|
129
|
+
stepsChip: countLabel(timeline.callouts.length, config.strings.stepsLabel, config.language),
|
|
130
|
+
secondsChip: countLabel(Math.round(timeline.total), config.strings.secondsLabel, config.language),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
const html = renderComposition(composition);
|
|
134
|
+
writeFileSync(resolve(videoDir, 'index.html'), html);
|
|
135
|
+
// The same video for phones (`reelson render --portrait`): from the phone take when there
|
|
136
|
+
// is one (video.json "portrait"), else a camera framing each element on this recording.
|
|
137
|
+
const portrait = renderPortrait(demoDir, config, spec, composition, result, assets, log);
|
|
138
|
+
writeFileSync(resolve(videoDir, 'portrait.html'), portrait.html);
|
|
139
|
+
// The square video (`reelson render --square`): only from a square take, which fills it.
|
|
140
|
+
const square = renderSquare(demoDir, config, spec, composition, result, assets, log);
|
|
141
|
+
if (square) {
|
|
142
|
+
writeFileSync(resolve(videoDir, 'square.html'), square.html);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
rmSync(resolve(videoDir, 'square.html'), { force: true });
|
|
146
|
+
}
|
|
147
|
+
writeFileSync(resolve(videoDir, 'hyperframes.json'), JSON.stringify({
|
|
148
|
+
$schema: 'https://hyperframes.heygen.com/schema/hyperframes.json',
|
|
149
|
+
paths: { blocks: 'compositions', components: 'compositions/components', assets: 'assets' },
|
|
150
|
+
// autoProxy swaps big media for a downscaled proxy, which throws away the 2x capture.
|
|
151
|
+
media: { autoProxy: false },
|
|
152
|
+
}, null, 2) + '\n');
|
|
153
|
+
const hf = `npx --yes hyperframes@${HYPERFRAMES_VERSION}`;
|
|
154
|
+
writeFileSync(resolve(videoDir, 'package.json'), JSON.stringify({
|
|
155
|
+
name: `reelson-video-${basename(demoDir)}`,
|
|
156
|
+
private: true,
|
|
157
|
+
type: 'module',
|
|
158
|
+
scripts: { dev: `${hf} preview`, check: `${hf} check`, render: `${hf} render ${RENDER_FLAGS.join(' ')}` },
|
|
159
|
+
}, null, 2) + '\n');
|
|
160
|
+
const t = timeline;
|
|
161
|
+
const { intro, recap, outro } = design.sections;
|
|
162
|
+
log(`built ${resolve(videoDir, 'index.html')} (template '${template.name}')`);
|
|
163
|
+
log(` timeline: intro/${intro.name} 0–${t.intro.duration}s | recording ${t.clipStart}–${t.clipEnd}s (media ${t.mediaStart}–${t.mediaEnd}s) | ` +
|
|
164
|
+
(recap && t.recap ? `recap/${recap.name} ${t.recap.start}–${round(t.recap.start + t.recap.duration)}s | ` : 'no recap | ') +
|
|
165
|
+
`outro/${outro.name} ${t.outro.start}–${t.total}s`);
|
|
166
|
+
if (t.transitions.length) {
|
|
167
|
+
log(` hand-off card(s) at ${t.transitions.map((tr) => `${tr.at}s`).join(', ')}`);
|
|
168
|
+
}
|
|
169
|
+
log(` frame ${t.frame.width}x${t.frame.height}, ${t.callouts.length} callout(s), ${result.zooms.length} zoom(s)`);
|
|
170
|
+
for (const w of result.warnings) {
|
|
171
|
+
log(` warning: ${w}`);
|
|
172
|
+
}
|
|
173
|
+
return { ...result, versions: { portrait: portrait.timeline, square: square?.timeline ?? null } };
|
|
174
|
+
}
|
|
175
|
+
/** The portrait composition (see `build`). */
|
|
176
|
+
function renderPortrait(demoDir, config, spec, composition, result, assets, log) {
|
|
177
|
+
const source = spec.portrait ?? 'auto';
|
|
178
|
+
const phoneTake = resolve(demoDir, 'recording.mobile.mp4');
|
|
179
|
+
const phoneMarkers = resolve(demoDir, 'markers.mobile.json');
|
|
180
|
+
const hasPhone = existsSync(phoneTake) && existsSync(phoneMarkers);
|
|
181
|
+
if (source === 'mobile' && !hasPhone) {
|
|
182
|
+
log(' warning: video.json "portrait": "mobile", but there is no phone take — run `reelson record <slug> --mobile`; using the desktop camera');
|
|
183
|
+
}
|
|
184
|
+
if (source !== 'desktop' && hasPhone) {
|
|
185
|
+
try {
|
|
186
|
+
// The same video.json on the phone take: callouts follow their markers; zooms are
|
|
187
|
+
// numbered by the desktop clicks, so they don't apply.
|
|
188
|
+
const markers = JSON.parse(readFileSync(phoneMarkers, 'utf8'));
|
|
189
|
+
const phone = planSpec(demoDir, { ...spec, zooms: [] }, markers, config);
|
|
190
|
+
copyIfChanged(phoneTake, resolve(assets, 'recording.mobile.mp4'));
|
|
191
|
+
const narration = extractNarration(phoneTake, resolve(assets, 'narration.mobile.m4a'), phone.timeline, () => { });
|
|
192
|
+
const voice = voiceTrack(demoDir, config, spec, phone.timeline, resolve(assets, 'voice.mobile.m4a'), () => { });
|
|
193
|
+
const music = renderMusicBed(spec, config, phone.timeline, resolve(assets, 'music.mobile.m4a'), narration || voice, () => { });
|
|
194
|
+
const layout = phoneLayout(phone.timeline);
|
|
195
|
+
log(` portrait: the phone take (${markers.viewport.width}x${markers.viewport.height}), ${phone.timeline.total}s`);
|
|
196
|
+
const html = renderComposition({
|
|
197
|
+
...composition,
|
|
198
|
+
timeline: phone.timeline,
|
|
199
|
+
layout: layout.layout,
|
|
200
|
+
cursor: layout.cursor,
|
|
201
|
+
zooms: [],
|
|
202
|
+
narration,
|
|
203
|
+
music,
|
|
204
|
+
voice,
|
|
205
|
+
media: {
|
|
206
|
+
recording: 'assets/recording.mobile.mp4',
|
|
207
|
+
narration: 'assets/narration.mobile.m4a',
|
|
208
|
+
music: 'assets/music.mobile.m4a',
|
|
209
|
+
voice: 'assets/voice.mobile.m4a',
|
|
210
|
+
},
|
|
211
|
+
text: {
|
|
212
|
+
...composition.text,
|
|
213
|
+
stepsChip: countLabel(phone.timeline.callouts.length, config.strings.stepsLabel, config.language),
|
|
214
|
+
secondsChip: countLabel(Math.round(phone.timeline.total), config.strings.secondsLabel, config.language),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return { html, timeline: phone.timeline };
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
log(` warning: the phone take does not fit video.json (${error.message}) — portrait uses the desktop camera`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const camera = portraitLayout(result.timeline);
|
|
224
|
+
return { html: renderComposition({ ...composition, layout: camera.layout, cursor: camera.cursor, zooms: camera.zooms }), timeline: result.timeline };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The square composition (see `build`), or null without a square take (or one that no longer
|
|
228
|
+
* fits video.json).
|
|
229
|
+
*/
|
|
230
|
+
function renderSquare(demoDir, config, spec, composition, result, assets, log) {
|
|
231
|
+
const take = resolve(demoDir, 'recording.square.mp4');
|
|
232
|
+
const takeMarkers = resolve(demoDir, 'markers.square.json');
|
|
233
|
+
if (!existsSync(take) || !existsSync(takeMarkers)) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
// The same video.json on the square take: callouts follow their markers. Zooms are
|
|
238
|
+
// anchored by click number, so they carry over only while the clicks match.
|
|
239
|
+
const markers = JSON.parse(readFileSync(takeMarkers, 'utf8'));
|
|
240
|
+
const clicks = markers.clicks?.length ?? 0;
|
|
241
|
+
const desktopClicks = result.markers.clicks?.length ?? 0;
|
|
242
|
+
const sameClicks = clicks === desktopClicks;
|
|
243
|
+
if (!sameClicks && spec.zooms?.length) {
|
|
244
|
+
log(` warning: the square take has ${clicks} click(s), the desktop take ${desktopClicks} — the square video leaves out the zooms`);
|
|
245
|
+
}
|
|
246
|
+
const plan = planSpec(demoDir, sameClicks ? spec : { ...spec, zooms: [] }, markers, config);
|
|
247
|
+
copyIfChanged(take, resolve(assets, 'recording.square.mp4'));
|
|
248
|
+
const narration = extractNarration(take, resolve(assets, 'narration.square.m4a'), plan.timeline, () => { });
|
|
249
|
+
const voice = voiceTrack(demoDir, config, spec, plan.timeline, resolve(assets, 'voice.square.m4a'), () => { });
|
|
250
|
+
const music = renderMusicBed(spec, config, plan.timeline, resolve(assets, 'music.square.m4a'), narration || voice, () => { });
|
|
251
|
+
const layout = squareLayout(plan.timeline);
|
|
252
|
+
log(` square: the square take (${markers.viewport.width}x${markers.viewport.height}), ${plan.timeline.total}s`);
|
|
253
|
+
const html = renderComposition({
|
|
254
|
+
...composition,
|
|
255
|
+
timeline: plan.timeline,
|
|
256
|
+
layout: layout.layout,
|
|
257
|
+
cursor: layout.cursor,
|
|
258
|
+
zooms: plan.zooms,
|
|
259
|
+
narration,
|
|
260
|
+
music,
|
|
261
|
+
voice,
|
|
262
|
+
media: {
|
|
263
|
+
recording: 'assets/recording.square.mp4',
|
|
264
|
+
narration: 'assets/narration.square.m4a',
|
|
265
|
+
music: 'assets/music.square.m4a',
|
|
266
|
+
voice: 'assets/voice.square.m4a',
|
|
267
|
+
},
|
|
268
|
+
text: {
|
|
269
|
+
...composition.text,
|
|
270
|
+
stepsChip: countLabel(plan.timeline.callouts.length, config.strings.stepsLabel, config.language),
|
|
271
|
+
secondsChip: countLabel(Math.round(plan.timeline.total), config.strings.secondsLabel, config.language),
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
return { html, timeline: plan.timeline };
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
log(` warning: the square take does not fit video.json (${error.message}) — re-record it: \`reelson record ${basename(demoDir)} --square\``);
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** The voice-over track for one version (see voice.ts); false when the video has none. */
|
|
282
|
+
function voiceTrack(demoDir, config, spec, t, target, log) {
|
|
283
|
+
const settings = voiceSettings(spec, config);
|
|
284
|
+
if (!settings) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
return renderVoiceTrack(voiceLines(t, spec, settings), settings, resolve(demoDir, 'voice'), t.total, target, log);
|
|
288
|
+
}
|
|
289
|
+
function applyOptions(spec, o) {
|
|
290
|
+
if (o.title !== undefined)
|
|
291
|
+
spec.title = o.title;
|
|
292
|
+
if (o.subtitle !== undefined)
|
|
293
|
+
spec.subtitle = o.subtitle;
|
|
294
|
+
if (o.template !== undefined)
|
|
295
|
+
spec.template = o.template;
|
|
296
|
+
if (o.sections && Object.keys(o.sections).length)
|
|
297
|
+
spec.sections = { ...spec.sections, ...o.sections };
|
|
298
|
+
if (o.trimStart !== undefined)
|
|
299
|
+
spec.trim = { ...spec.trim, start: o.trimStart };
|
|
300
|
+
if (o.trimEnd !== undefined)
|
|
301
|
+
spec.trim = { ...spec.trim, end: o.trimEnd };
|
|
302
|
+
if (o.music !== undefined)
|
|
303
|
+
spec.music = o.music;
|
|
304
|
+
}
|
|
305
|
+
function humanize(slug) {
|
|
306
|
+
const words = slug.replace(/[-_]+/g, ' ').trim();
|
|
307
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
308
|
+
}
|
|
309
|
+
/** video.json as the build writes it: `$schema` first, then a stable, readable key order. */
|
|
310
|
+
export function serializeVideoSpec(spec, demoDir, config) {
|
|
311
|
+
return JSON.stringify({ $schema: videoSchemaRef(demoDir, config), ...withoutSchema(spec) }, null, 4) + '\n';
|
|
312
|
+
}
|
|
313
|
+
/** video.json in a stable, readable key order, without `$schema` (re-added on write). */
|
|
314
|
+
function withoutSchema(spec) {
|
|
315
|
+
const { $schema: _ignored, ...rest } = spec;
|
|
316
|
+
const order = ['title', 'subtitle', 'template', 'sections', 'recapTitle', 'brand', 'trim', 'music', 'callouts', 'zooms', 'cursor', 'voice', 'portrait', 'formats'];
|
|
317
|
+
const known = order.filter((k) => rest[k] !== undefined).map((k) => [k, rest[k]]);
|
|
318
|
+
const others = Object.entries(rest).filter(([k]) => !order.includes(k));
|
|
319
|
+
return Object.fromEntries([...known, ...others]);
|
|
320
|
+
}
|
|
321
|
+
const LOGO_TYPES = ['.svg', '.png', '.webp'];
|
|
322
|
+
/**
|
|
323
|
+
* brand.logo (video.json over demo.config.json) → assets/brand-logo.<ext>. Returns its
|
|
324
|
+
* path for the composition, or '' when the brand is drawn as a text wordmark.
|
|
325
|
+
*/
|
|
326
|
+
function copyLogo(spec, config, assets, log) {
|
|
327
|
+
const file = spec.brand?.logo !== undefined ? spec.brand.logo : config.brand.logo;
|
|
328
|
+
if (!file) {
|
|
329
|
+
return '';
|
|
330
|
+
}
|
|
331
|
+
const source = fromRoot(config, file);
|
|
332
|
+
const ext = extname(source).toLowerCase();
|
|
333
|
+
if (!LOGO_TYPES.includes(ext)) {
|
|
334
|
+
throw new ReelsonError(`brand.logo must be ${LOGO_TYPES.join(', ')} (SVG stays sharpest), got ${source}`);
|
|
335
|
+
}
|
|
336
|
+
if (!existsSync(source)) {
|
|
337
|
+
throw new ReelsonError(`brand logo not found: ${source} — fix brand.logo, or set it to null for the text wordmark`);
|
|
338
|
+
}
|
|
339
|
+
const height = ext === '.png' ? pngHeight(source) : null;
|
|
340
|
+
if (height !== null && height < MIN_LOGO_HEIGHT) {
|
|
341
|
+
log(` warning: ${basename(source)} is ${height}px tall — under ${MIN_LOGO_HEIGHT}px it looks soft on the cards; use an SVG or a taller PNG`);
|
|
342
|
+
}
|
|
343
|
+
const name = `brand-logo${ext}`;
|
|
344
|
+
copyIfChanged(source, resolve(assets, name));
|
|
345
|
+
return `assets/${name}`;
|
|
346
|
+
}
|
|
347
|
+
/** The biggest wordmark (168px tall on the outro) at 2x, so the logo stays crisp. */
|
|
348
|
+
const MIN_LOGO_HEIGHT = 340;
|
|
349
|
+
function pngHeight(path) {
|
|
350
|
+
const header = readFileSync(path).subarray(0, 24);
|
|
351
|
+
return header.toString('ascii', 12, 16) === 'IHDR' ? header.readUInt32BE(20) : null;
|
|
352
|
+
}
|
|
353
|
+
function copyIfChanged(from, to) {
|
|
354
|
+
if (existsSync(to)) {
|
|
355
|
+
const a = statSync(from);
|
|
356
|
+
const b = statSync(to);
|
|
357
|
+
if (a.size === b.size && b.mtimeMs >= a.mtimeMs) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
copyFileSync(from, to);
|
|
362
|
+
}
|
|
363
|
+
/** Extracts the recording's audio track (OpenScreen voiceover), if any. */
|
|
364
|
+
function extractNarration(source, target, t, log) {
|
|
365
|
+
const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=codec_name', '-of', 'csv=p=0', source], { encoding: 'utf8' });
|
|
366
|
+
if (probe.status !== 0 || probe.stdout.trim() === '') {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
if (t.transitions.length) {
|
|
370
|
+
log(' warning: narration is not split at hand-offs; it drifts after the first transition card');
|
|
371
|
+
}
|
|
372
|
+
if (existsSync(target) && statSync(target).mtimeMs >= statSync(source).mtimeMs) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
const extract = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', source, '-vn', '-c:a', 'aac', '-b:a', '160k', target], {
|
|
376
|
+
stdio: 'inherit',
|
|
377
|
+
});
|
|
378
|
+
if (extract.status !== 0) {
|
|
379
|
+
log(' warning: ffmpeg could not extract the audio track; continuing without narration');
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
log(' audio track found — narration extracted to assets/narration.m4a');
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Pre-renders the music bed with ffmpeg (trim, loudnorm, fades) so the
|
|
387
|
+
* composition plays it at unity. Cached: re-rendered only when the source,
|
|
388
|
+
* length or loudness changes.
|
|
389
|
+
*/
|
|
390
|
+
function renderMusicBed(spec, config, t, target, underNarration, log) {
|
|
391
|
+
const choice = spec.music ?? null;
|
|
392
|
+
if (choice === false) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
const file = typeof choice === 'string' ? choice : config.music.file;
|
|
396
|
+
if (!file) {
|
|
397
|
+
log(' no music: set music.file in demo.config.json (or "music" in video.json)');
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
const source = fromRoot(config, file);
|
|
401
|
+
if (!existsSync(source)) {
|
|
402
|
+
throw new ReelsonError(`music not found: ${source} — fix music.file / video.json "music", or set "music": false`);
|
|
403
|
+
}
|
|
404
|
+
const lufs = underNarration ? config.music.lufsUnderNarration : config.music.lufs;
|
|
405
|
+
const fadeOut = 3;
|
|
406
|
+
const key = JSON.stringify({ source, mtime: statSync(source).mtimeMs, total: t.total, lufs });
|
|
407
|
+
const stamp = `${target}.key`;
|
|
408
|
+
if (existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
const filters = [
|
|
412
|
+
`atrim=0:${t.total}`,
|
|
413
|
+
`loudnorm=I=${lufs}:TP=-3:LRA=9`,
|
|
414
|
+
'afade=t=in:st=0:d=0.8',
|
|
415
|
+
`afade=t=out:st=${round(t.total - fadeOut)}:d=${fadeOut}`,
|
|
416
|
+
].join(',');
|
|
417
|
+
const render = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', source, '-vn', '-af', filters, '-c:a', 'aac', '-b:a', '160k', target], { stdio: 'inherit' });
|
|
418
|
+
if (render.status !== 0) {
|
|
419
|
+
throw new ReelsonError('ffmpeg could not render the music bed');
|
|
420
|
+
}
|
|
421
|
+
writeFileSync(stamp, key);
|
|
422
|
+
log(` music bed: ${basename(source)} → assets/music.m4a (${lufs} LUFS, fades 0.8s/${fadeOut}s)`);
|
|
423
|
+
return true;
|
|
424
|
+
}
|