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,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reelson studio <slug>`: a local preview and editor for a demo. The built
|
|
3
|
+
* composition plays in the HyperFrames player above a timeline drawn from the
|
|
4
|
+
* same plan the build uses (sections, callouts, zooms, hand-offs, clicks,
|
|
5
|
+
* markers, audio). video.json, markers.json, the recording and every
|
|
6
|
+
* template/section folder are watched: a change rebuilds and the page reloads
|
|
7
|
+
* in place, at the same time.
|
|
8
|
+
*
|
|
9
|
+
* Edits made on the page come back as a whole video.json (PUT /api/video): it
|
|
10
|
+
* is validated and planned before it is written, and refused if video.json
|
|
11
|
+
* changed on disk since the page loaded it.
|
|
12
|
+
*
|
|
13
|
+
* reelson studio <slug|dir> [--port 4800] [--no-open]
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { existsSync, readFileSync, statSync, watch, writeFileSync, type FSWatcher } from 'node:fs'
|
|
17
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
18
|
+
import { basename, extname, resolve, sep } from 'node:path'
|
|
19
|
+
import { fromRoot, type LoadedConfig } from '../../reelson-record/scripts/config.ts'
|
|
20
|
+
import { build, planSpec, serializeVideoSpec, type Plan } from './build.ts'
|
|
21
|
+
import { hyperframesDist } from './hyperframes.ts'
|
|
22
|
+
import { BUILTIN_SECTIONS, BUILTIN_TEMPLATES, catalog, readMarkers, ReelsonError, validateVideoSpec } from './project.ts'
|
|
23
|
+
import { defaultCallouts, round, SLOTS, type VideoSpec } from './timeline.ts'
|
|
24
|
+
import { checkZoom, zoomOverlaps } from './zooms.ts'
|
|
25
|
+
|
|
26
|
+
const STUDIO_DIR = resolve(import.meta.dirname, '../studio')
|
|
27
|
+
|
|
28
|
+
/** What the studio page draws: the plan, flattened to plain JSON in composition seconds. */
|
|
29
|
+
export interface StudioData {
|
|
30
|
+
slug: string
|
|
31
|
+
title: string
|
|
32
|
+
total: number
|
|
33
|
+
frame: { width: number; height: number }
|
|
34
|
+
template: string
|
|
35
|
+
/** video.json as on disk (without `$schema`): what the page edits. */
|
|
36
|
+
spec: VideoSpec
|
|
37
|
+
/** The trim window and the recording's length, in recording seconds. */
|
|
38
|
+
media: { start: number; end: number; duration: number }
|
|
39
|
+
/** What a trim edge can be tied to, in recording seconds (click: when its glide starts). */
|
|
40
|
+
anchors: { markers: { label: string; at: number }[]; clicks: { n: number; at: number }[] }
|
|
41
|
+
/** Composition ↔ recording time: one entry per stretch of footage between hand-offs. */
|
|
42
|
+
segments: { start: number; duration: number; mediaStart: number }[]
|
|
43
|
+
sections: { slot: 'intro' | 'recording' | 'recap' | 'outro'; name: string; start: number; end: number; detail: string }[]
|
|
44
|
+
/** `source`: index in `spec.callouts` (or in the default callouts when it has none). */
|
|
45
|
+
callouts: { n: number; source: number; at: number; end: number; text: string; group?: string }[]
|
|
46
|
+
zooms: { n: number; at: number; end: number; in: number; out: number; scale: number; x: number; y: number; problems: string[] }[]
|
|
47
|
+
handOffs: { at: number; end: number; belt: number; title: string; subtitle?: string; from?: string; to?: string }[]
|
|
48
|
+
clicks: { n: number; kind: 'click' | 'type'; glide: number; at: number; until: number }[]
|
|
49
|
+
markers: { label: string; at: number; recordingAt: number; used: boolean }[]
|
|
50
|
+
audio: { narration: { start: number; end: number } | null; music: { start: number; end: number } | null }
|
|
51
|
+
/**
|
|
52
|
+
* 'layer': drawn by the video (size, ripple and `presses` apply); 'hidden': logged but
|
|
53
|
+
* turned off in video.json; 'filmed': part of the footage, nothing to set.
|
|
54
|
+
*/
|
|
55
|
+
cursor: { state: 'layer' | 'hidden' | 'filmed'; size: number; ripple: boolean; presses: number[] }
|
|
56
|
+
warnings: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function studioData(slug: string, result: Plan, audio: { narration: boolean; music: boolean }): StudioData {
|
|
60
|
+
const { spec, markers, design, timeline: t, clicks, zooms, warnings } = result
|
|
61
|
+
const { intro, recap, outro } = design.sections
|
|
62
|
+
const sections: StudioData['sections'] = [
|
|
63
|
+
{ slot: 'intro', name: intro.name, start: 0, end: t.intro.duration, detail: `hands over at ${t.intro.exit}s` },
|
|
64
|
+
{
|
|
65
|
+
slot: 'recording',
|
|
66
|
+
name: 'recording',
|
|
67
|
+
start: t.clipStart,
|
|
68
|
+
end: t.clipEnd,
|
|
69
|
+
detail: `media ${t.mediaStart}–${t.mediaEnd}s of ${markers.durationSeconds}s`,
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
if (recap && t.recap) {
|
|
73
|
+
sections.push({
|
|
74
|
+
slot: 'recap',
|
|
75
|
+
name: recap.name,
|
|
76
|
+
start: t.recap.start,
|
|
77
|
+
end: round(t.recap.start + t.recap.duration),
|
|
78
|
+
detail: `holds ${t.recap.maxSteps} steps`,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
sections.push({ slot: 'outro', name: outro.name, start: t.outro.start, end: t.total, detail: '' })
|
|
82
|
+
|
|
83
|
+
// Without callouts in video.json the build makes one per marker, so all of them are used.
|
|
84
|
+
const overlaps = zoomOverlaps(zooms)
|
|
85
|
+
const used = new Set((spec.callouts ?? defaultCallouts(markers)).map((c) => c.marker).filter(Boolean))
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
slug,
|
|
89
|
+
title: spec.title,
|
|
90
|
+
total: t.total,
|
|
91
|
+
frame: t.frame,
|
|
92
|
+
template: design.template.name,
|
|
93
|
+
// The page edits callouts by index, so the default ones are spelled out.
|
|
94
|
+
spec: { ...withoutSchema(spec), callouts: spec.callouts ?? defaultCallouts(markers) },
|
|
95
|
+
media: { start: t.mediaStart, end: t.mediaEnd, duration: markers.durationSeconds },
|
|
96
|
+
anchors: {
|
|
97
|
+
markers: markers.markers.map((m) => ({ label: m.label, at: m.at })),
|
|
98
|
+
clicks: (markers.clicks ?? []).map((c, i) => ({ n: i + 1, at: c.move ?? c.at })),
|
|
99
|
+
},
|
|
100
|
+
segments: t.segments,
|
|
101
|
+
sections,
|
|
102
|
+
callouts: t.callouts.map((c, i) => ({
|
|
103
|
+
n: i + 1,
|
|
104
|
+
source: c.source,
|
|
105
|
+
at: c.at,
|
|
106
|
+
end: round(c.at + c.duration),
|
|
107
|
+
text: c.text,
|
|
108
|
+
...(c.group ? { group: c.group } : {}),
|
|
109
|
+
})),
|
|
110
|
+
zooms: zooms.map((z, i) => ({
|
|
111
|
+
n: i + 1,
|
|
112
|
+
at: z.at,
|
|
113
|
+
end: round(z.at + z.duration),
|
|
114
|
+
in: z.in,
|
|
115
|
+
out: z.out,
|
|
116
|
+
scale: z.scale,
|
|
117
|
+
x: z.x,
|
|
118
|
+
y: z.y,
|
|
119
|
+
problems: [...checkZoom(z, clicks, t).problems, ...overlaps.filter((o) => o.index === i).map((o) => o.message)],
|
|
120
|
+
})),
|
|
121
|
+
handOffs: t.transitions.map((tr) => ({
|
|
122
|
+
at: tr.at,
|
|
123
|
+
end: round(tr.at + tr.gap),
|
|
124
|
+
belt: t.belt,
|
|
125
|
+
title: tr.card.title,
|
|
126
|
+
...(tr.card.subtitle ? { subtitle: tr.card.subtitle } : {}),
|
|
127
|
+
...(tr.card.from ? { from: tr.card.from } : {}),
|
|
128
|
+
...(tr.card.to ? { to: tr.card.to } : {}),
|
|
129
|
+
})),
|
|
130
|
+
clicks: clicks.map((c) => ({ n: c.index, kind: c.kind, glide: c.glide, at: c.comp, until: c.until })),
|
|
131
|
+
markers: markers.markers
|
|
132
|
+
.filter((m) => m.at >= t.mediaStart && m.at <= t.mediaEnd)
|
|
133
|
+
.map((m) => ({ label: m.label, at: t.toComposition(m.at), recordingAt: m.at, used: used.has(m.label) })),
|
|
134
|
+
audio: {
|
|
135
|
+
narration: audio.narration ? { start: t.clipStart, end: t.clipEnd } : null,
|
|
136
|
+
music: audio.music ? { start: 0, end: t.total } : null,
|
|
137
|
+
},
|
|
138
|
+
cursor: t.cursor
|
|
139
|
+
? { state: 'layer', size: t.cursor.size, ripple: t.cursor.ripple, presses: t.cursor.presses.map(([at]) => at) }
|
|
140
|
+
: {
|
|
141
|
+
state: markers.cursor && !markers.cursor.drawn ? 'hidden' : 'filmed',
|
|
142
|
+
size: spec.cursor ? (spec.cursor.size ?? 44) : 44,
|
|
143
|
+
ripple: spec.cursor ? (spec.cursor.ripple ?? true) : true,
|
|
144
|
+
presses: [],
|
|
145
|
+
},
|
|
146
|
+
warnings,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A short hash of video.json as it is on disk ('' when there is none). */
|
|
151
|
+
export function specRevision(demoDir: string): string {
|
|
152
|
+
const path = resolve(demoDir, 'video.json')
|
|
153
|
+
return existsSync(path) ? createHash('sha1').update(readFileSync(path)).digest('hex').slice(0, 12) : ''
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The page's edit (PUT /api/video): refused (409) if video.json changed since the page read
|
|
158
|
+
* it (`base` is the revision it read), refused (422) if it fails the schema or the plan —
|
|
159
|
+
* nothing is written then — else written as the build would write it. The caller rebuilds.
|
|
160
|
+
*/
|
|
161
|
+
export function applyEdit(
|
|
162
|
+
demoDir: string,
|
|
163
|
+
config: LoadedConfig,
|
|
164
|
+
body: { base?: string; spec?: unknown },
|
|
165
|
+
): { status: number; body: { error?: string; warnings?: string[] }; written?: string } {
|
|
166
|
+
if (body.base !== specRevision(demoDir)) {
|
|
167
|
+
return { status: 409, body: { error: 'video.json changed on disk since the page loaded it — reloaded; redo the edit' } }
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const spec = validateVideoSpec(body.spec, 'the edit')
|
|
171
|
+
const { warnings } = planSpec(demoDir, spec, readMarkers(demoDir), config)
|
|
172
|
+
const written = serializeVideoSpec(spec, demoDir, config)
|
|
173
|
+
writeFileSync(resolve(demoDir, 'video.json'), written)
|
|
174
|
+
return { status: 200, body: { warnings }, written }
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return { status: 422, body: { error: (e as Error).message } }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function withoutSchema(spec: VideoSpec): VideoSpec {
|
|
181
|
+
const { $schema: _schema, ...rest } = spec as VideoSpec & { $schema?: string }
|
|
182
|
+
return rest
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface StudioOptions {
|
|
186
|
+
port?: number
|
|
187
|
+
log?: (line: string) => void
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Builds the demo, then serves the studio until the process exits. Resolves with its URL. */
|
|
191
|
+
export async function studio(demoDir: string, config: LoadedConfig, options: StudioOptions = {}): Promise<string> {
|
|
192
|
+
const log = options.log ?? console.log
|
|
193
|
+
const slug = basename(demoDir)
|
|
194
|
+
const videoDir = resolve(demoDir, 'video')
|
|
195
|
+
const dist = hyperframesDist()
|
|
196
|
+
|
|
197
|
+
let data: StudioData | null = null
|
|
198
|
+
let error: string | null = null
|
|
199
|
+
let version = 0
|
|
200
|
+
const clients = new Set<ServerResponse>()
|
|
201
|
+
const rebuild = (): void => {
|
|
202
|
+
try {
|
|
203
|
+
const result = build(demoDir, config, { log: () => {} })
|
|
204
|
+
const has = (file: string) => existsSync(resolve(videoDir, 'assets', file))
|
|
205
|
+
data = studioData(slug, result, { narration: has('narration.m4a'), music: has('music.m4a') })
|
|
206
|
+
error = null
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// Keep serving the last good build; the page shows the error until the next save fixes it.
|
|
209
|
+
error = (e as Error).message
|
|
210
|
+
}
|
|
211
|
+
version++
|
|
212
|
+
for (const client of clients) {
|
|
213
|
+
client.write(`data: ${JSON.stringify({ version, error })}\n\n`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
rebuild()
|
|
217
|
+
if (!data) {
|
|
218
|
+
throw new ReelsonError(error ?? 'build failed')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const specPath = resolve(demoDir, 'video.json')
|
|
222
|
+
const revision = (): string => specRevision(demoDir)
|
|
223
|
+
// What the page can pick from: re-read per request, so a new section shows up without a restart.
|
|
224
|
+
const choices = () => {
|
|
225
|
+
const found = catalog(config)
|
|
226
|
+
return {
|
|
227
|
+
templates: found.templates.map((t) => t.name),
|
|
228
|
+
...Object.fromEntries(SLOTS.map((slot) => [slot, found.sections[slot].map((s) => s.name)])),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** The page's edit: checked like a build would, then written and built. */
|
|
232
|
+
let written: string | null = null
|
|
233
|
+
const edit = (body: { base?: string; spec?: unknown }): { status: number; body: object } => {
|
|
234
|
+
const result = applyEdit(demoDir, config, body)
|
|
235
|
+
if (result.written === undefined) {
|
|
236
|
+
return result
|
|
237
|
+
}
|
|
238
|
+
written = result.written
|
|
239
|
+
rebuild()
|
|
240
|
+
// Written either way; a failed build shows on the page like any other.
|
|
241
|
+
return { status: 200, body: { ...result.body, error, revision: revision() } }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const server = createServer((req, res) => {
|
|
245
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
246
|
+
const path = decodeURIComponent(url.pathname)
|
|
247
|
+
if (path === '/') {
|
|
248
|
+
return sendFile(res, resolve(STUDIO_DIR, 'index.html'))
|
|
249
|
+
}
|
|
250
|
+
if (path.startsWith('/studio/')) {
|
|
251
|
+
const file = resolve(STUDIO_DIR, `.${path.slice('/studio'.length)}`)
|
|
252
|
+
if (file.startsWith(STUDIO_DIR + sep)) {
|
|
253
|
+
return sendFile(res, file)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (path === '/api/plan') {
|
|
257
|
+
return send(res, 200, 'application/json', JSON.stringify({ version, error, revision: revision(), choices: choices(), data }))
|
|
258
|
+
}
|
|
259
|
+
if (path === '/api/video' && req.method === 'PUT') {
|
|
260
|
+
readBody(req).then(
|
|
261
|
+
(raw) => {
|
|
262
|
+
let body
|
|
263
|
+
try {
|
|
264
|
+
body = JSON.parse(raw)
|
|
265
|
+
} catch {
|
|
266
|
+
return send(res, 400, 'application/json', JSON.stringify({ error: 'the edit is not JSON' }))
|
|
267
|
+
}
|
|
268
|
+
const result = edit(body)
|
|
269
|
+
send(res, result.status, 'application/json', JSON.stringify(result.body))
|
|
270
|
+
},
|
|
271
|
+
() => send(res, 400, 'application/json', JSON.stringify({ error: 'unreadable request' })),
|
|
272
|
+
)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (path === '/api/events') {
|
|
276
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
277
|
+
res.write(': connected\n\n')
|
|
278
|
+
clients.add(res)
|
|
279
|
+
req.on('close', () => clients.delete(res))
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (path === '/hf/player.js') {
|
|
283
|
+
return sendFile(res, resolve(dist, 'hyperframes-player.global.js'))
|
|
284
|
+
}
|
|
285
|
+
if (path === '/hf/runtime.js') {
|
|
286
|
+
return sendFile(res, resolve(dist, 'hyperframe.runtime.iife.js'))
|
|
287
|
+
}
|
|
288
|
+
if (path === '/video/index.html') {
|
|
289
|
+
// The runtime ships with the page instead of the player fetching it from a CDN.
|
|
290
|
+
const html = readFileSync(resolve(videoDir, 'index.html'), 'utf8').replace(
|
|
291
|
+
'</head>',
|
|
292
|
+
' <script src="/hf/runtime.js"></script>\n </head>',
|
|
293
|
+
)
|
|
294
|
+
return send(res, 200, 'text/html', html)
|
|
295
|
+
}
|
|
296
|
+
if (path.startsWith('/video/')) {
|
|
297
|
+
const file = resolve(videoDir, `.${path.slice('/video'.length)}`)
|
|
298
|
+
if (file.startsWith(videoDir + sep)) {
|
|
299
|
+
return sendFile(res, file, req.headers.range)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
send(res, 404, 'text/plain', 'not found')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// Rebuild on any input the build reads; editors write in bursts, so settle first.
|
|
306
|
+
let timer: NodeJS.Timeout | undefined
|
|
307
|
+
const schedule = (): void => {
|
|
308
|
+
clearTimeout(timer)
|
|
309
|
+
timer = setTimeout(() => {
|
|
310
|
+
rebuild()
|
|
311
|
+
log(error ? `rebuild failed: ${error}` : `rebuilt (${new Date().toLocaleTimeString()})`)
|
|
312
|
+
}, 250)
|
|
313
|
+
}
|
|
314
|
+
const inputs = new Set(['video.json', 'markers.json', 'recording.mp4'])
|
|
315
|
+
// The page's own writes are already built; only a change from elsewhere rebuilds.
|
|
316
|
+
const ownWrite = (file: string): boolean =>
|
|
317
|
+
file === 'video.json' && written !== null && existsSync(specPath) && readFileSync(specPath, 'utf8') === written
|
|
318
|
+
const watchers: FSWatcher[] = [
|
|
319
|
+
watch(demoDir, (_event, file) => file && inputs.has(file.toString()) && !ownWrite(file.toString()) && schedule()),
|
|
320
|
+
...[
|
|
321
|
+
BUILTIN_TEMPLATES,
|
|
322
|
+
BUILTIN_SECTIONS,
|
|
323
|
+
resolve(fromRoot(config, config.videosDir), '_templates'),
|
|
324
|
+
resolve(fromRoot(config, config.videosDir), '_sections'),
|
|
325
|
+
]
|
|
326
|
+
.filter((dir) => existsSync(dir))
|
|
327
|
+
.map((dir) => watch(dir, { recursive: true }, schedule)),
|
|
328
|
+
]
|
|
329
|
+
if (config.path) {
|
|
330
|
+
watchers.push(watch(config.path, schedule))
|
|
331
|
+
}
|
|
332
|
+
server.on('close', () => watchers.forEach((w) => w.close()))
|
|
333
|
+
|
|
334
|
+
const port = options.port ?? 4800
|
|
335
|
+
await new Promise<void>((done, fail) => {
|
|
336
|
+
server.once('error', (e: NodeJS.ErrnoException) =>
|
|
337
|
+
fail(e.code === 'EADDRINUSE' ? new ReelsonError(`port ${port} is in use — pass --port`) : e),
|
|
338
|
+
)
|
|
339
|
+
server.listen(port, '127.0.0.1', done)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
return `http://localhost:${port}/`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
346
|
+
return new Promise((done, fail) => {
|
|
347
|
+
const chunks: Buffer[] = []
|
|
348
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
349
|
+
req.on('end', () => done(Buffer.concat(chunks).toString('utf8')))
|
|
350
|
+
req.on('error', fail)
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const TYPES: Record<string, string> = {
|
|
355
|
+
'.html': 'text/html',
|
|
356
|
+
'.js': 'text/javascript',
|
|
357
|
+
'.css': 'text/css',
|
|
358
|
+
'.json': 'application/json',
|
|
359
|
+
'.svg': 'image/svg+xml',
|
|
360
|
+
'.png': 'image/png',
|
|
361
|
+
'.webp': 'image/webp',
|
|
362
|
+
'.woff2': 'font/woff2',
|
|
363
|
+
'.mp4': 'video/mp4',
|
|
364
|
+
'.m4a': 'audio/mp4',
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function send(res: ServerResponse, status: number, type: string, body: string | Buffer): void {
|
|
368
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' })
|
|
369
|
+
res.end(body)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A file, with byte ranges so the browser can seek video and audio. */
|
|
373
|
+
function sendFile(res: ServerResponse, file: string, range?: string): void {
|
|
374
|
+
if (!existsSync(file) || !statSync(file).isFile()) {
|
|
375
|
+
return send(res, 404, 'text/plain', 'not found')
|
|
376
|
+
}
|
|
377
|
+
const type = TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream'
|
|
378
|
+
const body = readFileSync(file)
|
|
379
|
+
const match = range?.match(/^bytes=(\d*)-(\d*)$/)
|
|
380
|
+
if (!match) {
|
|
381
|
+
res.writeHead(200, { 'content-type': type, 'content-length': body.length, 'accept-ranges': 'bytes', 'cache-control': 'no-store' })
|
|
382
|
+
return void res.end(body)
|
|
383
|
+
}
|
|
384
|
+
const start = match[1] ? Number(match[1]) : Math.max(0, body.length - Number(match[2]))
|
|
385
|
+
const end = match[1] && match[2] ? Math.min(Number(match[2]), body.length - 1) : body.length - 1
|
|
386
|
+
res.writeHead(206, {
|
|
387
|
+
'content-type': type,
|
|
388
|
+
'content-length': end - start + 1,
|
|
389
|
+
'content-range': `bytes ${start}-${end}/${body.length}`,
|
|
390
|
+
'accept-ranges': 'bytes',
|
|
391
|
+
'cache-control': 'no-store',
|
|
392
|
+
})
|
|
393
|
+
res.end(body.subarray(start, end + 1))
|
|
394
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composition timeline, computed from a recording's markers.json and the
|
|
3
|
+
* video's video.json. Pure: no files, no ffmpeg — so it is unit-tested.
|
|
4
|
+
*
|
|
5
|
+
* intro (0 → exit) → recording (callouts, zooms, hand-off cards)
|
|
6
|
+
* → recap (optional) → outro
|
|
7
|
+
*
|
|
8
|
+
* video.json times are recording times; everything this module returns is in
|
|
9
|
+
* composition seconds.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Timeline constants (seconds). The stage keys come from the template's
|
|
13
|
+
* template.json, the rest from the section chosen for each slot (section.json).
|
|
14
|
+
*/
|
|
15
|
+
export const STAGE_TIMING = {
|
|
16
|
+
overlap: 0.4, // recording → recap → outro cross-fades
|
|
17
|
+
calloutDuration: 3.0,
|
|
18
|
+
/** A step's callout stays this long after its marker (the result), next step permitting. */
|
|
19
|
+
calloutHold: 1.2,
|
|
20
|
+
/** The least a step's callout is up before the next step's may replace it. */
|
|
21
|
+
calloutMinimum: 1.8,
|
|
22
|
+
transitionGap: 2.6, // seconds a hand-off card holds between the two belts
|
|
23
|
+
belt: 0.9, // the recording's exit before a hand-off card (matches the stage)
|
|
24
|
+
maxW: 1600, // the framed recording's box inside the 1920x1080 stage
|
|
25
|
+
maxH: 940,
|
|
26
|
+
};
|
|
27
|
+
/** A spoken callout stays up this long after its line ends (a breath before the next step). */
|
|
28
|
+
const SPOKEN_TAIL = 0.3;
|
|
29
|
+
export const SECTION_TIMING = {
|
|
30
|
+
intro: {
|
|
31
|
+
duration: 4.0, // intro clip length
|
|
32
|
+
exit: 3.0, // the intro hands over and the recording starts (= clipStart)
|
|
33
|
+
},
|
|
34
|
+
recap: {
|
|
35
|
+
base: 2.4, // + perStep per step, capped at max
|
|
36
|
+
perStep: 0.45,
|
|
37
|
+
max: 7.5,
|
|
38
|
+
maxSteps: 10, // the recap's capacity
|
|
39
|
+
},
|
|
40
|
+
outro: {
|
|
41
|
+
duration: 2.6,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
export const SLOTS = Object.keys(SECTION_TIMING);
|
|
45
|
+
export const TIMING_DEFAULTS = { stage: STAGE_TIMING, ...SECTION_TIMING };
|
|
46
|
+
export class TimelineError extends Error {
|
|
47
|
+
}
|
|
48
|
+
export function computeTimeline(markers, spec, timing = TIMING_DEFAULTS,
|
|
49
|
+
/**
|
|
50
|
+
* Voice-over: seconds from a callout appearing until its spoken line is done (0: silent or
|
|
51
|
+
* not spoken yet). Such a callout stays up at least that long, and the next one waits.
|
|
52
|
+
*/
|
|
53
|
+
spoken = () => 0) {
|
|
54
|
+
const warnings = [];
|
|
55
|
+
const mediaStart = Math.max(0, resolveTrimPoint(spec.trim?.start, markers, 'start') ?? 0);
|
|
56
|
+
const mediaEnd = Math.min(resolveTrimPoint(spec.trim?.end, markers, 'end') ?? markers.durationSeconds, markers.durationSeconds);
|
|
57
|
+
const mediaDuration = round(mediaEnd - mediaStart);
|
|
58
|
+
if (mediaDuration <= 0) {
|
|
59
|
+
throw new TimelineError(`trim window is empty (start ${mediaStart}s, end ${mediaEnd}s)`);
|
|
60
|
+
}
|
|
61
|
+
const stage = timing.stage;
|
|
62
|
+
const clipStart = timing.intro.exit;
|
|
63
|
+
const handOffs = (markers.transitions ?? []).filter((t) => t.at > mediaStart && t.at < mediaEnd);
|
|
64
|
+
const gap = stage.transitionGap;
|
|
65
|
+
const clipDuration = round(mediaDuration + gap * handOffs.length);
|
|
66
|
+
const clipEnd = round(clipStart + clipDuration);
|
|
67
|
+
const toComposition = (t) => round(clipStart + (t - mediaStart) + gap * handOffs.filter((h) => h.at <= t).length);
|
|
68
|
+
const transitions = handOffs.map((card, i) => ({
|
|
69
|
+
at: round(clipStart + (card.at - mediaStart) + gap * i),
|
|
70
|
+
gap,
|
|
71
|
+
card,
|
|
72
|
+
}));
|
|
73
|
+
const bounds = [mediaStart, ...handOffs.map((t) => t.at), mediaEnd];
|
|
74
|
+
const segments = bounds.slice(0, -1).map((from, i) => ({
|
|
75
|
+
start: round(clipStart + (from - mediaStart) + gap * i),
|
|
76
|
+
duration: round(bounds[i + 1] - from),
|
|
77
|
+
mediaStart: round(from),
|
|
78
|
+
}));
|
|
79
|
+
// Callouts: resolve each to a recording time, keep the ones inside the window, in order.
|
|
80
|
+
const specs = spec.callouts ?? defaultCallouts(markers);
|
|
81
|
+
const unknown = specs.filter((c) => c.marker !== undefined && !markers.markers.some((m) => m.label === c.marker));
|
|
82
|
+
if (unknown.length) {
|
|
83
|
+
throw new TimelineError(`callout(s) reference unknown markers: ${unknown.map((c) => JSON.stringify(c.marker)).join(', ')} ` +
|
|
84
|
+
`— markers.json has ${markers.markers.map((m) => JSON.stringify(m.label)).join(', ') || 'none'}`);
|
|
85
|
+
}
|
|
86
|
+
const timed = specs
|
|
87
|
+
.map((c, source) => {
|
|
88
|
+
if ((c.marker === undefined) === (c.at === undefined)) {
|
|
89
|
+
throw new TimelineError(`callout "${c.text}" needs exactly one of \`marker\` or \`at\``);
|
|
90
|
+
}
|
|
91
|
+
if (c.offset !== undefined && c.marker === undefined) {
|
|
92
|
+
throw new TimelineError(`callout "${c.text}": \`offset\` shifts a \`marker\`; with \`at\`, change \`at\` instead`);
|
|
93
|
+
}
|
|
94
|
+
if (c.anchor !== undefined && c.marker === undefined) {
|
|
95
|
+
throw new TimelineError(`callout "${c.text}": \`anchor\` needs a \`marker\``);
|
|
96
|
+
}
|
|
97
|
+
if (c.at !== undefined) {
|
|
98
|
+
return { ...c, recordingAt: c.at, shownUntil: c.at, source };
|
|
99
|
+
}
|
|
100
|
+
const marker = markers.markers.find((m) => m.label === c.marker).at;
|
|
101
|
+
const start = c.anchor === 'marker' ? marker : stepStart(markers, marker, mediaStart);
|
|
102
|
+
return { ...c, recordingAt: round(start + (c.offset ?? 0)), shownUntil: marker, source };
|
|
103
|
+
})
|
|
104
|
+
.filter((c) => {
|
|
105
|
+
const inside = c.recordingAt >= mediaStart && c.recordingAt <= mediaEnd;
|
|
106
|
+
if (!inside) {
|
|
107
|
+
warnings.push(`callout "${c.text}" at ${c.recordingAt}s is outside the trim window — dropped`);
|
|
108
|
+
}
|
|
109
|
+
return inside;
|
|
110
|
+
})
|
|
111
|
+
.sort((a, b) => a.recordingAt - b.recordingAt);
|
|
112
|
+
const transitionTimes = transitions.map((t) => t.at);
|
|
113
|
+
// When each shows. A step that starts as the footage does waits for the recording to
|
|
114
|
+
// arrive; one that starts right after the previous (a step that was only a page load) waits
|
|
115
|
+
// until that one has been up long enough to read. An explicit `at` is kept as given.
|
|
116
|
+
// With a voice-over, that is until its line has been said (and never past the recording).
|
|
117
|
+
const speaking = timed.map((c) => spoken(c));
|
|
118
|
+
const minimum = (i) => Math.max(stage.calloutMinimum, speaking[i] ? speaking[i] + SPOKEN_TAIL : 0);
|
|
119
|
+
const starts = [];
|
|
120
|
+
timed.forEach((c, i) => {
|
|
121
|
+
const at = toComposition(c.recordingAt);
|
|
122
|
+
const after = i ? starts[i - 1] + minimum(i - 1) : 0;
|
|
123
|
+
starts.push(c.at === undefined
|
|
124
|
+
? round(Math.max(at, clipStart + stage.belt, Math.min(after, Math.max(at, clipEnd - stage.calloutMinimum))))
|
|
125
|
+
: at);
|
|
126
|
+
});
|
|
127
|
+
const callouts = timed.map((c, i) => {
|
|
128
|
+
const at = starts[i];
|
|
129
|
+
const next = starts[i + 1];
|
|
130
|
+
const nextTransition = transitionTimes.find((t) => t > at);
|
|
131
|
+
// Never overlap the next callout or a hand-off card; never outlive the recording.
|
|
132
|
+
const cap = Math.min(next !== undefined ? next - 0.2 : Infinity, nextTransition !== undefined ? nextTransition - stage.belt - 0.1 : Infinity, clipEnd - 0.3);
|
|
133
|
+
// Through its step, and a moment on its result (the marker), at least calloutDuration.
|
|
134
|
+
const wanted = Math.max(stage.calloutDuration, toComposition(c.shownUntil) - at + stage.calloutHold, speaking[i]);
|
|
135
|
+
const duration = round(c.duration ?? Math.max(1, Math.min(wanted, cap - at)));
|
|
136
|
+
if (c.duration !== undefined && at + c.duration > cap + 0.01) {
|
|
137
|
+
warnings.push(`callout "${c.text}" (${c.duration}s) overlaps the next step or the end of the recording`);
|
|
138
|
+
}
|
|
139
|
+
if (speaking[i] > duration + 0.25) {
|
|
140
|
+
warnings.push(`voice-over: "${c.say || c.text}" is still being said ${round(speaking[i] - duration)}s after its callout goes — ` +
|
|
141
|
+
'shorten its `say`, or pause longer in the scenario');
|
|
142
|
+
}
|
|
143
|
+
// Two-actor videos: tag each step with who does it (the hand-off card's roles).
|
|
144
|
+
const before = handOffs.filter((t) => t.at <= c.recordingAt);
|
|
145
|
+
const group = c.group ?? (before.length ? before.at(-1)?.to : handOffs[0]?.from);
|
|
146
|
+
return { at, duration, text: c.text, ...(group ? { group } : {}), ...(c.say !== undefined ? { say: c.say } : {}), source: c.source };
|
|
147
|
+
});
|
|
148
|
+
const recapTiming = timing.recap;
|
|
149
|
+
if (recapTiming && callouts.length > recapTiming.maxSteps) {
|
|
150
|
+
warnings.push(`${callouts.length} callouts — the recap holds ${recapTiming.maxSteps}; merge or drop steps (extra ones are left out of the recap)`);
|
|
151
|
+
}
|
|
152
|
+
const scale = Math.min(stage.maxW / markers.viewport.width, stage.maxH / markers.viewport.height);
|
|
153
|
+
// recording → recap → outro, each cross-fading into the next by `overlap`. Without a
|
|
154
|
+
// recap the outro waits for the recording to fade out: its text never lands on the footage.
|
|
155
|
+
const recapStart = round(clipEnd - stage.overlap);
|
|
156
|
+
const recap = recapTiming
|
|
157
|
+
? {
|
|
158
|
+
start: recapStart,
|
|
159
|
+
duration: round(Math.min(recapTiming.max, recapTiming.base + recapTiming.perStep * Math.min(callouts.length, recapTiming.maxSteps))),
|
|
160
|
+
maxSteps: recapTiming.maxSteps,
|
|
161
|
+
}
|
|
162
|
+
: null;
|
|
163
|
+
const outroStart = recap ? round(recap.start + recap.duration - stage.overlap) : clipEnd;
|
|
164
|
+
const total = round(outroStart + timing.outro.duration);
|
|
165
|
+
const frame = {
|
|
166
|
+
width: Math.round(markers.viewport.width * scale),
|
|
167
|
+
height: Math.round(markers.viewport.height * scale),
|
|
168
|
+
};
|
|
169
|
+
const log = markers.cursor;
|
|
170
|
+
if (spec.cursor !== undefined && (!log || log.drawn)) {
|
|
171
|
+
warnings.push('video.json `cursor` has no effect: this recording has the cursor filmed in — re-record (record.cursor "layer") to draw it as a layer');
|
|
172
|
+
}
|
|
173
|
+
let cursor = null;
|
|
174
|
+
if (log && !log.drawn && spec.cursor !== false && log.path.length) {
|
|
175
|
+
const inside = (t) => t >= mediaStart && t <= mediaEnd;
|
|
176
|
+
// Where the cursor rests when the footage starts: the last move before the trim.
|
|
177
|
+
const before = log.path.filter(([t]) => t < mediaStart).at(-1);
|
|
178
|
+
const path = [
|
|
179
|
+
...(before ? [[clipStart, before[1], before[2]]] : []),
|
|
180
|
+
...log.path.filter(([t]) => inside(t)).map(([t, x, y]) => [toComposition(t), x, y]),
|
|
181
|
+
];
|
|
182
|
+
cursor = {
|
|
183
|
+
size: spec.cursor?.size ?? 44,
|
|
184
|
+
ripple: spec.cursor?.ripple ?? true,
|
|
185
|
+
idle: spec.cursor?.idle ?? 0,
|
|
186
|
+
scale: Math.round((frame.width / markers.viewport.width) * 10000) / 10000,
|
|
187
|
+
path,
|
|
188
|
+
presses: log.presses.filter(([t]) => inside(t)).map(([t, x, y]) => [toComposition(t), x, y]),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
timeline: {
|
|
193
|
+
total,
|
|
194
|
+
intro: { start: 0, duration: timing.intro.duration, exit: timing.intro.exit },
|
|
195
|
+
recap,
|
|
196
|
+
outro: { start: outroStart, duration: timing.outro.duration },
|
|
197
|
+
clipStart,
|
|
198
|
+
clipDuration,
|
|
199
|
+
clipEnd,
|
|
200
|
+
mediaStart: round(mediaStart),
|
|
201
|
+
mediaEnd: round(mediaEnd),
|
|
202
|
+
belt: stage.belt,
|
|
203
|
+
frame,
|
|
204
|
+
viewport: markers.viewport,
|
|
205
|
+
transitions,
|
|
206
|
+
segments,
|
|
207
|
+
callouts,
|
|
208
|
+
cursor,
|
|
209
|
+
focus: (markers.focus ?? [])
|
|
210
|
+
.filter((f) => f.at >= mediaStart && f.at <= mediaEnd)
|
|
211
|
+
.map((f) => ({ ...f, at: toComposition(f.at) })),
|
|
212
|
+
toComposition,
|
|
213
|
+
},
|
|
214
|
+
warnings,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** A trim edge in recording seconds (undefined: not set). */
|
|
218
|
+
/**
|
|
219
|
+
* When the step that ends at `marker` began: the first glide (or click) after the previous
|
|
220
|
+
* marker — and after `from`, the trim — else the marker itself (nothing logged in between,
|
|
221
|
+
* e.g. a page load).
|
|
222
|
+
*/
|
|
223
|
+
export function stepStart(markers, marker, from) {
|
|
224
|
+
const previous = Math.max(from, ...markers.markers.map((m) => m.at).filter((at) => at < marker - 1e-6));
|
|
225
|
+
const first = (markers.clicks ?? [])
|
|
226
|
+
.map((c) => c.move ?? c.at)
|
|
227
|
+
.filter((t) => t >= previous - 1e-6 && t < marker)
|
|
228
|
+
.sort((a, b) => a - b)[0];
|
|
229
|
+
return first ?? marker;
|
|
230
|
+
}
|
|
231
|
+
export function resolveTrimPoint(point, markers, edge) {
|
|
232
|
+
if (point === undefined || typeof point === 'number') {
|
|
233
|
+
return point;
|
|
234
|
+
}
|
|
235
|
+
if (point === 'auto') {
|
|
236
|
+
if (edge === 'end') {
|
|
237
|
+
throw new TimelineError('trim.end cannot be "auto" — leave it out to keep the recording to its end');
|
|
238
|
+
}
|
|
239
|
+
return suggestTrimStart(markers);
|
|
240
|
+
}
|
|
241
|
+
if ('marker' in point) {
|
|
242
|
+
const found = markers.markers.find((m) => m.label === point.marker);
|
|
243
|
+
if (!found) {
|
|
244
|
+
throw new TimelineError(`trim.${edge}: no marker "${point.marker}" — markers.json has ${markers.markers.map((m) => JSON.stringify(m.label)).join(', ') || 'none'}`);
|
|
245
|
+
}
|
|
246
|
+
return round(found.at + (point.offset ?? 0));
|
|
247
|
+
}
|
|
248
|
+
const click = markers.clicks?.[point.click - 1];
|
|
249
|
+
if (!click) {
|
|
250
|
+
throw new TimelineError(`trim.${edge}: no click ${point.click} — markers.json has ${markers.clicks?.length ?? 0}`);
|
|
251
|
+
}
|
|
252
|
+
return round((click.move ?? click.at) + (point.offset ?? 0));
|
|
253
|
+
}
|
|
254
|
+
/** Where the footage should start: just before the first logged glide (after the login). */
|
|
255
|
+
export function suggestTrimStart(markers) {
|
|
256
|
+
const candidates = [
|
|
257
|
+
...(markers.clicks ?? []).map((c) => (c.move ?? c.at) - 0.5),
|
|
258
|
+
...markers.markers.map((m) => m.at - 0.8),
|
|
259
|
+
];
|
|
260
|
+
if (!candidates.length) {
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
return Math.max(0, round(Math.min(...candidates)));
|
|
264
|
+
}
|
|
265
|
+
/** One callout per marker, worded as the marker label: the starting point of a new video.json. */
|
|
266
|
+
export function defaultCallouts(markers) {
|
|
267
|
+
return markers.markers.map((m) => ({ marker: m.label, text: m.label }));
|
|
268
|
+
}
|
|
269
|
+
export function round(n) {
|
|
270
|
+
return Math.round(n * 100) / 100;
|
|
271
|
+
}
|