@mineo71/showtime 0.2.1 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +155 -19
  3. package/docs/script.schema.json +395 -0
  4. package/package.json +1 -1
  5. package/skill/SKILL.md +70 -13
  6. package/src/audio/decode.ts +19 -0
  7. package/src/audio/mix.ts +72 -0
  8. package/src/audio/sfx.ts +55 -0
  9. package/src/audio/soundtrack.ts +49 -0
  10. package/src/audio/tts.ts +186 -0
  11. package/src/audio/wav.ts +96 -0
  12. package/src/cli/format.ts +10 -0
  13. package/src/cli/main.ts +125 -40
  14. package/src/convert.ts +48 -0
  15. package/src/ffmpeg.ts +21 -0
  16. package/src/lint.ts +128 -0
  17. package/src/logo.ts +20 -0
  18. package/src/paths.ts +19 -0
  19. package/src/pipeline.ts +97 -19
  20. package/src/render/remotion/Intro.tsx +3 -1
  21. package/src/render/remotion/Logo.tsx +6 -0
  22. package/src/render/remotion/Outro.tsx +38 -0
  23. package/src/render/remotion/Overlays.tsx +41 -16
  24. package/src/render/remotion/ShowtimeVideo.tsx +56 -18
  25. package/src/render/render.ts +1 -1
  26. package/src/runner/browser.ts +47 -6
  27. package/src/runner/check.ts +10 -1
  28. package/src/runner/events.ts +2 -0
  29. package/src/runner/hooks.ts +111 -0
  30. package/src/runner/record.ts +53 -34
  31. package/src/runner/steps.ts +4 -1
  32. package/src/script/config.ts +74 -0
  33. package/src/script/hash.ts +7 -1
  34. package/src/script/load.ts +15 -2
  35. package/src/script/schema.ts +115 -1
  36. package/src/setup.ts +13 -4
  37. package/src/sheet.ts +47 -0
  38. package/src/timeline/build.ts +113 -13
  39. package/src/timeline/camera.ts +34 -11
  40. package/src/timeline/fades.ts +3 -0
  41. package/src/timeline/frames.ts +15 -4
  42. package/src/timeline/layout.ts +70 -0
  43. package/src/timeline/narration.ts +160 -0
  44. package/src/timeline/timemap.ts +40 -5
  45. package/src/timeline/types.ts +44 -1
@@ -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
 
@@ -20,8 +21,17 @@ export function introFrames(t: Timeline): number {
20
21
  return t.intro ? toFrames(t.intro.durationMs, t.fps) : 0;
21
22
  }
22
23
 
24
+ export function outroFrames(t: Timeline): number {
25
+ return t.outro ? toFrames(t.outro.durationMs, t.fps) : 0;
26
+ }
27
+
28
+ /** First frame after the video (where the outro starts). */
29
+ export function videoEndFrame(t: Timeline): number {
30
+ return introFrames(t) + toFrames(outputDurationMs(t.segments), t.fps);
31
+ }
32
+
23
33
  export function totalFrames(t: Timeline): number {
24
- return Math.max(1, introFrames(t) + toFrames(outputDurationMs(t.segments), t.fps));
34
+ return Math.max(1, videoEndFrame(t) + outroFrames(t));
25
35
  }
26
36
 
27
37
  export function segmentPlan(t: Timeline): SegmentPlan[] {
@@ -30,7 +40,7 @@ export function segmentPlan(t: Timeline): SegmentPlan[] {
30
40
  let outMs = 0;
31
41
  for (const s of t.segments) {
32
42
  const startF = toFrames(outMs, t.fps);
33
- outMs += (s.srcEndMs - s.srcStartMs) / s.speed;
43
+ outMs += segLength(s);
34
44
  const endF = toFrames(outMs, t.fps);
35
45
  if (endF > startF) {
36
46
  plan.push({ fromFrame: intro + startF, durationInFrames: endF - startF, trimBefore: toFrames(s.srcStartMs, t.fps), speed: s.speed });
@@ -48,7 +58,7 @@ export function sourceMsAtFrame(t: Timeline, frame: number): number | null {
48
58
 
49
59
  export function qaShots(t: Timeline, steps: StepEvent[]): Shot[] {
50
60
  const intro = introFrames(t);
51
- const last = totalFrames(t) - 1;
61
+ const last = Math.max(0, videoEndFrame(t) - 1);
52
62
  const shots: Shot[] = [];
53
63
  if (t.intro) shots.push({ frame: Math.floor(intro / 2), name: '00-intro' });
54
64
  steps.forEach((s, i) => {
@@ -56,5 +66,6 @@ export function qaShots(t: Timeline, steps: StepEvent[]): Shot[] {
56
66
  const frame = Math.min(last, intro + toFrames(sourceToOutput(t.segments, mid), t.fps));
57
67
  shots.push({ frame, name: `${String(i + 1).padStart(2, '0')}-${s.sceneId}-${s.index + 1}-${s.do}` });
58
68
  });
69
+ if (t.outro) shots.push({ frame: videoEndFrame(t) + Math.round(outroFrames(t) * 0.6), name: '99-outro' });
59
70
  return shots;
60
71
  }
@@ -1,3 +1,5 @@
1
+ import type { Aspect, CameraKey, Rect } from './types';
2
+
1
3
  export interface Box {
2
4
  x: number;
3
5
  y: number;
@@ -8,6 +10,21 @@ export interface Box {
8
10
  /** Height of the browser bar, in stage (viewport) pixels. */
9
11
  export const BROWSER_BAR = 44;
10
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
+
11
28
  /**
12
29
  * Where the recorded viewport sits inside the output frame. `bar` (stage px) reserves room for a
13
30
  * browser bar above it; the returned box is the viewport, `bar` the bar height in output px.
@@ -27,3 +44,56 @@ export function windowLayout(
27
44
  const top = Math.round((output.height - height - barPx) / 2);
28
45
  return { x: Math.round((output.width - width) / 2), y: top + barPx, width, height, bar: barPx };
29
46
  }
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
+
82
+ const r2 = (v: number) => Math.round(v * 100) / 100;
83
+
84
+ /** Where a stage rect (CSS px) lands in the output: through the camera, inside the window viewport. */
85
+ export function projectRect(
86
+ r: Rect,
87
+ cam: Pick<CameraKey, 'scale' | 'cx' | 'cy'>,
88
+ stage: { width: number; height: number },
89
+ win: Box & { content?: { width: number; height: number } },
90
+ ): Rect {
91
+ const w = (win.content?.width ?? win.width) * cam.scale;
92
+ const h = (win.content?.height ?? win.height) * cam.scale;
93
+ return {
94
+ x: r2(win.x + win.width / 2 + (r.x / stage.width - cam.cx) * w),
95
+ y: r2(win.y + win.height / 2 + (r.y / stage.height - cam.cy) * h),
96
+ width: r2((r.width / stage.width) * w),
97
+ height: r2((r.height / stage.height) * h),
98
+ };
99
+ }
@@ -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 {
@@ -81,6 +85,8 @@ export interface Caption {
81
85
  startMs: number;
82
86
  endMs: number;
83
87
  text: string;
88
+ /** Bottom unless the step's target would sit under a bottom caption. */
89
+ position: 'bottom' | 'top';
84
90
  }
85
91
 
86
92
  export interface Spotlight {
@@ -89,23 +95,60 @@ export interface Spotlight {
89
95
  rect: Rect;
90
96
  }
91
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
+
92
120
  export type ResolvedStyle = Style;
93
121
 
94
- /** 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. */
95
125
  export interface Timeline {
96
126
  version: 1;
97
127
  fps: number;
128
+ aspect: Aspect;
98
129
  output: { width: number; height: number };
99
130
  stage: { width: number; height: number };
100
131
  video: { src: string; durationMs: number };
101
132
  intro: { title: string; subtitle?: string; durationMs: number } | null;
102
133
  /** Browser window around the take, or null for the bare viewport. */
103
134
  chrome: { address: string; dark: boolean } | null;
135
+ /** Closing card after the video. */
136
+ outro: { title: string; subtitle?: string; url?: string; durationMs: number } | null;
137
+ /** Logo for the intro and outro cards, as a data URI. */
138
+ logo: string | null;
104
139
  segments: Segment[];
105
140
  camera: CameraKey[];
106
141
  moves: Move[];
107
142
  clicks: Click[];
108
143
  captions: Caption[];
109
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;
110
153
  style: ResolvedStyle;
111
154
  }