@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.
@@ -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, windowLayout, type Box } from './layout';
3
+ import { BROWSER_BAR, OUTPUT_SIZES, projectRect, viewLayout, visibleFraction, type View } from './layout';
4
4
  import { clamp } from './math';
5
- import { buildSegments, type SpeedSpan } from './timemap';
6
- import type { CameraKey, Caption, Rect, Spotlight, StepEvent, TakeEvents, Timeline } from './types';
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
- output?: { width: number; height: number };
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: Box,
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 output = opts.output ?? DEFAULT_OUTPUT;
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 scale = typeof zoom === 'number' ? zoom : zoomDepth(e.target, stage, style.maxZoom);
92
- if (scale >= MIN_ZOOM) {
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 camera = buildCamera(regions);
111
- const win = windowLayout(output, stage, style.padding, style.chrome === 'browser' ? BROWSER_BAR : 0);
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) => ({ startMs: d.startMs, endMs: d.endMs, text: d.text, position: captionPosition(d.target, d, camera, stage, win, output) }));
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, durationMs: ev.video.durationMs },
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: buildSegments(ev.video.durationMs, spans),
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
  }
@@ -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
- export function clampFocus(scale: number, cx: number, cy: number): { cx: number; cy: number } {
23
- const half = 0.5 / scale;
24
- return { cx: clamp(cx, half, 1 - half), cy: clamp(cy, half, 1 - half) };
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 function buildCamera(regions: ZoomRegion[]): CameraKey[] {
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(rest(Math.max(lastT, start - ZOOM_LEAD_MS)));
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
- if (!next || next.startMs - end > CONNECT_GAP_MS) keys.push(rest(end + ZOOM_LEAD_MS));
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
- /** CSS transform (transform-origin 0 0) that puts the focus point at the centre of a width×height box. */
64
- export function cameraTransform(cam: CameraKey, width: number, height: number): string {
65
- const tx = width / 2 - cam.cx * width * cam.scale;
66
- const ty = height / 2 - cam.cy * height * cam.scale;
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
  }
@@ -0,0 +1,3 @@
1
+ /** Fade lengths shared by the renderer and the timeline (holds must not freeze a fade). */
2
+ export const CAPTION_FADE_MS = 220;
3
+ export const SPOTLIGHT_FADE_MS = 250;
@@ -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.srcEndMs - s.srcStartMs) / s.speed;
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 });
@@ -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
+ }
@@ -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.srcEndMs - s.srcStartMs) / s.speed, 0);
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.srcEndMs - s.srcStartMs) / s.speed;
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.srcEndMs - s.srcStartMs) / s.speed;
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
  }
@@ -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
- /** The renderer contract. All overlay times are source ms. */
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
  }