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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks a demo before rendering:
|
|
3
|
+
* 1. demo.config.json and video.json match their schemas (typos are errors);
|
|
4
|
+
* 2. the timeline resolves (callouts point at real markers, trims make sense);
|
|
5
|
+
* 3. every zoom rides along with the cursor (style guide #13) — see zooms.ts;
|
|
6
|
+
* 4. `hyperframes check` on each built composition — landscape, portrait, square (lint,
|
|
7
|
+
* runtime, layout, contrast).
|
|
8
|
+
*
|
|
9
|
+
* reelson check <slug|dir> [--no-hyperframes]
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, statSync } from 'node:fs'
|
|
12
|
+
import { resolve } from 'node:path'
|
|
13
|
+
import type { LoadedConfig } from '../../reelson-record/scripts/config.ts'
|
|
14
|
+
import { plan } from './build.ts'
|
|
15
|
+
import { hyperframesOn } from './hyperframes.ts'
|
|
16
|
+
import { round } from './timeline.ts'
|
|
17
|
+
import { checkZoom, zoomOverlaps } from './zooms.ts'
|
|
18
|
+
|
|
19
|
+
export interface CheckOptions {
|
|
20
|
+
hyperframes?: boolean
|
|
21
|
+
log?: (line: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Returns the number of problems found (0 = ready to render). */
|
|
25
|
+
export function check(demoDir: string, config: LoadedConfig, options: CheckOptions = {}): number {
|
|
26
|
+
const log = options.log ?? console.log
|
|
27
|
+
const result = plan(demoDir, config)
|
|
28
|
+
let problems = 0
|
|
29
|
+
|
|
30
|
+
if (result.created) {
|
|
31
|
+
log('✗ no video.json yet — run `reelson build` first')
|
|
32
|
+
return 1
|
|
33
|
+
}
|
|
34
|
+
for (const w of result.warnings) {
|
|
35
|
+
log(`! ${w}`)
|
|
36
|
+
}
|
|
37
|
+
for (const edge of ['start', 'end'] as const) {
|
|
38
|
+
if (typeof result.spec.trim?.[edge] === 'number') {
|
|
39
|
+
log(
|
|
40
|
+
`! trim.${edge} is a fixed time — after a re-record it cuts in the wrong place; ` +
|
|
41
|
+
(edge === 'start' ? 'use "auto" or ' : 'use ') +
|
|
42
|
+
'{ "marker": "…", "offset": … } / { "click": n, "offset": … } instead',
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if ((result.spec.zooms ?? []).length && !result.clicks.length) {
|
|
47
|
+
log('✗ zooms need logged clicks — re-record with the current reelson-record scripts')
|
|
48
|
+
problems++
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const overlaps = zoomOverlaps(result.zooms)
|
|
52
|
+
result.zooms.forEach((z, i) => {
|
|
53
|
+
const { problems: checked, framed } = checkZoom(z, result.clicks, result.timeline)
|
|
54
|
+
const found = [...checked, ...overlaps.filter((o) => o.index === i).map((o) => o.message)]
|
|
55
|
+
const label = `zoom ${i + 1} (${z.at}–${round(z.at + z.duration)}s, in ${z.in}s / out ${z.out}s, ${z.scale}x at ${z.x},${z.y})`
|
|
56
|
+
if (found.length) {
|
|
57
|
+
problems += found.length
|
|
58
|
+
log(`✗ ${label}`)
|
|
59
|
+
found.forEach((p) => log(` ${p}`))
|
|
60
|
+
} else {
|
|
61
|
+
log(`✓ ${label}: ${framed.map((c) => `${c.kind} #${c.index} ${c.comp}s`).join(', ')}`)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
if (!result.zooms.length) {
|
|
65
|
+
log('✓ no zooms')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const videoDir = resolve(demoDir, 'video')
|
|
69
|
+
const index = resolve(videoDir, 'index.html')
|
|
70
|
+
if (!existsSync(index)) {
|
|
71
|
+
log('✗ video/ is not built — run `reelson build`')
|
|
72
|
+
return problems + 1
|
|
73
|
+
}
|
|
74
|
+
if (statSync(resolve(demoDir, 'video.json')).mtimeMs > statSync(index).mtimeMs) {
|
|
75
|
+
log('! video.json changed since the last build — run `reelson build` (render does it for you)')
|
|
76
|
+
}
|
|
77
|
+
if (options.hyperframes !== false) {
|
|
78
|
+
for (const composition of ['index.html', 'portrait.html', 'square.html']) {
|
|
79
|
+
if (!existsSync(resolve(videoDir, composition))) {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
log(`\n${composition}:`)
|
|
83
|
+
if (hyperframesOn(videoDir, composition, ['check', '.']) !== 0) {
|
|
84
|
+
log(`✗ hyperframes check failed (${composition})`)
|
|
85
|
+
problems++
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
log(problems ? `\n${problems} problem(s)` : '\nready to render')
|
|
91
|
+
return problems
|
|
92
|
+
}
|
|
93
|
+
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { round } from "./timeline.js";
|
|
2
|
+
import { calloutsAtTop, landscapeLayout } from "./portrait.js";
|
|
3
|
+
const MEDIA = { recording: 'assets/recording.mp4', narration: 'assets/narration.m4a', music: 'assets/music.m4a', voice: 'assets/voice.m4a' };
|
|
4
|
+
/** HyperFrames track per slot: the recap cross-fades with the outro, so they sit on different tracks. */
|
|
5
|
+
const TRACKS = { intro: 3, recap: 2, outro: 3 };
|
|
6
|
+
export function renderComposition(input) {
|
|
7
|
+
const { timeline: t, text } = input;
|
|
8
|
+
const layout = input.layout ?? landscapeLayout(t);
|
|
9
|
+
const atTop = calloutsAtTop(t, layout);
|
|
10
|
+
// Everything the stage and section scripts animate from. Times are composition seconds.
|
|
11
|
+
const demo = {
|
|
12
|
+
total: t.total,
|
|
13
|
+
clipStart: t.clipStart,
|
|
14
|
+
clipDuration: t.clipDuration,
|
|
15
|
+
mediaStart: t.mediaStart,
|
|
16
|
+
sections: { intro: t.intro, recap: t.recap, outro: t.outro },
|
|
17
|
+
callouts: t.callouts.map(({ source: _source, say: _say, ...c }, i) => (atTop.has(i) ? { ...c, top: true } : c)),
|
|
18
|
+
zooms: input.zooms,
|
|
19
|
+
cursor: input.cursor !== undefined ? input.cursor : t.cursor,
|
|
20
|
+
layout: { format: layout.format, stage: layout.stage, bandZoom: layout.bandZoom, camera: layout.camera },
|
|
21
|
+
transitions: t.transitions.map(({ at, gap }) => ({ at, gap })),
|
|
22
|
+
chip: { steps: text.stepsChip, seconds: text.secondsChip },
|
|
23
|
+
};
|
|
24
|
+
const shared = {
|
|
25
|
+
LANG: escapeHtml(text.language),
|
|
26
|
+
BRAND_COLOR: escapeHtml(text.brand.color),
|
|
27
|
+
BRAND_COLOR_SOFT: escapeHtml(text.brand.colorSoft),
|
|
28
|
+
BRAND: escapeHtml(text.brand.name),
|
|
29
|
+
BRAND_SUB: escapeHtml(text.brand.tagline),
|
|
30
|
+
BRAND_LOGO: escapeHtml(text.logo),
|
|
31
|
+
EYEBROW: escapeHtml(text.brand.eyebrow),
|
|
32
|
+
TITLE: escapeHtml(text.title),
|
|
33
|
+
SUBTITLE: escapeHtml(text.subtitle),
|
|
34
|
+
OUTRO_TITLE: escapeHtml(text.recapTitle),
|
|
35
|
+
TOTAL: String(t.total),
|
|
36
|
+
FRAME_W: String(layout.frame.width),
|
|
37
|
+
FRAME_H: String(layout.frame.height),
|
|
38
|
+
FOOTAGE_W: String(layout.footage.width),
|
|
39
|
+
FOOTAGE_H: String(layout.footage.height),
|
|
40
|
+
STAGE_W: String(layout.stage.width),
|
|
41
|
+
STAGE_H: String(layout.stage.height),
|
|
42
|
+
BAND_ZOOM: String(layout.bandZoom),
|
|
43
|
+
FORMAT: layout.phone ? `${layout.format} phone` : layout.format,
|
|
44
|
+
};
|
|
45
|
+
const unfilled = new Set();
|
|
46
|
+
const fill = (source, values) => source.replace(/{{([A-Z_]+)}}/g, (match, key) => {
|
|
47
|
+
if (key in values) {
|
|
48
|
+
return values[key];
|
|
49
|
+
}
|
|
50
|
+
unfilled.add(match);
|
|
51
|
+
return match;
|
|
52
|
+
});
|
|
53
|
+
const parts = input.sections.map((s) => {
|
|
54
|
+
const slot = t[s.slot];
|
|
55
|
+
if (!slot) {
|
|
56
|
+
throw new Error(`the timeline has no ${s.slot} (recap "none") but a ${s.slot} section was given`);
|
|
57
|
+
}
|
|
58
|
+
const values = {
|
|
59
|
+
...shared,
|
|
60
|
+
START: String(slot.start),
|
|
61
|
+
DURATION: String(slot.duration),
|
|
62
|
+
TRACK: String(TRACKS[s.slot]),
|
|
63
|
+
ASSETS: s.assets,
|
|
64
|
+
};
|
|
65
|
+
const label = `${s.slot}: ${s.name}`;
|
|
66
|
+
return {
|
|
67
|
+
slot: s.slot,
|
|
68
|
+
css: ` /* ── ${label} ── */\n${indent(fill(s.css, values).trim(), 6)}`,
|
|
69
|
+
html: ` <!-- ── ${label} ── -->\n${indent(band(fill(s.html, values).trim(), layout.format === 'landscape' || (layout.format === 'square' ? s.square : s.portrait)), 6)}`,
|
|
70
|
+
js: ` // ── ${label} ──\n ;((section) => {\n${indent(fill(s.js, values).trim(), 8)}\n` +
|
|
71
|
+
` })(DEMO.sections.${s.slot});`,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
const part = (slot) => parts.find((p) => p.slot === slot) ?? { html: '', css: '', js: '' };
|
|
75
|
+
const html = fill(input.stage, {
|
|
76
|
+
...shared,
|
|
77
|
+
SECTION_STYLES: parts.map((p) => p.css).join('\n\n'),
|
|
78
|
+
// Each slot has its own place in the stage: the intro sits under #screen, recap and outro above it.
|
|
79
|
+
INTRO: part('intro').html,
|
|
80
|
+
RECAP: part('recap').html,
|
|
81
|
+
OUTRO: part('outro').html,
|
|
82
|
+
SECTION_SCRIPTS: parts.map((p) => p.js).join('\n\n'),
|
|
83
|
+
VIDEOS: renderVideoSegments(t, (input.media ?? MEDIA).recording),
|
|
84
|
+
TRANSITIONS: t.transitions.map((tr, i) => renderTransitionCard(tr, i, t.belt)).join('\n'),
|
|
85
|
+
AUDIO: input.narration ? renderNarration(t, (input.media ?? MEDIA).narration) : '',
|
|
86
|
+
MUSIC: (input.music ? renderMusic(t, (input.media ?? MEDIA).music) : '') +
|
|
87
|
+
(input.voice ? `\n${renderVoice(t, input.media?.voice ?? MEDIA.voice)}` : ''),
|
|
88
|
+
// JSON is valid JS; escaping "<" keeps "</script>" in a callout from closing the tag.
|
|
89
|
+
DEMO: JSON.stringify(demo, null, 2)
|
|
90
|
+
// Number tuples (cursor [t, x, y], camera [t, k, x, y, h]) one per line, not one number per line.
|
|
91
|
+
.replace(/\[\s+((?:-?[\d.]+,\s+)+-?[\d.]+)\s+\]/g, (_m, inner) => `[${inner.split(/,\s+/).join(', ')}]`)
|
|
92
|
+
.replace(/</g, '\\u003c')
|
|
93
|
+
.replace(/\n/g, '\n '),
|
|
94
|
+
});
|
|
95
|
+
if (unfilled.size) {
|
|
96
|
+
throw new Error(`template placeholders left unfilled: ${[...unfilled].join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
return html;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A section without its own layout for this format (portrait, square): mark its root so the
|
|
102
|
+
* stage zooms its 16:9 card to the width.
|
|
103
|
+
*/
|
|
104
|
+
function band(html, laidOut = false) {
|
|
105
|
+
return laidOut ? html : html.replace(/^(<section\b[^>]*\bclass=")([^"]*)"/, '$1$2 band"');
|
|
106
|
+
}
|
|
107
|
+
function indent(block, spaces) {
|
|
108
|
+
const pad = ' '.repeat(spaces);
|
|
109
|
+
return block
|
|
110
|
+
.split('\n')
|
|
111
|
+
.map((line) => (line.trim() ? pad + line : ''))
|
|
112
|
+
.join('\n');
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* One <video> clip per stretch of footage between hand-offs (same file,
|
|
116
|
+
* different data-media-start), so the footage pauses while a card is on screen.
|
|
117
|
+
*/
|
|
118
|
+
function renderVideoSegments(t, src) {
|
|
119
|
+
return t.segments
|
|
120
|
+
.map((s, i) => {
|
|
121
|
+
const id = i === 0 ? 'recording' : `recording-${i + 1}`;
|
|
122
|
+
return ` <video id="${id}" class="clip" src="${src}" muted playsinline
|
|
123
|
+
data-start="${s.start}" data-duration="${s.duration}" data-media-start="${s.mediaStart}" data-track-index="1"></video>`;
|
|
124
|
+
})
|
|
125
|
+
.join('\n');
|
|
126
|
+
}
|
|
127
|
+
/** Markup for a hand-off card; the template's builder animates it from DEMO.transitions. */
|
|
128
|
+
function renderTransitionCard(tr, i, belt) {
|
|
129
|
+
const { card } = tr;
|
|
130
|
+
const roles = card.from && card.to
|
|
131
|
+
? `
|
|
132
|
+
<div class="roles">
|
|
133
|
+
<div class="role from">${escapeHtml(card.from)}</div>
|
|
134
|
+
<div class="arrow"></div>
|
|
135
|
+
<div class="role to"><div class="fill"></div><span>${escapeHtml(card.to)}</span></div>
|
|
136
|
+
</div>`
|
|
137
|
+
: '';
|
|
138
|
+
const subtitle = card.subtitle
|
|
139
|
+
? `
|
|
140
|
+
<p class="subtitle">${escapeHtml(card.subtitle)}</p>`
|
|
141
|
+
: '';
|
|
142
|
+
return ` <!-- Hand-off ${i + 1}: demo.transition() in the scenario -->
|
|
143
|
+
<section id="transition-${i}" class="clip transition-card" data-start="${round(tr.at - belt)}" data-duration="${round(tr.gap + 2 * belt)}" data-track-index="2">
|
|
144
|
+
<div class="stack">${roles}
|
|
145
|
+
<h1 class="title">${escapeHtml(card.title)}</h1>${subtitle}
|
|
146
|
+
</div>
|
|
147
|
+
</section>`;
|
|
148
|
+
}
|
|
149
|
+
function renderNarration(t, src) {
|
|
150
|
+
return ` <!-- Narration from the recording (kept in sync via the same start/media-start) -->
|
|
151
|
+
<audio id="narration" class="clip" src="${src}" data-start="${t.clipStart}" data-duration="${round(t.mediaEnd - t.mediaStart)}" data-media-start="${t.mediaStart}" data-track-index="3"></audio>`;
|
|
152
|
+
}
|
|
153
|
+
function renderMusic(t, src) {
|
|
154
|
+
return ` <!-- Music bed, pre-rendered by reelson build (trim + loudnorm + fades) -->
|
|
155
|
+
<audio id="music" class="clip" src="${src}" data-start="0" data-duration="${t.total}" data-track-index="4"></audio>`;
|
|
156
|
+
}
|
|
157
|
+
function renderVoice(t, src) {
|
|
158
|
+
return ` <!-- Voice-over, pre-mixed by reelson build (each callout spoken as it appears) -->
|
|
159
|
+
<audio id="voice" class="clip" src="${src}" data-start="0" data-duration="${t.total}" data-track-index="5"></audio>`;
|
|
160
|
+
}
|
|
161
|
+
export function escapeHtml(s) {
|
|
162
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
163
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembles a template's stage.html and the chosen sections into one
|
|
3
|
+
* composition, filled from a computed timeline. Pure (strings in, string out),
|
|
4
|
+
* so the output is golden-tested. See templates/README.md for the contract.
|
|
5
|
+
*/
|
|
6
|
+
import type { Slot, Timeline } from './timeline.ts'
|
|
7
|
+
import { round } from './timeline.ts'
|
|
8
|
+
import { calloutsAtTop, landscapeLayout, type Layout } from './portrait.ts'
|
|
9
|
+
import type { Zoom } from './zooms.ts'
|
|
10
|
+
|
|
11
|
+
export interface CompositionText {
|
|
12
|
+
language: string
|
|
13
|
+
brand: { name: string; tagline: string; eyebrow: string; color: string; colorSoft: string }
|
|
14
|
+
/** assets/ path of the brand logo, or '' to draw the text wordmark. */
|
|
15
|
+
logo: string
|
|
16
|
+
title: string
|
|
17
|
+
subtitle: string
|
|
18
|
+
recapTitle: string
|
|
19
|
+
/** Intro chip halves, already pluralised: "4 steps", "27 seconds". */
|
|
20
|
+
stepsChip: string
|
|
21
|
+
secondsChip: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One section's files, read by the build. */
|
|
25
|
+
export interface SectionSource {
|
|
26
|
+
slot: Slot
|
|
27
|
+
name: string
|
|
28
|
+
html: string
|
|
29
|
+
css: string
|
|
30
|
+
js: string
|
|
31
|
+
/** Where the section's own assets/ were copied, relative to video/ ('' if it has none). */
|
|
32
|
+
assets: string
|
|
33
|
+
/** Has its own portrait layout; otherwise its root gets class "band" (portrait zooms it). */
|
|
34
|
+
portrait?: boolean
|
|
35
|
+
/** Has its own square layout; otherwise its root gets class "band" (square zooms it). */
|
|
36
|
+
square?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CompositionInput {
|
|
40
|
+
stage: string
|
|
41
|
+
/** In slot order; the recap is left out when the video has none. */
|
|
42
|
+
sections: SectionSource[]
|
|
43
|
+
timeline: Timeline
|
|
44
|
+
zooms: Zoom[]
|
|
45
|
+
text: CompositionText
|
|
46
|
+
/** assets/narration.m4a exists (the recording had an audio track). */
|
|
47
|
+
narration: boolean
|
|
48
|
+
/** assets/music.m4a exists. */
|
|
49
|
+
music: boolean
|
|
50
|
+
/** The voice-over track exists (media.voice). */
|
|
51
|
+
voice?: boolean
|
|
52
|
+
/** Stage/frame/footage sizes and the portrait camera; default: the landscape stage. */
|
|
53
|
+
layout?: Layout
|
|
54
|
+
/** Overrides the timeline's cursor layer (portrait and square move it into their footage scale). */
|
|
55
|
+
cursor?: Timeline['cursor']
|
|
56
|
+
/** The media files (default assets/recording.mp4, narration.m4a, music.m4a). */
|
|
57
|
+
media?: { recording: string; narration: string; music: string; voice?: string }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MEDIA = { recording: 'assets/recording.mp4', narration: 'assets/narration.m4a', music: 'assets/music.m4a', voice: 'assets/voice.m4a' }
|
|
61
|
+
|
|
62
|
+
/** HyperFrames track per slot: the recap cross-fades with the outro, so they sit on different tracks. */
|
|
63
|
+
const TRACKS: Record<Slot, number> = { intro: 3, recap: 2, outro: 3 }
|
|
64
|
+
|
|
65
|
+
export function renderComposition(input: CompositionInput): string {
|
|
66
|
+
const { timeline: t, text } = input
|
|
67
|
+
const layout = input.layout ?? landscapeLayout(t)
|
|
68
|
+
const atTop = calloutsAtTop(t, layout)
|
|
69
|
+
|
|
70
|
+
// Everything the stage and section scripts animate from. Times are composition seconds.
|
|
71
|
+
const demo = {
|
|
72
|
+
total: t.total,
|
|
73
|
+
clipStart: t.clipStart,
|
|
74
|
+
clipDuration: t.clipDuration,
|
|
75
|
+
mediaStart: t.mediaStart,
|
|
76
|
+
sections: { intro: t.intro, recap: t.recap, outro: t.outro },
|
|
77
|
+
callouts: t.callouts.map(({ source: _source, say: _say, ...c }, i) => (atTop.has(i) ? { ...c, top: true } : c)),
|
|
78
|
+
zooms: input.zooms,
|
|
79
|
+
cursor: input.cursor !== undefined ? input.cursor : t.cursor,
|
|
80
|
+
layout: { format: layout.format, stage: layout.stage, bandZoom: layout.bandZoom, camera: layout.camera },
|
|
81
|
+
transitions: t.transitions.map(({ at, gap }) => ({ at, gap })),
|
|
82
|
+
chip: { steps: text.stepsChip, seconds: text.secondsChip },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const shared: Record<string, string> = {
|
|
86
|
+
LANG: escapeHtml(text.language),
|
|
87
|
+
BRAND_COLOR: escapeHtml(text.brand.color),
|
|
88
|
+
BRAND_COLOR_SOFT: escapeHtml(text.brand.colorSoft),
|
|
89
|
+
BRAND: escapeHtml(text.brand.name),
|
|
90
|
+
BRAND_SUB: escapeHtml(text.brand.tagline),
|
|
91
|
+
BRAND_LOGO: escapeHtml(text.logo),
|
|
92
|
+
EYEBROW: escapeHtml(text.brand.eyebrow),
|
|
93
|
+
TITLE: escapeHtml(text.title),
|
|
94
|
+
SUBTITLE: escapeHtml(text.subtitle),
|
|
95
|
+
OUTRO_TITLE: escapeHtml(text.recapTitle),
|
|
96
|
+
TOTAL: String(t.total),
|
|
97
|
+
FRAME_W: String(layout.frame.width),
|
|
98
|
+
FRAME_H: String(layout.frame.height),
|
|
99
|
+
FOOTAGE_W: String(layout.footage.width),
|
|
100
|
+
FOOTAGE_H: String(layout.footage.height),
|
|
101
|
+
STAGE_W: String(layout.stage.width),
|
|
102
|
+
STAGE_H: String(layout.stage.height),
|
|
103
|
+
BAND_ZOOM: String(layout.bandZoom),
|
|
104
|
+
FORMAT: layout.phone ? `${layout.format} phone` : layout.format,
|
|
105
|
+
}
|
|
106
|
+
const unfilled = new Set<string>()
|
|
107
|
+
const fill = (source: string, values: Record<string, string>): string =>
|
|
108
|
+
source.replace(/{{([A-Z_]+)}}/g, (match, key: string) => {
|
|
109
|
+
if (key in values) {
|
|
110
|
+
return values[key]
|
|
111
|
+
}
|
|
112
|
+
unfilled.add(match)
|
|
113
|
+
return match
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const parts = input.sections.map((s) => {
|
|
117
|
+
const slot = t[s.slot]
|
|
118
|
+
if (!slot) {
|
|
119
|
+
throw new Error(`the timeline has no ${s.slot} (recap "none") but a ${s.slot} section was given`)
|
|
120
|
+
}
|
|
121
|
+
const values = {
|
|
122
|
+
...shared,
|
|
123
|
+
START: String(slot.start),
|
|
124
|
+
DURATION: String(slot.duration),
|
|
125
|
+
TRACK: String(TRACKS[s.slot]),
|
|
126
|
+
ASSETS: s.assets,
|
|
127
|
+
}
|
|
128
|
+
const label = `${s.slot}: ${s.name}`
|
|
129
|
+
return {
|
|
130
|
+
slot: s.slot,
|
|
131
|
+
css: ` /* ── ${label} ── */\n${indent(fill(s.css, values).trim(), 6)}`,
|
|
132
|
+
html: ` <!-- ── ${label} ── -->\n${indent(band(fill(s.html, values).trim(), layout.format === 'landscape' || (layout.format === 'square' ? s.square : s.portrait)), 6)}`,
|
|
133
|
+
js:
|
|
134
|
+
` // ── ${label} ──\n ;((section) => {\n${indent(fill(s.js, values).trim(), 8)}\n` +
|
|
135
|
+
` })(DEMO.sections.${s.slot});`,
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const part = (slot: Slot) => parts.find((p) => p.slot === slot) ?? { html: '', css: '', js: '' }
|
|
140
|
+
const html = fill(input.stage, {
|
|
141
|
+
...shared,
|
|
142
|
+
SECTION_STYLES: parts.map((p) => p.css).join('\n\n'),
|
|
143
|
+
// Each slot has its own place in the stage: the intro sits under #screen, recap and outro above it.
|
|
144
|
+
INTRO: part('intro').html,
|
|
145
|
+
RECAP: part('recap').html,
|
|
146
|
+
OUTRO: part('outro').html,
|
|
147
|
+
SECTION_SCRIPTS: parts.map((p) => p.js).join('\n\n'),
|
|
148
|
+
VIDEOS: renderVideoSegments(t, (input.media ?? MEDIA).recording),
|
|
149
|
+
TRANSITIONS: t.transitions.map((tr, i) => renderTransitionCard(tr, i, t.belt)).join('\n'),
|
|
150
|
+
AUDIO: input.narration ? renderNarration(t, (input.media ?? MEDIA).narration) : '',
|
|
151
|
+
MUSIC:
|
|
152
|
+
(input.music ? renderMusic(t, (input.media ?? MEDIA).music) : '') +
|
|
153
|
+
(input.voice ? `\n${renderVoice(t, input.media?.voice ?? MEDIA.voice)}` : ''),
|
|
154
|
+
// JSON is valid JS; escaping "<" keeps "</script>" in a callout from closing the tag.
|
|
155
|
+
DEMO: JSON.stringify(demo, null, 2)
|
|
156
|
+
// Number tuples (cursor [t, x, y], camera [t, k, x, y, h]) one per line, not one number per line.
|
|
157
|
+
.replace(/\[\s+((?:-?[\d.]+,\s+)+-?[\d.]+)\s+\]/g, (_m, inner: string) => `[${inner.split(/,\s+/).join(', ')}]`)
|
|
158
|
+
.replace(/</g, '\\u003c')
|
|
159
|
+
.replace(/\n/g, '\n '),
|
|
160
|
+
})
|
|
161
|
+
if (unfilled.size) {
|
|
162
|
+
throw new Error(`template placeholders left unfilled: ${[...unfilled].join(', ')}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return html
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A section without its own layout for this format (portrait, square): mark its root so the
|
|
170
|
+
* stage zooms its 16:9 card to the width.
|
|
171
|
+
*/
|
|
172
|
+
function band(html: string, laidOut = false): string {
|
|
173
|
+
return laidOut ? html : html.replace(/^(<section\b[^>]*\bclass=")([^"]*)"/, '$1$2 band"')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function indent(block: string, spaces: number): string {
|
|
177
|
+
const pad = ' '.repeat(spaces)
|
|
178
|
+
return block
|
|
179
|
+
.split('\n')
|
|
180
|
+
.map((line) => (line.trim() ? pad + line : ''))
|
|
181
|
+
.join('\n')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* One <video> clip per stretch of footage between hand-offs (same file,
|
|
186
|
+
* different data-media-start), so the footage pauses while a card is on screen.
|
|
187
|
+
*/
|
|
188
|
+
function renderVideoSegments(t: Timeline, src: string): string {
|
|
189
|
+
return t.segments
|
|
190
|
+
.map((s, i) => {
|
|
191
|
+
const id = i === 0 ? 'recording' : `recording-${i + 1}`
|
|
192
|
+
|
|
193
|
+
return ` <video id="${id}" class="clip" src="${src}" muted playsinline
|
|
194
|
+
data-start="${s.start}" data-duration="${s.duration}" data-media-start="${s.mediaStart}" data-track-index="1"></video>`
|
|
195
|
+
})
|
|
196
|
+
.join('\n')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Markup for a hand-off card; the template's builder animates it from DEMO.transitions. */
|
|
200
|
+
function renderTransitionCard(tr: Timeline['transitions'][number], i: number, belt: number): string {
|
|
201
|
+
const { card } = tr
|
|
202
|
+
const roles =
|
|
203
|
+
card.from && card.to
|
|
204
|
+
? `
|
|
205
|
+
<div class="roles">
|
|
206
|
+
<div class="role from">${escapeHtml(card.from)}</div>
|
|
207
|
+
<div class="arrow"></div>
|
|
208
|
+
<div class="role to"><div class="fill"></div><span>${escapeHtml(card.to)}</span></div>
|
|
209
|
+
</div>`
|
|
210
|
+
: ''
|
|
211
|
+
const subtitle = card.subtitle
|
|
212
|
+
? `
|
|
213
|
+
<p class="subtitle">${escapeHtml(card.subtitle)}</p>`
|
|
214
|
+
: ''
|
|
215
|
+
|
|
216
|
+
return ` <!-- Hand-off ${i + 1}: demo.transition() in the scenario -->
|
|
217
|
+
<section id="transition-${i}" class="clip transition-card" data-start="${round(tr.at - belt)}" data-duration="${round(tr.gap + 2 * belt)}" data-track-index="2">
|
|
218
|
+
<div class="stack">${roles}
|
|
219
|
+
<h1 class="title">${escapeHtml(card.title)}</h1>${subtitle}
|
|
220
|
+
</div>
|
|
221
|
+
</section>`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderNarration(t: Timeline, src: string): string {
|
|
225
|
+
return ` <!-- Narration from the recording (kept in sync via the same start/media-start) -->
|
|
226
|
+
<audio id="narration" class="clip" src="${src}" data-start="${t.clipStart}" data-duration="${round(t.mediaEnd - t.mediaStart)}" data-media-start="${t.mediaStart}" data-track-index="3"></audio>`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function renderMusic(t: Timeline, src: string): string {
|
|
230
|
+
return ` <!-- Music bed, pre-rendered by reelson build (trim + loudnorm + fades) -->
|
|
231
|
+
<audio id="music" class="clip" src="${src}" data-start="0" data-duration="${t.total}" data-track-index="4"></audio>`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderVoice(t: Timeline, src: string): string {
|
|
235
|
+
return ` <!-- Voice-over, pre-mixed by reelson build (each callout spoken as it appears) -->
|
|
236
|
+
<audio id="voice" class="clip" src="${src}" data-start="0" data-duration="${t.total}" data-track-index="5"></audio>`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function escapeHtml(s: string): string {
|
|
240
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
241
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the HyperFrames version is pinned. Everything that renders or
|
|
3
|
+
* checks goes through `hyperframes()`, so an upgrade is a one-line change here
|
|
4
|
+
* (verified by the CI smoke test).
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { dirname, resolve } from 'node:path';
|
|
11
|
+
export const HYPERFRAMES_VERSION = '0.8.46';
|
|
12
|
+
/** Runs `npx hyperframes@<pinned> <args>` in `cwd`; returns the exit code. */
|
|
13
|
+
export function hyperframes(args, cwd) {
|
|
14
|
+
const run = spawnSync('npx', ['--yes', `hyperframes@${HYPERFRAMES_VERSION}`, ...args], {
|
|
15
|
+
cwd,
|
|
16
|
+
stdio: 'inherit',
|
|
17
|
+
});
|
|
18
|
+
return run.status ?? 1;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Runs `hyperframes <args>` on one composition of a built video (index.html, portrait.html,
|
|
22
|
+
* square.html). `check` and `snapshot` only look at index.html, and lint a project with several
|
|
23
|
+
* root compositions as an error — so the composition runs alone, as the index.html of a scratch
|
|
24
|
+
* project that links the built assets. Output paths in `args` must be absolute.
|
|
25
|
+
*/
|
|
26
|
+
export function hyperframesOn(videoDir, composition, args) {
|
|
27
|
+
if (composition === 'index.html' && args[0] !== 'check') {
|
|
28
|
+
return hyperframes(args, videoDir);
|
|
29
|
+
}
|
|
30
|
+
const scratch = mkdtempSync(resolve(tmpdir(), 'reelson-hf-'));
|
|
31
|
+
try {
|
|
32
|
+
symlinkSync(resolve(videoDir, 'assets'), resolve(scratch, 'assets'));
|
|
33
|
+
copyFileSync(resolve(videoDir, 'hyperframes.json'), resolve(scratch, 'hyperframes.json'));
|
|
34
|
+
copyFileSync(resolve(videoDir, composition), resolve(scratch, 'index.html'));
|
|
35
|
+
writeFileSync(resolve(scratch, 'package.json'), '{ "private": true }\n');
|
|
36
|
+
return hyperframes(args, scratch);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** HyperFrames' default frame rate (renders and snapshots): a video's last frame is at total − 1/FPS. */
|
|
43
|
+
export const FPS = 30;
|
|
44
|
+
/** Render flags that keep the 2x capture sharp (see the reelson-compose skill). */
|
|
45
|
+
export const RENDER_FLAGS = ['--video-frame-format', 'jpg', '-q', 'delivery'];
|
|
46
|
+
/** A quick look: half the frames, draft encoding — about twice as fast. */
|
|
47
|
+
export const DRAFT_FLAGS = ['--video-frame-format', 'jpg', '-q', 'draft', '--fps', '15'];
|
|
48
|
+
/**
|
|
49
|
+
* Renders `videoDir` to `output` (relative to it) with `flags`, unless nothing it depends on
|
|
50
|
+
* changed since the last render there: index.html, every asset (by size and mtime), the
|
|
51
|
+
* flags and the pinned HyperFrames version. Returns 'rendered' | 'unchanged' | 'failed'.
|
|
52
|
+
*/
|
|
53
|
+
export function renderIfChanged(videoDir, output, flags, force = false, composition = 'index.html') {
|
|
54
|
+
const target = resolve(videoDir, output);
|
|
55
|
+
const stamp = `${target}.key`;
|
|
56
|
+
const key = renderKey(videoDir, [...flags, output], composition);
|
|
57
|
+
if (!force && existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
|
|
58
|
+
return 'unchanged';
|
|
59
|
+
}
|
|
60
|
+
const which = composition === 'index.html' ? [] : ['-c', composition];
|
|
61
|
+
if (hyperframes(['render', '.', ...which, ...flags, '-o', output], videoDir) !== 0) {
|
|
62
|
+
return 'failed';
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(stamp, key);
|
|
65
|
+
return 'rendered';
|
|
66
|
+
}
|
|
67
|
+
/** The README-sized GIF `render --gif` makes: width (px), frame rate and palette size. */
|
|
68
|
+
export const GIF = { width: 720, fps: 12, colors: 128 };
|
|
69
|
+
/**
|
|
70
|
+
* Converts a rendered MP4 (relative to `videoDir`) to `output` with ffmpeg — a palette made for this
|
|
71
|
+
* video, no dithering (it turns the soft backgrounds into noise and doubles the size). Skipped when the
|
|
72
|
+
* MP4 has not been re-rendered since (its .key is unchanged). HyperFrames' own GIF encoder is not used:
|
|
73
|
+
* it fails with ffmpeg 7.0 and renders every frame a second time.
|
|
74
|
+
*/
|
|
75
|
+
export function gifIfChanged(videoDir, source, output, force = false) {
|
|
76
|
+
const target = resolve(videoDir, output);
|
|
77
|
+
const stamp = `${target}.key`;
|
|
78
|
+
const sourceKey = resolve(videoDir, `${source}.key`);
|
|
79
|
+
const key = createHash('sha1')
|
|
80
|
+
.update(existsSync(sourceKey) ? readFileSync(sourceKey) : String(statSync(resolve(videoDir, source)).mtimeMs))
|
|
81
|
+
.update(JSON.stringify(GIF))
|
|
82
|
+
.digest('hex');
|
|
83
|
+
if (!force && existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
|
|
84
|
+
return 'unchanged';
|
|
85
|
+
}
|
|
86
|
+
const filter = `fps=${GIF.fps},scale=${GIF.width}:-2:flags=lanczos,split[a][b];` +
|
|
87
|
+
`[a]palettegen=stats_mode=diff:max_colors=${GIF.colors}[p];[b][p]paletteuse=dither=none:diff_mode=rectangle`;
|
|
88
|
+
const run = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', resolve(videoDir, source), '-vf', filter, '-loop', '0', target], {
|
|
89
|
+
stdio: 'inherit',
|
|
90
|
+
});
|
|
91
|
+
if (run.status !== 0) {
|
|
92
|
+
return 'failed';
|
|
93
|
+
}
|
|
94
|
+
writeFileSync(stamp, key);
|
|
95
|
+
return 'rendered';
|
|
96
|
+
}
|
|
97
|
+
export function renderKey(videoDir, args, composition = 'index.html') {
|
|
98
|
+
const hash = createHash('sha1').update(HYPERFRAMES_VERSION).update(JSON.stringify(args));
|
|
99
|
+
hash.update(readFileSync(resolve(videoDir, composition)));
|
|
100
|
+
const walk = (dir) => {
|
|
101
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
102
|
+
const path = resolve(dir, entry.name);
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
walk(path);
|
|
105
|
+
}
|
|
106
|
+
else if (!entry.name.endsWith('.key')) {
|
|
107
|
+
const { size, mtimeMs } = statSync(path);
|
|
108
|
+
hash.update(`${path}:${size}:${mtimeMs}\n`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
if (existsSync(resolve(videoDir, 'assets'))) {
|
|
113
|
+
walk(resolve(videoDir, 'assets'));
|
|
114
|
+
}
|
|
115
|
+
return hash.digest('hex');
|
|
116
|
+
}
|
|
117
|
+
let dist = null;
|
|
118
|
+
/**
|
|
119
|
+
* The pinned HyperFrames package's dist/ (player + runtime), from the npx cache.
|
|
120
|
+
* The studio serves both from here, so the preview works offline.
|
|
121
|
+
*/
|
|
122
|
+
export function hyperframesDist() {
|
|
123
|
+
if (dist) {
|
|
124
|
+
return dist;
|
|
125
|
+
}
|
|
126
|
+
const which = spawnSync('npm', ['exec', '--yes', `--package=hyperframes@${HYPERFRAMES_VERSION}`, '-c', 'which hyperframes'], {
|
|
127
|
+
encoding: 'utf8',
|
|
128
|
+
});
|
|
129
|
+
const bin = which.stdout.trim().split('\n').at(-1);
|
|
130
|
+
if (which.status !== 0 || !bin) {
|
|
131
|
+
throw new Error(`could not install hyperframes@${HYPERFRAMES_VERSION}: ${which.stderr.trim()}`);
|
|
132
|
+
}
|
|
133
|
+
dist = resolve(dirname(realpathSync(bin)), '..', 'dist');
|
|
134
|
+
if (!existsSync(resolve(dist, 'hyperframes-player.global.js'))) {
|
|
135
|
+
throw new Error(`no HyperFrames player in ${dist}`);
|
|
136
|
+
}
|
|
137
|
+
return dist;
|
|
138
|
+
}
|