@mineo71/showtime 0.3.0 → 0.4.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 +15 -0
- package/README.md +78 -14
- package/docs/script.schema.json +178 -0
- package/package.json +1 -1
- package/skill/SKILL.md +40 -7
- package/src/audio/decode.ts +19 -0
- package/src/audio/mix.ts +72 -0
- package/src/audio/sfx.ts +55 -0
- package/src/audio/soundtrack.ts +49 -0
- package/src/audio/tts.ts +186 -0
- package/src/audio/wav.ts +96 -0
- package/src/cli/main.ts +53 -21
- package/src/convert.ts +48 -0
- package/src/ffmpeg.ts +21 -0
- package/src/lint.ts +28 -1
- package/src/paths.ts +13 -0
- package/src/pipeline.ts +89 -20
- package/src/render/remotion/Intro.tsx +1 -1
- package/src/render/remotion/Outro.tsx +1 -1
- package/src/render/remotion/Overlays.tsx +40 -16
- package/src/render/remotion/ShowtimeVideo.tsx +47 -16
- package/src/render/render.ts +1 -1
- package/src/runner/events.ts +2 -0
- package/src/runner/record.ts +1 -0
- package/src/runner/steps.ts +1 -0
- package/src/script/config.ts +24 -8
- package/src/script/load.ts +4 -1
- package/src/script/schema.ts +53 -0
- package/src/timeline/build.ts +75 -15
- package/src/timeline/camera.ts +34 -11
- package/src/timeline/fades.ts +3 -0
- package/src/timeline/frames.ts +3 -2
- package/src/timeline/layout.ts +53 -4
- package/src/timeline/narration.ts +160 -0
- package/src/timeline/timemap.ts +40 -5
- package/src/timeline/types.ts +38 -1
package/src/timeline/build.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { Script, Step } from '../script/schema';
|
|
2
2
|
import { buildCamera, cameraAt, MIN_ZOOM, zoomDepth, type ZoomRegion } from './camera';
|
|
3
|
-
import { BROWSER_BAR, projectRect,
|
|
3
|
+
import { BROWSER_BAR, OUTPUT_SIZES, projectRect, viewLayout, visibleFraction, type View } from './layout';
|
|
4
4
|
import { clamp } from './math';
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
5
|
+
import { RIPPLE_MS } from './cursor';
|
|
6
|
+
import { CAPTION_FADE_MS, SPOTLIGHT_FADE_MS } from './fades';
|
|
7
|
+
import { soundEvents, stepKey, subtitleCues, withNarration, type Span, type Typing } from './narration';
|
|
8
|
+
import { buildSegments, sourceToOutput, type SpeedSpan } from './timemap';
|
|
9
|
+
import type { Aspect, CameraKey, Caption, NarrationLine, Rect, Spotlight, StepEvent, TakeEvents, Timeline } from './types';
|
|
7
10
|
|
|
8
11
|
export const AUTO_SPEED_MIN_IDLE_MS = 800;
|
|
9
12
|
export const AUTO_SPEED_TARGET_MS = 600;
|
|
@@ -13,15 +16,17 @@ export const SPOTLIGHT_PAD = 8;
|
|
|
13
16
|
export const OUTRO_MS = 2600;
|
|
14
17
|
/** Bottom captions own the output below this fraction of its height. */
|
|
15
18
|
export const CAPTION_BAND = 0.78;
|
|
16
|
-
const DEFAULT_OUTPUT = { width: 1920, height: 1080 };
|
|
17
19
|
|
|
18
20
|
const ZOOMABLE = new Set<Step['do']>(['click', 'type', 'select', 'hover']);
|
|
19
21
|
|
|
20
22
|
export interface BuildOptions {
|
|
21
23
|
fps?: number;
|
|
22
|
-
|
|
24
|
+
/** 16:9 by default; 9:16 and 1:1 crop the viewport and follow the action. */
|
|
25
|
+
aspect?: Aspect;
|
|
23
26
|
/** Logo as a data URI. */
|
|
24
27
|
logo?: string;
|
|
28
|
+
/** Length of each synthesized `show.say` line, by step (`scene#n`); lines without one are left out. */
|
|
29
|
+
voices?: Record<string, number>;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
function stepOf(script: Script, e: StepEvent): Step {
|
|
@@ -46,7 +51,7 @@ function captionPosition(
|
|
|
46
51
|
c: { startMs: number; endMs: number },
|
|
47
52
|
camera: CameraKey[],
|
|
48
53
|
stage: { width: number; height: number },
|
|
49
|
-
win:
|
|
54
|
+
win: View,
|
|
50
55
|
output: { width: number; height: number },
|
|
51
56
|
): Caption['position'] {
|
|
52
57
|
if (!target) return 'bottom';
|
|
@@ -63,7 +68,13 @@ const pad = (r: Rect, p: number): Rect => ({ x: r.x - p, y: r.y - p, width: r.wi
|
|
|
63
68
|
export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions = {}): Timeline {
|
|
64
69
|
const { style } = script;
|
|
65
70
|
const stage = ev.viewport;
|
|
66
|
-
const
|
|
71
|
+
const aspect = opts.aspect ?? '16:9';
|
|
72
|
+
const output = OUTPUT_SIZES[aspect];
|
|
73
|
+
const win = viewLayout(output, stage, style.padding, style.chrome === 'browser' ? BROWSER_BAR : 0, aspect);
|
|
74
|
+
const visible = visibleFraction(win);
|
|
75
|
+
// Cropped formats follow every target; a zoom only adds what the crop doesn't already magnify.
|
|
76
|
+
const crop = aspect !== '16:9';
|
|
77
|
+
const cropDepth = Math.min(visible.x, visible.y);
|
|
67
78
|
const pairs = ev.steps.map((e) => ({ e, step: stepOf(script, e) }));
|
|
68
79
|
|
|
69
80
|
// Speed: auto idle first, explicit `show.speed` next, pauses last (later spans win).
|
|
@@ -87,9 +98,10 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
87
98
|
for (const { e, step } of pairs) {
|
|
88
99
|
const zoom = step.show?.zoom;
|
|
89
100
|
const wantsZoom = typeof zoom === 'number' || (zoom === undefined && style.autoZoom && ZOOMABLE.has(step.do));
|
|
90
|
-
if (e.target && wantsZoom) {
|
|
91
|
-
const
|
|
92
|
-
|
|
101
|
+
if (e.target && (wantsZoom || crop)) {
|
|
102
|
+
const depth = wantsZoom ? (typeof zoom === 'number' ? zoom : zoomDepth(e.target, stage, style.maxZoom)) : 1;
|
|
103
|
+
const scale = crop ? Math.max(1, depth * cropDepth) : depth;
|
|
104
|
+
if (scale >= MIN_ZOOM || crop) {
|
|
93
105
|
regions.push({
|
|
94
106
|
startMs: e.actionMs,
|
|
95
107
|
endMs: e.settledMs,
|
|
@@ -107,8 +119,14 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
107
119
|
}
|
|
108
120
|
}
|
|
109
121
|
|
|
110
|
-
const
|
|
111
|
-
|
|
122
|
+
const lines = pairs.flatMap(({ e, step }) => {
|
|
123
|
+
const where = stepKey(e.sceneId, e.index);
|
|
124
|
+
const durationMs = opts.voices?.[where];
|
|
125
|
+
return step.show?.say && durationMs !== undefined ? [{ where, text: step.show.say, startMs: e.startMs, durationMs }] : [];
|
|
126
|
+
});
|
|
127
|
+
const subtitlesBurned = script.audio.subtitles === 'burn' && lines.length > 0;
|
|
128
|
+
|
|
129
|
+
const camera = buildCamera(regions, { visible, hold: crop });
|
|
112
130
|
// A caption ends when the next one starts: no overlap, so captions dip instead of colliding.
|
|
113
131
|
drafts.sort((a, b) => a.startMs - b.startMs);
|
|
114
132
|
drafts.forEach((d, i) => {
|
|
@@ -117,14 +135,50 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
117
135
|
});
|
|
118
136
|
const captions: Caption[] = drafts
|
|
119
137
|
.filter((d) => d.endMs > d.startMs)
|
|
120
|
-
.map((d) => ({
|
|
138
|
+
.map((d) => ({
|
|
139
|
+
startMs: d.startMs,
|
|
140
|
+
endMs: d.endMs,
|
|
141
|
+
text: d.text,
|
|
142
|
+
// 9:16 captions sit in the band under the window, clear of the action.
|
|
143
|
+
position: subtitlesBurned ? 'top' : aspect === '9:16' ? 'bottom' : captionPosition(d.target, d, camera, stage, win, output),
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
// Narration holds freeze the frame, so they go where nothing is moving: not during an action,
|
|
147
|
+
// a cursor move, a ripple, a camera move or a fade.
|
|
148
|
+
const fades = (x: { startMs: number; endMs: number }, ms: number) => [
|
|
149
|
+
{ startMs: x.startMs, endMs: x.startMs + ms },
|
|
150
|
+
{ startMs: x.endMs - ms, endMs: x.endMs },
|
|
151
|
+
];
|
|
152
|
+
const busy: Span[] = [
|
|
153
|
+
...pairs.filter(({ step }) => step.do !== 'pause').map(({ e }) => ({ startMs: e.startMs, endMs: e.endMs })),
|
|
154
|
+
...ev.moves,
|
|
155
|
+
...ev.clicks.map((c) => ({ startMs: c.tMs, endMs: c.tMs + RIPPLE_MS })),
|
|
156
|
+
...camera.slice(1).flatMap((b, i) => {
|
|
157
|
+
const a = camera[i];
|
|
158
|
+
return a.scale !== b.scale || a.cx !== b.cx || a.cy !== b.cy ? [{ startMs: a.tMs, endMs: b.tMs }] : [];
|
|
159
|
+
}),
|
|
160
|
+
...captions.flatMap((c) => fades(c, CAPTION_FADE_MS)),
|
|
161
|
+
...spotlights.flatMap((s) => fades(s, SPOTLIGHT_FADE_MS)),
|
|
162
|
+
];
|
|
163
|
+
const segments = withNarration(buildSegments(ev.video.durationMs, spans), lines, ev.video.durationMs, busy);
|
|
164
|
+
const narration: NarrationLine[] = lines.map((l) => ({
|
|
165
|
+
where: l.where,
|
|
166
|
+
text: l.text,
|
|
167
|
+
atMs: Math.round(sourceToOutput(segments, l.startMs)),
|
|
168
|
+
durationMs: l.durationMs,
|
|
169
|
+
}));
|
|
170
|
+
const subtitles = script.audio.subtitles === 'off' ? [] : narration.flatMap((n) => subtitleCues(n.text, n.atMs, n.durationMs));
|
|
171
|
+
const typing: Typing[] = pairs.flatMap(({ e, step }) =>
|
|
172
|
+
step.do === 'type' && step.pace !== 'fast' ? [{ startMs: e.actionMs, endMs: e.endMs, chars: [...step.text].length }] : [],
|
|
173
|
+
);
|
|
121
174
|
|
|
122
175
|
return {
|
|
123
176
|
version: 1,
|
|
124
177
|
fps: opts.fps ?? 30,
|
|
178
|
+
aspect,
|
|
125
179
|
output,
|
|
126
180
|
stage,
|
|
127
|
-
video: { src: ev.video.file
|
|
181
|
+
video: { src: `take/${ev.video.file}`, durationMs: ev.video.durationMs },
|
|
128
182
|
intro: script.meta.title ? { title: script.meta.title, subtitle: script.meta.subtitle, durationMs: INTRO_MS } : null,
|
|
129
183
|
chrome:
|
|
130
184
|
style.chrome === 'browser'
|
|
@@ -132,12 +186,18 @@ export function buildTimeline(script: Script, ev: TakeEvents, opts: BuildOptions
|
|
|
132
186
|
: null,
|
|
133
187
|
outro: script.meta.outro ? { ...script.meta.outro, durationMs: OUTRO_MS } : null,
|
|
134
188
|
logo: opts.logo ?? null,
|
|
135
|
-
segments
|
|
189
|
+
segments,
|
|
136
190
|
camera,
|
|
137
191
|
moves: ev.moves,
|
|
138
192
|
clicks: ev.clicks,
|
|
139
193
|
captions,
|
|
140
194
|
spotlights,
|
|
195
|
+
narration,
|
|
196
|
+
subtitles,
|
|
197
|
+
subtitlesBurned,
|
|
198
|
+
sounds: script.audio.sounds ? soundEvents(ev, typing, segments) : [],
|
|
199
|
+
music: script.audio.music ? { volume: script.audio.music.volume } : null,
|
|
200
|
+
audio: null,
|
|
141
201
|
style,
|
|
142
202
|
};
|
|
143
203
|
}
|
package/src/timeline/camera.ts
CHANGED
|
@@ -19,14 +19,33 @@ export function zoomDepth(target: Rect, stage: { width: number; height: number }
|
|
|
19
19
|
return clamp(fit, 1, maxZoom);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/** Share of the content visible at scale 1, per axis: 1 unless the format crops the viewport. */
|
|
23
|
+
export interface Visible {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FULL: Visible = { x: 1, y: 1 };
|
|
29
|
+
|
|
30
|
+
/** Keep the focus where the visible part stays inside the content. */
|
|
31
|
+
export function clampFocus(scale: number, cx: number, cy: number, visible: Visible = FULL): { cx: number; cy: number } {
|
|
32
|
+
const hx = Math.min(0.5, visible.x / (2 * scale));
|
|
33
|
+
const hy = Math.min(0.5, visible.y / (2 * scale));
|
|
34
|
+
return { cx: clamp(cx, hx, 1 - hx), cy: clamp(cy, hy, 1 - hy) };
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
const rest = (tMs: number): CameraKey => ({ tMs, scale: 1, cx: 0.5, cy: 0.5 });
|
|
28
38
|
|
|
29
|
-
export
|
|
39
|
+
export interface CameraOptions {
|
|
40
|
+
visible?: Visible;
|
|
41
|
+
/** Between regions keep the last focus (at scale 1) instead of returning to the centre: the follow camera of cropped formats. */
|
|
42
|
+
hold?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildCamera(regions: ZoomRegion[], opts: CameraOptions = {}): CameraKey[] {
|
|
46
|
+
const visible = opts.visible ?? FULL;
|
|
47
|
+
let last = { cx: 0.5, cy: 0.5 };
|
|
48
|
+
const restAt = (tMs: number): CameraKey => (opts.hold ? { tMs, scale: 1, ...clampFocus(1, last.cx, last.cy, visible) } : rest(tMs));
|
|
30
49
|
const rs = [...regions].sort((a, b) => a.startMs - b.startMs);
|
|
31
50
|
const keys: CameraKey[] = [];
|
|
32
51
|
rs.forEach((r, i) => {
|
|
@@ -34,14 +53,15 @@ export function buildCamera(regions: ZoomRegion[]): CameraKey[] {
|
|
|
34
53
|
const next = rs[i + 1];
|
|
35
54
|
const start = prev ? Math.max(r.startMs, prev.endMs) : r.startMs;
|
|
36
55
|
const end = Math.max(start, r.endMs);
|
|
37
|
-
const focus = clampFocus(r.scale, r.cx, r.cy);
|
|
56
|
+
const focus = clampFocus(r.scale, r.cx, r.cy, visible);
|
|
38
57
|
if (!prev || start - prev.endMs > CONNECT_GAP_MS) {
|
|
39
58
|
const lastT = keys.length ? keys[keys.length - 1].tMs : 0;
|
|
40
|
-
keys.push(
|
|
59
|
+
keys.push(restAt(Math.max(lastT, start - ZOOM_LEAD_MS)));
|
|
41
60
|
}
|
|
42
61
|
keys.push({ tMs: start, scale: r.scale, ...focus });
|
|
43
62
|
keys.push({ tMs: end, scale: r.scale, ...focus });
|
|
44
|
-
|
|
63
|
+
last = focus;
|
|
64
|
+
if (!next || next.startMs - end > CONNECT_GAP_MS) keys.push(restAt(end + ZOOM_LEAD_MS));
|
|
45
65
|
});
|
|
46
66
|
return keys;
|
|
47
67
|
}
|
|
@@ -60,10 +80,13 @@ export function cameraAt(keys: CameraKey[], tMs: number): CameraKey {
|
|
|
60
80
|
return { ...keys[keys.length - 1], tMs };
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
/**
|
|
84
|
+
* CSS transform (transform-origin 0 0) for content of `content` size that puts the focus point at
|
|
85
|
+
* the centre of a width×height window (content defaults to the window's size).
|
|
86
|
+
*/
|
|
87
|
+
export function cameraTransform(cam: CameraKey, width: number, height: number, content = { width, height }): string {
|
|
88
|
+
const tx = width / 2 - cam.cx * content.width * cam.scale;
|
|
89
|
+
const ty = height / 2 - cam.cy * content.height * cam.scale;
|
|
67
90
|
const r = (v: number) => Math.round(v * 100) / 100 + 0;
|
|
68
91
|
return `translate(${r(tx)}px, ${r(ty)}px) scale(${r(cam.scale * 1000) / 1000})`;
|
|
69
92
|
}
|
package/src/timeline/frames.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { outputDurationMs, outputToSource, sourceToOutput } from './timemap';
|
|
1
|
+
import { outputDurationMs, outputToSource, segLength, sourceToOutput } from './timemap';
|
|
2
2
|
import type { StepEvent, Timeline } from './types';
|
|
3
3
|
|
|
4
4
|
export interface SegmentPlan {
|
|
@@ -6,6 +6,7 @@ export interface SegmentPlan {
|
|
|
6
6
|
durationInFrames: number;
|
|
7
7
|
/** source frame where this segment starts */
|
|
8
8
|
trimBefore: number;
|
|
9
|
+
/** 0: a hold, showing the `trimBefore` frame throughout */
|
|
9
10
|
speed: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -39,7 +40,7 @@ export function segmentPlan(t: Timeline): SegmentPlan[] {
|
|
|
39
40
|
let outMs = 0;
|
|
40
41
|
for (const s of t.segments) {
|
|
41
42
|
const startF = toFrames(outMs, t.fps);
|
|
42
|
-
outMs += (s
|
|
43
|
+
outMs += segLength(s);
|
|
43
44
|
const endF = toFrames(outMs, t.fps);
|
|
44
45
|
if (endF > startF) {
|
|
45
46
|
plan.push({ fromFrame: intro + startF, durationInFrames: endF - startF, trimBefore: toFrames(s.srcStartMs, t.fps), speed: s.speed });
|
package/src/timeline/layout.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CameraKey, Rect } from './types';
|
|
1
|
+
import type { Aspect, CameraKey, Rect } from './types';
|
|
2
2
|
|
|
3
3
|
export interface Box {
|
|
4
4
|
x: number;
|
|
@@ -10,6 +10,21 @@ export interface Box {
|
|
|
10
10
|
/** Height of the browser bar, in stage (viewport) pixels. */
|
|
11
11
|
export const BROWSER_BAR = 44;
|
|
12
12
|
|
|
13
|
+
export const OUTPUT_SIZES: Record<Aspect, { width: number; height: number }> = {
|
|
14
|
+
'16:9': { width: 1920, height: 1080 },
|
|
15
|
+
'9:16': { width: 1080, height: 1920 },
|
|
16
|
+
'1:1': { width: 1080, height: 1080 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** In 9:16 the window (bar included) is this many times taller than wide. */
|
|
20
|
+
const PORTRAIT_WINDOW = 1.3;
|
|
21
|
+
|
|
22
|
+
/** The window on screen, and the size the viewport is drawn at inside it (larger when cropped). */
|
|
23
|
+
export interface View extends Box {
|
|
24
|
+
bar: number;
|
|
25
|
+
content: { width: number; height: number };
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Where the recorded viewport sits inside the output frame. `bar` (stage px) reserves room for a
|
|
15
30
|
* browser bar above it; the returned box is the viewport, `bar` the bar height in output px.
|
|
@@ -30,6 +45,40 @@ export function windowLayout(
|
|
|
30
45
|
return { x: Math.round((output.width - width) / 2), y: top + barPx, width, height, bar: barPx };
|
|
31
46
|
}
|
|
32
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The window for an aspect ratio. 16:9 fits the whole viewport (the 0.3 layout). 1:1 fills the
|
|
50
|
+
* padded square and 9:16 uses a tall window in the middle; in both the viewport covers the window,
|
|
51
|
+
* so it is cropped and the camera decides what shows.
|
|
52
|
+
*/
|
|
53
|
+
export function viewLayout(
|
|
54
|
+
output: { width: number; height: number },
|
|
55
|
+
stage: { width: number; height: number },
|
|
56
|
+
padding: number,
|
|
57
|
+
bar = 0,
|
|
58
|
+
aspect: Aspect = '16:9',
|
|
59
|
+
): View {
|
|
60
|
+
if (aspect === '16:9') {
|
|
61
|
+
const w = windowLayout(output, stage, padding, bar);
|
|
62
|
+
return { ...w, content: { width: w.width, height: w.height } };
|
|
63
|
+
}
|
|
64
|
+
const width = Math.round(output.width * (1 - 2 * padding));
|
|
65
|
+
const total = aspect === '9:16' ? Math.round(width * PORTRAIT_WINDOW) : Math.round(output.height * (1 - 2 * padding));
|
|
66
|
+
const k = Math.max(width / stage.width, total / (stage.height + bar));
|
|
67
|
+
const barPx = Math.round(bar * k);
|
|
68
|
+
const top = Math.round((output.height - total) / 2);
|
|
69
|
+
return {
|
|
70
|
+
x: Math.round((output.width - width) / 2),
|
|
71
|
+
y: top + barPx,
|
|
72
|
+
width,
|
|
73
|
+
height: total - barPx,
|
|
74
|
+
bar: barPx,
|
|
75
|
+
content: { width: Math.round(stage.width * k), height: Math.round(stage.height * k) },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How much of the content shows at camera scale 1, per axis (1 = all of it). */
|
|
80
|
+
export const visibleFraction = (v: View) => ({ x: Math.min(1, v.width / v.content.width), y: Math.min(1, v.height / v.content.height) });
|
|
81
|
+
|
|
33
82
|
const r2 = (v: number) => Math.round(v * 100) / 100;
|
|
34
83
|
|
|
35
84
|
/** Where a stage rect (CSS px) lands in the output: through the camera, inside the window viewport. */
|
|
@@ -37,10 +86,10 @@ export function projectRect(
|
|
|
37
86
|
r: Rect,
|
|
38
87
|
cam: Pick<CameraKey, 'scale' | 'cx' | 'cy'>,
|
|
39
88
|
stage: { width: number; height: number },
|
|
40
|
-
win: Box,
|
|
89
|
+
win: Box & { content?: { width: number; height: number } },
|
|
41
90
|
): Rect {
|
|
42
|
-
const w = win.width * cam.scale;
|
|
43
|
-
const h = win.height * cam.scale;
|
|
91
|
+
const w = (win.content?.width ?? win.width) * cam.scale;
|
|
92
|
+
const h = (win.content?.height ?? win.height) * cam.scale;
|
|
44
93
|
return {
|
|
45
94
|
x: r2(win.x + win.width / 2 + (r.x / stage.width - cam.cx) * w),
|
|
46
95
|
y: r2(win.y + win.height / 2 + (r.y / stage.height - cam.cy) * h),
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { insertHolds, sourceToOutput, type Hold } from './timemap';
|
|
2
|
+
import type { Click, Cue, Segment, Sound } from './types';
|
|
3
|
+
|
|
4
|
+
export type { Cue, Sound };
|
|
5
|
+
|
|
6
|
+
/** Silence after a line before the next narrated step starts. */
|
|
7
|
+
export const NARRATION_GAP_MS = 400;
|
|
8
|
+
/** Two subtitle lines of 42 characters. */
|
|
9
|
+
export const CUE_MAX_CHARS = 84;
|
|
10
|
+
const LINE_CHARS = 42;
|
|
11
|
+
/** Keystroke sounds closer than this (output time) are dropped, so sped-up typing doesn't buzz. */
|
|
12
|
+
export const KEY_MIN_GAP_MS = 40;
|
|
13
|
+
|
|
14
|
+
/** `scene#n`, 1-based: how lint, errors and the voice cache name a step. */
|
|
15
|
+
export const stepKey = (sceneId: string, index: number) => `${sceneId}#${index + 1}`;
|
|
16
|
+
|
|
17
|
+
export interface Line {
|
|
18
|
+
/** source ms the step starts */
|
|
19
|
+
startMs: number;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Something moving on screen over (startMs, endMs), source ms. */
|
|
24
|
+
export interface Span {
|
|
25
|
+
startMs: number;
|
|
26
|
+
endMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The latest time in [lo, hi] that no span is moving through, or `hi` when there is none. */
|
|
30
|
+
export function stillPoint(lo: number, hi: number, busy: Span[]): number {
|
|
31
|
+
let t = hi;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const moving = busy.find((b) => b.startMs < t && t < b.endMs);
|
|
34
|
+
if (!moving) return t;
|
|
35
|
+
t = moving.startMs;
|
|
36
|
+
if (t < lo) return hi;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Holds that let every line finish (plus a gap) before the next narrated step starts, or before
|
|
42
|
+
* the video ends. `lines` are in source order; each hold sits at the last moment of its window
|
|
43
|
+
* when nothing in `busy` is moving, so the freeze never stops a camera move or a fade halfway.
|
|
44
|
+
*/
|
|
45
|
+
export function narrationHolds(lines: Line[], segments: Segment[], endMs: number, busy: Span[] = []): Hold[] {
|
|
46
|
+
const holds: Hold[] = [];
|
|
47
|
+
lines.forEach((line, i) => {
|
|
48
|
+
const next = lines[i + 1];
|
|
49
|
+
const until = next ? next.startMs : endMs;
|
|
50
|
+
const available = sourceToOutput(segments, until) - sourceToOutput(segments, line.startMs);
|
|
51
|
+
const short = line.durationMs + NARRATION_GAP_MS - available;
|
|
52
|
+
if (short <= 0) return;
|
|
53
|
+
// After the line's own start (a hold at its start would delay the line itself).
|
|
54
|
+
const lo = line.startMs + 1;
|
|
55
|
+
const hi = Math.max(lo, next ? next.startMs - 1 : endMs);
|
|
56
|
+
holds.push({ atMs: stillPoint(lo, hi, busy), holdMs: Math.round(short) });
|
|
57
|
+
});
|
|
58
|
+
return holds;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Segments with the narration holds in place. */
|
|
62
|
+
export const withNarration = (segments: Segment[], lines: Line[], endMs: number, busy: Span[] = []) =>
|
|
63
|
+
insertHolds(segments, narrationHolds(lines, segments, endMs, busy));
|
|
64
|
+
|
|
65
|
+
/** Greedily join pieces with spaces into chunks of at most CUE_MAX_CHARS. */
|
|
66
|
+
function pack(pieces: string[]): string[] {
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
let cur = '';
|
|
69
|
+
for (const p of pieces) {
|
|
70
|
+
if (!cur) cur = p;
|
|
71
|
+
else if (cur.length + 1 + p.length <= CUE_MAX_CHARS) cur += ` ${p}`;
|
|
72
|
+
else {
|
|
73
|
+
out.push(cur);
|
|
74
|
+
cur = p;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (cur) out.push(cur);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A sentence as cue texts: whole if it fits, else at commas, else between words. */
|
|
82
|
+
function sentenceChunks(sentence: string): string[] {
|
|
83
|
+
if (sentence.length <= CUE_MAX_CHARS) return [sentence];
|
|
84
|
+
const out: string[] = [];
|
|
85
|
+
let clauses: string[] = [];
|
|
86
|
+
for (const clause of sentence.split(/(?<=[,;:])\s+/)) {
|
|
87
|
+
if (clause.length <= CUE_MAX_CHARS) {
|
|
88
|
+
clauses.push(clause);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
out.push(...pack(clauses), ...pack(clause.split(' ')));
|
|
92
|
+
clauses = [];
|
|
93
|
+
}
|
|
94
|
+
return [...out, ...pack(clauses)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Subtitle cues for one line, timed in proportion to their length and back to back. */
|
|
98
|
+
export function subtitleCues(text: string, atMs: number, durationMs: number): Cue[] {
|
|
99
|
+
// Sentences first, long ones split at commas or words; then short neighbours share a cue, so
|
|
100
|
+
// a two-word sentence doesn't flash by on its own.
|
|
101
|
+
const chunks = pack(
|
|
102
|
+
text
|
|
103
|
+
.trim()
|
|
104
|
+
.replace(/\s+/g, ' ')
|
|
105
|
+
.split(/(?<=[.!?…])\s+/)
|
|
106
|
+
.flatMap(sentenceChunks),
|
|
107
|
+
);
|
|
108
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
109
|
+
let done = 0;
|
|
110
|
+
return chunks.map((chunk) => {
|
|
111
|
+
const startMs = atMs + Math.round((durationMs * done) / total);
|
|
112
|
+
done += chunk.length;
|
|
113
|
+
return { startMs, endMs: atMs + Math.round((durationMs * done) / total), text: chunk };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stamp = (ms: number) => {
|
|
118
|
+
const t = Math.max(0, Math.round(ms));
|
|
119
|
+
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
|
|
120
|
+
return `${pad(Math.floor(t / 3_600_000))}:${pad(Math.floor(t / 60_000) % 60)}:${pad(Math.floor(t / 1000) % 60)},${pad(t % 1000, 3)}`;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Break a cue over two lines at the space nearest its middle. */
|
|
124
|
+
function wrap(text: string): string {
|
|
125
|
+
if (text.length <= LINE_CHARS) return text;
|
|
126
|
+
let best = -1;
|
|
127
|
+
for (let i = 0; i < text.length; i++) {
|
|
128
|
+
if (text[i] === ' ' && (best < 0 || Math.abs(i - text.length / 2) < Math.abs(best - text.length / 2))) best = i;
|
|
129
|
+
}
|
|
130
|
+
return best < 0 ? text : `${text.slice(0, best)}\n${text.slice(best + 1)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** SubRip text; `offsetMs` shifts video-relative cues to file time (the intro card). */
|
|
134
|
+
export function toSrt(cues: Cue[], offsetMs: number): string {
|
|
135
|
+
return cues.map((c, i) => `${i + 1}\n${stamp(c.startMs + offsetMs)} --> ${stamp(c.endMs + offsetMs)}\n${wrap(c.text)}\n`).join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface Typing {
|
|
139
|
+
startMs: number;
|
|
140
|
+
endMs: number;
|
|
141
|
+
chars: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Click and keystroke sounds in output time. Takes without a keystroke log (before 0.4) spread
|
|
146
|
+
* each `type` step's keys evenly over its typing time.
|
|
147
|
+
*/
|
|
148
|
+
export function soundEvents(ev: { clicks: Click[]; keys?: number[] }, typing: Typing[], segments: Segment[]): Sound[] {
|
|
149
|
+
const keys =
|
|
150
|
+
ev.keys ?? typing.flatMap((t) => Array.from({ length: t.chars }, (_, i) => t.startMs + ((t.endMs - t.startMs) * (i + 0.5)) / t.chars));
|
|
151
|
+
const out: Sound[] = ev.clicks.map((c) => ({ kind: 'click', atMs: sourceToOutput(segments, c.tMs) }));
|
|
152
|
+
let last = -Infinity;
|
|
153
|
+
for (const k of [...keys].sort((a, b) => a - b)) {
|
|
154
|
+
const atMs = sourceToOutput(segments, k);
|
|
155
|
+
if (atMs - last < KEY_MIN_GAP_MS) continue;
|
|
156
|
+
out.push({ kind: 'key', atMs });
|
|
157
|
+
last = atMs;
|
|
158
|
+
}
|
|
159
|
+
return out.sort((a, b) => a.atMs - b.atMs);
|
|
160
|
+
}
|
package/src/timeline/timemap.ts
CHANGED
|
@@ -28,15 +28,50 @@ export function buildSegments(durationMs: number, spans: SpeedSpan[]): Segment[]
|
|
|
28
28
|
return out;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export interface Hold {
|
|
32
|
+
/** source ms whose frame stays on screen */
|
|
33
|
+
atMs: number;
|
|
34
|
+
holdMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Output length of one segment. */
|
|
38
|
+
export const segLength = (s: Segment): number => (s.speed === 0 ? (s.holdMs ?? 0) : (s.srcEndMs - s.srcStartMs) / s.speed);
|
|
39
|
+
|
|
40
|
+
/** Insert still-frame holds, splitting the segment each one falls in; holds at one point merge. */
|
|
41
|
+
export function insertHolds(segments: Segment[], holds: Hold[]): Segment[] {
|
|
42
|
+
const at = new Map<number, number>();
|
|
43
|
+
for (const h of holds) if (h.holdMs > 0) at.set(h.atMs, (at.get(h.atMs) ?? 0) + h.holdMs);
|
|
44
|
+
const points = [...at.keys()].sort((a, b) => a - b);
|
|
45
|
+
const hold = (t: number): Segment => ({ srcStartMs: t, srcEndMs: t, speed: 0, holdMs: at.get(t) });
|
|
46
|
+
const out: Segment[] = [];
|
|
47
|
+
let p = 0;
|
|
48
|
+
for (const s of segments) {
|
|
49
|
+
if (s.speed === 0) {
|
|
50
|
+
out.push(s);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let start = s.srcStartMs;
|
|
54
|
+
for (; p < points.length && points[p] < s.srcEndMs; p++) {
|
|
55
|
+
const t = Math.max(points[p], start);
|
|
56
|
+
if (t > start) out.push({ ...s, srcStartMs: start, srcEndMs: t });
|
|
57
|
+
out.push(hold(points[p]));
|
|
58
|
+
start = t;
|
|
59
|
+
}
|
|
60
|
+
out.push({ ...s, srcStartMs: start });
|
|
61
|
+
}
|
|
62
|
+
for (; p < points.length; p++) out.push(hold(points[p]));
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
export function outputDurationMs(segments: Segment[]): number {
|
|
32
|
-
return segments.reduce((acc, s) => acc + (s
|
|
67
|
+
return segments.reduce((acc, s) => acc + segLength(s), 0);
|
|
33
68
|
}
|
|
34
69
|
|
|
35
70
|
export function outputToSource(segments: Segment[], tOutMs: number): number {
|
|
36
71
|
let acc = 0;
|
|
37
72
|
for (const s of segments) {
|
|
38
|
-
const len = (s
|
|
39
|
-
if (tOutMs < acc + len) return s.srcStartMs + Math.max(0, tOutMs - acc) * s.speed;
|
|
73
|
+
const len = segLength(s);
|
|
74
|
+
if (tOutMs < acc + len) return s.speed === 0 ? s.srcStartMs : s.srcStartMs + Math.max(0, tOutMs - acc) * s.speed;
|
|
40
75
|
acc += len;
|
|
41
76
|
}
|
|
42
77
|
return segments.length ? segments[segments.length - 1].srcEndMs : 0;
|
|
@@ -45,8 +80,8 @@ export function outputToSource(segments: Segment[], tOutMs: number): number {
|
|
|
45
80
|
export function sourceToOutput(segments: Segment[], tSrcMs: number): number {
|
|
46
81
|
let acc = 0;
|
|
47
82
|
for (const s of segments) {
|
|
48
|
-
if (tSrcMs < s.srcEndMs) return acc + Math.max(0, tSrcMs - s.srcStartMs) / s.speed;
|
|
49
|
-
acc += (s
|
|
83
|
+
if (s.speed !== 0 && tSrcMs < s.srcEndMs) return acc + Math.max(0, tSrcMs - s.srcStartMs) / s.speed;
|
|
84
|
+
acc += segLength(s);
|
|
50
85
|
}
|
|
51
86
|
return acc;
|
|
52
87
|
}
|
package/src/timeline/types.ts
CHANGED
|
@@ -60,12 +60,16 @@ export interface TakeEvents {
|
|
|
60
60
|
moves: Move[];
|
|
61
61
|
clicks: Click[];
|
|
62
62
|
idle: Interval[];
|
|
63
|
+
/** keystroke times (human-paced typing); missing in takes recorded before 0.4 */
|
|
64
|
+
keys?: number[];
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
export interface Segment {
|
|
66
68
|
srcStartMs: number;
|
|
67
69
|
srcEndMs: number;
|
|
70
|
+
/** 0 for a hold: the frame at srcStartMs (= srcEndMs) stays on screen for `holdMs` */
|
|
68
71
|
speed: number;
|
|
72
|
+
holdMs?: number;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export interface CameraKey {
|
|
@@ -91,12 +95,37 @@ export interface Spotlight {
|
|
|
91
95
|
rect: Rect;
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
/** A subtitle cue, in output ms from the start of the video. */
|
|
99
|
+
export interface Cue {
|
|
100
|
+
startMs: number;
|
|
101
|
+
endMs: number;
|
|
102
|
+
text: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Sound {
|
|
106
|
+
kind: 'click' | 'key';
|
|
107
|
+
/** output ms from the start of the video */
|
|
108
|
+
atMs: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface NarrationLine {
|
|
112
|
+
/** `scene#n` */
|
|
113
|
+
where: string;
|
|
114
|
+
text: string;
|
|
115
|
+
/** output ms from the start of the video */
|
|
116
|
+
atMs: number;
|
|
117
|
+
durationMs: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
94
120
|
export type ResolvedStyle = Style;
|
|
95
121
|
|
|
96
|
-
|
|
122
|
+
export type Aspect = '16:9' | '9:16' | '1:1';
|
|
123
|
+
|
|
124
|
+
/** The renderer contract. Overlay times are source ms; sound and subtitle times are output ms from the video start. */
|
|
97
125
|
export interface Timeline {
|
|
98
126
|
version: 1;
|
|
99
127
|
fps: number;
|
|
128
|
+
aspect: Aspect;
|
|
100
129
|
output: { width: number; height: number };
|
|
101
130
|
stage: { width: number; height: number };
|
|
102
131
|
video: { src: string; durationMs: number };
|
|
@@ -113,5 +142,13 @@ export interface Timeline {
|
|
|
113
142
|
clicks: Click[];
|
|
114
143
|
captions: Caption[];
|
|
115
144
|
spotlights: Spotlight[];
|
|
145
|
+
narration: NarrationLine[];
|
|
146
|
+
subtitles: Cue[];
|
|
147
|
+
/** Subtitles drawn on the video (captions then sit at the top). */
|
|
148
|
+
subtitlesBurned: boolean;
|
|
149
|
+
sounds: Sound[];
|
|
150
|
+
music: { volume: number } | null;
|
|
151
|
+
/** The mixed soundtrack, relative to the renderer's public folder; set by the pipeline. */
|
|
152
|
+
audio: { src: string } | null;
|
|
116
153
|
style: ResolvedStyle;
|
|
117
154
|
}
|