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
package/bin/reelson.ts
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* reelson — scripted Playwright walkthroughs → branded HyperFrames demo videos.
|
|
4
|
+
* Run `reelson help` for the commands. Project settings come from the nearest
|
|
5
|
+
* demo.config.json (walking up from the working directory).
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { basename, relative, resolve } from 'node:path'
|
|
12
|
+
import { parseArgs, parseEnv } from 'node:util'
|
|
13
|
+
import { CONFIG_SCHEMA_PATH, ConfigError, fromRoot, loadConfig, type LoadedConfig } from '../skills/reelson-record/scripts/config.ts'
|
|
14
|
+
import { doctor } from '../skills/reelson-record/scripts/doctor.ts'
|
|
15
|
+
import { build, plan, type BuildOptions } from '../skills/reelson-compose/scripts/build.ts'
|
|
16
|
+
import { captionCues, toSrt, toVtt } from '../skills/reelson-compose/scripts/captions.ts'
|
|
17
|
+
import { check } from '../skills/reelson-compose/scripts/check.ts'
|
|
18
|
+
import { studio } from '../skills/reelson-compose/scripts/studio.ts'
|
|
19
|
+
import { fetchLines, spokenTexts, voiceSettings, type VoiceSettings } from '../skills/reelson-compose/scripts/voice.ts'
|
|
20
|
+
import { listVoices, missingSetup, PROVIDERS, type Provider } from '../skills/reelson-compose/scripts/tts.ts'
|
|
21
|
+
import { TAKES, verify } from '../skills/reelson-compose/scripts/verify.ts'
|
|
22
|
+
import { DRAFT_FLAGS, FPS, gifIfChanged, hyperframes, hyperframesOn, RENDER_FLAGS, renderIfChanged } from '../skills/reelson-compose/scripts/hyperframes.ts'
|
|
23
|
+
import { catalog, KIT_ROOT, listDemos, RECORD_SCRIPT, ReelsonError, resolveDemoDir } from '../skills/reelson-compose/scripts/project.ts'
|
|
24
|
+
import { SLOTS, type SectionChoice, type Timeline } from '../skills/reelson-compose/scripts/timeline.ts'
|
|
25
|
+
|
|
26
|
+
const HELP = `reelson — scripted walkthroughs → branded demo videos
|
|
27
|
+
|
|
28
|
+
Usage: reelson <command> [options]
|
|
29
|
+
|
|
30
|
+
install [<project-dir>] [--global] [--no-browser]
|
|
31
|
+
link the reelson-record + reelson-compose skills into
|
|
32
|
+
<project>/.claude/skills/ (default: this directory; --global:
|
|
33
|
+
~/.claude/skills/), create demo.config.json if missing and
|
|
34
|
+
download Playwright's Chromium (--no-browser: skip it)
|
|
35
|
+
init create demo.config.json in this directory
|
|
36
|
+
doctor check the tools and that the cursor layer lines up here
|
|
37
|
+
new <slug> [--url <origin>] start <videosDir>/<slug>/scenario.ts
|
|
38
|
+
record <slug> [--headed] [--mobile | --square | --all-takes]
|
|
39
|
+
run the scenario → recording.mp4 + markers.json; --mobile: on a
|
|
40
|
+
phone (record.mobile.device) → recording.mobile.mp4, for --portrait;
|
|
41
|
+
--square: in a square browser (record.square.viewport) →
|
|
42
|
+
recording.square.mp4, for --square; --all-takes: all three
|
|
43
|
+
build <slug> [options] video.json → video/ (HyperFrames project)
|
|
44
|
+
--title, --subtitle, --template <name>
|
|
45
|
+
--intro <name>, --recap <name|none>, --outro <name>
|
|
46
|
+
--trim-start <s|auto>, --trim-end <s>, --music <file> | --no-music
|
|
47
|
+
voice <slug> speak the voice-over lines not spoken yet (video.json "voice": true;
|
|
48
|
+
demo.config.json voice.provider: openai, elevenlabs, piper (local)
|
|
49
|
+
or command (local); cached in <demo>/voice/)
|
|
50
|
+
voices [--provider <name>] [--all] [--library]
|
|
51
|
+
the voices to pick for voice.voice (openai; elevenlabs: your
|
|
52
|
+
account's, --library: the Voice Library's in the project's
|
|
53
|
+
language, paid plans; piper: the project's language, --all: every one)
|
|
54
|
+
check <slug> [--no-hyperframes] schemas, zoom timing, hyperframes lint
|
|
55
|
+
verify <slug...> | --all [--update]
|
|
56
|
+
re-record every take (desktop, phone, square) headless into a
|
|
57
|
+
scratch folder and check video.json still fits (markers, click
|
|
58
|
+
numbers, zooms); --update keeps them
|
|
59
|
+
snapshot <slug> --at 1,3.5,8 [--portrait | --square]
|
|
60
|
+
PNG frames into video/snapshots/ (…/portrait/, …/square/)
|
|
61
|
+
studio <slug> [--port 4800] [--no-open]
|
|
62
|
+
preview + edit on a timeline of every layer (saves video.json)
|
|
63
|
+
preview <slug> open the HyperFrames studio (raw composition)
|
|
64
|
+
templates list templates and intro/recap/outro sections
|
|
65
|
+
render <slug...> | --all [--gif] [--square] [--portrait] [--all-formats] [--only <format>]
|
|
66
|
+
[--draft] [--force] [--no-build]
|
|
67
|
+
build + render video/renders/<slug>.mp4 + .srt/.vtt captions;
|
|
68
|
+
skips a video unchanged since its last render (--force);
|
|
69
|
+
--gif, --square (1080², the square take filling the frame),
|
|
70
|
+
--portrait (1080x1920 phone layout) add versions, --all-formats
|
|
71
|
+
both (also video.json "formats": ["portrait", "square"]);
|
|
72
|
+
--only landscape|portrait|square renders just that version;
|
|
73
|
+
--draft: a 2x faster 15 fps look → renders/<slug>.draft.mp4
|
|
74
|
+
|
|
75
|
+
<slug> is a folder under videosDir, or a path to a demo folder or its scenario.ts.
|
|
76
|
+
Docs: ${KIT_ROOT}/README.md`
|
|
77
|
+
|
|
78
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
79
|
+
const SECRETS = ['OPENAI_API_KEY', 'ELEVENLABS_API_KEY']
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
loadSecrets()
|
|
83
|
+
process.exitCode = await run(command, rest)
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof ReelsonError || error instanceof ConfigError) {
|
|
86
|
+
console.error(`reelson: ${error.message}`)
|
|
87
|
+
process.exitCode = 1
|
|
88
|
+
} else {
|
|
89
|
+
throw error
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Picks the secrets reelson reads (OPENAI_API_KEY, ELEVENLABS_API_KEY) up from a .env next to demo.config.json, else from one in the reelson checkout;
|
|
95
|
+
* a variable already in the environment wins. The rest of those files is left alone.
|
|
96
|
+
*/
|
|
97
|
+
function loadSecrets(): void {
|
|
98
|
+
for (const dir of [loadConfig(process.cwd()).root, KIT_ROOT]) {
|
|
99
|
+
const file = resolve(dir, '.env')
|
|
100
|
+
if (!existsSync(file)) {
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const values = parseEnv(readFileSync(file, 'utf8'))
|
|
104
|
+
for (const name of SECRETS) {
|
|
105
|
+
if (!process.env[name] && values[name]) {
|
|
106
|
+
process.env[name] = values[name]
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function run(cmd: string | undefined, argv: string[]): Promise<number> {
|
|
113
|
+
switch (cmd) {
|
|
114
|
+
case undefined:
|
|
115
|
+
case 'help':
|
|
116
|
+
case '--help':
|
|
117
|
+
case '-h':
|
|
118
|
+
console.log(HELP)
|
|
119
|
+
return 0
|
|
120
|
+
case '--version':
|
|
121
|
+
case '-v':
|
|
122
|
+
console.log(JSON.parse(readFileSync(resolve(KIT_ROOT, 'package.json'), 'utf8')).version)
|
|
123
|
+
return 0
|
|
124
|
+
case 'install':
|
|
125
|
+
return install(argv)
|
|
126
|
+
case 'init':
|
|
127
|
+
return init()
|
|
128
|
+
case 'doctor':
|
|
129
|
+
const problems = await doctor()
|
|
130
|
+
voiceDoctor()
|
|
131
|
+
return problems ? 1 : 0
|
|
132
|
+
case 'new':
|
|
133
|
+
return create(argv)
|
|
134
|
+
case 'record':
|
|
135
|
+
return record(argv)
|
|
136
|
+
case 'build':
|
|
137
|
+
return buildCommand(argv)
|
|
138
|
+
case 'voice':
|
|
139
|
+
return voiceCommand(argv)
|
|
140
|
+
case 'voices':
|
|
141
|
+
return voicesCommand(argv)
|
|
142
|
+
case 'check':
|
|
143
|
+
return checkCommand(argv)
|
|
144
|
+
case 'verify':
|
|
145
|
+
return verifyCommand(argv)
|
|
146
|
+
case 'snapshot':
|
|
147
|
+
return snapshot(argv)
|
|
148
|
+
case 'studio':
|
|
149
|
+
return studioCommand(argv)
|
|
150
|
+
case 'preview':
|
|
151
|
+
return preview(argv)
|
|
152
|
+
case 'render':
|
|
153
|
+
return await render(argv)
|
|
154
|
+
case 'templates':
|
|
155
|
+
return templates()
|
|
156
|
+
default:
|
|
157
|
+
console.error(`reelson: unknown command "${cmd}"\n\n${HELP}`)
|
|
158
|
+
return 2
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `reelson doctor`'s voice-over line: can the configured provider speak here? (A note: not every project uses it.) */
|
|
163
|
+
function voiceDoctor(): void {
|
|
164
|
+
const settings = voiceSettings({ title: '', voice: true }, loadConfig(process.cwd())) as VoiceSettings
|
|
165
|
+
const problem = missingSetup(settings)
|
|
166
|
+
const what = `voice-over: ${settings.provider}${settings.voice ? ` (${settings.voice})` : ''}`
|
|
167
|
+
console.log(problem ? `· ${what} — for "voice": true videos: ${problem}` : `✓ ${what}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function config(): LoadedConfig {
|
|
171
|
+
const loaded = loadConfig(process.cwd())
|
|
172
|
+
if (!loaded.path) {
|
|
173
|
+
console.warn('reelson: no demo.config.json found — using defaults (run `reelson init`)')
|
|
174
|
+
}
|
|
175
|
+
return loaded
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function one(positionals: string[], usage: string): string {
|
|
179
|
+
if (positionals.length !== 1) {
|
|
180
|
+
throw new ReelsonError(`usage: reelson ${usage}`)
|
|
181
|
+
}
|
|
182
|
+
return positionals[0]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `reelson install`: links the skills into a project (or ~/.claude) and prepares the machine. The links
|
|
187
|
+
* point at this installation, so `npm update -g reelson` (or `git pull` in a checkout) updates every project.
|
|
188
|
+
*/
|
|
189
|
+
function install(argv: string[]): number {
|
|
190
|
+
const skillNames = ['reelson-record', 'reelson-compose']
|
|
191
|
+
// Names used before 0.7 (demo-* before that): their links are dropped when they point at a reelson install.
|
|
192
|
+
const oldNames = ['demo-record', 'demo-video', 'reelkit-record', 'reelkit-compose']
|
|
193
|
+
const { values, positionals } = parseArgs({
|
|
194
|
+
args: argv,
|
|
195
|
+
allowPositionals: true,
|
|
196
|
+
options: { global: { type: 'boolean' }, 'no-browser': { type: 'boolean' } },
|
|
197
|
+
})
|
|
198
|
+
if (positionals.length > 1 || (values.global && positionals.length)) {
|
|
199
|
+
throw new ReelsonError('usage: reelson install [<project-dir> | --global] [--no-browser]')
|
|
200
|
+
}
|
|
201
|
+
const target = values.global ? homedir() : resolve(positionals[0] ?? '.')
|
|
202
|
+
if (!existsSync(target)) {
|
|
203
|
+
throw new ReelsonError(`${target} does not exist`)
|
|
204
|
+
}
|
|
205
|
+
if (spawnSync('ffmpeg', ['-version']).status !== 0) {
|
|
206
|
+
console.warn('warning: ffmpeg not found — brew install ffmpeg')
|
|
207
|
+
}
|
|
208
|
+
if (!values['no-browser']) {
|
|
209
|
+
console.log('Installing Playwright\'s Chromium (skipped when it is already there)…')
|
|
210
|
+
const cli = createRequire(import.meta.url).resolve('@playwright/test/cli')
|
|
211
|
+
const status = spawnSync(process.execPath, [cli, 'install', 'chromium'], { stdio: 'inherit' }).status
|
|
212
|
+
if (status !== 0) {
|
|
213
|
+
throw new ReelsonError('could not install Chromium — run `npx playwright install chromium`')
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const skills = resolve(target, '.claude/skills')
|
|
218
|
+
mkdirSync(skills, { recursive: true })
|
|
219
|
+
const isLink = (path: string): boolean => lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() ?? false
|
|
220
|
+
for (const old of oldNames) {
|
|
221
|
+
const link = resolve(skills, old)
|
|
222
|
+
if (isLink(link) && (resolve(skills, readlinkSync(link)).startsWith(`${KIT_ROOT}/`) || /[\\/]skills[\\/](reelkit|reelson)-/.test(readlinkSync(link)))) {
|
|
223
|
+
rmSync(link)
|
|
224
|
+
console.log(` removed old link ${link}`)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const skill of skillNames) {
|
|
228
|
+
const link = resolve(skills, skill)
|
|
229
|
+
if (existsSync(link) && !isLink(link)) {
|
|
230
|
+
throw new ReelsonError(`${link} is a folder, not a link to reelson — move it away and run install again`)
|
|
231
|
+
}
|
|
232
|
+
if (isLink(link)) {
|
|
233
|
+
rmSync(link)
|
|
234
|
+
}
|
|
235
|
+
symlinkSync(resolve(KIT_ROOT, 'skills', skill), link, 'dir')
|
|
236
|
+
console.log(` linked ${link}`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!values.global) {
|
|
240
|
+
if (!existsSync(resolve(target, 'demo.config.json'))) {
|
|
241
|
+
init(target)
|
|
242
|
+
}
|
|
243
|
+
const videos = loadConfig(target).videosDir.replace(/^\.?\/+|\/+$/g, '')
|
|
244
|
+
console.log(`
|
|
245
|
+
Add to ${resolve(target, '.gitignore')}:
|
|
246
|
+
|
|
247
|
+
/${videos}/**/recording*.mp4
|
|
248
|
+
/${videos}/**/.raw*/
|
|
249
|
+
/${videos}/**/video/
|
|
250
|
+
/${videos}/**/*.openscreen`)
|
|
251
|
+
}
|
|
252
|
+
console.log('\nDone. Try: reelson doctor')
|
|
253
|
+
return 0
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function init(dir = process.cwd()): number {
|
|
257
|
+
const target = resolve(dir, 'demo.config.json')
|
|
258
|
+
if (existsSync(target)) {
|
|
259
|
+
throw new ReelsonError(`${target} already exists`)
|
|
260
|
+
}
|
|
261
|
+
const example = JSON.parse(readFileSync(resolve(KIT_ROOT, 'demo.config.example.json'), 'utf8'))
|
|
262
|
+
delete example.$comment
|
|
263
|
+
delete example.$schema
|
|
264
|
+
const viaProject = resolve(dir, '.claude/skills/reelson-record/schemas/demo.config.schema.json')
|
|
265
|
+
const schema = existsSync(viaProject) ? viaProject : CONFIG_SCHEMA_PATH
|
|
266
|
+
let schemaRef = relative(dir, schema)
|
|
267
|
+
if (!/^\.{1,2}\//.test(schemaRef) && !schemaRef.startsWith('/')) {
|
|
268
|
+
schemaRef = `./${schemaRef}`
|
|
269
|
+
}
|
|
270
|
+
writeFileSync(target, JSON.stringify({ $schema: schemaRef, ...example }, null, 4) + '\n')
|
|
271
|
+
console.log(`created ${target} — set brand, language and music`)
|
|
272
|
+
return 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function create(argv: string[]): number {
|
|
276
|
+
const { values, positionals } = parseArgs({
|
|
277
|
+
args: argv,
|
|
278
|
+
allowPositionals: true,
|
|
279
|
+
options: { url: { type: 'string' } },
|
|
280
|
+
})
|
|
281
|
+
const slug = one(positionals, 'new <slug> [--url https://app.test]')
|
|
282
|
+
const cfg = config()
|
|
283
|
+
const dir = resolve(fromRoot(cfg, cfg.videosDir), slug)
|
|
284
|
+
const scenario = resolve(dir, 'scenario.ts')
|
|
285
|
+
if (existsSync(scenario)) {
|
|
286
|
+
throw new ReelsonError(`${scenario} already exists`)
|
|
287
|
+
}
|
|
288
|
+
mkdirSync(dir, { recursive: true })
|
|
289
|
+
const viaProject = resolve(cfg.root, '.claude/skills/reelson-record/scripts/scenario.ts')
|
|
290
|
+
const typesFile = existsSync(viaProject) ? viaProject : resolve(KIT_ROOT, 'skills/reelson-record/scripts/scenario.ts')
|
|
291
|
+
let importPath = relative(dir, typesFile)
|
|
292
|
+
if (!/^\.{1,2}\//.test(importPath)) {
|
|
293
|
+
importPath = `./${importPath}`
|
|
294
|
+
}
|
|
295
|
+
writeFileSync(
|
|
296
|
+
scenario,
|
|
297
|
+
`/**
|
|
298
|
+
* Demo: ${slug}
|
|
299
|
+
*
|
|
300
|
+
* reelson record ${slug} (--headed to watch)
|
|
301
|
+
* reelson build ${slug} --title "..."
|
|
302
|
+
*
|
|
303
|
+
* Call demo.marker('Step text') right after the UI reaches each state worth a
|
|
304
|
+
* callout (≤ 10); each becomes a callout in video.json, shown from the start of
|
|
305
|
+
* that step (the first glide/click after the previous marker).
|
|
306
|
+
*/
|
|
307
|
+
import type { Scenario } from '${importPath}'
|
|
308
|
+
|
|
309
|
+
export default {
|
|
310
|
+
name: '${slug}',
|
|
311
|
+
baseURL: '${values.url ?? 'https://app.test'}',
|
|
312
|
+
async run(demo) {
|
|
313
|
+
// Log in with the project's helpers here (it is trimmed away later), then:
|
|
314
|
+
await demo.goto('/')
|
|
315
|
+
demo.marker('First step')
|
|
316
|
+
|
|
317
|
+
// Hold so the last callout can be read before the recording ends.
|
|
318
|
+
await demo.pause(2000)
|
|
319
|
+
},
|
|
320
|
+
} satisfies Scenario
|
|
321
|
+
`,
|
|
322
|
+
)
|
|
323
|
+
console.log(`created ${scenario}`)
|
|
324
|
+
return 0
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function record(argv: string[]): number {
|
|
328
|
+
const { values, positionals } = parseArgs({
|
|
329
|
+
args: argv,
|
|
330
|
+
allowPositionals: true,
|
|
331
|
+
options: {
|
|
332
|
+
headed: { type: 'boolean' },
|
|
333
|
+
mobile: { type: 'boolean' },
|
|
334
|
+
square: { type: 'boolean' },
|
|
335
|
+
'all-takes': { type: 'boolean' },
|
|
336
|
+
out: { type: 'string' },
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
if ([values.mobile, values.square, values['all-takes']].filter(Boolean).length > 1) {
|
|
340
|
+
throw new ReelsonError('record: --mobile, --square or --all-takes')
|
|
341
|
+
}
|
|
342
|
+
const target = one(positionals, 'record <slug|scenario.ts> [--headed] [--mobile | --square | --all-takes]')
|
|
343
|
+
const scenario = target.endsWith('.ts') ? resolve(target) : resolve(resolveDemoDir(target, config()), 'scenario.ts')
|
|
344
|
+
if (!existsSync(scenario)) {
|
|
345
|
+
throw new ReelsonError(`no scenario at ${scenario} — create one with \`reelson new\``)
|
|
346
|
+
}
|
|
347
|
+
const takes = values['all-takes']
|
|
348
|
+
? TAKES.map((take) => take.flags)
|
|
349
|
+
: [[...(values.mobile ? ['--mobile'] : []), ...(values.square ? ['--square'] : [])]]
|
|
350
|
+
for (const flags of takes) {
|
|
351
|
+
const args = [RECORD_SCRIPT, scenario, ...(values.headed ? ['--headed'] : []), ...flags, ...(values.out ? ['--out', values.out] : [])]
|
|
352
|
+
const status = spawnSync(process.execPath, args, { stdio: 'inherit' }).status ?? 1
|
|
353
|
+
if (status !== 0) {
|
|
354
|
+
return status
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return 0
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function buildOptions(argv: string[]): { options: BuildOptions; positionals: string[] } {
|
|
361
|
+
const { values, positionals } = parseArgs({
|
|
362
|
+
args: argv,
|
|
363
|
+
allowPositionals: true,
|
|
364
|
+
options: {
|
|
365
|
+
title: { type: 'string' },
|
|
366
|
+
subtitle: { type: 'string' },
|
|
367
|
+
template: { type: 'string' },
|
|
368
|
+
intro: { type: 'string' },
|
|
369
|
+
recap: { type: 'string' },
|
|
370
|
+
outro: { type: 'string' },
|
|
371
|
+
'trim-start': { type: 'string' },
|
|
372
|
+
'trim-end': { type: 'string' },
|
|
373
|
+
music: { type: 'string' },
|
|
374
|
+
'no-music': { type: 'boolean' },
|
|
375
|
+
},
|
|
376
|
+
})
|
|
377
|
+
const seconds = (v: string | undefined, name: string): number | undefined => {
|
|
378
|
+
if (v === undefined) {
|
|
379
|
+
return undefined
|
|
380
|
+
}
|
|
381
|
+
const n = Number.parseFloat(v)
|
|
382
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
383
|
+
throw new ReelsonError(`--${name} expects seconds, got "${v}"`)
|
|
384
|
+
}
|
|
385
|
+
return n
|
|
386
|
+
}
|
|
387
|
+
const trimPoint = (v: string | undefined, name: string) => (v === 'auto' ? 'auto' : seconds(v, name))
|
|
388
|
+
const cfg = config()
|
|
389
|
+
const options: BuildOptions = {
|
|
390
|
+
title: values.title,
|
|
391
|
+
subtitle: values.subtitle,
|
|
392
|
+
template: values.template,
|
|
393
|
+
sections: Object.fromEntries(SLOTS.filter((slot) => values[slot] !== undefined).map((slot) => [slot, values[slot]])) as SectionChoice,
|
|
394
|
+
trimStart: trimPoint(values['trim-start'], 'trim-start'),
|
|
395
|
+
trimEnd: seconds(values['trim-end'], 'trim-end'),
|
|
396
|
+
// video.json stores the path relative to the project root.
|
|
397
|
+
music: values['no-music'] ? false : values.music ? relative(cfg.root, resolve(values.music)) : undefined,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { options, positionals }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function buildCommand(argv: string[]): Promise<number> {
|
|
404
|
+
const { options, positionals } = buildOptions(argv)
|
|
405
|
+
const cfg = config()
|
|
406
|
+
const dir = resolveDemoDir(one(positionals, 'build <slug> [options]'), cfg)
|
|
407
|
+
build(dir, cfg, options)
|
|
408
|
+
// Voice-over lines not spoken yet: fetch them, then build again to mix them in.
|
|
409
|
+
if (await speak(dir, cfg)) {
|
|
410
|
+
build(dir, cfg, { log: () => {} })
|
|
411
|
+
}
|
|
412
|
+
return 0
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Fetches the voice-over lines this demo is missing (see voice.ts); how many it fetched.
|
|
417
|
+
* Unless `strict`, a failure (no OPENAI_API_KEY, offline) is a warning: the video is built
|
|
418
|
+
* with the lines it has.
|
|
419
|
+
*/
|
|
420
|
+
async function speak(dir: string, cfg: LoadedConfig, strict = false): Promise<number> {
|
|
421
|
+
const { spec, timeline } = plan(dir, cfg)
|
|
422
|
+
const settings = voiceSettings(spec, cfg)
|
|
423
|
+
if (!settings) {
|
|
424
|
+
return 0
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
return await fetchLines(spokenTexts(spec, timeline.callouts), settings, resolve(dir, 'voice'), console.log)
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (strict || !(error instanceof ReelsonError || error instanceof TypeError)) {
|
|
430
|
+
throw error
|
|
431
|
+
}
|
|
432
|
+
console.warn(`reelson: ${error.message} — building without those lines`)
|
|
433
|
+
return 0
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function voiceCommand(argv: string[]): Promise<number> {
|
|
438
|
+
const { positionals } = parseArgs({ args: argv, allowPositionals: true })
|
|
439
|
+
const cfg = config()
|
|
440
|
+
const dir = resolveDemoDir(one(positionals, 'voice <slug>'), cfg)
|
|
441
|
+
if (!voiceSettings(plan(dir, cfg).spec, cfg)) {
|
|
442
|
+
throw new ReelsonError(`${basename(dir)} has no voice-over — set "voice": true in its video.json`)
|
|
443
|
+
}
|
|
444
|
+
const fetched = await speak(dir, cfg, true)
|
|
445
|
+
console.log(fetched ? `spoke ${fetched} line(s) into ${resolve(dir, 'voice')}` : 'every line is already spoken')
|
|
446
|
+
return 0
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function voicesCommand(argv: string[]): Promise<number> {
|
|
450
|
+
const { values } = parseArgs({ args: argv, options: { provider: { type: 'string' }, all: { type: 'boolean' }, library: { type: 'boolean' } } })
|
|
451
|
+
const cfg = loadConfig(process.cwd())
|
|
452
|
+
const provider = (values.provider ?? cfg.voice.provider) as Provider
|
|
453
|
+
if (!PROVIDERS.includes(provider)) {
|
|
454
|
+
throw new ReelsonError(`--provider expects ${PROVIDERS.join(', ')}, got "${provider}"`)
|
|
455
|
+
}
|
|
456
|
+
const current = voiceSettings({ title: '', voice: { provider } }, cfg) as VoiceSettings
|
|
457
|
+
const voices = await listVoices(provider, cfg.language, values.all, values.library)
|
|
458
|
+
const width = Math.min(40, Math.max(...voices.map((v) => (v.name === v.id ? v.id : `${v.name} ${v.id}`).length)))
|
|
459
|
+
const scope = values.library ? ` in the Voice Library for "${cfg.language}" (paid plans)` : provider === 'piper' && !values.all ? ` for "${cfg.language}"` : ''
|
|
460
|
+
console.log(`${provider} voices${scope} (voice.voice; * = the one used now):`)
|
|
461
|
+
for (const v of voices) {
|
|
462
|
+
const label = v.name === v.id ? v.id : `${v.name} ${v.id}`
|
|
463
|
+
const used = v.id === current.voice || v.name.toLowerCase() === current.voice.toLowerCase()
|
|
464
|
+
console.log(`${used ? '*' : ' '} ${label.padEnd(width)} ${v.about}`)
|
|
465
|
+
}
|
|
466
|
+
return 0
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function verifyCommand(argv: string[]): number {
|
|
470
|
+
const { values, positionals } = parseArgs({
|
|
471
|
+
args: argv,
|
|
472
|
+
allowPositionals: true,
|
|
473
|
+
options: { all: { type: 'boolean' }, update: { type: 'boolean' } },
|
|
474
|
+
})
|
|
475
|
+
const cfg = config()
|
|
476
|
+
const dirs = values.all ? listDemos(cfg) : positionals.map((p) => resolveDemoDir(p, cfg))
|
|
477
|
+
if (!dirs.length) {
|
|
478
|
+
throw new ReelsonError(values.all ? 'no demos with a video.json under videosDir' : 'usage: reelson verify <slug...> | --all [--update]')
|
|
479
|
+
}
|
|
480
|
+
let failing = 0
|
|
481
|
+
for (const dir of dirs) {
|
|
482
|
+
if (verify(dir, cfg, { update: values.update })) {
|
|
483
|
+
failing++
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
console.log(failing ? `\n${failing} of ${dirs.length} demo(s) need attention` : `\nall ${dirs.length} demo(s) still record and fit their video.json`)
|
|
487
|
+
return failing ? 1 : 0
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function checkCommand(argv: string[]): number {
|
|
491
|
+
const { values, positionals } = parseArgs({
|
|
492
|
+
args: argv,
|
|
493
|
+
allowPositionals: true,
|
|
494
|
+
options: { 'no-hyperframes': { type: 'boolean' } },
|
|
495
|
+
})
|
|
496
|
+
const cfg = config()
|
|
497
|
+
const problems = check(resolveDemoDir(one(positionals, 'check <slug> [--no-hyperframes]'), cfg), cfg, {
|
|
498
|
+
hyperframes: !values['no-hyperframes'],
|
|
499
|
+
})
|
|
500
|
+
return problems ? 1 : 0
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function templates(): number {
|
|
504
|
+
const cfg = config()
|
|
505
|
+
const { templates: found, sections } = catalog(cfg)
|
|
506
|
+
const row = (name: string, description: string, local: boolean) =>
|
|
507
|
+
` ${(name + (local ? ' (project)' : '')).padEnd(22)} ${description}`
|
|
508
|
+
console.log('Templates (the stage: background, frame, callouts) — "template":')
|
|
509
|
+
found.forEach((t) => console.log(row(t.name, t.description, t.local)))
|
|
510
|
+
for (const slot of SLOTS) {
|
|
511
|
+
console.log(`\nSections — "sections": { "${slot}": … }`)
|
|
512
|
+
sections[slot].forEach((s) => console.log(row(s.name, s.description, s.local)))
|
|
513
|
+
}
|
|
514
|
+
console.log(`\nProject: template "${cfg.template}", sections ${JSON.stringify(cfg.sections)} (video.json overrides both)`)
|
|
515
|
+
return 0
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function builtVideoDir(slug: string): string {
|
|
519
|
+
const dir = resolve(resolveDemoDir(slug, config()), 'video')
|
|
520
|
+
if (!existsSync(resolve(dir, 'index.html'))) {
|
|
521
|
+
throw new ReelsonError(`${dir} is not built — run \`reelson build ${slug}\``)
|
|
522
|
+
}
|
|
523
|
+
return dir
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function snapshot(argv: string[]): number {
|
|
527
|
+
const { values, positionals } = parseArgs({
|
|
528
|
+
args: argv,
|
|
529
|
+
allowPositionals: true,
|
|
530
|
+
options: { at: { type: 'string' }, portrait: { type: 'boolean' }, square: { type: 'boolean' } },
|
|
531
|
+
})
|
|
532
|
+
const slug = one(positionals, 'snapshot <slug> --at 1,3.5,8 [--portrait | --square]')
|
|
533
|
+
const videoDir = builtVideoDir(slug)
|
|
534
|
+
const format = values.portrait ? 'portrait' : values.square ? 'square' : null
|
|
535
|
+
const composition = format ? `${format}.html` : 'index.html'
|
|
536
|
+
if (!existsSync(resolve(videoDir, composition))) {
|
|
537
|
+
throw new ReelsonError(`no ${composition} — ${format === 'square' ? `record the square take (\`reelson record ${slug} --square\`) and ` : ''}run \`reelson build ${slug}\``)
|
|
538
|
+
}
|
|
539
|
+
let at = values.at
|
|
540
|
+
if (at) {
|
|
541
|
+
// There is no frame at the very end (it would come out blank): clamp to the last one.
|
|
542
|
+
const html = readFileSync(resolve(videoDir, composition), 'utf8')
|
|
543
|
+
const total = Number.parseFloat(/data-composition-id="main"[^>]*data-duration="([\d.]+)"/.exec(html)?.[1] ?? 'NaN')
|
|
544
|
+
const last = Math.floor(total * FPS - 1e-6) / FPS
|
|
545
|
+
at = at
|
|
546
|
+
.split(',')
|
|
547
|
+
.map((v) => {
|
|
548
|
+
const t = Number.parseFloat(v)
|
|
549
|
+
if (!Number.isFinite(t) || t < 0) {
|
|
550
|
+
throw new ReelsonError(`--at expects seconds, got "${v}"`)
|
|
551
|
+
}
|
|
552
|
+
if (t <= last) {
|
|
553
|
+
return v.trim()
|
|
554
|
+
}
|
|
555
|
+
console.log(`snapshot: ${t}s is past the last frame — using ${last.toFixed(3)}s`)
|
|
556
|
+
return last.toFixed(3)
|
|
557
|
+
})
|
|
558
|
+
.join(',')
|
|
559
|
+
}
|
|
560
|
+
const out = resolve(videoDir, 'snapshots', ...(format ? [format] : []))
|
|
561
|
+
return hyperframesOn(videoDir, composition, ['snapshot', '.', '-o', out, ...(at ? ['--at', at] : [])])
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function studioCommand(argv: string[]): Promise<number> {
|
|
565
|
+
const { values, positionals } = parseArgs({
|
|
566
|
+
args: argv,
|
|
567
|
+
allowPositionals: true,
|
|
568
|
+
options: { port: { type: 'string' }, 'no-open': { type: 'boolean' } },
|
|
569
|
+
})
|
|
570
|
+
const cfg = config()
|
|
571
|
+
const dir = resolveDemoDir(one(positionals, 'studio <slug> [--port 4800] [--no-open]'), cfg)
|
|
572
|
+
const port = values.port === undefined ? undefined : Number.parseInt(values.port, 10)
|
|
573
|
+
if (port !== undefined && !(port > 0 && port < 65536)) {
|
|
574
|
+
throw new ReelsonError(`--port expects a port number, got "${values.port}"`)
|
|
575
|
+
}
|
|
576
|
+
const url = await studio(dir, cfg, { port })
|
|
577
|
+
console.log(`studio for ${basename(dir)}: ${url} (watching video.json, markers.json, templates, sections — Ctrl+C to stop)`)
|
|
578
|
+
if (!values['no-open']) {
|
|
579
|
+
spawnSync(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { stdio: 'ignore' })
|
|
580
|
+
}
|
|
581
|
+
// The server keeps the process alive; the exit code is set when it is stopped.
|
|
582
|
+
return 0
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function preview(argv: string[]): number {
|
|
586
|
+
const { positionals } = parseArgs({ args: argv, allowPositionals: true })
|
|
587
|
+
return hyperframes(['preview', '.'], builtVideoDir(one(positionals, 'preview <slug>')))
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function render(argv: string[]): Promise<number> {
|
|
591
|
+
const { values, positionals } = parseArgs({
|
|
592
|
+
args: argv,
|
|
593
|
+
allowPositionals: true,
|
|
594
|
+
options: {
|
|
595
|
+
all: { type: 'boolean' },
|
|
596
|
+
gif: { type: 'boolean' },
|
|
597
|
+
square: { type: 'boolean' },
|
|
598
|
+
portrait: { type: 'boolean' },
|
|
599
|
+
'all-formats': { type: 'boolean' },
|
|
600
|
+
only: { type: 'string' },
|
|
601
|
+
draft: { type: 'boolean' },
|
|
602
|
+
force: { type: 'boolean' },
|
|
603
|
+
'no-build': { type: 'boolean' },
|
|
604
|
+
},
|
|
605
|
+
})
|
|
606
|
+
if (values.only && !['landscape', 'portrait', 'square'].includes(values.only)) {
|
|
607
|
+
throw new ReelsonError(`--only expects landscape, portrait or square, got "${values.only}"`)
|
|
608
|
+
}
|
|
609
|
+
const cfg = config()
|
|
610
|
+
const dirs = values.all ? listDemos(cfg) : positionals.map((p) => resolveDemoDir(p, cfg))
|
|
611
|
+
if (!dirs.length) {
|
|
612
|
+
throw new ReelsonError(values.all ? 'no demos with a video.json under videosDir' : 'usage: reelson render <slug...> | --all')
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let failures = 0
|
|
616
|
+
for (const dir of dirs) {
|
|
617
|
+
const slug = basename(dir)
|
|
618
|
+
if (!values['no-build']) {
|
|
619
|
+
await speak(dir, cfg)
|
|
620
|
+
}
|
|
621
|
+
const result = values['no-build'] ? { ...plan(dir, cfg), versions: null } : build(dir, cfg)
|
|
622
|
+
const videoDir = resolve(dir, 'video')
|
|
623
|
+
mkdirSync(resolve(videoDir, 'renders'), { recursive: true })
|
|
624
|
+
// Captions: the same words as the cards, timed to each video (the phone and square
|
|
625
|
+
// takes have their own timing).
|
|
626
|
+
const captions = (timeline: Timeline, name: string) => {
|
|
627
|
+
const cues = captionCues(timeline, result.spec.title, result.spec.subtitle)
|
|
628
|
+
writeFileSync(resolve(videoDir, `renders/${name}.srt`), toSrt(cues))
|
|
629
|
+
writeFileSync(resolve(videoDir, `renders/${name}.vtt`), toVtt(cues))
|
|
630
|
+
}
|
|
631
|
+
captions(result.timeline, slug)
|
|
632
|
+
const [video, flags] = values.draft ? [`renders/${slug}.draft.mp4`, DRAFT_FLAGS] : [`renders/${slug}.mp4`, RENDER_FLAGS]
|
|
633
|
+
const report = (outcome: 'rendered' | 'unchanged' | 'failed', output: string): void => {
|
|
634
|
+
if (outcome === 'failed') {
|
|
635
|
+
failures++
|
|
636
|
+
console.error(`reelson: render failed for ${slug} (${output})`)
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
console.log(`${outcome === 'unchanged' ? 'up to date' : 'rendered'} ${resolve(videoDir, output)}`)
|
|
640
|
+
}
|
|
641
|
+
if (!values.only || values.only === 'landscape') {
|
|
642
|
+
const outcome = renderIfChanged(videoDir, video, flags, values.force)
|
|
643
|
+
report(outcome, video)
|
|
644
|
+
// The GIF is cut from the MP4 just rendered (ffmpeg), not rendered a second time.
|
|
645
|
+
if (values.gif && outcome !== 'failed') {
|
|
646
|
+
const gif = video.replace(/\.mp4$/, '.gif')
|
|
647
|
+
report(gifIfChanged(videoDir, video, gif, values.force), gif)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// Asked for here (--portrait, --square, --all-formats, --only) or in video.json "formats".
|
|
651
|
+
const wanted = new Set(result.spec.formats ?? [])
|
|
652
|
+
const asked = (format: 'portrait' | 'square') =>
|
|
653
|
+
values.only ? values.only === format : values[format] || values['all-formats'] || wanted.has(format)
|
|
654
|
+
// Portrait: its own composition (tall frame, footage panning with the cursor).
|
|
655
|
+
if (asked('portrait')) {
|
|
656
|
+
if (result.versions) captions(result.versions.portrait, `${slug}.portrait`)
|
|
657
|
+
const output = video.replace(/\.mp4$/, '.portrait.mp4')
|
|
658
|
+
report(renderIfChanged(videoDir, output, flags, values.force, 'portrait.html'), output)
|
|
659
|
+
}
|
|
660
|
+
// Square: its own composition, from the square take (`reelson record --square`).
|
|
661
|
+
if (asked('square')) {
|
|
662
|
+
const output = video.replace(/\.mp4$/, '.square.mp4')
|
|
663
|
+
const hint = `run \`reelson record ${slug} --square\` (a square browser), then render again`
|
|
664
|
+
if (existsSync(resolve(videoDir, 'square.html'))) {
|
|
665
|
+
if (result.versions?.square) captions(result.versions.square, `${slug}.square`)
|
|
666
|
+
report(renderIfChanged(videoDir, output, flags, values.force, 'square.html'), output)
|
|
667
|
+
} else if (values.square || values.only === 'square') {
|
|
668
|
+
failures++
|
|
669
|
+
console.error(`reelson: no square take for ${slug} — ${hint}`)
|
|
670
|
+
} else {
|
|
671
|
+
console.log(`skipped the square version of ${slug}: no square take — ${hint}`)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return failures ? 1 : 0
|
|
676
|
+
}
|
package/bin/run.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `reelson` command. Node will not run TypeScript from under node_modules, so an npm install
|
|
3
|
+
// runs reelson.js (compiled beside reelson.ts when the package is packed); a checkout runs the source.
|
|
4
|
+
const installed = /[\\/]node_modules[\\/]/.test(import.meta.filename)
|
|
5
|
+
await import(installed ? './reelson.js' : './reelson.ts')
|