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,514 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* Timeline constants (seconds). The stage keys come from the template's
|
|
14
|
+
* template.json, the rest from the section chosen for each slot (section.json).
|
|
15
|
+
*/
|
|
16
|
+
export const STAGE_TIMING = {
|
|
17
|
+
overlap: 0.4, // recording → recap → outro cross-fades
|
|
18
|
+
calloutDuration: 3.0,
|
|
19
|
+
/** A step's callout stays this long after its marker (the result), next step permitting. */
|
|
20
|
+
calloutHold: 1.2,
|
|
21
|
+
/** The least a step's callout is up before the next step's may replace it. */
|
|
22
|
+
calloutMinimum: 1.8,
|
|
23
|
+
transitionGap: 2.6, // seconds a hand-off card holds between the two belts
|
|
24
|
+
belt: 0.9, // the recording's exit before a hand-off card (matches the stage)
|
|
25
|
+
maxW: 1600, // the framed recording's box inside the 1920x1080 stage
|
|
26
|
+
maxH: 940,
|
|
27
|
+
}
|
|
28
|
+
/** A spoken callout stays up this long after its line ends (a breath before the next step). */
|
|
29
|
+
const SPOKEN_TAIL = 0.3
|
|
30
|
+
|
|
31
|
+
export const SECTION_TIMING = {
|
|
32
|
+
intro: {
|
|
33
|
+
duration: 4.0, // intro clip length
|
|
34
|
+
exit: 3.0, // the intro hands over and the recording starts (= clipStart)
|
|
35
|
+
},
|
|
36
|
+
recap: {
|
|
37
|
+
base: 2.4, // + perStep per step, capped at max
|
|
38
|
+
perStep: 0.45,
|
|
39
|
+
max: 7.5,
|
|
40
|
+
maxSteps: 10, // the recap's capacity
|
|
41
|
+
},
|
|
42
|
+
outro: {
|
|
43
|
+
duration: 2.6,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
export type StageTiming = typeof STAGE_TIMING
|
|
47
|
+
export type IntroTiming = typeof SECTION_TIMING.intro
|
|
48
|
+
export type RecapTiming = typeof SECTION_TIMING.recap
|
|
49
|
+
export type OutroTiming = typeof SECTION_TIMING.outro
|
|
50
|
+
export type Slot = keyof typeof SECTION_TIMING
|
|
51
|
+
export const SLOTS = Object.keys(SECTION_TIMING) as Slot[]
|
|
52
|
+
|
|
53
|
+
export interface Timing {
|
|
54
|
+
stage: StageTiming
|
|
55
|
+
intro: IntroTiming
|
|
56
|
+
/** null: no recap section ("recap": "none"). */
|
|
57
|
+
recap: RecapTiming | null
|
|
58
|
+
outro: OutroTiming
|
|
59
|
+
}
|
|
60
|
+
export const TIMING_DEFAULTS: Timing = { stage: STAGE_TIMING, ...SECTION_TIMING }
|
|
61
|
+
|
|
62
|
+
export interface Click {
|
|
63
|
+
at: number
|
|
64
|
+
move?: number
|
|
65
|
+
until?: number
|
|
66
|
+
x: number
|
|
67
|
+
y: number
|
|
68
|
+
kind: 'click' | 'type'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface HandOff {
|
|
72
|
+
at: number
|
|
73
|
+
title: string
|
|
74
|
+
subtitle?: string
|
|
75
|
+
from?: string
|
|
76
|
+
to?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What reelson-record writes (or what build.ts probes from a manual recording). */
|
|
80
|
+
export interface Markers {
|
|
81
|
+
scenario?: string
|
|
82
|
+
viewport: { width: number; height: number }
|
|
83
|
+
durationSeconds: number
|
|
84
|
+
markers: { label: string; at: number }[]
|
|
85
|
+
clicks?: Click[]
|
|
86
|
+
transitions?: HandOff[]
|
|
87
|
+
cuts?: { from: number; to: number }[]
|
|
88
|
+
cursor?: CursorLog
|
|
89
|
+
/** What the demo worked on (reelson-record's Focus): recording s, viewport CSS px. */
|
|
90
|
+
focus?: { at: number; box: Box; area: Box }[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface Box {
|
|
94
|
+
x: number
|
|
95
|
+
y: number
|
|
96
|
+
width: number
|
|
97
|
+
height: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The cursor as reelson-record logged it: [t, x, y] in recording seconds and viewport CSS px. */
|
|
101
|
+
export interface CursorLog {
|
|
102
|
+
/** Already filmed into recording.mp4 (record.cursor "recorded"): the video must not draw it. */
|
|
103
|
+
drawn: boolean
|
|
104
|
+
path: [number, number, number][]
|
|
105
|
+
presses: [number, number, number][]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** video.json `cursor`: how the video draws a logged cursor. */
|
|
109
|
+
export interface CursorSpec {
|
|
110
|
+
/** Arrow height in recording CSS px (44 = the classic macOS-style size). */
|
|
111
|
+
size?: number
|
|
112
|
+
/** The ring on each press. */
|
|
113
|
+
ripple?: boolean
|
|
114
|
+
/** Fade the cursor out after this many seconds without moving or clicking (0 = never). */
|
|
115
|
+
idle?: number
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CalloutSpec {
|
|
119
|
+
text: string
|
|
120
|
+
marker?: string
|
|
121
|
+
/**
|
|
122
|
+
* Where a marker callout starts: "step" (default) — as its step begins, i.e. the first
|
|
123
|
+
* glide or click after the previous marker (the marker comes after the action it names);
|
|
124
|
+
* "marker" — on the marker itself.
|
|
125
|
+
*/
|
|
126
|
+
anchor?: 'step' | 'marker'
|
|
127
|
+
/** Seconds after (or, negative, before) that start. */
|
|
128
|
+
offset?: number
|
|
129
|
+
at?: number
|
|
130
|
+
duration?: number
|
|
131
|
+
group?: string
|
|
132
|
+
/** Voice-over: what is spoken for this step (default: its text); false: nothing. */
|
|
133
|
+
say?: string | false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ZoomSpec {
|
|
137
|
+
scale: number
|
|
138
|
+
/** Pan with the cursor while zoomed (needs a logged cursor: record.cursor "layer"). */
|
|
139
|
+
follow?: boolean
|
|
140
|
+
clicks?: number[]
|
|
141
|
+
x?: number
|
|
142
|
+
y?: number
|
|
143
|
+
at?: number
|
|
144
|
+
duration?: number
|
|
145
|
+
in?: number
|
|
146
|
+
out?: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A trim edge in video.json: a recording time, "auto" (start only: just before the first
|
|
151
|
+
* glide or marker), or a time relative to a marker / to the glide towards a click — so a
|
|
152
|
+
* re-record moves the trim with the footage.
|
|
153
|
+
*/
|
|
154
|
+
export type TrimPoint = number | 'auto' | { marker: string; offset?: number } | { click: number; offset?: number }
|
|
155
|
+
|
|
156
|
+
/** Section name per slot; "none" is allowed for the recap only. */
|
|
157
|
+
export type SectionChoice = Partial<Record<Slot, string>>
|
|
158
|
+
|
|
159
|
+
/** video.json */
|
|
160
|
+
export interface VideoSpec {
|
|
161
|
+
title: string
|
|
162
|
+
subtitle?: string
|
|
163
|
+
template?: string
|
|
164
|
+
sections?: SectionChoice
|
|
165
|
+
recapTitle?: string
|
|
166
|
+
brand?: { name?: string; tagline?: string; eyebrow?: string; logo?: string | null }
|
|
167
|
+
trim?: { start?: TrimPoint; end?: TrimPoint }
|
|
168
|
+
music?: string | boolean | null
|
|
169
|
+
callouts?: CalloutSpec[]
|
|
170
|
+
zooms?: ZoomSpec[]
|
|
171
|
+
/** false: no cursor at all. Only for recordings with a logged (not filmed) cursor. */
|
|
172
|
+
cursor?: false | CursorSpec
|
|
173
|
+
/**
|
|
174
|
+
* Where the portrait video (`render --portrait`) comes from: "mobile" — the phone take
|
|
175
|
+
* (`reelson record --mobile`); "desktop" — a camera over the desktop recording; "auto"
|
|
176
|
+
* (default) — the phone take when there is one.
|
|
177
|
+
*/
|
|
178
|
+
portrait?: 'auto' | 'mobile' | 'desktop'
|
|
179
|
+
/**
|
|
180
|
+
* Voice-over: true speaks each callout (demo.config.json `voice` settings); an object
|
|
181
|
+
* overrides the provider / model / voice / instructions / speed for this video and may add
|
|
182
|
+
* a line over the intro.
|
|
183
|
+
*/
|
|
184
|
+
voice?:
|
|
185
|
+
| boolean
|
|
186
|
+
| {
|
|
187
|
+
provider?: 'openai' | 'elevenlabs' | 'piper' | 'command'
|
|
188
|
+
model?: string
|
|
189
|
+
voice?: string
|
|
190
|
+
instructions?: string
|
|
191
|
+
speed?: number
|
|
192
|
+
intro?: string
|
|
193
|
+
}
|
|
194
|
+
/** Versions every `reelson render` adds besides the 16:9 one (as --portrait / --square). */
|
|
195
|
+
formats?: ('portrait' | 'square')[]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface Callout {
|
|
199
|
+
at: number
|
|
200
|
+
duration: number
|
|
201
|
+
text: string
|
|
202
|
+
group?: string
|
|
203
|
+
/** Voice-over line, when it differs from the text (false: silent). */
|
|
204
|
+
say?: string | false
|
|
205
|
+
/** Index in video.json `callouts` (or in the default callouts when it has none). */
|
|
206
|
+
source: number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface Timeline {
|
|
210
|
+
total: number
|
|
211
|
+
intro: { start: number; duration: number; exit: number }
|
|
212
|
+
recap: { start: number; duration: number; maxSteps: number } | null
|
|
213
|
+
outro: { start: number; duration: number }
|
|
214
|
+
clipStart: number
|
|
215
|
+
clipDuration: number
|
|
216
|
+
clipEnd: number
|
|
217
|
+
/** Recording window used (trim). */
|
|
218
|
+
mediaStart: number
|
|
219
|
+
mediaEnd: number
|
|
220
|
+
/** Seconds the recording takes to leave before a hand-off card. */
|
|
221
|
+
belt: number
|
|
222
|
+
frame: { width: number; height: number }
|
|
223
|
+
viewport: { width: number; height: number }
|
|
224
|
+
/** Hand-off cards: composition time the card is fully in, and how long it holds. */
|
|
225
|
+
transitions: { at: number; gap: number; card: HandOff }[]
|
|
226
|
+
/** One <video> clip per stretch of footage between hand-offs. */
|
|
227
|
+
segments: { start: number; duration: number; mediaStart: number }[]
|
|
228
|
+
callouts: Callout[]
|
|
229
|
+
/**
|
|
230
|
+
* The cursor layer, or null when the footage already shows the cursor (or none is wanted).
|
|
231
|
+
* Composition seconds; x/y in recording CSS px (× `scale` for frame px).
|
|
232
|
+
*/
|
|
233
|
+
cursor: {
|
|
234
|
+
size: number
|
|
235
|
+
ripple: boolean
|
|
236
|
+
/** Seconds of stillness before it fades out; 0 = always shown. */
|
|
237
|
+
idle: number
|
|
238
|
+
/** A phone take: show the taps (ripples) only, no arrow. */
|
|
239
|
+
touch?: boolean
|
|
240
|
+
scale: number
|
|
241
|
+
path: [number, number, number][]
|
|
242
|
+
presses: [number, number, number][]
|
|
243
|
+
} | null
|
|
244
|
+
/** What the demo worked on, in composition time (empty for recordings made before 0.5). */
|
|
245
|
+
focus: { at: number; box: Box; area: Box }[]
|
|
246
|
+
/** Recording time → composition time (footage after a hand-off is pushed back by the gap). */
|
|
247
|
+
toComposition: (recordingTime: number) => number
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export class TimelineError extends Error {}
|
|
251
|
+
|
|
252
|
+
export function computeTimeline(
|
|
253
|
+
markers: Markers,
|
|
254
|
+
spec: VideoSpec,
|
|
255
|
+
timing: Timing = TIMING_DEFAULTS,
|
|
256
|
+
/**
|
|
257
|
+
* Voice-over: seconds from a callout appearing until its spoken line is done (0: silent or
|
|
258
|
+
* not spoken yet). Such a callout stays up at least that long, and the next one waits.
|
|
259
|
+
*/
|
|
260
|
+
spoken: (c: { text: string; say?: string | false }) => number = () => 0,
|
|
261
|
+
): { timeline: Timeline; warnings: string[] } {
|
|
262
|
+
const warnings: string[] = []
|
|
263
|
+
const mediaStart = Math.max(0, resolveTrimPoint(spec.trim?.start, markers, 'start') ?? 0)
|
|
264
|
+
const mediaEnd = Math.min(resolveTrimPoint(spec.trim?.end, markers, 'end') ?? markers.durationSeconds, markers.durationSeconds)
|
|
265
|
+
const mediaDuration = round(mediaEnd - mediaStart)
|
|
266
|
+
if (mediaDuration <= 0) {
|
|
267
|
+
throw new TimelineError(`trim window is empty (start ${mediaStart}s, end ${mediaEnd}s)`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const stage = timing.stage
|
|
271
|
+
const clipStart = timing.intro.exit
|
|
272
|
+
const handOffs = (markers.transitions ?? []).filter((t) => t.at > mediaStart && t.at < mediaEnd)
|
|
273
|
+
const gap = stage.transitionGap
|
|
274
|
+
const clipDuration = round(mediaDuration + gap * handOffs.length)
|
|
275
|
+
const clipEnd = round(clipStart + clipDuration)
|
|
276
|
+
const toComposition = (t: number): number =>
|
|
277
|
+
round(clipStart + (t - mediaStart) + gap * handOffs.filter((h) => h.at <= t).length)
|
|
278
|
+
const transitions = handOffs.map((card, i) => ({
|
|
279
|
+
at: round(clipStart + (card.at - mediaStart) + gap * i),
|
|
280
|
+
gap,
|
|
281
|
+
card,
|
|
282
|
+
}))
|
|
283
|
+
|
|
284
|
+
const bounds = [mediaStart, ...handOffs.map((t) => t.at), mediaEnd]
|
|
285
|
+
const segments = bounds.slice(0, -1).map((from, i) => ({
|
|
286
|
+
start: round(clipStart + (from - mediaStart) + gap * i),
|
|
287
|
+
duration: round(bounds[i + 1] - from),
|
|
288
|
+
mediaStart: round(from),
|
|
289
|
+
}))
|
|
290
|
+
|
|
291
|
+
// Callouts: resolve each to a recording time, keep the ones inside the window, in order.
|
|
292
|
+
const specs = spec.callouts ?? defaultCallouts(markers)
|
|
293
|
+
const unknown = specs.filter((c) => c.marker !== undefined && !markers.markers.some((m) => m.label === c.marker))
|
|
294
|
+
if (unknown.length) {
|
|
295
|
+
throw new TimelineError(
|
|
296
|
+
`callout(s) reference unknown markers: ${unknown.map((c) => JSON.stringify(c.marker)).join(', ')} ` +
|
|
297
|
+
`— markers.json has ${markers.markers.map((m) => JSON.stringify(m.label)).join(', ') || 'none'}`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
const timed = specs
|
|
301
|
+
.map((c, source) => {
|
|
302
|
+
if ((c.marker === undefined) === (c.at === undefined)) {
|
|
303
|
+
throw new TimelineError(`callout "${c.text}" needs exactly one of \`marker\` or \`at\``)
|
|
304
|
+
}
|
|
305
|
+
if (c.offset !== undefined && c.marker === undefined) {
|
|
306
|
+
throw new TimelineError(`callout "${c.text}": \`offset\` shifts a \`marker\`; with \`at\`, change \`at\` instead`)
|
|
307
|
+
}
|
|
308
|
+
if (c.anchor !== undefined && c.marker === undefined) {
|
|
309
|
+
throw new TimelineError(`callout "${c.text}": \`anchor\` needs a \`marker\``)
|
|
310
|
+
}
|
|
311
|
+
if (c.at !== undefined) {
|
|
312
|
+
return { ...c, recordingAt: c.at, shownUntil: c.at, source }
|
|
313
|
+
}
|
|
314
|
+
const marker = (markers.markers.find((m) => m.label === c.marker) as { at: number }).at
|
|
315
|
+
const start = c.anchor === 'marker' ? marker : stepStart(markers, marker, mediaStart)
|
|
316
|
+
return { ...c, recordingAt: round(start + (c.offset ?? 0)), shownUntil: marker, source }
|
|
317
|
+
})
|
|
318
|
+
.filter((c) => {
|
|
319
|
+
const inside = c.recordingAt >= mediaStart && c.recordingAt <= mediaEnd
|
|
320
|
+
if (!inside) {
|
|
321
|
+
warnings.push(`callout "${c.text}" at ${c.recordingAt}s is outside the trim window — dropped`)
|
|
322
|
+
}
|
|
323
|
+
return inside
|
|
324
|
+
})
|
|
325
|
+
.sort((a, b) => a.recordingAt - b.recordingAt)
|
|
326
|
+
|
|
327
|
+
const transitionTimes = transitions.map((t) => t.at)
|
|
328
|
+
// When each shows. A step that starts as the footage does waits for the recording to
|
|
329
|
+
// arrive; one that starts right after the previous (a step that was only a page load) waits
|
|
330
|
+
// until that one has been up long enough to read. An explicit `at` is kept as given.
|
|
331
|
+
// With a voice-over, that is until its line has been said (and never past the recording).
|
|
332
|
+
const speaking = timed.map((c) => spoken(c))
|
|
333
|
+
const minimum = (i: number): number => Math.max(stage.calloutMinimum, speaking[i] ? speaking[i] + SPOKEN_TAIL : 0)
|
|
334
|
+
const starts: number[] = []
|
|
335
|
+
timed.forEach((c, i) => {
|
|
336
|
+
const at = toComposition(c.recordingAt)
|
|
337
|
+
const after = i ? starts[i - 1] + minimum(i - 1) : 0
|
|
338
|
+
starts.push(
|
|
339
|
+
c.at === undefined
|
|
340
|
+
? round(Math.max(at, clipStart + stage.belt, Math.min(after, Math.max(at, clipEnd - stage.calloutMinimum))))
|
|
341
|
+
: at,
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
const callouts: Callout[] = timed.map((c, i) => {
|
|
345
|
+
const at = starts[i]
|
|
346
|
+
const next = starts[i + 1]
|
|
347
|
+
const nextTransition = transitionTimes.find((t) => t > at)
|
|
348
|
+
// Never overlap the next callout or a hand-off card; never outlive the recording.
|
|
349
|
+
const cap = Math.min(
|
|
350
|
+
next !== undefined ? next - 0.2 : Infinity,
|
|
351
|
+
nextTransition !== undefined ? nextTransition - stage.belt - 0.1 : Infinity,
|
|
352
|
+
clipEnd - 0.3,
|
|
353
|
+
)
|
|
354
|
+
// Through its step, and a moment on its result (the marker), at least calloutDuration.
|
|
355
|
+
const wanted = Math.max(stage.calloutDuration, toComposition(c.shownUntil) - at + stage.calloutHold, speaking[i])
|
|
356
|
+
const duration = round(c.duration ?? Math.max(1, Math.min(wanted, cap - at)))
|
|
357
|
+
if (c.duration !== undefined && at + c.duration > cap + 0.01) {
|
|
358
|
+
warnings.push(`callout "${c.text}" (${c.duration}s) overlaps the next step or the end of the recording`)
|
|
359
|
+
}
|
|
360
|
+
if (speaking[i] > duration + 0.25) {
|
|
361
|
+
warnings.push(
|
|
362
|
+
`voice-over: "${c.say || c.text}" is still being said ${round(speaking[i] - duration)}s after its callout goes — ` +
|
|
363
|
+
'shorten its `say`, or pause longer in the scenario',
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
// Two-actor videos: tag each step with who does it (the hand-off card's roles).
|
|
367
|
+
const before = handOffs.filter((t) => t.at <= c.recordingAt)
|
|
368
|
+
const group = c.group ?? (before.length ? before.at(-1)?.to : handOffs[0]?.from)
|
|
369
|
+
|
|
370
|
+
return { at, duration, text: c.text, ...(group ? { group } : {}), ...(c.say !== undefined ? { say: c.say } : {}), source: c.source }
|
|
371
|
+
})
|
|
372
|
+
const recapTiming = timing.recap
|
|
373
|
+
if (recapTiming && callouts.length > recapTiming.maxSteps) {
|
|
374
|
+
warnings.push(
|
|
375
|
+
`${callouts.length} callouts — the recap holds ${recapTiming.maxSteps}; merge or drop steps (extra ones are left out of the recap)`,
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const scale = Math.min(stage.maxW / markers.viewport.width, stage.maxH / markers.viewport.height)
|
|
380
|
+
// recording → recap → outro, each cross-fading into the next by `overlap`. Without a
|
|
381
|
+
// recap the outro waits for the recording to fade out: its text never lands on the footage.
|
|
382
|
+
const recapStart = round(clipEnd - stage.overlap)
|
|
383
|
+
const recap = recapTiming
|
|
384
|
+
? {
|
|
385
|
+
start: recapStart,
|
|
386
|
+
duration: round(
|
|
387
|
+
Math.min(
|
|
388
|
+
recapTiming.max,
|
|
389
|
+
recapTiming.base + recapTiming.perStep * Math.min(callouts.length, recapTiming.maxSteps),
|
|
390
|
+
),
|
|
391
|
+
),
|
|
392
|
+
maxSteps: recapTiming.maxSteps,
|
|
393
|
+
}
|
|
394
|
+
: null
|
|
395
|
+
const outroStart = recap ? round(recap.start + recap.duration - stage.overlap) : clipEnd
|
|
396
|
+
const total = round(outroStart + timing.outro.duration)
|
|
397
|
+
|
|
398
|
+
const frame = {
|
|
399
|
+
width: Math.round(markers.viewport.width * scale),
|
|
400
|
+
height: Math.round(markers.viewport.height * scale),
|
|
401
|
+
}
|
|
402
|
+
const log = markers.cursor
|
|
403
|
+
if (spec.cursor !== undefined && (!log || log.drawn)) {
|
|
404
|
+
warnings.push(
|
|
405
|
+
'video.json `cursor` has no effect: this recording has the cursor filmed in — re-record (record.cursor "layer") to draw it as a layer',
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
let cursor: Timeline['cursor'] = null
|
|
409
|
+
if (log && !log.drawn && spec.cursor !== false && log.path.length) {
|
|
410
|
+
const inside = (t: number): boolean => t >= mediaStart && t <= mediaEnd
|
|
411
|
+
// Where the cursor rests when the footage starts: the last move before the trim.
|
|
412
|
+
const before = log.path.filter(([t]) => t < mediaStart).at(-1)
|
|
413
|
+
const path: [number, number, number][] = [
|
|
414
|
+
...(before ? [[clipStart, before[1], before[2]] as [number, number, number]] : []),
|
|
415
|
+
...log.path.filter(([t]) => inside(t)).map(([t, x, y]): [number, number, number] => [toComposition(t), x, y]),
|
|
416
|
+
]
|
|
417
|
+
cursor = {
|
|
418
|
+
size: spec.cursor?.size ?? 44,
|
|
419
|
+
ripple: spec.cursor?.ripple ?? true,
|
|
420
|
+
idle: spec.cursor?.idle ?? 0,
|
|
421
|
+
scale: Math.round((frame.width / markers.viewport.width) * 10000) / 10000,
|
|
422
|
+
path,
|
|
423
|
+
presses: log.presses.filter(([t]) => inside(t)).map(([t, x, y]) => [toComposition(t), x, y]),
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
timeline: {
|
|
429
|
+
total,
|
|
430
|
+
intro: { start: 0, duration: timing.intro.duration, exit: timing.intro.exit },
|
|
431
|
+
recap,
|
|
432
|
+
outro: { start: outroStart, duration: timing.outro.duration },
|
|
433
|
+
clipStart,
|
|
434
|
+
clipDuration,
|
|
435
|
+
clipEnd,
|
|
436
|
+
mediaStart: round(mediaStart),
|
|
437
|
+
mediaEnd: round(mediaEnd),
|
|
438
|
+
belt: stage.belt,
|
|
439
|
+
frame,
|
|
440
|
+
viewport: markers.viewport,
|
|
441
|
+
transitions,
|
|
442
|
+
segments,
|
|
443
|
+
callouts,
|
|
444
|
+
cursor,
|
|
445
|
+
focus: (markers.focus ?? [])
|
|
446
|
+
.filter((f) => f.at >= mediaStart && f.at <= mediaEnd)
|
|
447
|
+
.map((f) => ({ ...f, at: toComposition(f.at) })),
|
|
448
|
+
toComposition,
|
|
449
|
+
},
|
|
450
|
+
warnings,
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** A trim edge in recording seconds (undefined: not set). */
|
|
455
|
+
/**
|
|
456
|
+
* When the step that ends at `marker` began: the first glide (or click) after the previous
|
|
457
|
+
* marker — and after `from`, the trim — else the marker itself (nothing logged in between,
|
|
458
|
+
* e.g. a page load).
|
|
459
|
+
*/
|
|
460
|
+
export function stepStart(markers: Markers, marker: number, from: number): number {
|
|
461
|
+
const previous = Math.max(from, ...markers.markers.map((m) => m.at).filter((at) => at < marker - 1e-6))
|
|
462
|
+
const first = (markers.clicks ?? [])
|
|
463
|
+
.map((c) => c.move ?? c.at)
|
|
464
|
+
.filter((t) => t >= previous - 1e-6 && t < marker)
|
|
465
|
+
.sort((a, b) => a - b)[0]
|
|
466
|
+
return first ?? marker
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function resolveTrimPoint(point: TrimPoint | undefined, markers: Markers, edge: 'start' | 'end'): number | undefined {
|
|
470
|
+
if (point === undefined || typeof point === 'number') {
|
|
471
|
+
return point
|
|
472
|
+
}
|
|
473
|
+
if (point === 'auto') {
|
|
474
|
+
if (edge === 'end') {
|
|
475
|
+
throw new TimelineError('trim.end cannot be "auto" — leave it out to keep the recording to its end')
|
|
476
|
+
}
|
|
477
|
+
return suggestTrimStart(markers)
|
|
478
|
+
}
|
|
479
|
+
if ('marker' in point) {
|
|
480
|
+
const found = markers.markers.find((m) => m.label === point.marker)
|
|
481
|
+
if (!found) {
|
|
482
|
+
throw new TimelineError(
|
|
483
|
+
`trim.${edge}: no marker "${point.marker}" — markers.json has ${markers.markers.map((m) => JSON.stringify(m.label)).join(', ') || 'none'}`,
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
return round(found.at + (point.offset ?? 0))
|
|
487
|
+
}
|
|
488
|
+
const click = markers.clicks?.[point.click - 1]
|
|
489
|
+
if (!click) {
|
|
490
|
+
throw new TimelineError(`trim.${edge}: no click ${point.click} — markers.json has ${markers.clicks?.length ?? 0}`)
|
|
491
|
+
}
|
|
492
|
+
return round((click.move ?? click.at) + (point.offset ?? 0))
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Where the footage should start: just before the first logged glide (after the login). */
|
|
496
|
+
export function suggestTrimStart(markers: Markers): number {
|
|
497
|
+
const candidates = [
|
|
498
|
+
...(markers.clicks ?? []).map((c) => (c.move ?? c.at) - 0.5),
|
|
499
|
+
...markers.markers.map((m) => m.at - 0.8),
|
|
500
|
+
]
|
|
501
|
+
if (!candidates.length) {
|
|
502
|
+
return 0
|
|
503
|
+
}
|
|
504
|
+
return Math.max(0, round(Math.min(...candidates)))
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** One callout per marker, worded as the marker label: the starting point of a new video.json. */
|
|
508
|
+
export function defaultCallouts(markers: Markers): CalloutSpec[] {
|
|
509
|
+
return markers.markers.map((m) => ({ marker: m.label, text: m.label }))
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function round(n: number): number {
|
|
513
|
+
return Math.round(n * 100) / 100
|
|
514
|
+
}
|