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.
Files changed (105) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +15 -0
  4. package/README.md +180 -0
  5. package/bin/reelson.js +649 -0
  6. package/bin/reelson.ts +676 -0
  7. package/bin/run.js +5 -0
  8. package/demo.config.example.json +51 -0
  9. package/docs/demo.webp +0 -0
  10. package/docs/prompting.md +72 -0
  11. package/docs/style-guide.md +117 -0
  12. package/examples/demo.config.json +18 -0
  13. package/examples/todo-add-item/scenario.ts +45 -0
  14. package/examples/todo-add-item/video.json +35 -0
  15. package/music/README.md +7 -0
  16. package/package.json +68 -0
  17. package/skills/reelson-compose/SKILL.md +226 -0
  18. package/skills/reelson-compose/schemas/video.schema.json +172 -0
  19. package/skills/reelson-compose/scripts/build.js +424 -0
  20. package/skills/reelson-compose/scripts/build.ts +532 -0
  21. package/skills/reelson-compose/scripts/captions.js +24 -0
  22. package/skills/reelson-compose/scripts/captions.ts +40 -0
  23. package/skills/reelson-compose/scripts/check.js +80 -0
  24. package/skills/reelson-compose/scripts/check.ts +93 -0
  25. package/skills/reelson-compose/scripts/composition.js +163 -0
  26. package/skills/reelson-compose/scripts/composition.ts +241 -0
  27. package/skills/reelson-compose/scripts/hyperframes.js +138 -0
  28. package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
  29. package/skills/reelson-compose/scripts/portrait.js +150 -0
  30. package/skills/reelson-compose/scripts/portrait.ts +218 -0
  31. package/skills/reelson-compose/scripts/project.js +238 -0
  32. package/skills/reelson-compose/scripts/project.ts +324 -0
  33. package/skills/reelson-compose/scripts/studio.js +330 -0
  34. package/skills/reelson-compose/scripts/studio.ts +394 -0
  35. package/skills/reelson-compose/scripts/timeline.js +271 -0
  36. package/skills/reelson-compose/scripts/timeline.ts +514 -0
  37. package/skills/reelson-compose/scripts/tts.js +303 -0
  38. package/skills/reelson-compose/scripts/tts.ts +371 -0
  39. package/skills/reelson-compose/scripts/verify.js +190 -0
  40. package/skills/reelson-compose/scripts/verify.ts +209 -0
  41. package/skills/reelson-compose/scripts/voice.js +178 -0
  42. package/skills/reelson-compose/scripts/voice.ts +224 -0
  43. package/skills/reelson-compose/scripts/zooms.js +259 -0
  44. package/skills/reelson-compose/scripts/zooms.ts +333 -0
  45. package/skills/reelson-compose/sections/README.md +5 -0
  46. package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
  47. package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
  48. package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
  49. package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
  50. package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
  51. package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
  52. package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
  53. package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
  54. package/skills/reelson-compose/sections/intro/split/section.css +30 -0
  55. package/skills/reelson-compose/sections/intro/split/section.html +12 -0
  56. package/skills/reelson-compose/sections/intro/split/section.js +19 -0
  57. package/skills/reelson-compose/sections/intro/split/section.json +9 -0
  58. package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
  59. package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
  60. package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
  61. package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
  62. package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
  63. package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
  64. package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
  65. package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
  66. package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
  67. package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
  68. package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
  69. package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
  70. package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
  71. package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
  72. package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
  73. package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
  74. package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
  75. package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
  76. package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
  77. package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
  78. package/skills/reelson-compose/studio/index.html +81 -0
  79. package/skills/reelson-compose/studio/studio.css +142 -0
  80. package/skills/reelson-compose/studio/studio.js +1031 -0
  81. package/skills/reelson-compose/templates/README.md +193 -0
  82. package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
  83. package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
  84. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
  85. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
  86. package/skills/reelson-compose/templates/classic/stage.html +447 -0
  87. package/skills/reelson-compose/templates/classic/template.json +5 -0
  88. package/skills/reelson-record/SKILL.md +220 -0
  89. package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
  90. package/skills/reelson-record/scripts/capture.js +142 -0
  91. package/skills/reelson-record/scripts/capture.ts +190 -0
  92. package/skills/reelson-record/scripts/config.js +148 -0
  93. package/skills/reelson-record/scripts/config.ts +264 -0
  94. package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
  95. package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
  96. package/skills/reelson-record/scripts/doctor.js +128 -0
  97. package/skills/reelson-record/scripts/doctor.ts +147 -0
  98. package/skills/reelson-record/scripts/languages.js +29 -0
  99. package/skills/reelson-record/scripts/languages.ts +41 -0
  100. package/skills/reelson-record/scripts/record.js +365 -0
  101. package/skills/reelson-record/scripts/record.ts +451 -0
  102. package/skills/reelson-record/scripts/scenario.js +356 -0
  103. package/skills/reelson-record/scripts/scenario.ts +570 -0
  104. package/skills/reelson-record/scripts/validate.js +143 -0
  105. package/skills/reelson-record/scripts/validate.ts +179 -0
@@ -0,0 +1,157 @@
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
+
12
+ export const HYPERFRAMES_VERSION = '0.8.46'
13
+
14
+ /** Runs `npx hyperframes@<pinned> <args>` in `cwd`; returns the exit code. */
15
+ export function hyperframes(args: string[], cwd: string): number {
16
+ const run = spawnSync('npx', ['--yes', `hyperframes@${HYPERFRAMES_VERSION}`, ...args], {
17
+ cwd,
18
+ stdio: 'inherit',
19
+ })
20
+
21
+ return run.status ?? 1
22
+ }
23
+
24
+ /**
25
+ * Runs `hyperframes <args>` on one composition of a built video (index.html, portrait.html,
26
+ * square.html). `check` and `snapshot` only look at index.html, and lint a project with several
27
+ * root compositions as an error — so the composition runs alone, as the index.html of a scratch
28
+ * project that links the built assets. Output paths in `args` must be absolute.
29
+ */
30
+ export function hyperframesOn(videoDir: string, composition: string, args: string[]): number {
31
+ if (composition === 'index.html' && args[0] !== 'check') {
32
+ return hyperframes(args, videoDir)
33
+ }
34
+ const scratch = mkdtempSync(resolve(tmpdir(), 'reelson-hf-'))
35
+ try {
36
+ symlinkSync(resolve(videoDir, 'assets'), resolve(scratch, 'assets'))
37
+ copyFileSync(resolve(videoDir, 'hyperframes.json'), resolve(scratch, 'hyperframes.json'))
38
+ copyFileSync(resolve(videoDir, composition), resolve(scratch, 'index.html'))
39
+ writeFileSync(resolve(scratch, 'package.json'), '{ "private": true }\n')
40
+ return hyperframes(args, scratch)
41
+ } finally {
42
+ rmSync(scratch, { recursive: true, force: true })
43
+ }
44
+ }
45
+
46
+ /** HyperFrames' default frame rate (renders and snapshots): a video's last frame is at total − 1/FPS. */
47
+ export const FPS = 30
48
+
49
+ /** Render flags that keep the 2x capture sharp (see the reelson-compose skill). */
50
+ export const RENDER_FLAGS = ['--video-frame-format', 'jpg', '-q', 'delivery']
51
+
52
+ /** A quick look: half the frames, draft encoding — about twice as fast. */
53
+ export const DRAFT_FLAGS = ['--video-frame-format', 'jpg', '-q', 'draft', '--fps', '15']
54
+
55
+ /**
56
+ * Renders `videoDir` to `output` (relative to it) with `flags`, unless nothing it depends on
57
+ * changed since the last render there: index.html, every asset (by size and mtime), the
58
+ * flags and the pinned HyperFrames version. Returns 'rendered' | 'unchanged' | 'failed'.
59
+ */
60
+ export function renderIfChanged(
61
+ videoDir: string,
62
+ output: string,
63
+ flags: string[],
64
+ force = false,
65
+ composition = 'index.html',
66
+ ): 'rendered' | 'unchanged' | 'failed' {
67
+ const target = resolve(videoDir, output)
68
+ const stamp = `${target}.key`
69
+ const key = renderKey(videoDir, [...flags, output], composition)
70
+ if (!force && existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
71
+ return 'unchanged'
72
+ }
73
+ const which = composition === 'index.html' ? [] : ['-c', composition]
74
+ if (hyperframes(['render', '.', ...which, ...flags, '-o', output], videoDir) !== 0) {
75
+ return 'failed'
76
+ }
77
+ writeFileSync(stamp, key)
78
+ return 'rendered'
79
+ }
80
+
81
+ /** The README-sized GIF `render --gif` makes: width (px), frame rate and palette size. */
82
+ export const GIF = { width: 720, fps: 12, colors: 128 }
83
+
84
+ /**
85
+ * Converts a rendered MP4 (relative to `videoDir`) to `output` with ffmpeg — a palette made for this
86
+ * video, no dithering (it turns the soft backgrounds into noise and doubles the size). Skipped when the
87
+ * MP4 has not been re-rendered since (its .key is unchanged). HyperFrames' own GIF encoder is not used:
88
+ * it fails with ffmpeg 7.0 and renders every frame a second time.
89
+ */
90
+ export function gifIfChanged(videoDir: string, source: string, output: string, force = false): 'rendered' | 'unchanged' | 'failed' {
91
+ const target = resolve(videoDir, output)
92
+ const stamp = `${target}.key`
93
+ const sourceKey = resolve(videoDir, `${source}.key`)
94
+ const key = createHash('sha1')
95
+ .update(existsSync(sourceKey) ? readFileSync(sourceKey) : String(statSync(resolve(videoDir, source)).mtimeMs))
96
+ .update(JSON.stringify(GIF))
97
+ .digest('hex')
98
+ if (!force && existsSync(target) && existsSync(stamp) && readFileSync(stamp, 'utf8') === key) {
99
+ return 'unchanged'
100
+ }
101
+ const filter =
102
+ `fps=${GIF.fps},scale=${GIF.width}:-2:flags=lanczos,split[a][b];` +
103
+ `[a]palettegen=stats_mode=diff:max_colors=${GIF.colors}[p];[b][p]paletteuse=dither=none:diff_mode=rectangle`
104
+ const run = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', resolve(videoDir, source), '-vf', filter, '-loop', '0', target], {
105
+ stdio: 'inherit',
106
+ })
107
+ if (run.status !== 0) {
108
+ return 'failed'
109
+ }
110
+ writeFileSync(stamp, key)
111
+ return 'rendered'
112
+ }
113
+
114
+ export function renderKey(videoDir: string, args: string[], composition = 'index.html'): string {
115
+ const hash = createHash('sha1').update(HYPERFRAMES_VERSION).update(JSON.stringify(args))
116
+ hash.update(readFileSync(resolve(videoDir, composition)))
117
+ const walk = (dir: string): void => {
118
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
119
+ const path = resolve(dir, entry.name)
120
+ if (entry.isDirectory()) {
121
+ walk(path)
122
+ } else if (!entry.name.endsWith('.key')) {
123
+ const { size, mtimeMs } = statSync(path)
124
+ hash.update(`${path}:${size}:${mtimeMs}\n`)
125
+ }
126
+ }
127
+ }
128
+ if (existsSync(resolve(videoDir, 'assets'))) {
129
+ walk(resolve(videoDir, 'assets'))
130
+ }
131
+ return hash.digest('hex')
132
+ }
133
+
134
+ let dist: string | null = null
135
+
136
+ /**
137
+ * The pinned HyperFrames package's dist/ (player + runtime), from the npx cache.
138
+ * The studio serves both from here, so the preview works offline.
139
+ */
140
+ export function hyperframesDist(): string {
141
+ if (dist) {
142
+ return dist
143
+ }
144
+ const which = spawnSync('npm', ['exec', '--yes', `--package=hyperframes@${HYPERFRAMES_VERSION}`, '-c', 'which hyperframes'], {
145
+ encoding: 'utf8',
146
+ })
147
+ const bin = which.stdout.trim().split('\n').at(-1)
148
+ if (which.status !== 0 || !bin) {
149
+ throw new Error(`could not install hyperframes@${HYPERFRAMES_VERSION}: ${which.stderr.trim()}`)
150
+ }
151
+ dist = resolve(dirname(realpathSync(bin)), '..', 'dist')
152
+ if (!existsSync(resolve(dist, 'hyperframes-player.global.js'))) {
153
+ throw new Error(`no HyperFrames player in ${dist}`)
154
+ }
155
+
156
+ return dist
157
+ }
@@ -0,0 +1,150 @@
1
+ import { round } from "./timeline.js";
2
+ export const PORTRAIT = {
3
+ stage: { width: 1080, height: 1920 },
4
+ frame: { width: 1000, maxHeight: 1450 },
5
+ /** A mobile recording's frame fits in this box (the stage keeps room for one callout). */
6
+ phone: { maxWidth: 900, maxHeight: 1660 },
7
+ /** Closest framing: stage px per recording CSS px (1.4 ≈ text at 1.4x its size). */
8
+ maxScale: 1.4,
9
+ /** Room around the framed area, recording CSS px. */
10
+ padding: 40,
11
+ /** Moving to a new framing: starts as the cursor heads there, takes this long (s). */
12
+ ease: 0.7,
13
+ lead: 0.15,
14
+ /** After this long with nothing new to frame, ease back to the full width. */
15
+ rest: 3.5,
16
+ step: 1 / 15,
17
+ };
18
+ export function landscapeLayout(t) {
19
+ return { format: 'landscape', stage: { width: 1920, height: 1080 }, frame: t.frame, footage: t.frame, bandZoom: 1, camera: [] };
20
+ }
21
+ const bandZoom = PORTRAIT.stage.width / 1920;
22
+ export const SQUARE = { stage: { width: 1080, height: 1080 } };
23
+ /** Square from a square take: the recording fills the stage, edge to edge. */
24
+ export function squareLayout(t) {
25
+ const { width, height } = SQUARE.stage;
26
+ const scale = Math.max(width / t.viewport.width, height / t.viewport.height);
27
+ const footage = { width: Math.round(t.viewport.width * scale), height: Math.round(t.viewport.height * scale) };
28
+ return {
29
+ layout: { format: 'square', stage: SQUARE.stage, frame: SQUARE.stage, footage, bandZoom: width / 1920, camera: [] },
30
+ cursor: t.cursor ? { ...t.cursor, scale: Math.round(scale * 10000) / 10000 } : null,
31
+ };
32
+ }
33
+ /** Portrait from a mobile recording: the whole phone screen in a tall frame. */
34
+ export function phoneLayout(t) {
35
+ const view = t.viewport;
36
+ const scale = Math.min(PORTRAIT.phone.maxWidth / view.width, PORTRAIT.phone.maxHeight / view.height);
37
+ const box = { width: Math.round(view.width * scale), height: Math.round(view.height * scale) };
38
+ return {
39
+ layout: { format: 'portrait', phone: true, stage: PORTRAIT.stage, frame: box, footage: box, bandZoom, camera: [] },
40
+ // A phone is tapped: the ripples show where, an arrow would look wrong.
41
+ cursor: t.cursor ? { ...t.cursor, scale: Math.round(scale * 10000) / 10000, touch: true } : null,
42
+ zooms: [],
43
+ };
44
+ }
45
+ /** Portrait from the desktop recording: the camera described above. */
46
+ export function portraitLayout(t) {
47
+ const view = t.viewport;
48
+ const { width: W, maxHeight: H } = PORTRAIT.frame;
49
+ const fit = W / view.width;
50
+ const frameHeight = (s) => Math.min(H, view.height * s);
51
+ const overview = { s: fit, cx: view.width / 2, cy: view.height / 2 };
52
+ const shotFor = (area) => {
53
+ const pad = PORTRAIT.padding;
54
+ const s = clamp(Math.min(PORTRAIT.maxScale, W / (area.width + 2 * pad), H / (area.height + 2 * pad)), fit, Math.max(fit, PORTRAIT.maxScale));
55
+ const halfW = W / (2 * s);
56
+ const halfH = frameHeight(s) / (2 * s);
57
+ return {
58
+ s,
59
+ cx: clamp(area.x + area.width / 2, halfW, view.width - halfW),
60
+ cy: clamp(area.y + area.height / 2, halfH, view.height - halfH),
61
+ };
62
+ };
63
+ // Moves: when a new framing starts, and where it goes.
64
+ const moves = [];
65
+ const focus = [...t.focus].sort((a, b) => a.at - b.at);
66
+ focus.forEach((f, i) => {
67
+ moves.push({ at: Math.max(t.clipStart, f.at - PORTRAIT.lead), to: shotFor(f.area) });
68
+ const next = focus[i + 1];
69
+ const quiet = f.at + PORTRAIT.rest;
70
+ if (!next || next.at - PORTRAIT.lead > quiet + PORTRAIT.ease) {
71
+ moves.push({ at: quiet, to: overview });
72
+ }
73
+ });
74
+ // Each move starts from wherever the previous one had got to when it begins.
75
+ const progress = (j, from, time) => mix(from, moves[j].to, easeInOut(Math.min(1, Math.max(0, (time - moves[j].at) / PORTRAIT.ease))));
76
+ const starts = [];
77
+ moves.forEach((move, j) => {
78
+ starts.push(j === 0 ? overview : progress(j - 1, starts[j - 1], move.at));
79
+ });
80
+ const shotAt = (time) => {
81
+ const j = moves.findLastIndex((m) => m.at <= time);
82
+ return j === -1 ? overview : progress(j, starts[j], time);
83
+ };
84
+ const camera = [];
85
+ for (let time = t.clipStart; time < t.clipEnd + 1e-9; time += PORTRAIT.step) {
86
+ const shot = shotAt(time);
87
+ const h = frameHeight(shot.s);
88
+ camera.push([
89
+ Math.round(time * 1000) / 1000,
90
+ Math.round((shot.s / fit) * 10000) / 10000,
91
+ Math.round((W / 2 - shot.cx * shot.s) * 10) / 10,
92
+ Math.round((h / 2 - shot.cy * shot.s) * 10) / 10,
93
+ Math.round(h),
94
+ ]);
95
+ }
96
+ const landscapeScale = t.frame.width / view.width;
97
+ return {
98
+ layout: {
99
+ format: 'portrait',
100
+ stage: PORTRAIT.stage,
101
+ frame: { width: W, height: H },
102
+ footage: { width: W, height: Math.round(view.height * fit) },
103
+ bandZoom,
104
+ camera,
105
+ },
106
+ // Positions in footage px (scale `fit`); drawn at the size it has in landscape.
107
+ cursor: t.cursor
108
+ ? { ...t.cursor, scale: Math.round(fit * 10000) / 10000, size: round((t.cursor.size * landscapeScale) / fit) }
109
+ : null,
110
+ // The camera frames what matters; landscape zooms would fight it.
111
+ zooms: [],
112
+ };
113
+ }
114
+ function mix(a, b, u) {
115
+ return { s: a.s + (b.s - a.s) * u, cx: a.cx + (b.cx - a.cx) * u, cy: a.cy + (b.cy - a.cy) * u };
116
+ }
117
+ function easeInOut(u) {
118
+ return u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
119
+ }
120
+ function clamp(n, lo, hi) {
121
+ return Math.min(hi, Math.max(lo, n));
122
+ }
123
+ /** Stage px a bottom callout covers (its 34–40 px margin, a line or two of pill). */
124
+ export const CALLOUT_BAND = 150;
125
+ /**
126
+ * Callouts that would cover what the demo works on while they show: the cursor (moves and
127
+ * presses) or the focused area reaching into the band a bottom callout covers. The stage
128
+ * shows those at the top instead. Only where callouts sit over the footage (landscape,
129
+ * square); portrait puts them below the frame.
130
+ */
131
+ export function calloutsAtTop(t, layout) {
132
+ const top = new Set();
133
+ if (layout.format === 'portrait') {
134
+ return top;
135
+ }
136
+ // Where the band starts, as recording CSS px (the frame is centred on the stage).
137
+ const frameTop = (layout.stage.height - layout.frame.height) / 2;
138
+ const scale = layout.footage.height / t.viewport.height;
139
+ const footageTop = frameTop + (layout.frame.height - layout.footage.height) / 2;
140
+ const limit = (layout.stage.height - CALLOUT_BAND - footageTop) / scale;
141
+ const points = [...(t.cursor?.path ?? []), ...(t.cursor?.presses ?? [])];
142
+ t.callouts.forEach((c, i) => {
143
+ const during = (at) => at >= c.at && at <= c.at + c.duration;
144
+ const low = points.some(([at, , y]) => during(at) && y > limit) ||
145
+ t.focus.some((f) => during(f.at) && f.area.y + f.area.height > limit);
146
+ if (low)
147
+ top.add(i);
148
+ });
149
+ return top;
150
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Layouts: where the stage, the recording frame and the footage sit, per output format.
3
+ *
4
+ * Landscape (1920x1080) is the classic stage: the footage fills the frame.
5
+ *
6
+ * Portrait (1080x1920) has two sources:
7
+ * - a mobile recording (`reelson record --mobile`: the app at a phone viewport) — shown whole
8
+ * in a tall frame, like a phone screen: nothing cropped, text at full size;
9
+ * - the desktop recording — too wide to show whole at a readable size, so a camera frames
10
+ * what the demo is working on (reelson-record's `focus`: the element it moved to, with its
11
+ * surroundings, e.g. a form field with its label) as close as it can while the whole area
12
+ * still fits, and eases between framings. It never crops the element in use; with nothing
13
+ * to frame it shows the full width. The frame grows and shrinks with the camera, so it
14
+ * never shows empty space.
15
+ *
16
+ * Square (1080x1080) comes from a square take (`reelson record --square`: the app in a square
17
+ * browser), which fills the whole stage: no bands above or below it.
18
+ *
19
+ * Sections lay themselves out per format (`#root.portrait …`, `#root.square …`); one without
20
+ * its own layout keeps its 16:9 card, zoomed to the stage width. Pure.
21
+ */
22
+ import type { Box, Timeline } from './timeline.ts'
23
+ import { round } from './timeline.ts'
24
+ import type { Zoom } from './zooms.ts'
25
+
26
+ export type LayoutFormat = 'landscape' | 'portrait' | 'square'
27
+
28
+ export interface Layout {
29
+ format: LayoutFormat
30
+ /** Portrait from a phone take (the frame is the phone screen). */
31
+ phone?: boolean
32
+ stage: { width: number; height: number }
33
+ /** The frame box (desktop portrait: its tallest; its height follows the camera). */
34
+ frame: { width: number; height: number }
35
+ /** The footage box at camera scale 1. */
36
+ footage: { width: number; height: number }
37
+ /** Zoom of the 16:9 cards (intro, recap, outro, hand-offs) on this stage. */
38
+ bandZoom: number
39
+ /**
40
+ * The camera over time: [t, k, x, y, h] — footage scale k (1 = full width), footage offset
41
+ * x/y and frame height h, px. Empty: the footage fills the frame, still.
42
+ */
43
+ camera: [number, number, number, number, number][]
44
+ }
45
+
46
+ export const PORTRAIT = {
47
+ stage: { width: 1080, height: 1920 },
48
+ frame: { width: 1000, maxHeight: 1450 },
49
+ /** A mobile recording's frame fits in this box (the stage keeps room for one callout). */
50
+ phone: { maxWidth: 900, maxHeight: 1660 },
51
+ /** Closest framing: stage px per recording CSS px (1.4 ≈ text at 1.4x its size). */
52
+ maxScale: 1.4,
53
+ /** Room around the framed area, recording CSS px. */
54
+ padding: 40,
55
+ /** Moving to a new framing: starts as the cursor heads there, takes this long (s). */
56
+ ease: 0.7,
57
+ lead: 0.15,
58
+ /** After this long with nothing new to frame, ease back to the full width. */
59
+ rest: 3.5,
60
+ step: 1 / 15,
61
+ }
62
+
63
+ export function landscapeLayout(t: Timeline): Layout {
64
+ return { format: 'landscape', stage: { width: 1920, height: 1080 }, frame: t.frame, footage: t.frame, bandZoom: 1, camera: [] }
65
+ }
66
+
67
+ const bandZoom = PORTRAIT.stage.width / 1920
68
+
69
+ export const SQUARE = { stage: { width: 1080, height: 1080 } }
70
+
71
+ /** Square from a square take: the recording fills the stage, edge to edge. */
72
+ export function squareLayout(t: Timeline): { layout: Layout; cursor: Timeline['cursor'] } {
73
+ const { width, height } = SQUARE.stage
74
+ const scale = Math.max(width / t.viewport.width, height / t.viewport.height)
75
+ const footage = { width: Math.round(t.viewport.width * scale), height: Math.round(t.viewport.height * scale) }
76
+ return {
77
+ layout: { format: 'square', stage: SQUARE.stage, frame: SQUARE.stage, footage, bandZoom: width / 1920, camera: [] },
78
+ cursor: t.cursor ? { ...t.cursor, scale: Math.round(scale * 10000) / 10000 } : null,
79
+ }
80
+ }
81
+
82
+ /** Portrait from a mobile recording: the whole phone screen in a tall frame. */
83
+ export function phoneLayout(t: Timeline): { layout: Layout; cursor: Timeline['cursor']; zooms: Zoom[] } {
84
+ const view = t.viewport
85
+ const scale = Math.min(PORTRAIT.phone.maxWidth / view.width, PORTRAIT.phone.maxHeight / view.height)
86
+ const box = { width: Math.round(view.width * scale), height: Math.round(view.height * scale) }
87
+ return {
88
+ layout: { format: 'portrait', phone: true, stage: PORTRAIT.stage, frame: box, footage: box, bandZoom, camera: [] },
89
+ // A phone is tapped: the ripples show where, an arrow would look wrong.
90
+ cursor: t.cursor ? { ...t.cursor, scale: Math.round(scale * 10000) / 10000, touch: true } : null,
91
+ zooms: [],
92
+ }
93
+ }
94
+
95
+ interface Shot {
96
+ /** Stage px per recording CSS px. */
97
+ s: number
98
+ /** Recording CSS px at the middle of the frame. */
99
+ cx: number
100
+ cy: number
101
+ }
102
+
103
+ /** Portrait from the desktop recording: the camera described above. */
104
+ export function portraitLayout(t: Timeline): { layout: Layout; cursor: Timeline['cursor']; zooms: Zoom[] } {
105
+ const view = t.viewport
106
+ const { width: W, maxHeight: H } = PORTRAIT.frame
107
+ const fit = W / view.width
108
+ const frameHeight = (s: number) => Math.min(H, view.height * s)
109
+
110
+ const overview: Shot = { s: fit, cx: view.width / 2, cy: view.height / 2 }
111
+ const shotFor = (area: Box): Shot => {
112
+ const pad = PORTRAIT.padding
113
+ const s = clamp(Math.min(PORTRAIT.maxScale, W / (area.width + 2 * pad), H / (area.height + 2 * pad)), fit, Math.max(fit, PORTRAIT.maxScale))
114
+ const halfW = W / (2 * s)
115
+ const halfH = frameHeight(s) / (2 * s)
116
+ return {
117
+ s,
118
+ cx: clamp(area.x + area.width / 2, halfW, view.width - halfW),
119
+ cy: clamp(area.y + area.height / 2, halfH, view.height - halfH),
120
+ }
121
+ }
122
+
123
+ // Moves: when a new framing starts, and where it goes.
124
+ const moves: { at: number; to: Shot }[] = []
125
+ const focus = [...t.focus].sort((a, b) => a.at - b.at)
126
+ focus.forEach((f, i) => {
127
+ moves.push({ at: Math.max(t.clipStart, f.at - PORTRAIT.lead), to: shotFor(f.area) })
128
+ const next = focus[i + 1]
129
+ const quiet = f.at + PORTRAIT.rest
130
+ if (!next || next.at - PORTRAIT.lead > quiet + PORTRAIT.ease) {
131
+ moves.push({ at: quiet, to: overview })
132
+ }
133
+ })
134
+ // Each move starts from wherever the previous one had got to when it begins.
135
+ const progress = (j: number, from: Shot, time: number): Shot =>
136
+ mix(from, moves[j].to, easeInOut(Math.min(1, Math.max(0, (time - moves[j].at) / PORTRAIT.ease))))
137
+ const starts: Shot[] = []
138
+ moves.forEach((move, j) => {
139
+ starts.push(j === 0 ? overview : progress(j - 1, starts[j - 1], move.at))
140
+ })
141
+ const shotAt = (time: number): Shot => {
142
+ const j = moves.findLastIndex((m) => m.at <= time)
143
+ return j === -1 ? overview : progress(j, starts[j], time)
144
+ }
145
+
146
+ const camera: Layout['camera'] = []
147
+ for (let time = t.clipStart; time < t.clipEnd + 1e-9; time += PORTRAIT.step) {
148
+ const shot = shotAt(time)
149
+ const h = frameHeight(shot.s)
150
+ camera.push([
151
+ Math.round(time * 1000) / 1000,
152
+ Math.round((shot.s / fit) * 10000) / 10000,
153
+ Math.round((W / 2 - shot.cx * shot.s) * 10) / 10,
154
+ Math.round((h / 2 - shot.cy * shot.s) * 10) / 10,
155
+ Math.round(h),
156
+ ])
157
+ }
158
+
159
+ const landscapeScale = t.frame.width / view.width
160
+ return {
161
+ layout: {
162
+ format: 'portrait',
163
+ stage: PORTRAIT.stage,
164
+ frame: { width: W, height: H },
165
+ footage: { width: W, height: Math.round(view.height * fit) },
166
+ bandZoom,
167
+ camera,
168
+ },
169
+ // Positions in footage px (scale `fit`); drawn at the size it has in landscape.
170
+ cursor: t.cursor
171
+ ? { ...t.cursor, scale: Math.round(fit * 10000) / 10000, size: round((t.cursor.size * landscapeScale) / fit) }
172
+ : null,
173
+ // The camera frames what matters; landscape zooms would fight it.
174
+ zooms: [],
175
+ }
176
+ }
177
+
178
+ function mix(a: Shot, b: Shot, u: number): Shot {
179
+ return { s: a.s + (b.s - a.s) * u, cx: a.cx + (b.cx - a.cx) * u, cy: a.cy + (b.cy - a.cy) * u }
180
+ }
181
+
182
+ function easeInOut(u: number): number {
183
+ return u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2
184
+ }
185
+
186
+ function clamp(n: number, lo: number, hi: number): number {
187
+ return Math.min(hi, Math.max(lo, n))
188
+ }
189
+
190
+ /** Stage px a bottom callout covers (its 34–40 px margin, a line or two of pill). */
191
+ export const CALLOUT_BAND = 150
192
+
193
+ /**
194
+ * Callouts that would cover what the demo works on while they show: the cursor (moves and
195
+ * presses) or the focused area reaching into the band a bottom callout covers. The stage
196
+ * shows those at the top instead. Only where callouts sit over the footage (landscape,
197
+ * square); portrait puts them below the frame.
198
+ */
199
+ export function calloutsAtTop(t: Timeline, layout: Layout): Set<number> {
200
+ const top = new Set<number>()
201
+ if (layout.format === 'portrait') {
202
+ return top
203
+ }
204
+ // Where the band starts, as recording CSS px (the frame is centred on the stage).
205
+ const frameTop = (layout.stage.height - layout.frame.height) / 2
206
+ const scale = layout.footage.height / t.viewport.height
207
+ const footageTop = frameTop + (layout.frame.height - layout.footage.height) / 2
208
+ const limit = (layout.stage.height - CALLOUT_BAND - footageTop) / scale
209
+ const points = [...(t.cursor?.path ?? []), ...(t.cursor?.presses ?? [])]
210
+ t.callouts.forEach((c, i) => {
211
+ const during = (at: number) => at >= c.at && at <= c.at + c.duration
212
+ const low =
213
+ points.some(([at, , y]) => during(at) && y > limit) ||
214
+ t.focus.some((f) => during(f.at) && f.area.y + f.area.height > limit)
215
+ if (low) top.add(i)
216
+ })
217
+ return top
218
+ }