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,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
|
+
}
|