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,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice-over: each callout spoken as it appears (video.json "voice": true).
|
|
3
|
+
*
|
|
4
|
+
* Lines are spoken by a text-to-speech provider (demo.config.json `voice.provider`: OpenAI,
|
|
5
|
+
* ElevenLabs, a local Piper voice or any local command — see tts.ts), trimmed of the silence
|
|
6
|
+
* around them and cached in <demo>/voice/<hash>.mp3 — the hash covers the words and every
|
|
7
|
+
* setting that changes the sound, so a line is only made once and a re-render never calls the
|
|
8
|
+
* provider. `reelson voice <slug>` (and `render`) make the missing lines; the build (sync)
|
|
9
|
+
* mixes whatever is cached into one track per version (landscape, portrait, square: each has
|
|
10
|
+
* its own timing), and says what is missing.
|
|
11
|
+
*
|
|
12
|
+
* What is spoken: a callout's `say` (false: nothing), else its text; plus video.json
|
|
13
|
+
* `voice.intro` over the intro, when given.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { join, resolve } from 'node:path'
|
|
20
|
+
import type { LoadedConfig } from '../../reelson-record/scripts/config.ts'
|
|
21
|
+
import { ReelsonError } from './project.ts'
|
|
22
|
+
import { round, type Timeline, type VideoSpec } from './timeline.ts'
|
|
23
|
+
import { missingSetup, providerDefaults, speak, type SpeechSettings } from './tts.ts'
|
|
24
|
+
|
|
25
|
+
export interface VoiceSettings extends SpeechSettings {
|
|
26
|
+
/** Loudness of the voice track. */
|
|
27
|
+
lufs: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface VoiceLine {
|
|
31
|
+
/** Composition seconds the line starts. */
|
|
32
|
+
at: number
|
|
33
|
+
text: string
|
|
34
|
+
/** Cache file name: <hash>.mp3 in <demo>/voice/. */
|
|
35
|
+
file: string
|
|
36
|
+
/** When the next line (or the end of the recording) comes — the line should be done by then. */
|
|
37
|
+
until: number
|
|
38
|
+
/** The line over the intro (the steps' lines are checked by the timeline). */
|
|
39
|
+
intro?: true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A line starts this long after its callout appears (the pill is read, then heard). */
|
|
43
|
+
const LEAD_IN = 0.25
|
|
44
|
+
|
|
45
|
+
/** The voice settings for a video, or null when it has no voice-over. */
|
|
46
|
+
export function voiceSettings(spec: VideoSpec, config: LoadedConfig): VoiceSettings | null {
|
|
47
|
+
if (!spec.voice) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
const own = typeof spec.voice === 'object' ? spec.voice : {}
|
|
51
|
+
const project = config.voice
|
|
52
|
+
const provider = own.provider ?? project.provider
|
|
53
|
+
// A video switching provider starts from that provider's defaults, not the project's voice.
|
|
54
|
+
const same = provider === project.provider
|
|
55
|
+
const defaults = providerDefaults(provider, config.language)
|
|
56
|
+
return {
|
|
57
|
+
provider,
|
|
58
|
+
model: own.model ?? (same ? project.model : undefined) ?? defaults.model,
|
|
59
|
+
voice: own.voice ?? (same ? project.voice : undefined) ?? defaults.voice,
|
|
60
|
+
instructions: own.instructions ?? project.instructions,
|
|
61
|
+
...((own.speed ?? project.speed) !== undefined ? { speed: own.speed ?? project.speed } : {}),
|
|
62
|
+
language: config.language,
|
|
63
|
+
...(same && project.baseURL ? { baseURL: project.baseURL } : {}),
|
|
64
|
+
...(same && project.command ? { command: project.command } : {}),
|
|
65
|
+
...(same && project.options ? { options: project.options } : {}),
|
|
66
|
+
lufs: project.lufs,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Everything spoken in this video, in order, timed to `t`. Pure. */
|
|
71
|
+
export function voiceLines(t: Timeline, spec: VideoSpec, settings: VoiceSettings): VoiceLine[] {
|
|
72
|
+
const intro = typeof spec.voice === 'object' ? spec.voice.intro : undefined
|
|
73
|
+
const spoken: { at: number; text: string; intro?: true }[] = [
|
|
74
|
+
...(intro ? [{ at: round(t.intro.start + 0.4), text: intro, intro: true as const }] : []),
|
|
75
|
+
...t.callouts.map((c) => ({ at: round(c.at + LEAD_IN), text: lineOf(c) })),
|
|
76
|
+
].filter((l) => l.text)
|
|
77
|
+
return spoken.map((line, i) => ({
|
|
78
|
+
...line,
|
|
79
|
+
file: `${lineHash(line.text, settings)}.mp3`,
|
|
80
|
+
// The intro line runs until the recording comes in; a step's, until the next line.
|
|
81
|
+
until: round(i === 0 && intro ? t.clipStart + 0.6 : (spoken[i + 1]?.at ?? t.clipEnd)),
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Bumped when the post-processing (the trim) changes, so cached lines are made again. */
|
|
86
|
+
const CACHE_VERSION = 2
|
|
87
|
+
|
|
88
|
+
/** The cache name of a line: everything that changes how it sounds (not the loudness: the mix sets it). */
|
|
89
|
+
export function lineHash(text: string, s: VoiceSettings): string {
|
|
90
|
+
const { lufs: _, ...sound } = s
|
|
91
|
+
return createHash('sha1')
|
|
92
|
+
.update(JSON.stringify([CACHE_VERSION, sound, text]))
|
|
93
|
+
.digest('hex')
|
|
94
|
+
.slice(0, 16)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What a callout says (its `say`, else its text); '' when it is silent. */
|
|
98
|
+
function lineOf(c: { text: string; say?: string | false }): string {
|
|
99
|
+
return c.say === false ? '' : (c.say || c.text).trim()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* For the timeline: seconds from a callout appearing until its cached line is done (the lead-in
|
|
104
|
+
* plus the line); 0 when it is silent or not spoken yet.
|
|
105
|
+
*/
|
|
106
|
+
export function spokenLength(settings: VoiceSettings, cacheDir: string): (c: { text: string; say?: string | false }) => number {
|
|
107
|
+
const lengths = new Map<string, number>()
|
|
108
|
+
return (c) => {
|
|
109
|
+
const text = lineOf(c)
|
|
110
|
+
const file = resolve(cacheDir, `${lineHash(text, settings)}.mp3`)
|
|
111
|
+
if (!text || !existsSync(file)) {
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
if (!lengths.has(file)) {
|
|
115
|
+
lengths.set(file, lengthOf(file))
|
|
116
|
+
}
|
|
117
|
+
return round(LEAD_IN + (lengths.get(file) as number))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every line the video would speak, without a timeline (for fetching). */
|
|
122
|
+
export function spokenTexts(spec: VideoSpec, callouts: { text: string; say?: string | false }[]): string[] {
|
|
123
|
+
const intro = typeof spec.voice === 'object' ? spec.voice.intro : undefined
|
|
124
|
+
return [intro, ...callouts.map(lineOf)].filter((t): t is string => !!t)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Speaks the lines missing from `cacheDir` (see tts.ts). Returns how many it made.
|
|
129
|
+
* Throws a ReelsonError when the provider cannot speak here (only when something is missing).
|
|
130
|
+
*/
|
|
131
|
+
export async function fetchLines(texts: string[], settings: VoiceSettings, cacheDir: string, log: (l: string) => void): Promise<number> {
|
|
132
|
+
const missing = [...new Set(texts)].filter((text) => !existsSync(resolve(cacheDir, `${lineHash(text, settings)}.mp3`)))
|
|
133
|
+
if (!missing.length) {
|
|
134
|
+
return 0
|
|
135
|
+
}
|
|
136
|
+
const problem = missingSetup(settings)
|
|
137
|
+
if (problem) {
|
|
138
|
+
throw new ReelsonError(`voice-over: ${missing.length} line(s) to speak with ${settings.provider} and ${problem} (or turn "voice" off in video.json)`)
|
|
139
|
+
}
|
|
140
|
+
mkdirSync(cacheDir, { recursive: true })
|
|
141
|
+
const scratch = mkdtempSync(join(tmpdir(), 'reelson-voice-'))
|
|
142
|
+
try {
|
|
143
|
+
for (const text of missing) {
|
|
144
|
+
const raw = resolve(scratch, settings.provider === 'openai' || settings.provider === 'elevenlabs' ? 'line.mp3' : 'line.wav')
|
|
145
|
+
rmSync(raw, { force: true })
|
|
146
|
+
await speak(text, settings, raw)
|
|
147
|
+
trimInto(raw, resolve(cacheDir, `${lineHash(text, settings)}.mp3`))
|
|
148
|
+
log(` voice: spoke "${text}" (${settings.provider})`)
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
152
|
+
}
|
|
153
|
+
return missing.length
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The silence around a line, cut (a breath of it kept) and saved as mp3. */
|
|
157
|
+
const TRIM = 'silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.05'
|
|
158
|
+
|
|
159
|
+
function trimInto(raw: string, target: string): void {
|
|
160
|
+
const run = spawnSync(
|
|
161
|
+
'ffmpeg',
|
|
162
|
+
['-y', '-loglevel', 'error', '-i', raw, '-af', `${TRIM},areverse,${TRIM},areverse`, '-ar', '48000', '-c:a', 'libmp3lame', '-q:a', '2', target],
|
|
163
|
+
{ stdio: 'inherit' },
|
|
164
|
+
)
|
|
165
|
+
if (run.status !== 0 || !existsSync(target)) {
|
|
166
|
+
throw new ReelsonError(`ffmpeg could not read the spoken line ${raw}`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Seconds of audio in `file` (ffprobe). */
|
|
171
|
+
function lengthOf(file: string): number {
|
|
172
|
+
const probe = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file], { encoding: 'utf8' })
|
|
173
|
+
return Number.parseFloat(probe.stdout.trim()) || 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Mixes the cached lines into one track as long as the video (`target`, m4a). Returns false
|
|
178
|
+
* when none is cached. Warns about missing lines and an intro line that outlasts the intro.
|
|
179
|
+
*/
|
|
180
|
+
export function renderVoiceTrack(
|
|
181
|
+
lines: VoiceLine[],
|
|
182
|
+
settings: VoiceSettings,
|
|
183
|
+
cacheDir: string,
|
|
184
|
+
total: number,
|
|
185
|
+
target: string,
|
|
186
|
+
log: (l: string) => void,
|
|
187
|
+
): boolean {
|
|
188
|
+
const cached = lines.filter((l) => existsSync(resolve(cacheDir, l.file)))
|
|
189
|
+
const missing = lines.length - cached.length
|
|
190
|
+
if (missing) {
|
|
191
|
+
log(` warning: voice-over: ${missing} line(s) not spoken yet — run \`reelson voice <slug>\` (render does it for you)`)
|
|
192
|
+
}
|
|
193
|
+
if (!cached.length) {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
for (const line of cached.filter((l) => l.intro)) {
|
|
197
|
+
const length = lengthOf(resolve(cacheDir, line.file))
|
|
198
|
+
if (line.at + length > line.until + 0.05) {
|
|
199
|
+
log(` warning: voice-over: "${line.text}" (${length.toFixed(1)}s) runs ${(line.at + length - line.until).toFixed(1)}s into the recording — shorten \`voice.intro\``)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const key = JSON.stringify({ lines: cached.map((l) => [l.at, l.file]), total, lufs: settings.lufs, v: 2 })
|
|
203
|
+
const stamp = `${target}.key`
|
|
204
|
+
if (existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
const inputs = cached.flatMap((l) => ['-i', resolve(cacheDir, l.file)])
|
|
208
|
+
const delayed = cached.map((l, i) => `[${i}:a]aresample=48000,adelay=${Math.round(l.at * 1000)}:all=1[v${i}]`).join(';')
|
|
209
|
+
// 0.1 s past the video: AAC's encoder padding would otherwise leave the track a frame or
|
|
210
|
+
// two short of its slot. loudnorm works at 192 kHz, hence the resample back.
|
|
211
|
+
const mix =
|
|
212
|
+
`${delayed};${cached.map((_, i) => `[v${i}]`).join('')}amix=inputs=${cached.length}:normalize=0,` +
|
|
213
|
+
`loudnorm=I=${settings.lufs}:TP=-1.5:LRA=11,aresample=48000,apad,atrim=0:${round(total + 0.1)}[out]`
|
|
214
|
+
const render = spawnSync(
|
|
215
|
+
'ffmpeg',
|
|
216
|
+
['-y', '-loglevel', 'error', ...inputs, '-filter_complex', mix, '-map', '[out]', '-c:a', 'aac', '-b:a', '160k', target],
|
|
217
|
+
{ stdio: 'inherit' },
|
|
218
|
+
)
|
|
219
|
+
if (render.status !== 0) {
|
|
220
|
+
throw new ReelsonError('ffmpeg could not mix the voice-over')
|
|
221
|
+
}
|
|
222
|
+
writeFileSync(stamp, key)
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { round } from "./timeline.js";
|
|
2
|
+
export const ZOOM_RULES = {
|
|
3
|
+
defaultEase: 0.8, // the template's default zoom in/out duration
|
|
4
|
+
minEase: 0.4, // shorter reads as a jump
|
|
5
|
+
settle: 0.1, // the zoom is done this long before the click
|
|
6
|
+
early: 0.35, // may start this long before the glide (not more: no waiting)
|
|
7
|
+
linger: 1.2, // with no next target, hold this long after the last action
|
|
8
|
+
absorb: 1.0, // a visible later click whose glide starts within this joins the hold
|
|
9
|
+
maxIdle: 3.0, // a longer wait for the next glide: linger and zoom out, don't sit zoomed
|
|
10
|
+
eps: 0.005, // float slack (13.03 + 4.36 must not fail against 17.39)
|
|
11
|
+
margin: 0.03, // keep clicks this far (fraction of the frame) inside the view
|
|
12
|
+
};
|
|
13
|
+
const R = ZOOM_RULES;
|
|
14
|
+
/**
|
|
15
|
+
* How a following zoom pans: sampled every `step` s, easing towards the cursor with `lag` s
|
|
16
|
+
* of smoothing, aimed `lead` s ahead — where the cursor is going, not where it was (which
|
|
17
|
+
* also cancels the smoothing's delay, and keeps the zoom-in on the click it glides to).
|
|
18
|
+
*/
|
|
19
|
+
export const FOLLOW = { step: 1 / 15, lag: 0.45, lead: 0.45 };
|
|
20
|
+
/**
|
|
21
|
+
* The focus path of a zoom that follows the cursor: at each sample, the transform-origin
|
|
22
|
+
* that puts the cursor in the middle of the zoomed view (clamped so the view never leaves
|
|
23
|
+
* the frame), smoothed so the camera glides instead of jittering. Starts from the planned
|
|
24
|
+
* focus. Pure; `cursor` is the timeline's cursor layer (composition s, recording px).
|
|
25
|
+
*/
|
|
26
|
+
export function followPath(z, cursor, viewport) {
|
|
27
|
+
const at = (t) => {
|
|
28
|
+
const path = cursor.path;
|
|
29
|
+
let i = path.findIndex((p) => p[0] > t);
|
|
30
|
+
if (i === -1)
|
|
31
|
+
i = path.length;
|
|
32
|
+
const a = path[Math.max(0, i - 1)];
|
|
33
|
+
const b = path[i];
|
|
34
|
+
if (!b || i === 0 || b[0] - a[0] > 0.1) {
|
|
35
|
+
return [a[1], a[2]];
|
|
36
|
+
}
|
|
37
|
+
const u = (t - a[0]) / (b[0] - a[0]);
|
|
38
|
+
return [a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u];
|
|
39
|
+
};
|
|
40
|
+
const originFor = (p) => Math.min(1, Math.max(0, (p - 0.5 / z.scale) / (1 - 1 / z.scale)));
|
|
41
|
+
const alpha = 1 - Math.exp(-FOLLOW.step / FOLLOW.lag);
|
|
42
|
+
const end = z.at + z.duration;
|
|
43
|
+
let ox = z.x;
|
|
44
|
+
let oy = z.y;
|
|
45
|
+
const out = [[round(z.at), z.x, z.y]];
|
|
46
|
+
for (let t = z.at + FOLLOW.step; t < end + 1e-9; t += FOLLOW.step) {
|
|
47
|
+
const [cx, cy] = at(Math.min(t + FOLLOW.lead, end));
|
|
48
|
+
ox += (originFor(cx / viewport.width) - ox) * alpha;
|
|
49
|
+
oy += (originFor(cy / viewport.height) - oy) * alpha;
|
|
50
|
+
out.push([Math.round(t * 1000) / 1000, Math.round(ox * 10000) / 10000, Math.round(oy * 10000) / 10000]);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
export class ZoomError extends Error {
|
|
55
|
+
}
|
|
56
|
+
export function compositionClicks(markers, timeline) {
|
|
57
|
+
if (!markers.clicks || markers.clicks.some((c) => c.move === undefined)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return markers.clicks
|
|
61
|
+
.map((c, i) => ({ c, index: i + 1 }))
|
|
62
|
+
.filter(({ c }) => c.at >= timeline.mediaStart && c.at <= timeline.mediaEnd)
|
|
63
|
+
.map(({ c, index }) => ({
|
|
64
|
+
index,
|
|
65
|
+
kind: c.kind,
|
|
66
|
+
comp: timeline.toComposition(c.at),
|
|
67
|
+
glide: timeline.toComposition(c.move ?? c.at),
|
|
68
|
+
until: timeline.toComposition(c.until ?? c.at),
|
|
69
|
+
fx: c.x / timeline.viewport.width,
|
|
70
|
+
fy: c.y / timeline.viewport.height,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
/** Resolves a video.json zoom (click-anchored or manual) to composition time. */
|
|
74
|
+
export function planZoom(spec, clicks, timeline) {
|
|
75
|
+
const scale = spec.scale;
|
|
76
|
+
if (!spec.clicks) {
|
|
77
|
+
if (spec.at === undefined || spec.duration === undefined || spec.x === undefined || spec.y === undefined) {
|
|
78
|
+
throw new ZoomError('a zoom needs `clicks`, or all of `at`, `duration`, `x`, `y` (manual)');
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
at: timeline.toComposition(spec.at),
|
|
82
|
+
duration: spec.duration,
|
|
83
|
+
x: spec.x,
|
|
84
|
+
y: spec.y,
|
|
85
|
+
scale,
|
|
86
|
+
in: spec.in ?? R.defaultEase,
|
|
87
|
+
out: spec.out ?? R.defaultEase,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (spec.at !== undefined || spec.duration !== undefined) {
|
|
91
|
+
throw new ZoomError('a zoom with `clicks` is timed automatically; drop `at`/`duration` (or drop `clicks` for a manual zoom)');
|
|
92
|
+
}
|
|
93
|
+
const [a, b = a] = spec.clicks;
|
|
94
|
+
if (b < a) {
|
|
95
|
+
throw new ZoomError(`clicks [${a}, ${b}]: the first number must not be after the second`);
|
|
96
|
+
}
|
|
97
|
+
const byIndex = (n) => {
|
|
98
|
+
const found = clicks.find((c) => c.index === n);
|
|
99
|
+
if (!found) {
|
|
100
|
+
const available = clicks.map((c) => c.index);
|
|
101
|
+
throw new ZoomError(`click ${n} is not in the trimmed recording (available: ${available.length ? `${available[0]}–${available.at(-1)}` : 'none — re-record to log clicks'})`);
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
};
|
|
105
|
+
const first = byIndex(a);
|
|
106
|
+
byIndex(b);
|
|
107
|
+
const selected = clicks.filter((c) => c.index >= a && c.index <= b);
|
|
108
|
+
// A following zoom pans to each click, so they need not fit in one view: it starts with
|
|
109
|
+
// the first click in the middle of the view.
|
|
110
|
+
const centred = (p) => Math.round(Math.min(1, Math.max(0, (p - 0.5 / scale) / (1 - 1 / scale))) * 10000) / 10000;
|
|
111
|
+
const x = spec.x ?? (spec.follow ? centred(first.fx) : fitFocus(selected.map((c) => c.fx), scale, `clicks ${a}–${b}`, 'wide'));
|
|
112
|
+
const y = spec.y ?? (spec.follow ? centred(first.fy) : fitFocus(selected.map((c) => c.fy), scale, `clicks ${a}–${b}`, 'tall'));
|
|
113
|
+
const visible = spec.follow ? () => true : isVisibleIn(viewOf(x, y, scale));
|
|
114
|
+
const zoomIn = spec.in ?? R.defaultEase;
|
|
115
|
+
const at = spec.in !== undefined
|
|
116
|
+
? round(first.comp - R.settle - zoomIn)
|
|
117
|
+
: round(Math.max(first.glide - R.early, first.comp - R.settle - R.defaultEase));
|
|
118
|
+
const inEase = spec.in ?? round(Math.max(R.minEase, Math.min(R.defaultEase, first.comp - R.settle - at)));
|
|
119
|
+
// The zoom holds its clicks, plus later ones it still shows that follow right away
|
|
120
|
+
// (zooming out and straight back in would jump); the first other click ends it.
|
|
121
|
+
let holdEnd = Math.max(...selected.map((c) => c.until));
|
|
122
|
+
let next;
|
|
123
|
+
for (const c of clicks.filter((c) => c.index > b)) {
|
|
124
|
+
if (visible(c) && c.glide - holdEnd <= R.absorb) {
|
|
125
|
+
holdEnd = Math.max(holdEnd, c.until);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
next = c;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
// The recording belts out before a hand-off card and fades out at the end.
|
|
132
|
+
const nextCard = timeline.transitions.find((t) => t.at > first.comp);
|
|
133
|
+
const limit = Math.min(timeline.clipEnd - 0.1, nextCard ? nextCard.at - timeline.belt : Infinity);
|
|
134
|
+
let outEnd;
|
|
135
|
+
let outEase;
|
|
136
|
+
if (next && next.comp - R.settle <= limit && next.glide - holdEnd <= R.maxIdle) {
|
|
137
|
+
outEnd = next.comp - R.settle;
|
|
138
|
+
const outStart = Math.max(next.glide - R.early, outEnd - R.defaultEase, holdEnd + 0.2);
|
|
139
|
+
outEase = spec.out ?? Math.max(R.minEase, outEnd - outStart);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
outEase = spec.out ?? R.defaultEase;
|
|
143
|
+
outEnd = Math.min(holdEnd + R.linger + outEase, limit);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
at,
|
|
147
|
+
duration: round(outEnd - at),
|
|
148
|
+
x,
|
|
149
|
+
y,
|
|
150
|
+
scale,
|
|
151
|
+
in: round(inEase),
|
|
152
|
+
out: round(outEase),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Zooms on screen at the same time (they would fight over the frame): one message per
|
|
157
|
+
* overlapping pair, keyed by the 0-based index of the later zoom in video.json order.
|
|
158
|
+
*/
|
|
159
|
+
export function zoomOverlaps(zooms) {
|
|
160
|
+
const found = [];
|
|
161
|
+
zooms.forEach((z, j) => {
|
|
162
|
+
zooms.forEach((other, i) => {
|
|
163
|
+
if (i < j && z.at < round(other.at + other.duration) && other.at < round(z.at + z.duration)) {
|
|
164
|
+
found.push({
|
|
165
|
+
index: j,
|
|
166
|
+
message: `overlaps zoom ${i + 1} (${other.at}–${round(other.at + other.duration)}s) — one zoom over both click ranges, or drop one`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
/** Verifies a zoom against the clicks; problems carry a copy-paste fix. */
|
|
174
|
+
export function checkZoom(z, clicks, timeline) {
|
|
175
|
+
const end = round(z.at + z.duration);
|
|
176
|
+
const visible = isVisibleIn(viewOf(z.x, z.y, z.scale));
|
|
177
|
+
const describe = (c) => `${c.kind} #${c.index} at ${c.comp}s (glide from ${c.glide}s, ${Math.round(c.fx * 100)}%, ${Math.round(c.fy * 100)}%)`;
|
|
178
|
+
const problems = [];
|
|
179
|
+
const inside = clicks.filter((c) => c.comp >= z.at && c.comp <= end);
|
|
180
|
+
// A following zoom keeps the cursor in view: every click in it is framed.
|
|
181
|
+
const framed = z.path ? inside : inside.filter(visible);
|
|
182
|
+
const first = framed[0];
|
|
183
|
+
const last = framed.at(-1);
|
|
184
|
+
const next = clicks.find((c) => c.comp > (last?.comp ?? z.at) && !(c.comp <= end && (z.path || visible(c))));
|
|
185
|
+
if (!first) {
|
|
186
|
+
problems.push('frames no click — anchor it with `clicks`, or drop the zoom');
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const fix = ` → use { "clicks": [${first.index}, ${(last ?? first).index}], "scale": ${z.scale} }`;
|
|
190
|
+
if (z.at + z.in > first.comp - R.settle + R.eps) {
|
|
191
|
+
problems.push(`zoom-in not done before the ${describe(first)}${fix}`);
|
|
192
|
+
}
|
|
193
|
+
else if (z.at < first.glide - R.early - R.eps) {
|
|
194
|
+
problems.push(`zoom-in starts before the cursor moves (the viewer waits)${fix}`);
|
|
195
|
+
}
|
|
196
|
+
for (const c of inside) {
|
|
197
|
+
if (!visible(c) && !z.path) {
|
|
198
|
+
problems.push(`${describe(c)} is outside the zoomed view${fix}`);
|
|
199
|
+
}
|
|
200
|
+
else if (c.comp > end - z.out + R.eps) {
|
|
201
|
+
problems.push(`${describe(c)} happens while the zoom eases out${fix}`);
|
|
202
|
+
}
|
|
203
|
+
else if (c.until > end - z.out + R.eps) {
|
|
204
|
+
problems.push(`typing (${describe(c)}) is still going when the zoom eases out${fix}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (next && end > next.comp - R.settle + R.eps && !inside.includes(next)) {
|
|
208
|
+
problems.push(`zoom-out not done before the ${describe(next)}${fix}`);
|
|
209
|
+
}
|
|
210
|
+
else if (next && end - z.out < next.glide - R.early - R.eps && end > next.glide - R.early + R.eps) {
|
|
211
|
+
// Easing out across the start of the glide without riding it (a zoom that is fully
|
|
212
|
+
// out before the cursor even moves is fine: it lingered, then left).
|
|
213
|
+
problems.push(`zoom-out starts before the cursor heads for the next target${fix}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (z.in < R.minEase || z.out < R.minEase) {
|
|
217
|
+
problems.push(`ease shorter than ${R.minEase}s reads as a jump — add a beat in the scenario instead`);
|
|
218
|
+
}
|
|
219
|
+
if (end > timeline.clipEnd + R.eps) {
|
|
220
|
+
problems.push(`runs past the end of the recording (${timeline.clipEnd}s)`);
|
|
221
|
+
}
|
|
222
|
+
for (const t of timeline.transitions) {
|
|
223
|
+
if (z.at < t.at && end > t.at - timeline.belt + R.eps) {
|
|
224
|
+
problems.push(`still zoomed when the recording leaves for the hand-off card at ${t.at}s`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { problems, framed };
|
|
228
|
+
}
|
|
229
|
+
/** The part of the frame visible at full zoom (transform-origin = focus point). */
|
|
230
|
+
function viewOf(x, y, scale) {
|
|
231
|
+
return {
|
|
232
|
+
left: x - x / scale,
|
|
233
|
+
right: x + (1 - x) / scale,
|
|
234
|
+
top: y - y / scale,
|
|
235
|
+
bottom: y + (1 - y) / scale,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function isVisibleIn(view) {
|
|
239
|
+
return (c) => c.fx >= view.left + R.margin - R.eps &&
|
|
240
|
+
c.fx <= view.right - R.margin + R.eps &&
|
|
241
|
+
c.fy >= view.top + R.margin - R.eps &&
|
|
242
|
+
c.fy <= view.bottom - R.margin + R.eps;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* A focus coordinate (0..1) that keeps every point visible at `scale`, as close
|
|
246
|
+
* to their centre as possible. With origin o, point p shows iff
|
|
247
|
+
* o - o/s ≤ p ≤ o + (1 - o)/s.
|
|
248
|
+
*/
|
|
249
|
+
function fitFocus(points, scale, what, axis) {
|
|
250
|
+
const p1 = Math.min(...points) - R.margin;
|
|
251
|
+
const p2 = Math.max(...points) + R.margin;
|
|
252
|
+
const lo = Math.max(0, (p2 * scale - 1) / (scale - 1));
|
|
253
|
+
const hi = Math.min(1, (p1 * scale) / (scale - 1));
|
|
254
|
+
if (lo > hi + 1e-9) {
|
|
255
|
+
throw new ZoomError(`${what} are too far apart (${axis}) to show at ${scale}x — lower the scale or split the zoom`);
|
|
256
|
+
}
|
|
257
|
+
const mid = (Math.min(...points) + Math.max(...points)) / 2;
|
|
258
|
+
return Math.round(Math.min(hi, Math.max(lo, mid)) * 1000) / 1000;
|
|
259
|
+
}
|