@bitmagic/cli 0.1.61-dev.0 → 0.1.61-dev.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.
- package/README.md +47 -8
- package/assets/fonts/NotoSans-Bold.ttf +0 -0
- package/assets/fonts/OFL.txt +94 -0
- package/dist/chrome-launch.d.ts +18 -0
- package/dist/chrome-launch.js +24 -0
- package/dist/chrome-launch.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/generate.d.ts +44 -0
- package/dist/commands/generate.js +143 -0
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/trailer-publish.d.ts +15 -0
- package/dist/commands/trailer-publish.js +45 -0
- package/dist/commands/trailer-publish.js.map +1 -0
- package/dist/commands/trailer.d.ts +3 -0
- package/dist/commands/trailer.js +396 -0
- package/dist/commands/trailer.js.map +1 -0
- package/dist/editor/hud-replay-page.d.ts +7 -0
- package/dist/editor/hud-replay-page.js +219 -0
- package/dist/editor/hud-replay-page.js.map +1 -0
- package/dist/editor/recording-panel.d.ts +3 -1
- package/dist/editor/recording-panel.js +6 -2
- package/dist/editor/recording-panel.js.map +1 -1
- package/dist/editor/recording-sink.d.ts +3 -20
- package/dist/editor/recording-sink.js +14 -35
- package/dist/editor/recording-sink.js.map +1 -1
- package/dist/editor/server.js +15 -18
- package/dist/editor/server.js.map +1 -1
- package/dist/editor/shell-page.d.ts +0 -8
- package/dist/editor/shell-page.js +23 -19
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/generate/music.d.ts +51 -0
- package/dist/generate/music.js +93 -0
- package/dist/generate/music.js.map +1 -0
- package/dist/generate/stream.d.ts +13 -0
- package/dist/generate/stream.js +17 -1
- package/dist/generate/stream.js.map +1 -1
- package/dist/project/jobs.d.ts +1 -1
- package/dist/scaffold/project-files.d.ts +0 -1
- package/dist/scaffold/project-files.js +6 -33
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +3 -1
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.d.ts +22 -0
- package/dist/scaffold/skill-files.js +198 -0
- package/dist/scaffold/skill-files.js.map +1 -0
- package/dist/trailer/analyze.d.ts +23 -0
- package/dist/trailer/analyze.js +186 -0
- package/dist/trailer/analyze.js.map +1 -0
- package/dist/trailer/auto-edl.d.ts +46 -0
- package/dist/trailer/auto-edl.js +143 -0
- package/dist/trailer/auto-edl.js.map +1 -0
- package/dist/trailer/beat-cut.d.ts +57 -0
- package/dist/trailer/beat-cut.js +232 -0
- package/dist/trailer/beat-cut.js.map +1 -0
- package/dist/trailer/client.d.ts +52 -0
- package/dist/trailer/client.js +133 -0
- package/dist/trailer/client.js.map +1 -0
- package/dist/trailer/edl-time.d.ts +61 -0
- package/dist/trailer/edl-time.js +105 -0
- package/dist/trailer/edl-time.js.map +1 -0
- package/dist/trailer/ffmpeg.d.ts +8 -0
- package/dist/trailer/ffmpeg.js +55 -0
- package/dist/trailer/ffmpeg.js.map +1 -0
- package/dist/trailer/hud-raster.d.ts +28 -0
- package/dist/trailer/hud-raster.js +114 -0
- package/dist/trailer/hud-raster.js.map +1 -0
- package/dist/trailer/hud-state.d.ts +110 -0
- package/dist/trailer/hud-state.js +410 -0
- package/dist/trailer/hud-state.js.map +1 -0
- package/dist/trailer/io.d.ts +20 -0
- package/dist/trailer/io.js +15 -0
- package/dist/trailer/io.js.map +1 -0
- package/dist/trailer/make.d.ts +54 -0
- package/dist/trailer/make.js +205 -0
- package/dist/trailer/make.js.map +1 -0
- package/dist/trailer/merge.d.ts +7 -0
- package/dist/trailer/merge.js +144 -0
- package/dist/trailer/merge.js.map +1 -0
- package/dist/trailer/music-beats.d.ts +123 -0
- package/dist/trailer/music-beats.js +707 -0
- package/dist/trailer/music-beats.js.map +1 -0
- package/dist/trailer/paths.d.ts +28 -0
- package/dist/trailer/paths.js +101 -0
- package/dist/trailer/paths.js.map +1 -0
- package/dist/trailer/publish-record.d.ts +17 -0
- package/dist/trailer/publish-record.js +59 -0
- package/dist/trailer/publish-record.js.map +1 -0
- package/dist/trailer/publish.d.ts +45 -0
- package/dist/trailer/publish.js +149 -0
- package/dist/trailer/publish.js.map +1 -0
- package/dist/trailer/record-session.d.ts +72 -0
- package/dist/trailer/record-session.js +328 -0
- package/dist/trailer/record-session.js.map +1 -0
- package/dist/trailer/render.d.ts +57 -0
- package/dist/trailer/render.js +475 -0
- package/dist/trailer/render.js.map +1 -0
- package/dist/trailer/revision.d.ts +8 -0
- package/dist/trailer/revision.js +31 -0
- package/dist/trailer/revision.js.map +1 -0
- package/dist/trailer/synth.d.ts +9 -0
- package/dist/trailer/synth.js +120 -0
- package/dist/trailer/synth.js.map +1 -0
- package/dist/trailer/timeline-types.d.ts +176 -0
- package/dist/trailer/timeline-types.js +379 -0
- package/dist/trailer/timeline-types.js.map +1 -0
- package/dist/trailer/title-raster.d.ts +6 -0
- package/dist/trailer/title-raster.js +47 -0
- package/dist/trailer/title-raster.js.map +1 -0
- package/dist/trailer/titles.d.ts +44 -0
- package/dist/trailer/titles.js +119 -0
- package/dist/trailer/titles.js.map +1 -0
- package/dist/trailer/video-chain.d.ts +41 -0
- package/dist/trailer/video-chain.js +78 -0
- package/dist/trailer/video-chain.js.map +1 -0
- package/dist/verify/browser.js +12 -19
- package/dist/verify/browser.js.map +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-TypeScript sound synthesizer for timeline synth recipes.
|
|
3
|
+
*
|
|
4
|
+
* Renders a SynthRecipe to 48kHz mono 16-bit PCM WAV: layers are summed,
|
|
5
|
+
* [start, end] freq/gain values ramp linearly, the mix is clamped to [-1, 1]
|
|
6
|
+
* and a ~5ms linear fade-in/out avoids clicks. No audio libraries; the WAV
|
|
7
|
+
* header is written by hand. Output is fully deterministic (seeded noise) so
|
|
8
|
+
* recipe-hash-named cache files are reproducible.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
export const SYNTH_SAMPLE_RATE = 48000;
|
|
12
|
+
const FADE_SEC = 0.005;
|
|
13
|
+
const NOISE_SEED = 0x9e3779b9;
|
|
14
|
+
/** Deterministic PRNG (mulberry32) so noise layers render identically every run. */
|
|
15
|
+
function mulberry32(seed) {
|
|
16
|
+
let state = seed >>> 0;
|
|
17
|
+
return () => {
|
|
18
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
19
|
+
let t = state;
|
|
20
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
21
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
22
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function interp(value, fallback, progress) {
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
return fallback;
|
|
28
|
+
if (typeof value === 'number')
|
|
29
|
+
return value;
|
|
30
|
+
return value[0] + (value[1] - value[0]) * progress;
|
|
31
|
+
}
|
|
32
|
+
function layerSamples(layer, sampleCount) {
|
|
33
|
+
const buf = new Float64Array(sampleCount);
|
|
34
|
+
const wave = layer.wave;
|
|
35
|
+
const rand = mulberry32(NOISE_SEED);
|
|
36
|
+
// Phase in cycles, accumulated per sample so frequency ramps chirp smoothly.
|
|
37
|
+
let phase = 0;
|
|
38
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
39
|
+
const progress = sampleCount > 1 ? i / (sampleCount - 1) : 0;
|
|
40
|
+
const gain = interp(layer.gain, 1, progress);
|
|
41
|
+
let value;
|
|
42
|
+
if (wave === 'noise') {
|
|
43
|
+
value = rand() * 2 - 1;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const frac = phase - Math.floor(phase);
|
|
47
|
+
switch (wave) {
|
|
48
|
+
case 'sine':
|
|
49
|
+
value = Math.sin(2 * Math.PI * phase);
|
|
50
|
+
break;
|
|
51
|
+
case 'square':
|
|
52
|
+
value = frac < 0.5 ? 1 : -1;
|
|
53
|
+
break;
|
|
54
|
+
case 'sawtooth':
|
|
55
|
+
value = 2 * frac - 1;
|
|
56
|
+
break;
|
|
57
|
+
case 'triangle':
|
|
58
|
+
value = 1 - 4 * Math.abs(frac - 0.5);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
phase += interp(layer.freq, 440, progress) / SYNTH_SAMPLE_RATE;
|
|
62
|
+
}
|
|
63
|
+
buf[i] = value * gain;
|
|
64
|
+
}
|
|
65
|
+
return buf;
|
|
66
|
+
}
|
|
67
|
+
/** Render a recipe to mono float samples in [-1, 1], faded at both ends. */
|
|
68
|
+
export function synthesizeSamples(recipe) {
|
|
69
|
+
const sampleCount = Math.max(1, Math.round(recipe.dur * SYNTH_SAMPLE_RATE));
|
|
70
|
+
const mix = new Float64Array(sampleCount);
|
|
71
|
+
for (const layer of recipe.layers) {
|
|
72
|
+
const layerBuf = layerSamples(layer, sampleCount);
|
|
73
|
+
for (let i = 0; i < sampleCount; i++)
|
|
74
|
+
mix[i] += layerBuf[i];
|
|
75
|
+
}
|
|
76
|
+
const fadeSamples = Math.min(Math.round(FADE_SEC * SYNTH_SAMPLE_RATE), Math.floor(sampleCount / 2));
|
|
77
|
+
const result = new Float32Array(sampleCount);
|
|
78
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
79
|
+
let v = Math.max(-1, Math.min(1, mix[i]));
|
|
80
|
+
if (fadeSamples > 0) {
|
|
81
|
+
if (i < fadeSamples)
|
|
82
|
+
v *= i / fadeSamples;
|
|
83
|
+
const fromEnd = sampleCount - 1 - i;
|
|
84
|
+
if (fromEnd < fadeSamples)
|
|
85
|
+
v *= fromEnd / fadeSamples;
|
|
86
|
+
}
|
|
87
|
+
result[i] = v;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
/** Encode mono float samples as a 48kHz 16-bit PCM WAV file. */
|
|
92
|
+
export function samplesToWav(samples) {
|
|
93
|
+
const dataSize = samples.length * 2;
|
|
94
|
+
const buffer = Buffer.alloc(44 + dataSize);
|
|
95
|
+
buffer.write('RIFF', 0, 'ascii');
|
|
96
|
+
buffer.writeUInt32LE(36 + dataSize, 4);
|
|
97
|
+
buffer.write('WAVE', 8, 'ascii');
|
|
98
|
+
buffer.write('fmt ', 12, 'ascii');
|
|
99
|
+
buffer.writeUInt32LE(16, 16); // fmt chunk size
|
|
100
|
+
buffer.writeUInt16LE(1, 20); // PCM
|
|
101
|
+
buffer.writeUInt16LE(1, 22); // mono
|
|
102
|
+
buffer.writeUInt32LE(SYNTH_SAMPLE_RATE, 24);
|
|
103
|
+
buffer.writeUInt32LE(SYNTH_SAMPLE_RATE * 2, 28); // byte rate
|
|
104
|
+
buffer.writeUInt16LE(2, 32); // block align
|
|
105
|
+
buffer.writeUInt16LE(16, 34); // bits per sample
|
|
106
|
+
buffer.write('data', 36, 'ascii');
|
|
107
|
+
buffer.writeUInt32LE(dataSize, 40);
|
|
108
|
+
for (let i = 0; i < samples.length; i++) {
|
|
109
|
+
buffer.writeInt16LE(Math.round(samples[i] * 32767), 44 + i * 2);
|
|
110
|
+
}
|
|
111
|
+
return buffer;
|
|
112
|
+
}
|
|
113
|
+
export function synthesizeWav(recipe) {
|
|
114
|
+
return samplesToWav(synthesizeSamples(recipe));
|
|
115
|
+
}
|
|
116
|
+
/** Stable cache key for a recipe (16 hex chars of sha256 over its JSON). */
|
|
117
|
+
export function recipeHash(recipe) {
|
|
118
|
+
return createHash('sha256').update(JSON.stringify(recipe)).digest('hex').slice(0, 16);
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=synth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"synth.js","sourceRoot":"","sources":["../../src/trailer/synth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { TitleCue } from './titles.js';
|
|
2
|
+
export interface FrameClockEntry {
|
|
3
|
+
frame: number;
|
|
4
|
+
wallMs: number;
|
|
5
|
+
}
|
|
6
|
+
export interface TimelineRecording {
|
|
7
|
+
/** Frame files are named `<name>_%06d.png`, indexed from 0. */
|
|
8
|
+
name: string;
|
|
9
|
+
gameId?: string;
|
|
10
|
+
genre?: string;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
fps: number;
|
|
14
|
+
frameCount: number;
|
|
15
|
+
/** Frame indices whose PNG failed to save; may be absent or empty. */
|
|
16
|
+
missingFrames?: number[];
|
|
17
|
+
/** Ignored in v1. */
|
|
18
|
+
frameClock?: FrameClockEntry[];
|
|
19
|
+
}
|
|
20
|
+
export interface TimelineEvent {
|
|
21
|
+
frame: number;
|
|
22
|
+
type: string;
|
|
23
|
+
intensity?: number;
|
|
24
|
+
position?: number[];
|
|
25
|
+
actor?: string;
|
|
26
|
+
data?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
export type SynthWave = 'sine' | 'square' | 'sawtooth' | 'triangle' | 'noise';
|
|
29
|
+
export interface SynthLayer {
|
|
30
|
+
wave: SynthWave;
|
|
31
|
+
/** Constant Hz or [start, end] linear ramp. Ignored for 'noise'. */
|
|
32
|
+
freq?: number | [number, number];
|
|
33
|
+
/** Constant gain or [start, end] linear ramp. Default 1. */
|
|
34
|
+
gain?: number | [number, number];
|
|
35
|
+
}
|
|
36
|
+
export interface SynthRecipe {
|
|
37
|
+
/** Duration in seconds. */
|
|
38
|
+
dur: number;
|
|
39
|
+
layers: SynthLayer[];
|
|
40
|
+
}
|
|
41
|
+
export interface AssetSound {
|
|
42
|
+
frame: number;
|
|
43
|
+
kind: 'asset';
|
|
44
|
+
assetId: string;
|
|
45
|
+
url: string;
|
|
46
|
+
volume?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface SynthSound {
|
|
49
|
+
frame: number;
|
|
50
|
+
kind: 'synth';
|
|
51
|
+
name?: string;
|
|
52
|
+
recipe: SynthRecipe;
|
|
53
|
+
volume?: number;
|
|
54
|
+
}
|
|
55
|
+
export type TimelineSound = AssetSound | SynthSound;
|
|
56
|
+
export type HudElementKind = 'counter' | 'progress' | 'icon-text' | 'timer' | 'custom';
|
|
57
|
+
/**
|
|
58
|
+
* Recorded HUD state changes (the engine's `TimelineHudOp`). Frames use the
|
|
59
|
+
* recording's source clock. Elements that existed before the recording started
|
|
60
|
+
* are emitted as frame-0 `create` ops, so frame 0 is always a complete
|
|
61
|
+
* snapshot, and array order within a frame is stacking order in an anchor.
|
|
62
|
+
*
|
|
63
|
+
* `update` carries already-rendered text, so the replay never applies a game's
|
|
64
|
+
* `format` callback. Timers are the exception — they log start/pause/reset
|
|
65
|
+
* only and the displayed value is derived (see hud-state.ts).
|
|
66
|
+
*/
|
|
67
|
+
export type HudOp = {
|
|
68
|
+
frame: number;
|
|
69
|
+
op: 'create';
|
|
70
|
+
id: string;
|
|
71
|
+
elType: HudElementKind;
|
|
72
|
+
params: Record<string, unknown>;
|
|
73
|
+
} | {
|
|
74
|
+
frame: number;
|
|
75
|
+
op: 'update';
|
|
76
|
+
id: string;
|
|
77
|
+
text?: string;
|
|
78
|
+
icon?: string;
|
|
79
|
+
percent?: number;
|
|
80
|
+
} | {
|
|
81
|
+
frame: number;
|
|
82
|
+
op: 'custom-html';
|
|
83
|
+
id: string;
|
|
84
|
+
html: string;
|
|
85
|
+
} | {
|
|
86
|
+
frame: number;
|
|
87
|
+
op: 'timer';
|
|
88
|
+
id: string;
|
|
89
|
+
action: 'start' | 'pause' | 'reset';
|
|
90
|
+
seconds?: number;
|
|
91
|
+
} | {
|
|
92
|
+
frame: number;
|
|
93
|
+
op: 'show' | 'hide' | 'remove';
|
|
94
|
+
id: string;
|
|
95
|
+
} | {
|
|
96
|
+
frame: number;
|
|
97
|
+
op: 'health';
|
|
98
|
+
current: number;
|
|
99
|
+
max: number;
|
|
100
|
+
} | {
|
|
101
|
+
frame: number;
|
|
102
|
+
op: 'health-visible';
|
|
103
|
+
visible: boolean;
|
|
104
|
+
} | {
|
|
105
|
+
frame: number;
|
|
106
|
+
op: 'toast';
|
|
107
|
+
message: string;
|
|
108
|
+
variant?: string;
|
|
109
|
+
durationMs?: number;
|
|
110
|
+
} | {
|
|
111
|
+
frame: number;
|
|
112
|
+
op: 'hud-visible';
|
|
113
|
+
visible: boolean;
|
|
114
|
+
} | {
|
|
115
|
+
frame: number;
|
|
116
|
+
op: 'theme';
|
|
117
|
+
theme: unknown;
|
|
118
|
+
};
|
|
119
|
+
export interface Timeline {
|
|
120
|
+
version: number;
|
|
121
|
+
recording: TimelineRecording;
|
|
122
|
+
events: TimelineEvent[];
|
|
123
|
+
sounds: TimelineSound[];
|
|
124
|
+
/** HUD state changes; absent/empty when the recording captured no HUD. */
|
|
125
|
+
hud: HudOp[];
|
|
126
|
+
}
|
|
127
|
+
export interface EdlClip {
|
|
128
|
+
/** Source frame index, inclusive. */
|
|
129
|
+
in: number;
|
|
130
|
+
/** Source frame index, exclusive. */
|
|
131
|
+
out: number;
|
|
132
|
+
/** Playback speed 0.25–2.0; default 1.0 (0.5 = slow-mo). */
|
|
133
|
+
speed?: number;
|
|
134
|
+
/** Transition between the PREVIOUS clip and this one; first clip's is ignored. */
|
|
135
|
+
transition?: 'cut' | 'crossfade';
|
|
136
|
+
/** Crossfade length in seconds; default 0.25. */
|
|
137
|
+
transitionDuration?: number;
|
|
138
|
+
label?: string;
|
|
139
|
+
/** Draw the recorded HUD over this clip. Overrides the global --ui flag. */
|
|
140
|
+
ui?: boolean;
|
|
141
|
+
}
|
|
142
|
+
export interface Edl {
|
|
143
|
+
version: number;
|
|
144
|
+
/** Informational only. */
|
|
145
|
+
targetDuration?: number;
|
|
146
|
+
clips: EdlClip[];
|
|
147
|
+
/** Burned-in title cards, timed in OUTPUT seconds. */
|
|
148
|
+
titles?: TitleCue[];
|
|
149
|
+
}
|
|
150
|
+
export interface AnalysisWindow {
|
|
151
|
+
startFrame: number;
|
|
152
|
+
endFrame: number;
|
|
153
|
+
score: number;
|
|
154
|
+
/** Compact digest lines, e.g. "f3121 explosion i0.9". */
|
|
155
|
+
events: string[];
|
|
156
|
+
/** Path relative to the recording dir, e.g. "sheets/window_00.png". */
|
|
157
|
+
sheet: string;
|
|
158
|
+
}
|
|
159
|
+
export interface Analysis {
|
|
160
|
+
version: number;
|
|
161
|
+
fps: number;
|
|
162
|
+
frameCount: number;
|
|
163
|
+
windows: AnalysisWindow[];
|
|
164
|
+
}
|
|
165
|
+
export declare function frameFileName(recordingName: string, frame: number): string;
|
|
166
|
+
export declare function frameFilePath(recordingDir: string, recordingName: string, frame: number): string;
|
|
167
|
+
export declare function parseRecipe(raw: unknown, ctx: string): SynthRecipe;
|
|
168
|
+
/**
|
|
169
|
+
* Parse the hud[] track. Skipped entries are reported (grouped by op name) so
|
|
170
|
+
* a forward-compatible skip is never silent.
|
|
171
|
+
*/
|
|
172
|
+
export declare function parseHudOps(raw: unknown, source: string): HudOp[];
|
|
173
|
+
export declare function parseTimeline(raw: unknown, source: string): Timeline;
|
|
174
|
+
export declare function loadTimeline(recordingDir: string): Timeline;
|
|
175
|
+
export declare function parseEdl(raw: unknown, source: string): Edl;
|
|
176
|
+
export declare function loadEdl(filePath: string): Edl;
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable JSON contracts for the trailer pipeline plus validating loaders.
|
|
3
|
+
*
|
|
4
|
+
* timeline.json / edl.json are inputs written by the game engine and an AI
|
|
5
|
+
* editor respectively; analysis.json is our output. Loaders validate with
|
|
6
|
+
* type guards and throw descriptive errors — never silently coerce.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { CliError } from '../errors.js';
|
|
11
|
+
import { warn } from './io.js';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Frame file naming
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export function frameFileName(recordingName, frame) {
|
|
16
|
+
return `${recordingName}_${String(frame).padStart(6, '0')}.png`;
|
|
17
|
+
}
|
|
18
|
+
export function frameFilePath(recordingDir, recordingName, frame) {
|
|
19
|
+
return path.join(recordingDir, frameFileName(recordingName, frame));
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Validation helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
function asNumber(value, ctx) {
|
|
28
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
29
|
+
throw new Error(`${ctx}: expected a finite number, got ${JSON.stringify(value)}`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function asString(value, ctx) {
|
|
34
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
35
|
+
throw new Error(`${ctx}: expected a non-empty string, got ${JSON.stringify(value)}`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function parseNumOrRamp(value, ctx) {
|
|
40
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
41
|
+
return value;
|
|
42
|
+
if (Array.isArray(value) && value.length === 2) {
|
|
43
|
+
const [a, b] = value;
|
|
44
|
+
if (typeof a === 'number' && Number.isFinite(a) && typeof b === 'number' && Number.isFinite(b)) {
|
|
45
|
+
return [a, b];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`${ctx}: expected a number or a [start, end] pair`);
|
|
49
|
+
}
|
|
50
|
+
function readJson(filePath) {
|
|
51
|
+
if (!fs.existsSync(filePath))
|
|
52
|
+
throw new CliError(`File not found: ${filePath}`);
|
|
53
|
+
const text = fs.readFileSync(filePath, 'utf-8');
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
throw new CliError(`Invalid JSON in ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// timeline.json parsing
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
const SYNTH_WAVES = ['sine', 'square', 'sawtooth', 'triangle', 'noise'];
|
|
65
|
+
function isSynthWave(value) {
|
|
66
|
+
return typeof value === 'string' && SYNTH_WAVES.includes(value);
|
|
67
|
+
}
|
|
68
|
+
export function parseRecipe(raw, ctx) {
|
|
69
|
+
if (!isRecord(raw))
|
|
70
|
+
throw new Error(`${ctx}: expected an object`);
|
|
71
|
+
const dur = asNumber(raw.dur, `${ctx}.dur`);
|
|
72
|
+
if (dur <= 0)
|
|
73
|
+
throw new Error(`${ctx}.dur: must be > 0, got ${dur}`);
|
|
74
|
+
if (!Array.isArray(raw.layers) || raw.layers.length === 0) {
|
|
75
|
+
throw new Error(`${ctx}.layers: expected a non-empty array`);
|
|
76
|
+
}
|
|
77
|
+
const layers = raw.layers.map((layerRaw, i) => parseLayer(layerRaw, `${ctx}.layers[${i}]`));
|
|
78
|
+
return { dur, layers };
|
|
79
|
+
}
|
|
80
|
+
function parseLayer(raw, ctx) {
|
|
81
|
+
if (!isRecord(raw))
|
|
82
|
+
throw new Error(`${ctx}: expected an object`);
|
|
83
|
+
if (!isSynthWave(raw.wave)) {
|
|
84
|
+
throw new Error(`${ctx}.wave: expected one of ${SYNTH_WAVES.join(', ')}, got ${JSON.stringify(raw.wave)}`);
|
|
85
|
+
}
|
|
86
|
+
const layer = { wave: raw.wave };
|
|
87
|
+
if (raw.freq !== undefined)
|
|
88
|
+
layer.freq = parseNumOrRamp(raw.freq, `${ctx}.freq`);
|
|
89
|
+
if (raw.gain !== undefined)
|
|
90
|
+
layer.gain = parseNumOrRamp(raw.gain, `${ctx}.gain`);
|
|
91
|
+
return layer;
|
|
92
|
+
}
|
|
93
|
+
function parseEvent(raw, ctx) {
|
|
94
|
+
if (!isRecord(raw))
|
|
95
|
+
throw new Error(`${ctx}: expected an object`);
|
|
96
|
+
const event = {
|
|
97
|
+
frame: asNumber(raw.frame, `${ctx}.frame`),
|
|
98
|
+
type: asString(raw.type, `${ctx}.type`)
|
|
99
|
+
};
|
|
100
|
+
if (raw.intensity !== undefined)
|
|
101
|
+
event.intensity = asNumber(raw.intensity, `${ctx}.intensity`);
|
|
102
|
+
if (Array.isArray(raw.position) && raw.position.every((v) => typeof v === 'number')) {
|
|
103
|
+
event.position = raw.position;
|
|
104
|
+
}
|
|
105
|
+
if (typeof raw.actor === 'string')
|
|
106
|
+
event.actor = raw.actor;
|
|
107
|
+
if (isRecord(raw.data))
|
|
108
|
+
event.data = raw.data;
|
|
109
|
+
return event;
|
|
110
|
+
}
|
|
111
|
+
function parseSound(raw, ctx) {
|
|
112
|
+
if (!isRecord(raw))
|
|
113
|
+
throw new Error(`${ctx}: expected an object`);
|
|
114
|
+
const frame = asNumber(raw.frame, `${ctx}.frame`);
|
|
115
|
+
const volume = raw.volume !== undefined ? asNumber(raw.volume, `${ctx}.volume`) : undefined;
|
|
116
|
+
if (raw.kind === 'asset') {
|
|
117
|
+
const sound = {
|
|
118
|
+
frame,
|
|
119
|
+
kind: 'asset',
|
|
120
|
+
assetId: asString(raw.assetId, `${ctx}.assetId`),
|
|
121
|
+
url: asString(raw.url, `${ctx}.url`)
|
|
122
|
+
};
|
|
123
|
+
if (volume !== undefined)
|
|
124
|
+
sound.volume = volume;
|
|
125
|
+
return sound;
|
|
126
|
+
}
|
|
127
|
+
if (raw.kind === 'synth') {
|
|
128
|
+
const sound = {
|
|
129
|
+
frame,
|
|
130
|
+
kind: 'synth',
|
|
131
|
+
recipe: parseRecipe(raw.recipe, `${ctx}.recipe`)
|
|
132
|
+
};
|
|
133
|
+
if (typeof raw.name === 'string')
|
|
134
|
+
sound.name = raw.name;
|
|
135
|
+
if (volume !== undefined)
|
|
136
|
+
sound.volume = volume;
|
|
137
|
+
return sound;
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`${ctx}.kind: expected "asset" or "synth", got ${JSON.stringify(raw.kind)}`);
|
|
140
|
+
}
|
|
141
|
+
const HUD_ELEMENT_KINDS = ['counter', 'progress', 'icon-text', 'timer', 'custom'];
|
|
142
|
+
function isHudElementKind(value) {
|
|
143
|
+
return typeof value === 'string' && HUD_ELEMENT_KINDS.includes(value);
|
|
144
|
+
}
|
|
145
|
+
function optString(value) {
|
|
146
|
+
return typeof value === 'string' ? value : undefined;
|
|
147
|
+
}
|
|
148
|
+
function optFiniteNumber(value) {
|
|
149
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Parse one hud[] entry. Returns null for a malformed entry or an op name this
|
|
153
|
+
* build predates — the track is forward-compatible by design, so an unknown op
|
|
154
|
+
* is skipped (and counted by the caller), never fatal.
|
|
155
|
+
*/
|
|
156
|
+
function parseHudOp(raw) {
|
|
157
|
+
if (!isRecord(raw))
|
|
158
|
+
return null;
|
|
159
|
+
const frame = optFiniteNumber(raw.frame);
|
|
160
|
+
if (frame === undefined)
|
|
161
|
+
return null;
|
|
162
|
+
const id = optString(raw.id);
|
|
163
|
+
switch (raw.op) {
|
|
164
|
+
case 'create': {
|
|
165
|
+
if (id === undefined || !isHudElementKind(raw.elType))
|
|
166
|
+
return null;
|
|
167
|
+
return { frame, op: 'create', id, elType: raw.elType, params: isRecord(raw.params) ? raw.params : {} };
|
|
168
|
+
}
|
|
169
|
+
case 'update': {
|
|
170
|
+
if (id === undefined)
|
|
171
|
+
return null;
|
|
172
|
+
const op = { frame, op: 'update', id };
|
|
173
|
+
const text = optString(raw.text);
|
|
174
|
+
const icon = optString(raw.icon);
|
|
175
|
+
const percent = optFiniteNumber(raw.percent);
|
|
176
|
+
if (text !== undefined)
|
|
177
|
+
op.text = text;
|
|
178
|
+
if (icon !== undefined)
|
|
179
|
+
op.icon = icon;
|
|
180
|
+
if (percent !== undefined)
|
|
181
|
+
op.percent = percent;
|
|
182
|
+
return op;
|
|
183
|
+
}
|
|
184
|
+
case 'custom-html': {
|
|
185
|
+
const html = optString(raw.html);
|
|
186
|
+
if (id === undefined || html === undefined)
|
|
187
|
+
return null;
|
|
188
|
+
return { frame, op: 'custom-html', id, html };
|
|
189
|
+
}
|
|
190
|
+
case 'timer': {
|
|
191
|
+
if (id === undefined)
|
|
192
|
+
return null;
|
|
193
|
+
if (raw.action !== 'start' && raw.action !== 'pause' && raw.action !== 'reset')
|
|
194
|
+
return null;
|
|
195
|
+
const op = { frame, op: 'timer', id, action: raw.action };
|
|
196
|
+
const seconds = optFiniteNumber(raw.seconds);
|
|
197
|
+
if (seconds !== undefined)
|
|
198
|
+
op.seconds = seconds;
|
|
199
|
+
return op;
|
|
200
|
+
}
|
|
201
|
+
case 'show':
|
|
202
|
+
case 'hide':
|
|
203
|
+
case 'remove':
|
|
204
|
+
return id === undefined ? null : { frame, op: raw.op, id };
|
|
205
|
+
case 'health': {
|
|
206
|
+
const current = optFiniteNumber(raw.current);
|
|
207
|
+
const max = optFiniteNumber(raw.max);
|
|
208
|
+
if (current === undefined || max === undefined)
|
|
209
|
+
return null;
|
|
210
|
+
return { frame, op: 'health', current, max };
|
|
211
|
+
}
|
|
212
|
+
case 'health-visible':
|
|
213
|
+
case 'hud-visible':
|
|
214
|
+
return typeof raw.visible === 'boolean' ? { frame, op: raw.op, visible: raw.visible } : null;
|
|
215
|
+
case 'toast': {
|
|
216
|
+
const message = optString(raw.message);
|
|
217
|
+
if (message === undefined)
|
|
218
|
+
return null;
|
|
219
|
+
const op = { frame, op: 'toast', message };
|
|
220
|
+
const variant = optString(raw.variant);
|
|
221
|
+
const durationMs = optFiniteNumber(raw.durationMs);
|
|
222
|
+
if (variant !== undefined)
|
|
223
|
+
op.variant = variant;
|
|
224
|
+
if (durationMs !== undefined)
|
|
225
|
+
op.durationMs = durationMs;
|
|
226
|
+
return op;
|
|
227
|
+
}
|
|
228
|
+
case 'theme':
|
|
229
|
+
return { frame, op: 'theme', theme: raw.theme };
|
|
230
|
+
default:
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Parse the hud[] track. Skipped entries are reported (grouped by op name) so
|
|
236
|
+
* a forward-compatible skip is never silent.
|
|
237
|
+
*/
|
|
238
|
+
export function parseHudOps(raw, source) {
|
|
239
|
+
if (raw === undefined)
|
|
240
|
+
return [];
|
|
241
|
+
if (!Array.isArray(raw))
|
|
242
|
+
throw new Error(`${source}: "hud" must be an array`);
|
|
243
|
+
const ops = [];
|
|
244
|
+
const skipped = new Map();
|
|
245
|
+
for (const entry of raw) {
|
|
246
|
+
const parsed = parseHudOp(entry);
|
|
247
|
+
if (parsed === null) {
|
|
248
|
+
const name = isRecord(entry) && typeof entry.op === 'string' ? entry.op : '<malformed>';
|
|
249
|
+
skipped.set(name, (skipped.get(name) ?? 0) + 1);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
ops.push(parsed);
|
|
253
|
+
}
|
|
254
|
+
if (skipped.size > 0) {
|
|
255
|
+
const detail = [...skipped.entries()].map(([name, count]) => `${name} x${count}`).join(', ');
|
|
256
|
+
warn(`${source}: skipped ${[...skipped.values()].reduce((a, b) => a + b, 0)} unsupported hud op(s): ${detail}`);
|
|
257
|
+
}
|
|
258
|
+
return ops;
|
|
259
|
+
}
|
|
260
|
+
export function parseTimeline(raw, source) {
|
|
261
|
+
if (!isRecord(raw))
|
|
262
|
+
throw new Error(`${source}: expected a JSON object`);
|
|
263
|
+
if (!isRecord(raw.recording))
|
|
264
|
+
throw new Error(`${source}: missing "recording" object`);
|
|
265
|
+
const rec = raw.recording;
|
|
266
|
+
const recording = {
|
|
267
|
+
name: asString(rec.name, `${source}: recording.name`),
|
|
268
|
+
width: asNumber(rec.width, `${source}: recording.width`),
|
|
269
|
+
height: asNumber(rec.height, `${source}: recording.height`),
|
|
270
|
+
fps: asNumber(rec.fps, `${source}: recording.fps`),
|
|
271
|
+
frameCount: asNumber(rec.frameCount, `${source}: recording.frameCount`)
|
|
272
|
+
};
|
|
273
|
+
if (recording.fps <= 0)
|
|
274
|
+
throw new Error(`${source}: recording.fps must be > 0`);
|
|
275
|
+
if (recording.frameCount <= 0)
|
|
276
|
+
throw new Error(`${source}: recording.frameCount must be > 0`);
|
|
277
|
+
if (typeof rec.gameId === 'string')
|
|
278
|
+
recording.gameId = rec.gameId;
|
|
279
|
+
if (typeof rec.genre === 'string')
|
|
280
|
+
recording.genre = rec.genre;
|
|
281
|
+
if (rec.missingFrames !== undefined) {
|
|
282
|
+
if (!Array.isArray(rec.missingFrames) || !rec.missingFrames.every((v) => Number.isInteger(v))) {
|
|
283
|
+
throw new Error(`${source}: recording.missingFrames must be an array of integers`);
|
|
284
|
+
}
|
|
285
|
+
recording.missingFrames = rec.missingFrames;
|
|
286
|
+
}
|
|
287
|
+
const eventsRaw = raw.events ?? [];
|
|
288
|
+
if (!Array.isArray(eventsRaw))
|
|
289
|
+
throw new Error(`${source}: "events" must be an array`);
|
|
290
|
+
const events = eventsRaw.map((e, i) => parseEvent(e, `${source}: events[${i}]`));
|
|
291
|
+
const soundsRaw = raw.sounds ?? [];
|
|
292
|
+
if (!Array.isArray(soundsRaw))
|
|
293
|
+
throw new Error(`${source}: "sounds" must be an array`);
|
|
294
|
+
const sounds = soundsRaw.map((s, i) => parseSound(s, `${source}: sounds[${i}]`));
|
|
295
|
+
return {
|
|
296
|
+
version: typeof raw.version === 'number' ? raw.version : 1,
|
|
297
|
+
recording,
|
|
298
|
+
events,
|
|
299
|
+
sounds,
|
|
300
|
+
hud: parseHudOps(raw.hud, source)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
export function loadTimeline(recordingDir) {
|
|
304
|
+
const filePath = path.join(recordingDir, 'timeline.json');
|
|
305
|
+
return parseTimeline(readJson(filePath), filePath);
|
|
306
|
+
}
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// edl.json parsing
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
function parseClip(raw, ctx) {
|
|
311
|
+
if (!isRecord(raw))
|
|
312
|
+
throw new Error(`${ctx}: expected an object`);
|
|
313
|
+
const clip = {
|
|
314
|
+
in: asNumber(raw.in, `${ctx}.in`),
|
|
315
|
+
out: asNumber(raw.out, `${ctx}.out`)
|
|
316
|
+
};
|
|
317
|
+
if (raw.speed !== undefined)
|
|
318
|
+
clip.speed = asNumber(raw.speed, `${ctx}.speed`);
|
|
319
|
+
if (raw.transition !== undefined) {
|
|
320
|
+
if (raw.transition !== 'cut' && raw.transition !== 'crossfade') {
|
|
321
|
+
throw new Error(`${ctx}.transition: expected "cut" or "crossfade", got ${JSON.stringify(raw.transition)}`);
|
|
322
|
+
}
|
|
323
|
+
clip.transition = raw.transition;
|
|
324
|
+
}
|
|
325
|
+
if (raw.transitionDuration !== undefined) {
|
|
326
|
+
clip.transitionDuration = asNumber(raw.transitionDuration, `${ctx}.transitionDuration`);
|
|
327
|
+
}
|
|
328
|
+
if (typeof raw.label === 'string')
|
|
329
|
+
clip.label = raw.label;
|
|
330
|
+
if (raw.ui !== undefined) {
|
|
331
|
+
if (typeof raw.ui !== 'boolean')
|
|
332
|
+
throw new Error(`${ctx}.ui: expected a boolean, got ${JSON.stringify(raw.ui)}`);
|
|
333
|
+
clip.ui = raw.ui;
|
|
334
|
+
}
|
|
335
|
+
return clip;
|
|
336
|
+
}
|
|
337
|
+
function parseTitle(raw, source) {
|
|
338
|
+
if (!isRecord(raw))
|
|
339
|
+
throw new Error(`${source}: expected an object`);
|
|
340
|
+
if (typeof raw.at !== 'number' || raw.at < 0)
|
|
341
|
+
throw new Error(`${source}: "at" must be a non-negative number of seconds`);
|
|
342
|
+
if (typeof raw.dur !== 'number' || raw.dur <= 0)
|
|
343
|
+
throw new Error(`${source}: "dur" must be a positive number of seconds`);
|
|
344
|
+
if (typeof raw.text !== 'string' || raw.text.length === 0)
|
|
345
|
+
throw new Error(`${source}: "text" must be a non-empty string`);
|
|
346
|
+
const cue = { at: raw.at, dur: raw.dur, text: raw.text };
|
|
347
|
+
if (typeof raw.sub === 'string')
|
|
348
|
+
cue.sub = raw.sub;
|
|
349
|
+
if (typeof raw.note === 'string')
|
|
350
|
+
cue.note = raw.note;
|
|
351
|
+
if (raw.style !== undefined) {
|
|
352
|
+
if (raw.style !== 'title' && raw.style !== 'line' && raw.style !== 'card') {
|
|
353
|
+
throw new Error(`${source}: "style" must be title, line or card`);
|
|
354
|
+
}
|
|
355
|
+
cue.style = raw.style;
|
|
356
|
+
}
|
|
357
|
+
return cue;
|
|
358
|
+
}
|
|
359
|
+
export function parseEdl(raw, source) {
|
|
360
|
+
if (!isRecord(raw))
|
|
361
|
+
throw new Error(`${source}: expected a JSON object`);
|
|
362
|
+
if (!Array.isArray(raw.clips) || raw.clips.length === 0) {
|
|
363
|
+
throw new Error(`${source}: "clips" must be a non-empty array`);
|
|
364
|
+
}
|
|
365
|
+
const clips = raw.clips.map((c, i) => parseClip(c, `${source}: clips[${i}]`));
|
|
366
|
+
const edl = { version: typeof raw.version === 'number' ? raw.version : 1, clips };
|
|
367
|
+
if (typeof raw.targetDuration === 'number')
|
|
368
|
+
edl.targetDuration = raw.targetDuration;
|
|
369
|
+
if (raw.titles !== undefined) {
|
|
370
|
+
if (!Array.isArray(raw.titles))
|
|
371
|
+
throw new Error(`${source}: "titles" must be an array`);
|
|
372
|
+
edl.titles = raw.titles.map((t, i) => parseTitle(t, `${source}: titles[${i}]`));
|
|
373
|
+
}
|
|
374
|
+
return edl;
|
|
375
|
+
}
|
|
376
|
+
export function loadEdl(filePath) {
|
|
377
|
+
return parseEdl(readJson(filePath), filePath);
|
|
378
|
+
}
|
|
379
|
+
//# sourceMappingURL=timeline-types.js.map
|