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,532 @@
|
|
|
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, type LoadedConfig } from '../../reelson-record/scripts/config.ts'
|
|
22
|
+
import { renderComposition, type CompositionInput } from './composition.ts'
|
|
23
|
+
import { phoneLayout, portraitLayout, squareLayout } from './portrait.ts'
|
|
24
|
+
import { renderVoiceTrack, spokenLength, voiceLines, voiceSettings } from './voice.ts'
|
|
25
|
+
import { HYPERFRAMES_VERSION, RENDER_FLAGS } from './hyperframes.ts'
|
|
26
|
+
import {
|
|
27
|
+
readMarkers,
|
|
28
|
+
readSection,
|
|
29
|
+
readVideoSpec,
|
|
30
|
+
ReelsonError,
|
|
31
|
+
resolveDesign,
|
|
32
|
+
videoSchemaRef,
|
|
33
|
+
type Design,
|
|
34
|
+
type Section,
|
|
35
|
+
} from './project.ts'
|
|
36
|
+
import {
|
|
37
|
+
computeTimeline,
|
|
38
|
+
defaultCallouts,
|
|
39
|
+
round,
|
|
40
|
+
type Markers,
|
|
41
|
+
type SectionChoice,
|
|
42
|
+
type Timeline,
|
|
43
|
+
type TrimPoint,
|
|
44
|
+
type VideoSpec,
|
|
45
|
+
} from './timeline.ts'
|
|
46
|
+
import { compositionClicks, followPath, planZoom, ZoomError, type CompClick, type Zoom } from './zooms.ts'
|
|
47
|
+
|
|
48
|
+
export interface BuildOptions {
|
|
49
|
+
title?: string
|
|
50
|
+
subtitle?: string
|
|
51
|
+
trimStart?: TrimPoint
|
|
52
|
+
trimEnd?: TrimPoint
|
|
53
|
+
template?: string
|
|
54
|
+
/** Section per slot; stored in video.json `sections`. */
|
|
55
|
+
sections?: SectionChoice
|
|
56
|
+
/** A file for this video, or false for none; undefined keeps video.json / config. */
|
|
57
|
+
music?: string | false
|
|
58
|
+
log?: (line: string) => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Plan {
|
|
62
|
+
spec: VideoSpec
|
|
63
|
+
markers: Markers
|
|
64
|
+
design: Design
|
|
65
|
+
timeline: Timeline
|
|
66
|
+
clicks: CompClick[]
|
|
67
|
+
zooms: Zoom[]
|
|
68
|
+
warnings: string[]
|
|
69
|
+
/** video.json did not exist and was derived from markers.json. */
|
|
70
|
+
created: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Everything a build needs, computed without touching video/ (check uses it too). */
|
|
74
|
+
export function plan(demoDir: string, config: LoadedConfig, options: BuildOptions = {}): Plan {
|
|
75
|
+
const markers = readMarkers(demoDir)
|
|
76
|
+
const existing = readVideoSpec(demoDir)
|
|
77
|
+
const spec: VideoSpec = existing ?? {
|
|
78
|
+
title: options.title ?? humanize(basename(demoDir)),
|
|
79
|
+
// Follows the recording: a re-record never leaves a stale trim behind.
|
|
80
|
+
trim: { start: 'auto' },
|
|
81
|
+
callouts: defaultCallouts(markers),
|
|
82
|
+
zooms: [],
|
|
83
|
+
}
|
|
84
|
+
applyOptions(spec, options)
|
|
85
|
+
|
|
86
|
+
return { ...planSpec(demoDir, spec, markers, config), created: existing === null }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The plan for a given video.json (validated elsewhere), without reading it from disk. */
|
|
90
|
+
export function planSpec(demoDir: string, spec: VideoSpec, markers: Markers, config: LoadedConfig): Omit<Plan, 'created'> {
|
|
91
|
+
const design = resolveDesign(spec.template ?? config.template, [config.sections, spec.sections], config)
|
|
92
|
+
let computed
|
|
93
|
+
try {
|
|
94
|
+
// With a voice-over, the callouts wait for their (cached) lines to be said.
|
|
95
|
+
const voice = voiceSettings(spec, config)
|
|
96
|
+
computed = computeTimeline(markers, spec, design.timing, voice ? spokenLength(voice, resolve(demoDir, 'voice')) : undefined)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new ReelsonError(`${resolve(demoDir, 'video.json')}: ${(error as Error).message}`)
|
|
99
|
+
}
|
|
100
|
+
const { timeline, warnings } = computed
|
|
101
|
+
const clicks = compositionClicks(markers, timeline)
|
|
102
|
+
const zooms = (spec.zooms ?? []).map((z, i) => {
|
|
103
|
+
try {
|
|
104
|
+
const zoom = planZoom(z, clicks, timeline)
|
|
105
|
+
if (z.follow && timeline.cursor) {
|
|
106
|
+
zoom.path = followPath(zoom, timeline.cursor, timeline.viewport)
|
|
107
|
+
} else if (z.follow) {
|
|
108
|
+
warnings.push(`zoom ${i + 1}: "follow" needs the cursor log of a record.cursor "layer" recording — it stays on its focus`)
|
|
109
|
+
}
|
|
110
|
+
return zoom
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof ZoomError) {
|
|
113
|
+
throw new ReelsonError(`video.json zooms[${i}]: ${error.message}`)
|
|
114
|
+
}
|
|
115
|
+
throw error
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return { spec, markers, design, timeline, clicks, zooms, warnings }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** What `build` made: the plan, plus the timelines of the portrait and square versions (their takes' own timing). */
|
|
123
|
+
export interface Built extends Plan {
|
|
124
|
+
versions: { portrait: Timeline; square: Timeline | null }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function build(demoDir: string, config: LoadedConfig, options: BuildOptions = {}): Built {
|
|
128
|
+
const log = options.log ?? console.log
|
|
129
|
+
const recording = resolve(demoDir, 'recording.mp4')
|
|
130
|
+
if (!existsSync(recording)) {
|
|
131
|
+
throw new ReelsonError(`missing ${recording} — run \`reelson record\` first (or export one from OpenScreen)`)
|
|
132
|
+
}
|
|
133
|
+
const result = plan(demoDir, config, options)
|
|
134
|
+
const { spec, timeline, design } = result
|
|
135
|
+
const { template } = design
|
|
136
|
+
|
|
137
|
+
// video.json first: it is the source of truth even if a later step fails.
|
|
138
|
+
const specPath = resolve(demoDir, 'video.json')
|
|
139
|
+
const serialized = serializeVideoSpec(spec, demoDir, config)
|
|
140
|
+
if (!existsSync(specPath) || readFileSync(specPath, 'utf8') !== serialized) {
|
|
141
|
+
writeFileSync(specPath, serialized)
|
|
142
|
+
log(result.created ? `created ${specPath} — word the callouts and add zooms there` : `updated ${specPath}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const videoDir = resolve(demoDir, 'video')
|
|
146
|
+
const assets = resolve(videoDir, 'assets')
|
|
147
|
+
mkdirSync(assets, { recursive: true })
|
|
148
|
+
if (existsSync(resolve(template.dir, 'assets'))) {
|
|
149
|
+
cpSync(resolve(template.dir, 'assets'), assets, { recursive: true, preserveTimestamps: true })
|
|
150
|
+
}
|
|
151
|
+
const sections = Object.values(design.sections)
|
|
152
|
+
.filter((section): section is Section => section !== null)
|
|
153
|
+
.map((section) => {
|
|
154
|
+
const source = readSection(section)
|
|
155
|
+
if (source.assets) {
|
|
156
|
+
cpSync(resolve(section.dir, 'assets'), resolve(videoDir, source.assets), { recursive: true, preserveTimestamps: true })
|
|
157
|
+
}
|
|
158
|
+
return source
|
|
159
|
+
})
|
|
160
|
+
const logo = copyLogo(spec, config, assets, log)
|
|
161
|
+
copyIfChanged(recording, resolve(assets, 'recording.mp4'))
|
|
162
|
+
const narration = extractNarration(recording, resolve(assets, 'narration.m4a'), timeline, log)
|
|
163
|
+
const voice = voiceTrack(demoDir, config, spec, timeline, resolve(assets, 'voice.m4a'), log)
|
|
164
|
+
const music = renderMusicBed(spec, config, timeline, resolve(assets, 'music.m4a'), narration || voice, log)
|
|
165
|
+
|
|
166
|
+
const brand = { ...config.brand, ...spec.brand }
|
|
167
|
+
const composition = {
|
|
168
|
+
stage: readFileSync(resolve(template.dir, 'stage.html'), 'utf8'),
|
|
169
|
+
sections,
|
|
170
|
+
timeline,
|
|
171
|
+
zooms: result.zooms,
|
|
172
|
+
narration,
|
|
173
|
+
music,
|
|
174
|
+
voice,
|
|
175
|
+
text: {
|
|
176
|
+
language: config.language,
|
|
177
|
+
brand,
|
|
178
|
+
logo,
|
|
179
|
+
title: spec.title,
|
|
180
|
+
subtitle: spec.subtitle ?? '',
|
|
181
|
+
recapTitle: spec.recapTitle ?? config.strings.recapTitle,
|
|
182
|
+
stepsChip: countLabel(timeline.callouts.length, config.strings.stepsLabel, config.language),
|
|
183
|
+
secondsChip: countLabel(Math.round(timeline.total), config.strings.secondsLabel, config.language),
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
const html = renderComposition(composition)
|
|
187
|
+
writeFileSync(resolve(videoDir, 'index.html'), html)
|
|
188
|
+
// The same video for phones (`reelson render --portrait`): from the phone take when there
|
|
189
|
+
// is one (video.json "portrait"), else a camera framing each element on this recording.
|
|
190
|
+
const portrait = renderPortrait(demoDir, config, spec, composition, result, assets, log)
|
|
191
|
+
writeFileSync(resolve(videoDir, 'portrait.html'), portrait.html)
|
|
192
|
+
// The square video (`reelson render --square`): only from a square take, which fills it.
|
|
193
|
+
const square = renderSquare(demoDir, config, spec, composition, result, assets, log)
|
|
194
|
+
if (square) {
|
|
195
|
+
writeFileSync(resolve(videoDir, 'square.html'), square.html)
|
|
196
|
+
} else {
|
|
197
|
+
rmSync(resolve(videoDir, 'square.html'), { force: true })
|
|
198
|
+
}
|
|
199
|
+
writeFileSync(
|
|
200
|
+
resolve(videoDir, 'hyperframes.json'),
|
|
201
|
+
JSON.stringify(
|
|
202
|
+
{
|
|
203
|
+
$schema: 'https://hyperframes.heygen.com/schema/hyperframes.json',
|
|
204
|
+
paths: { blocks: 'compositions', components: 'compositions/components', assets: 'assets' },
|
|
205
|
+
// autoProxy swaps big media for a downscaled proxy, which throws away the 2x capture.
|
|
206
|
+
media: { autoProxy: false },
|
|
207
|
+
},
|
|
208
|
+
null,
|
|
209
|
+
2,
|
|
210
|
+
) + '\n',
|
|
211
|
+
)
|
|
212
|
+
const hf = `npx --yes hyperframes@${HYPERFRAMES_VERSION}`
|
|
213
|
+
writeFileSync(
|
|
214
|
+
resolve(videoDir, 'package.json'),
|
|
215
|
+
JSON.stringify(
|
|
216
|
+
{
|
|
217
|
+
name: `reelson-video-${basename(demoDir)}`,
|
|
218
|
+
private: true,
|
|
219
|
+
type: 'module',
|
|
220
|
+
scripts: { dev: `${hf} preview`, check: `${hf} check`, render: `${hf} render ${RENDER_FLAGS.join(' ')}` },
|
|
221
|
+
},
|
|
222
|
+
null,
|
|
223
|
+
2,
|
|
224
|
+
) + '\n',
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
const t = timeline
|
|
228
|
+
const { intro, recap, outro } = design.sections
|
|
229
|
+
log(`built ${resolve(videoDir, 'index.html')} (template '${template.name}')`)
|
|
230
|
+
log(
|
|
231
|
+
` timeline: intro/${intro.name} 0–${t.intro.duration}s | recording ${t.clipStart}–${t.clipEnd}s (media ${t.mediaStart}–${t.mediaEnd}s) | ` +
|
|
232
|
+
(recap && t.recap ? `recap/${recap.name} ${t.recap.start}–${round(t.recap.start + t.recap.duration)}s | ` : 'no recap | ') +
|
|
233
|
+
`outro/${outro.name} ${t.outro.start}–${t.total}s`,
|
|
234
|
+
)
|
|
235
|
+
if (t.transitions.length) {
|
|
236
|
+
log(` hand-off card(s) at ${t.transitions.map((tr) => `${tr.at}s`).join(', ')}`)
|
|
237
|
+
}
|
|
238
|
+
log(` frame ${t.frame.width}x${t.frame.height}, ${t.callouts.length} callout(s), ${result.zooms.length} zoom(s)`)
|
|
239
|
+
for (const w of result.warnings) {
|
|
240
|
+
log(` warning: ${w}`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { ...result, versions: { portrait: portrait.timeline, square: square?.timeline ?? null } }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The portrait composition (see `build`). */
|
|
247
|
+
function renderPortrait(
|
|
248
|
+
demoDir: string,
|
|
249
|
+
config: LoadedConfig,
|
|
250
|
+
spec: VideoSpec,
|
|
251
|
+
composition: CompositionInput,
|
|
252
|
+
result: Omit<Plan, 'created'>,
|
|
253
|
+
assets: string,
|
|
254
|
+
log: (line: string) => void,
|
|
255
|
+
): { html: string; timeline: Timeline } {
|
|
256
|
+
const source = spec.portrait ?? 'auto'
|
|
257
|
+
const phoneTake = resolve(demoDir, 'recording.mobile.mp4')
|
|
258
|
+
const phoneMarkers = resolve(demoDir, 'markers.mobile.json')
|
|
259
|
+
const hasPhone = existsSync(phoneTake) && existsSync(phoneMarkers)
|
|
260
|
+
if (source === 'mobile' && !hasPhone) {
|
|
261
|
+
log(' warning: video.json "portrait": "mobile", but there is no phone take — run `reelson record <slug> --mobile`; using the desktop camera')
|
|
262
|
+
}
|
|
263
|
+
if (source !== 'desktop' && hasPhone) {
|
|
264
|
+
try {
|
|
265
|
+
// The same video.json on the phone take: callouts follow their markers; zooms are
|
|
266
|
+
// numbered by the desktop clicks, so they don't apply.
|
|
267
|
+
const markers = JSON.parse(readFileSync(phoneMarkers, 'utf8')) as Markers
|
|
268
|
+
const phone = planSpec(demoDir, { ...spec, zooms: [] }, markers, config)
|
|
269
|
+
copyIfChanged(phoneTake, resolve(assets, 'recording.mobile.mp4'))
|
|
270
|
+
const narration = extractNarration(phoneTake, resolve(assets, 'narration.mobile.m4a'), phone.timeline, () => {})
|
|
271
|
+
const voice = voiceTrack(demoDir, config, spec, phone.timeline, resolve(assets, 'voice.mobile.m4a'), () => {})
|
|
272
|
+
const music = renderMusicBed(spec, config, phone.timeline, resolve(assets, 'music.mobile.m4a'), narration || voice, () => {})
|
|
273
|
+
const layout = phoneLayout(phone.timeline)
|
|
274
|
+
log(` portrait: the phone take (${markers.viewport.width}x${markers.viewport.height}), ${phone.timeline.total}s`)
|
|
275
|
+
const html = renderComposition({
|
|
276
|
+
...composition,
|
|
277
|
+
timeline: phone.timeline,
|
|
278
|
+
layout: layout.layout,
|
|
279
|
+
cursor: layout.cursor,
|
|
280
|
+
zooms: [],
|
|
281
|
+
narration,
|
|
282
|
+
music,
|
|
283
|
+
voice,
|
|
284
|
+
media: {
|
|
285
|
+
recording: 'assets/recording.mobile.mp4',
|
|
286
|
+
narration: 'assets/narration.mobile.m4a',
|
|
287
|
+
music: 'assets/music.mobile.m4a',
|
|
288
|
+
voice: 'assets/voice.mobile.m4a',
|
|
289
|
+
},
|
|
290
|
+
text: {
|
|
291
|
+
...composition.text,
|
|
292
|
+
stepsChip: countLabel(phone.timeline.callouts.length, config.strings.stepsLabel, config.language),
|
|
293
|
+
secondsChip: countLabel(Math.round(phone.timeline.total), config.strings.secondsLabel, config.language),
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
return { html, timeline: phone.timeline }
|
|
297
|
+
} catch (error) {
|
|
298
|
+
log(` warning: the phone take does not fit video.json (${(error as Error).message}) — portrait uses the desktop camera`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const camera = portraitLayout(result.timeline)
|
|
302
|
+
return { html: renderComposition({ ...composition, layout: camera.layout, cursor: camera.cursor, zooms: camera.zooms }), timeline: result.timeline }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The square composition (see `build`), or null without a square take (or one that no longer
|
|
307
|
+
* fits video.json).
|
|
308
|
+
*/
|
|
309
|
+
function renderSquare(
|
|
310
|
+
demoDir: string,
|
|
311
|
+
config: LoadedConfig,
|
|
312
|
+
spec: VideoSpec,
|
|
313
|
+
composition: CompositionInput,
|
|
314
|
+
result: Omit<Plan, 'created'>,
|
|
315
|
+
assets: string,
|
|
316
|
+
log: (line: string) => void,
|
|
317
|
+
): { html: string; timeline: Timeline } | null {
|
|
318
|
+
const take = resolve(demoDir, 'recording.square.mp4')
|
|
319
|
+
const takeMarkers = resolve(demoDir, 'markers.square.json')
|
|
320
|
+
if (!existsSync(take) || !existsSync(takeMarkers)) {
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
// The same video.json on the square take: callouts follow their markers. Zooms are
|
|
325
|
+
// anchored by click number, so they carry over only while the clicks match.
|
|
326
|
+
const markers = JSON.parse(readFileSync(takeMarkers, 'utf8')) as Markers
|
|
327
|
+
const clicks = markers.clicks?.length ?? 0
|
|
328
|
+
const desktopClicks = result.markers.clicks?.length ?? 0
|
|
329
|
+
const sameClicks = clicks === desktopClicks
|
|
330
|
+
if (!sameClicks && spec.zooms?.length) {
|
|
331
|
+
log(` warning: the square take has ${clicks} click(s), the desktop take ${desktopClicks} — the square video leaves out the zooms`)
|
|
332
|
+
}
|
|
333
|
+
const plan = planSpec(demoDir, sameClicks ? spec : { ...spec, zooms: [] }, markers, config)
|
|
334
|
+
copyIfChanged(take, resolve(assets, 'recording.square.mp4'))
|
|
335
|
+
const narration = extractNarration(take, resolve(assets, 'narration.square.m4a'), plan.timeline, () => {})
|
|
336
|
+
const voice = voiceTrack(demoDir, config, spec, plan.timeline, resolve(assets, 'voice.square.m4a'), () => {})
|
|
337
|
+
const music = renderMusicBed(spec, config, plan.timeline, resolve(assets, 'music.square.m4a'), narration || voice, () => {})
|
|
338
|
+
const layout = squareLayout(plan.timeline)
|
|
339
|
+
log(` square: the square take (${markers.viewport.width}x${markers.viewport.height}), ${plan.timeline.total}s`)
|
|
340
|
+
const html = renderComposition({
|
|
341
|
+
...composition,
|
|
342
|
+
timeline: plan.timeline,
|
|
343
|
+
layout: layout.layout,
|
|
344
|
+
cursor: layout.cursor,
|
|
345
|
+
zooms: plan.zooms,
|
|
346
|
+
narration,
|
|
347
|
+
music,
|
|
348
|
+
voice,
|
|
349
|
+
media: {
|
|
350
|
+
recording: 'assets/recording.square.mp4',
|
|
351
|
+
narration: 'assets/narration.square.m4a',
|
|
352
|
+
music: 'assets/music.square.m4a',
|
|
353
|
+
voice: 'assets/voice.square.m4a',
|
|
354
|
+
},
|
|
355
|
+
text: {
|
|
356
|
+
...composition.text,
|
|
357
|
+
stepsChip: countLabel(plan.timeline.callouts.length, config.strings.stepsLabel, config.language),
|
|
358
|
+
secondsChip: countLabel(Math.round(plan.timeline.total), config.strings.secondsLabel, config.language),
|
|
359
|
+
},
|
|
360
|
+
})
|
|
361
|
+
return { html, timeline: plan.timeline }
|
|
362
|
+
} catch (error) {
|
|
363
|
+
log(` warning: the square take does not fit video.json (${(error as Error).message}) — re-record it: \`reelson record ${basename(demoDir)} --square\``)
|
|
364
|
+
return null
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The voice-over track for one version (see voice.ts); false when the video has none. */
|
|
369
|
+
function voiceTrack(demoDir: string, config: LoadedConfig, spec: VideoSpec, t: Timeline, target: string, log: (l: string) => void): boolean {
|
|
370
|
+
const settings = voiceSettings(spec, config)
|
|
371
|
+
if (!settings) {
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
return renderVoiceTrack(voiceLines(t, spec, settings), settings, resolve(demoDir, 'voice'), t.total, target, log)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function applyOptions(spec: VideoSpec, o: BuildOptions): void {
|
|
378
|
+
if (o.title !== undefined) spec.title = o.title
|
|
379
|
+
if (o.subtitle !== undefined) spec.subtitle = o.subtitle
|
|
380
|
+
if (o.template !== undefined) spec.template = o.template
|
|
381
|
+
if (o.sections && Object.keys(o.sections).length) spec.sections = { ...spec.sections, ...o.sections }
|
|
382
|
+
if (o.trimStart !== undefined) spec.trim = { ...spec.trim, start: o.trimStart }
|
|
383
|
+
if (o.trimEnd !== undefined) spec.trim = { ...spec.trim, end: o.trimEnd }
|
|
384
|
+
if (o.music !== undefined) spec.music = o.music
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function humanize(slug: string): string {
|
|
388
|
+
const words = slug.replace(/[-_]+/g, ' ').trim()
|
|
389
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** video.json as the build writes it: `$schema` first, then a stable, readable key order. */
|
|
393
|
+
export function serializeVideoSpec(spec: VideoSpec, demoDir: string, config: LoadedConfig): string {
|
|
394
|
+
return JSON.stringify({ $schema: videoSchemaRef(demoDir, config), ...withoutSchema(spec) }, null, 4) + '\n'
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** video.json in a stable, readable key order, without `$schema` (re-added on write). */
|
|
398
|
+
function withoutSchema(spec: VideoSpec): VideoSpec {
|
|
399
|
+
const { $schema: _ignored, ...rest } = spec as VideoSpec & { $schema?: string }
|
|
400
|
+
const order: (keyof VideoSpec)[] = ['title', 'subtitle', 'template', 'sections', 'recapTitle', 'brand', 'trim', 'music', 'callouts', 'zooms', 'cursor', 'voice', 'portrait', 'formats']
|
|
401
|
+
const known = order.filter((k) => rest[k] !== undefined).map((k) => [k, rest[k]])
|
|
402
|
+
const others = Object.entries(rest).filter(([k]) => !order.includes(k as keyof VideoSpec))
|
|
403
|
+
|
|
404
|
+
return Object.fromEntries([...known, ...others]) as VideoSpec
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const LOGO_TYPES = ['.svg', '.png', '.webp']
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* brand.logo (video.json over demo.config.json) → assets/brand-logo.<ext>. Returns its
|
|
411
|
+
* path for the composition, or '' when the brand is drawn as a text wordmark.
|
|
412
|
+
*/
|
|
413
|
+
function copyLogo(spec: VideoSpec, config: LoadedConfig, assets: string, log: (l: string) => void): string {
|
|
414
|
+
const file = spec.brand?.logo !== undefined ? spec.brand.logo : config.brand.logo
|
|
415
|
+
if (!file) {
|
|
416
|
+
return ''
|
|
417
|
+
}
|
|
418
|
+
const source = fromRoot(config, file)
|
|
419
|
+
const ext = extname(source).toLowerCase()
|
|
420
|
+
if (!LOGO_TYPES.includes(ext)) {
|
|
421
|
+
throw new ReelsonError(`brand.logo must be ${LOGO_TYPES.join(', ')} (SVG stays sharpest), got ${source}`)
|
|
422
|
+
}
|
|
423
|
+
if (!existsSync(source)) {
|
|
424
|
+
throw new ReelsonError(`brand logo not found: ${source} — fix brand.logo, or set it to null for the text wordmark`)
|
|
425
|
+
}
|
|
426
|
+
const height = ext === '.png' ? pngHeight(source) : null
|
|
427
|
+
if (height !== null && height < MIN_LOGO_HEIGHT) {
|
|
428
|
+
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`)
|
|
429
|
+
}
|
|
430
|
+
const name = `brand-logo${ext}`
|
|
431
|
+
copyIfChanged(source, resolve(assets, name))
|
|
432
|
+
|
|
433
|
+
return `assets/${name}`
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The biggest wordmark (168px tall on the outro) at 2x, so the logo stays crisp. */
|
|
437
|
+
const MIN_LOGO_HEIGHT = 340
|
|
438
|
+
|
|
439
|
+
function pngHeight(path: string): number | null {
|
|
440
|
+
const header = readFileSync(path).subarray(0, 24)
|
|
441
|
+
return header.toString('ascii', 12, 16) === 'IHDR' ? header.readUInt32BE(20) : null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function copyIfChanged(from: string, to: string): void {
|
|
445
|
+
if (existsSync(to)) {
|
|
446
|
+
const a = statSync(from)
|
|
447
|
+
const b = statSync(to)
|
|
448
|
+
if (a.size === b.size && b.mtimeMs >= a.mtimeMs) {
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
copyFileSync(from, to)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Extracts the recording's audio track (OpenScreen voiceover), if any. */
|
|
456
|
+
function extractNarration(source: string, target: string, t: Timeline, log: (l: string) => void): boolean {
|
|
457
|
+
const probe = spawnSync(
|
|
458
|
+
'ffprobe',
|
|
459
|
+
['-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=codec_name', '-of', 'csv=p=0', source],
|
|
460
|
+
{ encoding: 'utf8' },
|
|
461
|
+
)
|
|
462
|
+
if (probe.status !== 0 || probe.stdout.trim() === '') {
|
|
463
|
+
return false
|
|
464
|
+
}
|
|
465
|
+
if (t.transitions.length) {
|
|
466
|
+
log(' warning: narration is not split at hand-offs; it drifts after the first transition card')
|
|
467
|
+
}
|
|
468
|
+
if (existsSync(target) && statSync(target).mtimeMs >= statSync(source).mtimeMs) {
|
|
469
|
+
return true
|
|
470
|
+
}
|
|
471
|
+
const extract = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', source, '-vn', '-c:a', 'aac', '-b:a', '160k', target], {
|
|
472
|
+
stdio: 'inherit',
|
|
473
|
+
})
|
|
474
|
+
if (extract.status !== 0) {
|
|
475
|
+
log(' warning: ffmpeg could not extract the audio track; continuing without narration')
|
|
476
|
+
return false
|
|
477
|
+
}
|
|
478
|
+
log(' audio track found — narration extracted to assets/narration.m4a')
|
|
479
|
+
return true
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Pre-renders the music bed with ffmpeg (trim, loudnorm, fades) so the
|
|
484
|
+
* composition plays it at unity. Cached: re-rendered only when the source,
|
|
485
|
+
* length or loudness changes.
|
|
486
|
+
*/
|
|
487
|
+
function renderMusicBed(
|
|
488
|
+
spec: VideoSpec,
|
|
489
|
+
config: LoadedConfig,
|
|
490
|
+
t: Timeline,
|
|
491
|
+
target: string,
|
|
492
|
+
underNarration: boolean,
|
|
493
|
+
log: (l: string) => void,
|
|
494
|
+
): boolean {
|
|
495
|
+
const choice = spec.music ?? null
|
|
496
|
+
if (choice === false) {
|
|
497
|
+
return false
|
|
498
|
+
}
|
|
499
|
+
const file = typeof choice === 'string' ? choice : config.music.file
|
|
500
|
+
if (!file) {
|
|
501
|
+
log(' no music: set music.file in demo.config.json (or "music" in video.json)')
|
|
502
|
+
return false
|
|
503
|
+
}
|
|
504
|
+
const source = fromRoot(config, file)
|
|
505
|
+
if (!existsSync(source)) {
|
|
506
|
+
throw new ReelsonError(`music not found: ${source} — fix music.file / video.json "music", or set "music": false`)
|
|
507
|
+
}
|
|
508
|
+
const lufs = underNarration ? config.music.lufsUnderNarration : config.music.lufs
|
|
509
|
+
const fadeOut = 3
|
|
510
|
+
const key = JSON.stringify({ source, mtime: statSync(source).mtimeMs, total: t.total, lufs })
|
|
511
|
+
const stamp = `${target}.key`
|
|
512
|
+
if (existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
|
|
513
|
+
return true
|
|
514
|
+
}
|
|
515
|
+
const filters = [
|
|
516
|
+
`atrim=0:${t.total}`,
|
|
517
|
+
`loudnorm=I=${lufs}:TP=-3:LRA=9`,
|
|
518
|
+
'afade=t=in:st=0:d=0.8',
|
|
519
|
+
`afade=t=out:st=${round(t.total - fadeOut)}:d=${fadeOut}`,
|
|
520
|
+
].join(',')
|
|
521
|
+
const render = spawnSync(
|
|
522
|
+
'ffmpeg',
|
|
523
|
+
['-y', '-loglevel', 'error', '-i', source, '-vn', '-af', filters, '-c:a', 'aac', '-b:a', '160k', target],
|
|
524
|
+
{ stdio: 'inherit' },
|
|
525
|
+
)
|
|
526
|
+
if (render.status !== 0) {
|
|
527
|
+
throw new ReelsonError('ffmpeg could not render the music bed')
|
|
528
|
+
}
|
|
529
|
+
writeFileSync(stamp, key)
|
|
530
|
+
log(` music bed: ${basename(source)} → assets/music.m4a (${lufs} LUFS, fades 0.8s/${fadeOut}s)`)
|
|
531
|
+
return true
|
|
532
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function captionCues(timeline, title, subtitle) {
|
|
2
|
+
const intro = subtitle ? `${title}\n${subtitle}` : title;
|
|
3
|
+
return [
|
|
4
|
+
{ start: 0, end: timeline.intro.exit, text: intro },
|
|
5
|
+
...timeline.callouts.map((c) => ({ start: c.at, end: c.at + c.duration, text: c.text })),
|
|
6
|
+
].filter((c) => c.end > c.start && c.text.trim());
|
|
7
|
+
}
|
|
8
|
+
export function toSrt(cues) {
|
|
9
|
+
const time = (s) => clock(s, ',');
|
|
10
|
+
return cues.map((c, i) => `${i + 1}\n${time(c.start)} --> ${time(c.end)}\n${c.text}\n`).join('\n');
|
|
11
|
+
}
|
|
12
|
+
export function toVtt(cues) {
|
|
13
|
+
const time = (s) => clock(s, '.');
|
|
14
|
+
return `WEBVTT\n\n${cues.map((c) => `${time(c.start)} --> ${time(c.end)}\n${c.text}\n`).join('\n')}`;
|
|
15
|
+
}
|
|
16
|
+
/** 00:01:02,345 (SRT) / 00:01:02.345 (WebVTT). */
|
|
17
|
+
function clock(seconds, separator) {
|
|
18
|
+
const ms = Math.max(0, Math.round(seconds * 1000));
|
|
19
|
+
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
|
20
|
+
const h = Math.floor(ms / 3_600_000);
|
|
21
|
+
const m = Math.floor((ms % 3_600_000) / 60_000);
|
|
22
|
+
const s = Math.floor((ms % 60_000) / 1000);
|
|
23
|
+
return `${pad(h)}:${pad(m)}:${pad(s)}${separator}${pad(ms % 1000, 3)}`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captions for a rendered video: the title while the intro is on screen, then one cue per
|
|
3
|
+
* callout, in composition time — the same words the viewer reads on the cards, as a
|
|
4
|
+
* subtitle track for players, social uploads and screen readers. Pure.
|
|
5
|
+
*/
|
|
6
|
+
import type { Timeline } from './timeline.ts'
|
|
7
|
+
|
|
8
|
+
export interface Cue {
|
|
9
|
+
start: number
|
|
10
|
+
end: number
|
|
11
|
+
text: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function captionCues(timeline: Timeline, title: string, subtitle?: string): Cue[] {
|
|
15
|
+
const intro = subtitle ? `${title}\n${subtitle}` : title
|
|
16
|
+
return [
|
|
17
|
+
{ start: 0, end: timeline.intro.exit, text: intro },
|
|
18
|
+
...timeline.callouts.map((c) => ({ start: c.at, end: c.at + c.duration, text: c.text })),
|
|
19
|
+
].filter((c) => c.end > c.start && c.text.trim())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function toSrt(cues: Cue[]): string {
|
|
23
|
+
const time = (s: number) => clock(s, ',')
|
|
24
|
+
return cues.map((c, i) => `${i + 1}\n${time(c.start)} --> ${time(c.end)}\n${c.text}\n`).join('\n')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toVtt(cues: Cue[]): string {
|
|
28
|
+
const time = (s: number) => clock(s, '.')
|
|
29
|
+
return `WEBVTT\n\n${cues.map((c) => `${time(c.start)} --> ${time(c.end)}\n${c.text}\n`).join('\n')}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 00:01:02,345 (SRT) / 00:01:02.345 (WebVTT). */
|
|
33
|
+
function clock(seconds: number, separator: ',' | '.'): string {
|
|
34
|
+
const ms = Math.max(0, Math.round(seconds * 1000))
|
|
35
|
+
const pad = (n: number, width = 2) => String(n).padStart(width, '0')
|
|
36
|
+
const h = Math.floor(ms / 3_600_000)
|
|
37
|
+
const m = Math.floor((ms % 3_600_000) / 60_000)
|
|
38
|
+
const s = Math.floor((ms % 60_000) / 1000)
|
|
39
|
+
return `${pad(h)}:${pad(m)}:${pad(s)}${separator}${pad(ms % 1000, 3)}`
|
|
40
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks a demo before rendering:
|
|
3
|
+
* 1. demo.config.json and video.json match their schemas (typos are errors);
|
|
4
|
+
* 2. the timeline resolves (callouts point at real markers, trims make sense);
|
|
5
|
+
* 3. every zoom rides along with the cursor (style guide #13) — see zooms.ts;
|
|
6
|
+
* 4. `hyperframes check` on each built composition — landscape, portrait, square (lint,
|
|
7
|
+
* runtime, layout, contrast).
|
|
8
|
+
*
|
|
9
|
+
* reelson check <slug|dir> [--no-hyperframes]
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, statSync } from 'node:fs';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { plan } from "./build.js";
|
|
14
|
+
import { hyperframesOn } from "./hyperframes.js";
|
|
15
|
+
import { round } from "./timeline.js";
|
|
16
|
+
import { checkZoom, zoomOverlaps } from "./zooms.js";
|
|
17
|
+
/** Returns the number of problems found (0 = ready to render). */
|
|
18
|
+
export function check(demoDir, config, options = {}) {
|
|
19
|
+
const log = options.log ?? console.log;
|
|
20
|
+
const result = plan(demoDir, config);
|
|
21
|
+
let problems = 0;
|
|
22
|
+
if (result.created) {
|
|
23
|
+
log('✗ no video.json yet — run `reelson build` first');
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
for (const w of result.warnings) {
|
|
27
|
+
log(`! ${w}`);
|
|
28
|
+
}
|
|
29
|
+
for (const edge of ['start', 'end']) {
|
|
30
|
+
if (typeof result.spec.trim?.[edge] === 'number') {
|
|
31
|
+
log(`! trim.${edge} is a fixed time — after a re-record it cuts in the wrong place; ` +
|
|
32
|
+
(edge === 'start' ? 'use "auto" or ' : 'use ') +
|
|
33
|
+
'{ "marker": "…", "offset": … } / { "click": n, "offset": … } instead');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if ((result.spec.zooms ?? []).length && !result.clicks.length) {
|
|
37
|
+
log('✗ zooms need logged clicks — re-record with the current reelson-record scripts');
|
|
38
|
+
problems++;
|
|
39
|
+
}
|
|
40
|
+
const overlaps = zoomOverlaps(result.zooms);
|
|
41
|
+
result.zooms.forEach((z, i) => {
|
|
42
|
+
const { problems: checked, framed } = checkZoom(z, result.clicks, result.timeline);
|
|
43
|
+
const found = [...checked, ...overlaps.filter((o) => o.index === i).map((o) => o.message)];
|
|
44
|
+
const label = `zoom ${i + 1} (${z.at}–${round(z.at + z.duration)}s, in ${z.in}s / out ${z.out}s, ${z.scale}x at ${z.x},${z.y})`;
|
|
45
|
+
if (found.length) {
|
|
46
|
+
problems += found.length;
|
|
47
|
+
log(`✗ ${label}`);
|
|
48
|
+
found.forEach((p) => log(` ${p}`));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
log(`✓ ${label}: ${framed.map((c) => `${c.kind} #${c.index} ${c.comp}s`).join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
if (!result.zooms.length) {
|
|
55
|
+
log('✓ no zooms');
|
|
56
|
+
}
|
|
57
|
+
const videoDir = resolve(demoDir, 'video');
|
|
58
|
+
const index = resolve(videoDir, 'index.html');
|
|
59
|
+
if (!existsSync(index)) {
|
|
60
|
+
log('✗ video/ is not built — run `reelson build`');
|
|
61
|
+
return problems + 1;
|
|
62
|
+
}
|
|
63
|
+
if (statSync(resolve(demoDir, 'video.json')).mtimeMs > statSync(index).mtimeMs) {
|
|
64
|
+
log('! video.json changed since the last build — run `reelson build` (render does it for you)');
|
|
65
|
+
}
|
|
66
|
+
if (options.hyperframes !== false) {
|
|
67
|
+
for (const composition of ['index.html', 'portrait.html', 'square.html']) {
|
|
68
|
+
if (!existsSync(resolve(videoDir, composition))) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
log(`\n${composition}:`);
|
|
72
|
+
if (hyperframesOn(videoDir, composition, ['check', '.']) !== 0) {
|
|
73
|
+
log(`✗ hyperframes check failed (${composition})`);
|
|
74
|
+
problems++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
log(problems ? `\n${problems} problem(s)` : '\nready to render');
|
|
79
|
+
return problems;
|
|
80
|
+
}
|