@mineo71/showtime 0.0.0-stage → 0.2.1

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 (41) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +365 -2
  4. package/bin/showtime.mjs +5 -0
  5. package/docs/script.schema.json +1346 -0
  6. package/package.json +74 -4
  7. package/skill/SKILL.md +112 -0
  8. package/src/cli/format.ts +19 -0
  9. package/src/cli/main.ts +115 -0
  10. package/src/ffmpeg.ts +72 -0
  11. package/src/paths.ts +30 -0
  12. package/src/pipeline.ts +38 -0
  13. package/src/render/remotion/BrowserBar.tsx +61 -0
  14. package/src/render/remotion/Cursor.tsx +40 -0
  15. package/src/render/remotion/Intro.tsx +29 -0
  16. package/src/render/remotion/Overlays.tsx +95 -0
  17. package/src/render/remotion/Root.tsx +21 -0
  18. package/src/render/remotion/ShowtimeVideo.tsx +92 -0
  19. package/src/render/remotion/index.ts +4 -0
  20. package/src/render/render.ts +46 -0
  21. package/src/runner/browser.ts +122 -0
  22. package/src/runner/capture.ts +53 -0
  23. package/src/runner/check.ts +73 -0
  24. package/src/runner/encode.ts +49 -0
  25. package/src/runner/events.ts +15 -0
  26. package/src/runner/record.ts +96 -0
  27. package/src/runner/steps.ts +217 -0
  28. package/src/runner/time.ts +17 -0
  29. package/src/script/hash.ts +29 -0
  30. package/src/script/load.ts +36 -0
  31. package/src/script/schema.ts +144 -0
  32. package/src/setup.ts +41 -0
  33. package/src/take.ts +24 -0
  34. package/src/timeline/build.ts +103 -0
  35. package/src/timeline/camera.ts +69 -0
  36. package/src/timeline/cursor.ts +59 -0
  37. package/src/timeline/frames.ts +60 -0
  38. package/src/timeline/layout.ts +29 -0
  39. package/src/timeline/math.ts +7 -0
  40. package/src/timeline/timemap.ts +52 -0
  41. package/src/timeline/types.ts +111 -0
@@ -0,0 +1,59 @@
1
+ import { clamp, easeInOutCubic } from './math';
2
+ import type { Click, CursorKind, Move } from './types';
3
+
4
+ export const RIPPLE_MS = 450;
5
+ const PRESS_MS = 140;
6
+ const FADE_IN_MS = 200;
7
+ const KIND_SWITCH_AT = 0.9;
8
+
9
+ export interface CursorState {
10
+ x: number;
11
+ y: number;
12
+ kind: CursorKind;
13
+ visible: boolean;
14
+ opacity: number;
15
+ press: number;
16
+ }
17
+
18
+ export function pressAt(clicks: Click[], tMs: number): number {
19
+ for (const c of clicks) {
20
+ const d = tMs - c.tMs;
21
+ if (d >= 0 && d <= PRESS_MS) return 1 - 0.15 * Math.sin((Math.PI * d) / PRESS_MS);
22
+ }
23
+ return 1;
24
+ }
25
+
26
+ export function ripplesAt(clicks: Click[], tMs: number): Array<{ x: number; y: number; tMs: number; progress: number }> {
27
+ return clicks
28
+ .filter((c) => tMs >= c.tMs && tMs <= c.tMs + RIPPLE_MS)
29
+ .map((c) => ({ x: c.x, y: c.y, tMs: c.tMs, progress: (tMs - c.tMs) / RIPPLE_MS }));
30
+ }
31
+
32
+ export function cursorAt(moves: Move[], clicks: Click[], tMs: number): CursorState {
33
+ if (!moves.length) return { x: 0, y: 0, kind: 'arrow', visible: false, opacity: 0, press: 1 };
34
+ const first = moves[0];
35
+ if (tMs < first.startMs) return { ...first.from, kind: 'arrow', visible: false, opacity: 0, press: 1 };
36
+
37
+ let i = 0;
38
+ while (i + 1 < moves.length && moves[i + 1].startMs <= tMs) i++;
39
+ const m = moves[i];
40
+ const prevKind: CursorKind = i > 0 ? moves[i - 1].cursor : 'arrow';
41
+
42
+ let { x, y } = m.to;
43
+ let kind = m.cursor;
44
+ if (tMs < m.endMs) {
45
+ const p = clamp((tMs - m.startMs) / Math.max(1, m.endMs - m.startMs), 0, 1);
46
+ const e = easeInOutCubic(p);
47
+ const dx = m.to.x - m.from.x;
48
+ const dy = m.to.y - m.from.y;
49
+ const dist = Math.hypot(dx, dy);
50
+ const arc = Math.sin(Math.PI * p) * Math.min(40, dist * 0.08);
51
+ const nx = dist ? -dy / dist : 0;
52
+ const ny = dist ? dx / dist : 0;
53
+ x = m.from.x + dx * e + nx * arc;
54
+ y = m.from.y + dy * e + ny * arc;
55
+ kind = p < KIND_SWITCH_AT ? prevKind : m.cursor;
56
+ }
57
+ const opacity = clamp((tMs - first.startMs) / FADE_IN_MS, 0, 1);
58
+ return { x, y, kind, visible: true, opacity, press: pressAt(clicks, tMs) };
59
+ }
@@ -0,0 +1,60 @@
1
+ import { outputDurationMs, outputToSource, sourceToOutput } from './timemap';
2
+ import type { StepEvent, Timeline } from './types';
3
+
4
+ export interface SegmentPlan {
5
+ fromFrame: number;
6
+ durationInFrames: number;
7
+ /** source frame where this segment starts */
8
+ trimBefore: number;
9
+ speed: number;
10
+ }
11
+
12
+ export interface Shot {
13
+ frame: number;
14
+ name: string;
15
+ }
16
+
17
+ const toFrames = (ms: number, fps: number) => Math.round((ms * fps) / 1000);
18
+
19
+ export function introFrames(t: Timeline): number {
20
+ return t.intro ? toFrames(t.intro.durationMs, t.fps) : 0;
21
+ }
22
+
23
+ export function totalFrames(t: Timeline): number {
24
+ return Math.max(1, introFrames(t) + toFrames(outputDurationMs(t.segments), t.fps));
25
+ }
26
+
27
+ export function segmentPlan(t: Timeline): SegmentPlan[] {
28
+ const intro = introFrames(t);
29
+ const plan: SegmentPlan[] = [];
30
+ let outMs = 0;
31
+ for (const s of t.segments) {
32
+ const startF = toFrames(outMs, t.fps);
33
+ outMs += (s.srcEndMs - s.srcStartMs) / s.speed;
34
+ const endF = toFrames(outMs, t.fps);
35
+ if (endF > startF) {
36
+ plan.push({ fromFrame: intro + startF, durationInFrames: endF - startF, trimBefore: toFrames(s.srcStartMs, t.fps), speed: s.speed });
37
+ }
38
+ }
39
+ return plan;
40
+ }
41
+
42
+ /** Source ms shown at a composition frame, or null during the intro. */
43
+ export function sourceMsAtFrame(t: Timeline, frame: number): number | null {
44
+ const f = frame - introFrames(t);
45
+ if (f < 0) return null;
46
+ return outputToSource(t.segments, (f * 1000) / t.fps);
47
+ }
48
+
49
+ export function qaShots(t: Timeline, steps: StepEvent[]): Shot[] {
50
+ const intro = introFrames(t);
51
+ const last = totalFrames(t) - 1;
52
+ const shots: Shot[] = [];
53
+ if (t.intro) shots.push({ frame: Math.floor(intro / 2), name: '00-intro' });
54
+ steps.forEach((s, i) => {
55
+ const mid = (s.actionMs + s.settledMs) / 2;
56
+ const frame = Math.min(last, intro + toFrames(sourceToOutput(t.segments, mid), t.fps));
57
+ shots.push({ frame, name: `${String(i + 1).padStart(2, '0')}-${s.sceneId}-${s.index + 1}-${s.do}` });
58
+ });
59
+ return shots;
60
+ }
@@ -0,0 +1,29 @@
1
+ export interface Box {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+
8
+ /** Height of the browser bar, in stage (viewport) pixels. */
9
+ export const BROWSER_BAR = 44;
10
+
11
+ /**
12
+ * Where the recorded viewport sits inside the output frame. `bar` (stage px) reserves room for a
13
+ * browser bar above it; the returned box is the viewport, `bar` the bar height in output px.
14
+ */
15
+ export function windowLayout(
16
+ output: { width: number; height: number },
17
+ stage: { width: number; height: number },
18
+ padding: number,
19
+ bar = 0,
20
+ ): Box & { bar: number } {
21
+ const availW = output.width * (1 - 2 * padding);
22
+ const availH = output.height * (1 - 2 * padding);
23
+ const k = Math.min(availW / stage.width, availH / (stage.height + bar));
24
+ const width = Math.round(stage.width * k);
25
+ const height = Math.round(stage.height * k);
26
+ const barPx = Math.round(bar * k);
27
+ const top = Math.round((output.height - height - barPx) / 2);
28
+ return { x: Math.round((output.width - width) / 2), y: top + barPx, width, height, bar: barPx };
29
+ }
@@ -0,0 +1,7 @@
1
+ export const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
2
+
3
+ export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
4
+
5
+ export const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
6
+
7
+ export const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;
@@ -0,0 +1,52 @@
1
+ import type { Interval, Segment } from './types';
2
+
3
+ export interface SpeedSpan extends Interval {
4
+ speed: number;
5
+ }
6
+
7
+ /** Cover [0, durationMs] with speed segments. Later spans win where spans overlap. */
8
+ export function buildSegments(durationMs: number, spans: SpeedSpan[]): Segment[] {
9
+ const clip = (t: number) => Math.min(durationMs, Math.max(0, t));
10
+ const cuts = new Set<number>([0, durationMs]);
11
+ for (const s of spans) {
12
+ cuts.add(clip(s.startMs));
13
+ cuts.add(clip(s.endMs));
14
+ }
15
+ const points = [...cuts].sort((a, b) => a - b);
16
+ const out: Segment[] = [];
17
+ for (let i = 0; i < points.length - 1; i++) {
18
+ const a = points[i];
19
+ const b = points[i + 1];
20
+ if (b <= a) continue;
21
+ const mid = (a + b) / 2;
22
+ let speed = 1;
23
+ for (const s of spans) if (s.startMs <= mid && mid < s.endMs) speed = s.speed;
24
+ const last = out[out.length - 1];
25
+ if (last && last.speed === speed) last.srcEndMs = b;
26
+ else out.push({ srcStartMs: a, srcEndMs: b, speed });
27
+ }
28
+ return out;
29
+ }
30
+
31
+ export function outputDurationMs(segments: Segment[]): number {
32
+ return segments.reduce((acc, s) => acc + (s.srcEndMs - s.srcStartMs) / s.speed, 0);
33
+ }
34
+
35
+ export function outputToSource(segments: Segment[], tOutMs: number): number {
36
+ let acc = 0;
37
+ 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;
40
+ acc += len;
41
+ }
42
+ return segments.length ? segments[segments.length - 1].srcEndMs : 0;
43
+ }
44
+
45
+ export function sourceToOutput(segments: Segment[], tSrcMs: number): number {
46
+ let acc = 0;
47
+ 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;
50
+ }
51
+ return acc;
52
+ }
@@ -0,0 +1,111 @@
1
+ import type { Step, Style } from '../script/schema';
2
+
3
+ export interface Pt {
4
+ x: number;
5
+ y: number;
6
+ }
7
+
8
+ export interface Rect {
9
+ x: number;
10
+ y: number;
11
+ width: number;
12
+ height: number;
13
+ }
14
+
15
+ export type CursorKind = 'arrow' | 'pointer' | 'text';
16
+
17
+ export interface Move {
18
+ startMs: number;
19
+ endMs: number;
20
+ from: Pt;
21
+ to: Pt;
22
+ cursor: CursorKind;
23
+ }
24
+
25
+ export interface Click {
26
+ tMs: number;
27
+ x: number;
28
+ y: number;
29
+ }
30
+
31
+ export interface Interval {
32
+ startMs: number;
33
+ endMs: number;
34
+ }
35
+
36
+ export interface StepEvent {
37
+ sceneId: string;
38
+ index: number;
39
+ do: Step['do'];
40
+ /** step begins (before scroll / cursor travel) */
41
+ startMs: number;
42
+ /** cursor arrived, action performed */
43
+ actionMs: number;
44
+ /** action finished (excludes settle / hold) */
45
+ endMs: number;
46
+ /** after settle + beat + hold */
47
+ settledMs: number;
48
+ /** CSS px, viewport-relative, measured after scrolling */
49
+ target?: Rect;
50
+ }
51
+
52
+ /** Written by `record` to take/events.json. All times: ms since capture start. */
53
+ export interface TakeEvents {
54
+ version: 1;
55
+ actionHash: string;
56
+ viewport: { width: number; height: number };
57
+ deviceScaleFactor: number;
58
+ video: { file: string; width: number; height: number; fps: number; durationMs: number };
59
+ steps: StepEvent[];
60
+ moves: Move[];
61
+ clicks: Click[];
62
+ idle: Interval[];
63
+ }
64
+
65
+ export interface Segment {
66
+ srcStartMs: number;
67
+ srcEndMs: number;
68
+ speed: number;
69
+ }
70
+
71
+ export interface CameraKey {
72
+ tMs: number;
73
+ scale: number;
74
+ /** focus, 0..1 of stage width */
75
+ cx: number;
76
+ /** focus, 0..1 of stage height */
77
+ cy: number;
78
+ }
79
+
80
+ export interface Caption {
81
+ startMs: number;
82
+ endMs: number;
83
+ text: string;
84
+ }
85
+
86
+ export interface Spotlight {
87
+ startMs: number;
88
+ endMs: number;
89
+ rect: Rect;
90
+ }
91
+
92
+ export type ResolvedStyle = Style;
93
+
94
+ /** The renderer contract. All overlay times are source ms. */
95
+ export interface Timeline {
96
+ version: 1;
97
+ fps: number;
98
+ output: { width: number; height: number };
99
+ stage: { width: number; height: number };
100
+ video: { src: string; durationMs: number };
101
+ intro: { title: string; subtitle?: string; durationMs: number } | null;
102
+ /** Browser window around the take, or null for the bare viewport. */
103
+ chrome: { address: string; dark: boolean } | null;
104
+ segments: Segment[];
105
+ camera: CameraKey[];
106
+ moves: Move[];
107
+ clicks: Click[];
108
+ captions: Caption[];
109
+ spotlights: Spotlight[];
110
+ style: ResolvedStyle;
111
+ }