@hraness/slopcamera 3.2.7 → 3.2.8
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/NOTICE.md +1 -7
- package/PRIVACY.md +16 -7
- package/README.md +37 -12
- package/apps/desktop/README.md +14 -130
- package/apps/desktop/application/context.ts +0 -9
- package/apps/desktop/application/default-registry.ts +0 -10
- package/apps/desktop/application/operation.ts +1 -5
- package/apps/desktop/application/operations/index.ts +0 -1
- package/apps/desktop/cli/args.ts +14 -86
- package/apps/desktop/cli/command-host-resources.ts +2 -1
- package/apps/desktop/cli/commands.ts +28 -67
- package/apps/desktop/cli/help.ts +12 -18
- package/apps/desktop/cli/html-scene.ts +139 -28
- package/apps/desktop/cli/io.ts +2 -1
- package/apps/desktop/cli/main.ts +23 -47
- package/apps/desktop/cli/menubar.ts +244 -0
- package/apps/desktop/cli/music-analysis-service.ts +61 -0
- package/apps/desktop/cli/portable-surface.ts +12 -4
- package/apps/desktop/code/application-node-planner.ts +0 -47
- package/apps/desktop/code/host-source-layout.ts +46 -0
- package/apps/desktop/code/public.ts +0 -1
- package/apps/desktop/code/runtime-identity.ts +50 -8
- package/apps/desktop/code/semantic-builder.ts +0 -43
- package/apps/desktop/code/source-bundle.ts +23 -6
- package/apps/desktop/code/source-typecheck.ts +131 -43
- package/apps/desktop/code/worker-client.ts +14 -2
- package/apps/desktop/core/music-analysis.ts +124 -1
- package/apps/desktop/dist/cli/main.js +244 -250
- package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
- package/apps/desktop/html-overlay/index.ts +9 -0
- package/apps/desktop/html-overlay/runtime.ts +2 -0
- package/apps/desktop/html-overlay/scene.ts +29 -0
- package/dist/cli.js +83 -81
- package/dist/{index-rvghzt7b.js → index-7131pg1c.js} +43 -236
- package/dist/{index-mcy8z0br.js → index-fava6pge.js} +1 -1
- package/dist/{index-z7239b4h.js → index-h1k0fnjq.js} +1 -1
- package/dist/{index-p63wavx0.js → index-zfnddgay.js} +1 -1
- package/dist/index.js +4 -18
- package/dist/operations.js +2 -2
- package/dist/vectorize/worker.js +1 -1
- package/dist/workflow.js +3 -3
- package/docs/README.md +1 -1
- package/docs/studio.md +1 -1
- package/package.json +6 -21
- package/skills/slopcamera/SKILL.md +8 -0
- package/skills/slopcamera/references/diagrams.md +5 -14
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/music-video.md +23 -3
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/support.md +25 -0
- package/skills/slopcamera/references/web-media-excerpts.md +120 -0
- package/src/cli.ts +28 -98
- package/src/index.ts +0 -23
- package/src/support-completion.ts +16 -0
- package/src/support.ts +42 -0
- package/src/version.ts +1 -1
- package/apps/desktop/application/operations/recording/index.ts +0 -5
- package/apps/desktop/application/operations/recording/pause.ts +0 -34
- package/apps/desktop/application/operations/recording/resume.ts +0 -34
- package/apps/desktop/application/operations/recording/shared.ts +0 -199
- package/apps/desktop/application/operations/recording/start.ts +0 -169
- package/apps/desktop/application/operations/recording/stop.ts +0 -34
- package/apps/desktop/capture/protocol.ts +0 -689
- package/apps/desktop/cli/capture-bundle.ts +0 -1117
- package/apps/desktop/cli/recording-controller.ts +0 -1567
- package/apps/desktop/cli/recording-daemon.ts +0 -931
- package/src/desktop.ts +0 -253
|
@@ -1,1117 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { constants, type Stats } from "node:fs";
|
|
3
|
-
import { lstat, open } from "node:fs/promises";
|
|
4
|
-
import { release } from "node:os";
|
|
5
|
-
import type {
|
|
6
|
-
CaptureEvent,
|
|
7
|
-
CaptureInterruption,
|
|
8
|
-
CaptureOptions,
|
|
9
|
-
SegmentCompletion,
|
|
10
|
-
} from "../capture/protocol";
|
|
11
|
-
import {
|
|
12
|
-
deriveCaptureSyncDurationDriftPpm,
|
|
13
|
-
deriveCaptureSyncPlacement,
|
|
14
|
-
deriveCaptureSyncSpanToleranceUs,
|
|
15
|
-
deriveCaptureSyncTolerances,
|
|
16
|
-
EventStreamReferenceSchema,
|
|
17
|
-
LogicalTrackSchema,
|
|
18
|
-
MediaSegmentSchema,
|
|
19
|
-
RecordingManifestV3Schema,
|
|
20
|
-
TrackIdSchema,
|
|
21
|
-
type RecordingManifestV3,
|
|
22
|
-
} from "../contracts";
|
|
23
|
-
import {
|
|
24
|
-
createNodeBundleFileSystem,
|
|
25
|
-
saveRecordingManifest,
|
|
26
|
-
type BundleFileSystem,
|
|
27
|
-
} from "../core";
|
|
28
|
-
import { CliError } from "./errors";
|
|
29
|
-
import { BunProcessRunner, type ProcessRunner } from "./io";
|
|
30
|
-
import { ensurePrivateDirectory, resolveSafePath } from "./paths";
|
|
31
|
-
|
|
32
|
-
interface ProbeStream {
|
|
33
|
-
readonly codec: string;
|
|
34
|
-
readonly endPtsUs: number;
|
|
35
|
-
readonly firstPtsUs: number;
|
|
36
|
-
readonly id: string | undefined;
|
|
37
|
-
readonly index: number;
|
|
38
|
-
readonly tickUs: number;
|
|
39
|
-
readonly type: "audio" | "video" | "unknown";
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface VerifiedStream {
|
|
43
|
-
readonly codec: string;
|
|
44
|
-
readonly containerTrackIdentity:
|
|
45
|
-
| { readonly containerTrackId: string; readonly kind: "verified" }
|
|
46
|
-
| { readonly diagnosticCode: string; readonly expectedRole: "camera-video" | "display-video" | "microphone-audio" | "system-audio"; readonly kind: "provisional" };
|
|
47
|
-
readonly filePresentation: {
|
|
48
|
-
readonly endPtsUs: number;
|
|
49
|
-
readonly firstPtsUs: number;
|
|
50
|
-
readonly spanToleranceUs: number;
|
|
51
|
-
readonly tickUs: number;
|
|
52
|
-
};
|
|
53
|
-
readonly streamIndex: number;
|
|
54
|
-
readonly timing: NonNullable<SegmentCompletion["displays"][number]["streams"][number]["timing"]>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface FinalizationDiagnostic {
|
|
58
|
-
readonly code: string;
|
|
59
|
-
readonly level: "warning" | "error";
|
|
60
|
-
readonly message: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface CaptureSyncPublicationViolation {
|
|
64
|
-
readonly captureSegmentIndex: number;
|
|
65
|
-
readonly durationDriftUs: number;
|
|
66
|
-
readonly durationDriftToleranceUs: number;
|
|
67
|
-
readonly endUs: number;
|
|
68
|
-
readonly kind: RecordingManifestV3["tracks"][number]["kind"];
|
|
69
|
-
readonly onsetSkewUs: number;
|
|
70
|
-
readonly onsetSkewToleranceUs: number;
|
|
71
|
-
readonly path: string;
|
|
72
|
-
readonly segmentId: string;
|
|
73
|
-
readonly trackId: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export const CAPTURE_INTEGRITY_CHUNK_BYTES = 1024 * 1024;
|
|
77
|
-
export const CAPTURE_MEDIA_PROBE_TIMEOUT_MS = 30_000;
|
|
78
|
-
const MAX_CAPTURE_SYNC_PUBLICATION_VIOLATIONS = 16;
|
|
79
|
-
|
|
80
|
-
interface CaptureIntegrityReader {
|
|
81
|
-
read(
|
|
82
|
-
buffer: Buffer,
|
|
83
|
-
offset: number,
|
|
84
|
-
length: number,
|
|
85
|
-
position: number,
|
|
86
|
-
): Promise<{ readonly bytesRead: number }>;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function opaque(prefix: "event" | "segment" | "track", value: string): string {
|
|
90
|
-
const schemaPrefix = prefix === "event" ? "events" : prefix;
|
|
91
|
-
return `${schemaPrefix}_${createHash("sha256").update(value).digest("hex").slice(0, 16)}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export async function hashCaptureOutputChunks(
|
|
95
|
-
reader: CaptureIntegrityReader,
|
|
96
|
-
expectedBytes: number,
|
|
97
|
-
): Promise<string> {
|
|
98
|
-
if (!Number.isSafeInteger(expectedBytes) || expectedBytes <= 0) {
|
|
99
|
-
throw new CliError("invalid-data", "Capture output has an invalid byte length.");
|
|
100
|
-
}
|
|
101
|
-
const hash = createHash("sha256");
|
|
102
|
-
const buffer = Buffer.allocUnsafe(Math.min(CAPTURE_INTEGRITY_CHUNK_BYTES, expectedBytes));
|
|
103
|
-
let position = 0;
|
|
104
|
-
while (position < expectedBytes) {
|
|
105
|
-
const requested = Math.min(buffer.byteLength, expectedBytes - position);
|
|
106
|
-
const result = await reader.read(buffer, 0, requested, position);
|
|
107
|
-
if (!Number.isSafeInteger(result.bytesRead) || result.bytesRead <= 0 || result.bytesRead > requested) {
|
|
108
|
-
throw new CliError("invalid-data", "Capture output ended or changed while its integrity was being recorded.");
|
|
109
|
-
}
|
|
110
|
-
hash.update(buffer.subarray(0, result.bytesRead));
|
|
111
|
-
position += result.bytesRead;
|
|
112
|
-
}
|
|
113
|
-
return hash.digest("hex");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function sameCaptureFile(
|
|
117
|
-
left: Stats,
|
|
118
|
-
right: Stats,
|
|
119
|
-
): boolean {
|
|
120
|
-
return left.dev === right.dev
|
|
121
|
-
&& left.ino === right.ino
|
|
122
|
-
&& left.size === right.size
|
|
123
|
-
&& left.mtimeMs === right.mtimeMs
|
|
124
|
-
&& left.ctimeMs === right.ctimeMs;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async function integrity(path: string): Promise<{ readonly bytes: number; readonly sha256: string; readonly state: "verified" }> {
|
|
128
|
-
const lexical = await lstat(path);
|
|
129
|
-
if (
|
|
130
|
-
lexical.isSymbolicLink()
|
|
131
|
-
|| !lexical.isFile()
|
|
132
|
-
|| !Number.isSafeInteger(lexical.size)
|
|
133
|
-
|| lexical.size <= 0
|
|
134
|
-
) {
|
|
135
|
-
throw new CliError("invalid-data", `Capture output is not a stable physical regular file: ${path}`);
|
|
136
|
-
}
|
|
137
|
-
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
138
|
-
try {
|
|
139
|
-
const before = await handle.stat();
|
|
140
|
-
if (!before.isFile() || !sameCaptureFile(lexical, before)) {
|
|
141
|
-
throw new CliError("conflict", `Capture output changed before its integrity could be recorded: ${path}`);
|
|
142
|
-
}
|
|
143
|
-
const sha256 = await hashCaptureOutputChunks(handle, before.size);
|
|
144
|
-
const after = await handle.stat();
|
|
145
|
-
if (!sameCaptureFile(before, after)) {
|
|
146
|
-
throw new CliError("conflict", `Capture output changed while its integrity was being recorded: ${path}`);
|
|
147
|
-
}
|
|
148
|
-
return {
|
|
149
|
-
bytes: before.size,
|
|
150
|
-
sha256,
|
|
151
|
-
state: "verified",
|
|
152
|
-
};
|
|
153
|
-
} finally {
|
|
154
|
-
await handle.close();
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function parseProbeStreams(stdout: string): readonly ProbeStream[] {
|
|
159
|
-
let value: unknown;
|
|
160
|
-
try {
|
|
161
|
-
value = JSON.parse(stdout);
|
|
162
|
-
} catch {
|
|
163
|
-
throw new CliError("invalid-data", "FFprobe stream output is not valid JSON.");
|
|
164
|
-
}
|
|
165
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
166
|
-
throw new CliError("invalid-data", "FFprobe stream output must be an object.");
|
|
167
|
-
}
|
|
168
|
-
const streams = (value as Readonly<Record<string, unknown>>).streams;
|
|
169
|
-
if (!Array.isArray(streams)) throw new CliError("invalid-data", "FFprobe stream output omits streams.");
|
|
170
|
-
const parsed = streams.flatMap((raw): readonly ProbeStream[] => {
|
|
171
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
172
|
-
throw new CliError("invalid-data", "FFprobe stream entry must be an object.");
|
|
173
|
-
}
|
|
174
|
-
const stream = raw as Readonly<Record<string, unknown>>;
|
|
175
|
-
if (typeof stream.index !== "number" || !Number.isSafeInteger(stream.index) || stream.index < 0) {
|
|
176
|
-
throw new CliError("invalid-data", "FFprobe stream index is invalid.");
|
|
177
|
-
}
|
|
178
|
-
const codec = typeof stream.codec_name === "string" && stream.codec_name !== ""
|
|
179
|
-
? stream.codec_name
|
|
180
|
-
: "unknown";
|
|
181
|
-
const type = stream.codec_type === "video" ? "video" : stream.codec_type === "audio" ? "audio" : "unknown";
|
|
182
|
-
if (type === "unknown") return [];
|
|
183
|
-
const id = typeof stream.id === "string" || typeof stream.id === "number" ? String(stream.id) : undefined;
|
|
184
|
-
const firstPtsUs = timestampTicksToMicroseconds(stream.start_pts, stream.time_base)
|
|
185
|
-
?? decimalSecondsToMicroseconds(stream.start_time);
|
|
186
|
-
const tickUs = timebaseTickUs(stream.time_base);
|
|
187
|
-
const durationUs = durationTicksToMicroseconds(stream.duration_ts, stream.time_base)
|
|
188
|
-
?? decimalSecondsToMicroseconds(stream.duration);
|
|
189
|
-
if (firstPtsUs === null || durationUs === null || durationUs <= 0 || tickUs === null) {
|
|
190
|
-
throw new CliError("invalid-data", `FFprobe stream ${stream.index} omits finite first/end PTS evidence.`);
|
|
191
|
-
}
|
|
192
|
-
const endPtsUs = firstPtsUs + durationUs;
|
|
193
|
-
if (!Number.isSafeInteger(endPtsUs)) {
|
|
194
|
-
throw new CliError("invalid-data", `FFprobe stream ${stream.index} PTS exceeds the supported range.`);
|
|
195
|
-
}
|
|
196
|
-
return [{ codec, endPtsUs, firstPtsUs, id, index: stream.index, tickUs, type }];
|
|
197
|
-
});
|
|
198
|
-
if (new Set(parsed.map(({ index }) => index)).size !== parsed.length) {
|
|
199
|
-
throw new CliError("invalid-data", "FFprobe audio/video stream indices must be unique.");
|
|
200
|
-
}
|
|
201
|
-
return parsed;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function durationTicksToMicroseconds(ticksValue: unknown, timebaseValue: unknown): number | null {
|
|
205
|
-
const microseconds = timestampTicksToMicroseconds(ticksValue, timebaseValue);
|
|
206
|
-
return microseconds !== null && microseconds > 0 ? microseconds : null;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function timestampTicksToMicroseconds(ticksValue: unknown, timebaseValue: unknown): number | null {
|
|
210
|
-
if (
|
|
211
|
-
(typeof ticksValue !== "string" && typeof ticksValue !== "number")
|
|
212
|
-
|| typeof timebaseValue !== "string"
|
|
213
|
-
) return null;
|
|
214
|
-
const ticks = Number(ticksValue);
|
|
215
|
-
const match = /^(?<numerator>[1-9][0-9]*)\/(?<denominator>[1-9][0-9]*)$/u.exec(timebaseValue);
|
|
216
|
-
if (match?.groups === undefined) return null;
|
|
217
|
-
const microseconds = Math.round(
|
|
218
|
-
ticks * Number(match.groups.numerator) * 1_000_000 / Number(match.groups.denominator),
|
|
219
|
-
);
|
|
220
|
-
return Number.isSafeInteger(ticks) && Number.isSafeInteger(microseconds) ? microseconds : null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function decimalSecondsToMicroseconds(value: unknown): number | null {
|
|
224
|
-
if (typeof value !== "string" || value.trim() === "") return null;
|
|
225
|
-
const seconds = Number(value);
|
|
226
|
-
const microseconds = Math.round(seconds * 1_000_000);
|
|
227
|
-
return Number.isFinite(seconds) && Number.isSafeInteger(microseconds) ? microseconds : null;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function timebaseTickUs(value: unknown): number | null {
|
|
231
|
-
if (typeof value !== "string") return null;
|
|
232
|
-
const match = /^(?<numerator>[1-9][0-9]*)\/(?<denominator>[1-9][0-9]*)$/u.exec(value);
|
|
233
|
-
if (match?.groups === undefined) return null;
|
|
234
|
-
const seconds = Number(match.groups.numerator) / Number(match.groups.denominator);
|
|
235
|
-
const tickUs = Math.ceil(Math.abs(seconds) * 1_000_000);
|
|
236
|
-
return Number.isFinite(seconds) && Number.isSafeInteger(tickUs) && tickUs > 0 ? tickUs : null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function numericTrackId(value: string | undefined): number | undefined {
|
|
240
|
-
if (value === undefined) return undefined;
|
|
241
|
-
const parsed = value.startsWith("0x") ? Number.parseInt(value.slice(2), 16) : Number(value);
|
|
242
|
-
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export interface CaptureMediaVerifierOptions {
|
|
246
|
-
readonly ffprobe?: string;
|
|
247
|
-
readonly runner?: ProcessRunner;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export class CaptureMediaVerifier {
|
|
251
|
-
readonly #ffprobe: string;
|
|
252
|
-
readonly #runner: ProcessRunner;
|
|
253
|
-
|
|
254
|
-
constructor(options: CaptureMediaVerifierOptions = {}) {
|
|
255
|
-
this.#ffprobe = options.ffprobe ?? "ffprobe";
|
|
256
|
-
this.#runner = options.runner ?? new BunProcessRunner();
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async verify(
|
|
260
|
-
absolutePath: string,
|
|
261
|
-
streams: readonly SegmentCompletion["displays"][number]["streams"][number][],
|
|
262
|
-
): Promise<{
|
|
263
|
-
readonly diagnostics: readonly FinalizationDiagnostic[];
|
|
264
|
-
readonly fileOriginPtsUs: number;
|
|
265
|
-
readonly streams: readonly (VerifiedStream | null)[];
|
|
266
|
-
}> {
|
|
267
|
-
const result = await this.#runner.run([
|
|
268
|
-
this.#ffprobe,
|
|
269
|
-
"-v", "error",
|
|
270
|
-
"-show_entries", "stream=index,id,codec_name,codec_type,start_time,start_pts,duration,duration_ts,time_base",
|
|
271
|
-
"-of", "json",
|
|
272
|
-
absolutePath,
|
|
273
|
-
], {
|
|
274
|
-
maxOutputBytes: 1_000_000,
|
|
275
|
-
timeoutMs: CAPTURE_MEDIA_PROBE_TIMEOUT_MS,
|
|
276
|
-
});
|
|
277
|
-
if (result.exitCode !== 0) {
|
|
278
|
-
throw new CliError(
|
|
279
|
-
"subprocess",
|
|
280
|
-
`Could not verify finalized capture timing for ${absolutePath}: ${result.stderr.trim() || `exit ${result.exitCode}`}`,
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
const probed = parseProbeStreams(result.stdout);
|
|
284
|
-
if (probed.length === 0) {
|
|
285
|
-
throw new CliError("invalid-data", `FFprobe found no timed audio/video stream in ${absolutePath}.`);
|
|
286
|
-
}
|
|
287
|
-
const fileOriginPtsUs = Math.min(...probed.map(({ firstPtsUs }) => firstPtsUs));
|
|
288
|
-
const used = new Set(streams.flatMap(stream => {
|
|
289
|
-
if (stream.role !== "unclassified-audio") return [];
|
|
290
|
-
const reserved = probed.find(candidate => (
|
|
291
|
-
candidate.type === "audio"
|
|
292
|
-
&& (
|
|
293
|
-
candidate.index === stream.streamIndex
|
|
294
|
-
|| (
|
|
295
|
-
stream.trackId !== undefined
|
|
296
|
-
&& numericTrackId(candidate.id) === stream.trackId
|
|
297
|
-
)
|
|
298
|
-
)
|
|
299
|
-
));
|
|
300
|
-
return reserved === undefined ? [] : [reserved.index];
|
|
301
|
-
}));
|
|
302
|
-
const diagnostics: FinalizationDiagnostic[] = [];
|
|
303
|
-
const verified = streams.map((stream): VerifiedStream | null => {
|
|
304
|
-
if (stream.role === "unclassified-audio") {
|
|
305
|
-
diagnostics.push({
|
|
306
|
-
code: "unclassified-audio-stream",
|
|
307
|
-
level: "warning",
|
|
308
|
-
message: `Ignored unclassified audio stream ${stream.streamIndex} in ${absolutePath}.`,
|
|
309
|
-
});
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
const expectedType = stream.role.endsWith("video") ? "video" : "audio";
|
|
313
|
-
const exact = stream.trackId === undefined
|
|
314
|
-
? undefined
|
|
315
|
-
: probed.find((candidate) =>
|
|
316
|
-
numericTrackId(candidate.id) === stream.trackId
|
|
317
|
-
&& candidate.type === expectedType
|
|
318
|
-
&& !used.has(candidate.index)
|
|
319
|
-
);
|
|
320
|
-
const indexed = probed.find((candidate) =>
|
|
321
|
-
candidate.index === stream.streamIndex && candidate.type === expectedType && !used.has(candidate.index)
|
|
322
|
-
);
|
|
323
|
-
const roleMatch = probed.find((candidate) => candidate.type === expectedType && !used.has(candidate.index));
|
|
324
|
-
const match = exact ?? indexed ?? roleMatch;
|
|
325
|
-
if (match === undefined) {
|
|
326
|
-
throw new CliError(
|
|
327
|
-
"invalid-data",
|
|
328
|
-
`FFprobe found no ${expectedType} timing evidence for ${stream.role} in ${absolutePath}.`,
|
|
329
|
-
{
|
|
330
|
-
diagnosticCode: "timing-evidence-mismatch",
|
|
331
|
-
emittedStreamIndex: stream.streamIndex,
|
|
332
|
-
role: stream.role,
|
|
333
|
-
},
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
used.add(match.index);
|
|
337
|
-
const timingToleranceUs = deriveCaptureSyncSpanToleranceUs(
|
|
338
|
-
match.tickUs,
|
|
339
|
-
stream.timing.presentation.maximumSampleDurationUs,
|
|
340
|
-
);
|
|
341
|
-
if (timingToleranceUs === null) {
|
|
342
|
-
throw new CliError(
|
|
343
|
-
"invalid-data",
|
|
344
|
-
`Finalized PTS tolerance for ${stream.role} in ${absolutePath} is outside the supported range.`,
|
|
345
|
-
);
|
|
346
|
-
}
|
|
347
|
-
const emittedSpanUs = stream.timing.presentation.endPtsUs - stream.timing.presentation.firstPtsUs;
|
|
348
|
-
const probedSpanUs = match.endPtsUs - match.firstPtsUs;
|
|
349
|
-
if (Math.abs(probedSpanUs - emittedSpanUs) > timingToleranceUs) {
|
|
350
|
-
throw new CliError(
|
|
351
|
-
"invalid-data",
|
|
352
|
-
`Finalized PTS span for ${stream.role} in ${absolutePath} disagrees with the capture helper.`,
|
|
353
|
-
{
|
|
354
|
-
diagnosticCode: "timing-evidence-mismatch",
|
|
355
|
-
emittedSpanUs,
|
|
356
|
-
probedSpanUs,
|
|
357
|
-
streamIndex: match.index,
|
|
358
|
-
toleranceUs: timingToleranceUs,
|
|
359
|
-
},
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
const exactIdentity = stream.trackId !== undefined && numericTrackId(match.id) === stream.trackId;
|
|
363
|
-
if (!exactIdentity) {
|
|
364
|
-
diagnostics.push({
|
|
365
|
-
code: "ffprobe-track-id-mismatch",
|
|
366
|
-
level: stream.mapping === "exact" ? "error" : "warning",
|
|
367
|
-
message: `Corrected ${stream.role} from emitted index ${stream.streamIndex} to FFmpeg stream ${match.index} in ${absolutePath}.`,
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
return {
|
|
371
|
-
codec: match.codec === "unknown" ? stream.codec : match.codec,
|
|
372
|
-
containerTrackIdentity: exactIdentity && match.id !== undefined
|
|
373
|
-
? { containerTrackId: match.id, kind: "verified" }
|
|
374
|
-
: {
|
|
375
|
-
diagnosticCode: "ffprobe-track-id-mismatch",
|
|
376
|
-
expectedRole: stream.role,
|
|
377
|
-
kind: "provisional",
|
|
378
|
-
},
|
|
379
|
-
filePresentation: {
|
|
380
|
-
endPtsUs: match.endPtsUs,
|
|
381
|
-
firstPtsUs: match.firstPtsUs,
|
|
382
|
-
spanToleranceUs: timingToleranceUs,
|
|
383
|
-
tickUs: match.tickUs,
|
|
384
|
-
},
|
|
385
|
-
streamIndex: match.index,
|
|
386
|
-
timing: stream.timing,
|
|
387
|
-
};
|
|
388
|
-
});
|
|
389
|
-
return { diagnostics, fileOriginPtsUs, streams: verified };
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
interface CaptureBundleWriterOptions {
|
|
394
|
-
readonly bundleRoot: string;
|
|
395
|
-
readonly captureOptions: CaptureOptions;
|
|
396
|
-
readonly configured: Extract<CaptureEvent, { readonly event: "configured" }>;
|
|
397
|
-
readonly helperVersion: string;
|
|
398
|
-
readonly now: () => Date;
|
|
399
|
-
readonly recordingId: string;
|
|
400
|
-
readonly toolVersion: string;
|
|
401
|
-
readonly verifier?: CaptureMediaVerifier;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function initialManifest(options: CaptureBundleWriterOptions): RecordingManifestV3 {
|
|
405
|
-
const timestamp = options.now().toISOString();
|
|
406
|
-
return RecordingManifestV3Schema.parse({
|
|
407
|
-
capture: {
|
|
408
|
-
cursor: options.captureOptions.metadata ? "metadata" : "disabled",
|
|
409
|
-
typedText: options.captureOptions.typedText ? "enabled" : "disabled",
|
|
410
|
-
windowMetadata: options.captureOptions.metadata ? "titles-and-bounds" : "disabled",
|
|
411
|
-
},
|
|
412
|
-
coordinateSpace: { kind: "global-display-points", origin: "top-left", xAxis: "right", yAxis: "down" },
|
|
413
|
-
createdAt: timestamp,
|
|
414
|
-
diagnostics: [],
|
|
415
|
-
eventStreams: [],
|
|
416
|
-
interruptions: [],
|
|
417
|
-
kind: "slopcamera.recording-bundle",
|
|
418
|
-
permissions: options.configured.permissions,
|
|
419
|
-
platform: {
|
|
420
|
-
architecture: process.arch,
|
|
421
|
-
os: process.platform === "darwin" ? "macos" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "windows" : "unknown",
|
|
422
|
-
osVersion: release(),
|
|
423
|
-
},
|
|
424
|
-
recordingId: options.recordingId,
|
|
425
|
-
schemaVersion: 3,
|
|
426
|
-
sources: options.configured.sources,
|
|
427
|
-
state: "preparing",
|
|
428
|
-
timeline: {
|
|
429
|
-
durationUs: 0,
|
|
430
|
-
nativeClock: { kind: "mach-continuous-microseconds", segments: [] },
|
|
431
|
-
timebase: "microseconds",
|
|
432
|
-
},
|
|
433
|
-
tool: { captureVersion: options.helperVersion, name: "slopcamera", version: options.toolVersion },
|
|
434
|
-
tracks: [],
|
|
435
|
-
updatedAt: timestamp,
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function appendDiagnostic(
|
|
440
|
-
manifest: RecordingManifestV3,
|
|
441
|
-
diagnostic: FinalizationDiagnostic,
|
|
442
|
-
sourceTimeUs: number,
|
|
443
|
-
): RecordingManifestV3["diagnostics"] {
|
|
444
|
-
const prior = manifest.diagnostics.find(({ code, message, level }) =>
|
|
445
|
-
code === diagnostic.code && message === diagnostic.message && level === diagnostic.level
|
|
446
|
-
);
|
|
447
|
-
if (prior === undefined) {
|
|
448
|
-
return [...manifest.diagnostics, {
|
|
449
|
-
code: diagnostic.code,
|
|
450
|
-
count: 1,
|
|
451
|
-
firstSourceTimeUs: sourceTimeUs,
|
|
452
|
-
lastSourceTimeUs: sourceTimeUs,
|
|
453
|
-
level: diagnostic.level,
|
|
454
|
-
message: diagnostic.message.slice(0, 4_096),
|
|
455
|
-
}];
|
|
456
|
-
}
|
|
457
|
-
return manifest.diagnostics.map((item) => item === prior
|
|
458
|
-
? { ...item, count: item.count + 1, lastSourceTimeUs: sourceTimeUs }
|
|
459
|
-
: item);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function mergeSources(
|
|
463
|
-
current: RecordingManifestV3["sources"],
|
|
464
|
-
incoming: RecordingManifestV3["sources"],
|
|
465
|
-
): RecordingManifestV3["sources"] {
|
|
466
|
-
const unique = <Item>(items: readonly Item[], key: (item: Item) => string): readonly Item[] => [
|
|
467
|
-
...new Map(items.map((item) => [key(item), item] as const)).values(),
|
|
468
|
-
];
|
|
469
|
-
const displays = unique([...current.displays, ...incoming.displays], ({ displayId }) => displayId);
|
|
470
|
-
const primaryDisplayId = current.displays.find(({ isPrimary }) => isPrimary)?.displayId
|
|
471
|
-
?? incoming.displays.find(({ isPrimary }) => isPrimary)?.displayId
|
|
472
|
-
?? displays[0]?.displayId;
|
|
473
|
-
return {
|
|
474
|
-
audio: unique([...current.audio, ...incoming.audio], ({ audioSourceId }) => audioSourceId),
|
|
475
|
-
cameras: unique([...current.cameras, ...incoming.cameras], ({ cameraId }) => cameraId),
|
|
476
|
-
displays: displays.map((display) => ({
|
|
477
|
-
...display,
|
|
478
|
-
isPrimary: display.displayId === primaryDisplayId,
|
|
479
|
-
})),
|
|
480
|
-
};
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function captureSyncPublicationViolations(
|
|
484
|
-
manifest: RecordingManifestV3,
|
|
485
|
-
): readonly CaptureSyncPublicationViolation[] {
|
|
486
|
-
return manifest.tracks.flatMap(track => track.segments.flatMap(segment => {
|
|
487
|
-
if (segment.timing.kind !== "measured" || segment.timing.status !== "out-of-tolerance") return [];
|
|
488
|
-
return [{
|
|
489
|
-
captureSegmentIndex: segment.timing.captureSegmentIndex,
|
|
490
|
-
durationDriftUs: segment.timing.durationDriftUs,
|
|
491
|
-
durationDriftToleranceUs: segment.timing.tolerance.durationDriftUs,
|
|
492
|
-
endUs: segment.endUs,
|
|
493
|
-
kind: track.kind,
|
|
494
|
-
onsetSkewUs: segment.timing.onsetSkewUs,
|
|
495
|
-
onsetSkewToleranceUs: segment.timing.tolerance.onsetSkewUs,
|
|
496
|
-
path: segment.path,
|
|
497
|
-
segmentId: segment.segmentId,
|
|
498
|
-
trackId: track.trackId,
|
|
499
|
-
}];
|
|
500
|
-
}));
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function captureInterruptionDiagnostic(
|
|
504
|
-
interruption: CaptureInterruption,
|
|
505
|
-
): FinalizationDiagnostic {
|
|
506
|
-
return {
|
|
507
|
-
code: `capture-interruption-${interruption.code}`.slice(0, 128),
|
|
508
|
-
level: interruption.recoverable ? "warning" : "error",
|
|
509
|
-
message: [
|
|
510
|
-
`${interruption.source} capture interruption ${interruption.code}`,
|
|
511
|
-
`closed segment ${interruption.segmentIndex} at source time`,
|
|
512
|
-
`${interruption.sourceTimeUs} microseconds.`,
|
|
513
|
-
].join(" ").slice(0, 4_096),
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function sameCaptureInterruption(
|
|
518
|
-
left: CaptureInterruption,
|
|
519
|
-
right: CaptureInterruption,
|
|
520
|
-
): boolean {
|
|
521
|
-
return left.code === right.code
|
|
522
|
-
&& left.nativeTimeUs === right.nativeTimeUs
|
|
523
|
-
&& left.recoverable === right.recoverable
|
|
524
|
-
&& left.segmentIndex === right.segmentIndex
|
|
525
|
-
&& left.source === right.source
|
|
526
|
-
&& left.sourceId === right.sourceId
|
|
527
|
-
&& left.sourceTimeUs === right.sourceTimeUs;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function failedSegmentPublicationDiagnostic(
|
|
531
|
-
segment: SegmentCompletion,
|
|
532
|
-
failure: unknown,
|
|
533
|
-
): FinalizationDiagnostic {
|
|
534
|
-
const failureKind = failure instanceof CliError ? failure.code : "internal";
|
|
535
|
-
return {
|
|
536
|
-
code: "capture-segment-publication-failed",
|
|
537
|
-
level: "error",
|
|
538
|
-
message: [
|
|
539
|
-
`Capture segment ${segment.index} completed at source time`,
|
|
540
|
-
`${segment.clock.end.sourceTimeUs} microseconds, but its outputs were not admitted`,
|
|
541
|
-
`because ${failureKind} finalization verification failed.`,
|
|
542
|
-
].join(" "),
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function sameCaptureClockSegment(
|
|
547
|
-
left: RecordingManifestV3["timeline"]["nativeClock"]["segments"][number],
|
|
548
|
-
right: RecordingManifestV3["timeline"]["nativeClock"]["segments"][number],
|
|
549
|
-
): boolean {
|
|
550
|
-
return left.index === right.index
|
|
551
|
-
&& left.nativeRange.endUs === right.nativeRange.endUs
|
|
552
|
-
&& left.nativeRange.startUs === right.nativeRange.startUs
|
|
553
|
-
&& left.sourceRange.endUs === right.sourceRange.endUs
|
|
554
|
-
&& left.sourceRange.startUs === right.sourceRange.startUs;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
export class CaptureBundleWriter {
|
|
558
|
-
readonly #bundleRoot: string;
|
|
559
|
-
readonly #fileSystem: BundleFileSystem;
|
|
560
|
-
readonly #now: () => Date;
|
|
561
|
-
readonly #verifier: CaptureMediaVerifier;
|
|
562
|
-
#manifest: RecordingManifestV3;
|
|
563
|
-
|
|
564
|
-
constructor(options: CaptureBundleWriterOptions) {
|
|
565
|
-
this.#bundleRoot = options.bundleRoot;
|
|
566
|
-
this.#fileSystem = createNodeBundleFileSystem(options.bundleRoot);
|
|
567
|
-
this.#now = options.now;
|
|
568
|
-
this.#verifier = options.verifier ?? new CaptureMediaVerifier();
|
|
569
|
-
this.#manifest = initialManifest(options);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
get manifest(): RecordingManifestV3 {
|
|
573
|
-
return this.#manifest;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
async initialize(): Promise<void> {
|
|
577
|
-
await ensurePrivateDirectory(this.#bundleRoot);
|
|
578
|
-
await saveRecordingManifest(this.#fileSystem, this.#manifest);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
async setCaptureEnvironment(
|
|
582
|
-
permissions: RecordingManifestV3["permissions"],
|
|
583
|
-
sources: RecordingManifestV3["sources"],
|
|
584
|
-
): Promise<void> {
|
|
585
|
-
const nextSources = this.#manifest.timeline.nativeClock.segments.length === 0
|
|
586
|
-
? sources
|
|
587
|
-
: mergeSources(this.#manifest.sources, sources);
|
|
588
|
-
this.#manifest = RecordingManifestV3Schema.parse({
|
|
589
|
-
...this.#manifest,
|
|
590
|
-
permissions,
|
|
591
|
-
sources: nextSources,
|
|
592
|
-
updatedAt: this.#now().toISOString(),
|
|
593
|
-
});
|
|
594
|
-
await saveRecordingManifest(this.#fileSystem, this.#manifest);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
async setState(state: RecordingManifestV3["state"], durationUs?: number): Promise<void> {
|
|
598
|
-
const nextDurationUs = durationUs ?? this.#manifest.timeline.durationUs;
|
|
599
|
-
if (state === "stopped") {
|
|
600
|
-
const violations = captureSyncPublicationViolations(this.#manifest);
|
|
601
|
-
if (violations.length > 0) {
|
|
602
|
-
const first = violations[0]!;
|
|
603
|
-
const message = [
|
|
604
|
-
`Recording publication rejected because ${violations.length} stream segment(s) exceeded capture synchronization tolerance.`,
|
|
605
|
-
`First: ${first.kind} ${first.segmentId} (onset ${first.onsetSkewUs}/${first.onsetSkewToleranceUs} us,`,
|
|
606
|
-
`duration drift ${first.durationDriftUs}/${first.durationDriftToleranceUs} us).`,
|
|
607
|
-
].join(" ");
|
|
608
|
-
const diagnostics = appendDiagnostic(this.#manifest, {
|
|
609
|
-
code: "capture-sync-publication-rejected",
|
|
610
|
-
level: "error",
|
|
611
|
-
message,
|
|
612
|
-
}, violations.reduce((latest, violation) => Math.max(latest, violation.endUs), 0));
|
|
613
|
-
this.#manifest = RecordingManifestV3Schema.parse({
|
|
614
|
-
...this.#manifest,
|
|
615
|
-
diagnostics,
|
|
616
|
-
state: "failed",
|
|
617
|
-
timeline: {
|
|
618
|
-
...this.#manifest.timeline,
|
|
619
|
-
durationUs: nextDurationUs,
|
|
620
|
-
},
|
|
621
|
-
updatedAt: this.#now().toISOString(),
|
|
622
|
-
});
|
|
623
|
-
await saveRecordingManifest(this.#fileSystem, this.#manifest);
|
|
624
|
-
throw new CliError("invalid-data", message, {
|
|
625
|
-
diagnosticCode: "capture-sync-publication-rejected",
|
|
626
|
-
violationCount: violations.length,
|
|
627
|
-
violations: violations.slice(0, MAX_CAPTURE_SYNC_PUBLICATION_VIOLATIONS),
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
this.#manifest = RecordingManifestV3Schema.parse({
|
|
632
|
-
...this.#manifest,
|
|
633
|
-
state,
|
|
634
|
-
timeline: {
|
|
635
|
-
...this.#manifest.timeline,
|
|
636
|
-
durationUs: nextDurationUs,
|
|
637
|
-
},
|
|
638
|
-
updatedAt: this.#now().toISOString(),
|
|
639
|
-
});
|
|
640
|
-
await saveRecordingManifest(this.#fileSystem, this.#manifest);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
async appendSegment(
|
|
644
|
-
segment: SegmentCompletion,
|
|
645
|
-
interruption: CaptureInterruption | null,
|
|
646
|
-
): Promise<void> {
|
|
647
|
-
let diagnostics = this.#manifest.diagnostics;
|
|
648
|
-
const tracks = [...this.#manifest.tracks];
|
|
649
|
-
const eventStreams = [...this.#manifest.eventStreams];
|
|
650
|
-
const seenAudio = new Set<"microphone-audio" | "system-audio">();
|
|
651
|
-
const segmentStartUs = segment.clock.start.sourceTimeUs;
|
|
652
|
-
const segmentEndUs = segment.clock.end.sourceTimeUs;
|
|
653
|
-
const primaryDisplay = segment.displays.find(({ display }) => display.isPrimary);
|
|
654
|
-
const referenceStream = primaryDisplay?.streams.find(({ role }) => role === "display-video");
|
|
655
|
-
const referenceTiming = referenceStream?.timing;
|
|
656
|
-
if (primaryDisplay === undefined || referenceStream === undefined || referenceTiming === undefined) {
|
|
657
|
-
throw new CliError("invalid-data", `Capture segment ${segment.index} has no primary display timing reference.`);
|
|
658
|
-
}
|
|
659
|
-
const referenceTrackId = TrackIdSchema.parse(opaque(
|
|
660
|
-
"track",
|
|
661
|
-
`display-video:${JSON.stringify({ displayId: primaryDisplay.display.displayId })}`,
|
|
662
|
-
));
|
|
663
|
-
|
|
664
|
-
const addMedia = async (
|
|
665
|
-
path: string,
|
|
666
|
-
container: string,
|
|
667
|
-
streams: readonly SegmentCompletion["displays"][number]["streams"][number][],
|
|
668
|
-
source: { readonly displayId: string } | { readonly cameraId: string } | null,
|
|
669
|
-
label: string,
|
|
670
|
-
containerDurationUs: number,
|
|
671
|
-
origin: "camera" | "display" | "microphone",
|
|
672
|
-
): Promise<void> => {
|
|
673
|
-
const absolute = await resolveSafePath(this.#bundleRoot, path);
|
|
674
|
-
const [fileIntegrity, verification] = await Promise.all([
|
|
675
|
-
integrity(absolute),
|
|
676
|
-
this.#verifier.verify(absolute, streams),
|
|
677
|
-
]);
|
|
678
|
-
for (const diagnostic of verification.diagnostics) {
|
|
679
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, diagnostic, segmentEndUs);
|
|
680
|
-
}
|
|
681
|
-
if (verification.streams.every(stream => stream === null)) {
|
|
682
|
-
throw new CliError("invalid-data", `Capture output ${path} has no verified media stream timing.`);
|
|
683
|
-
}
|
|
684
|
-
const fileOriginPtsUs = verification.fileOriginPtsUs;
|
|
685
|
-
for (let index = 0; index < streams.length; index += 1) {
|
|
686
|
-
const emitted = streams[index]!;
|
|
687
|
-
const verified = verification.streams[index];
|
|
688
|
-
if (verified === undefined || verified === null || emitted.role === "unclassified-audio") continue;
|
|
689
|
-
const roleMatchesOrigin = origin === "display"
|
|
690
|
-
? emitted.role === "display-video" || emitted.role === "system-audio"
|
|
691
|
-
: origin === "camera"
|
|
692
|
-
? emitted.role === "camera-video"
|
|
693
|
-
: emitted.role === "microphone-audio";
|
|
694
|
-
if (!roleMatchesOrigin) {
|
|
695
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, {
|
|
696
|
-
code: "capture-role-origin-mismatch",
|
|
697
|
-
level: "error",
|
|
698
|
-
message: `Ignored ${emitted.role} from ${origin} output ${path}.`,
|
|
699
|
-
}, segmentEndUs);
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
if ((emitted.role === "system-audio" || emitted.role === "microphone-audio") && seenAudio.has(emitted.role)) continue;
|
|
703
|
-
if (emitted.role === "system-audio" || emitted.role === "microphone-audio") seenAudio.add(emitted.role);
|
|
704
|
-
const trackSource = emitted.role === "display-video"
|
|
705
|
-
? source !== null && "displayId" in source
|
|
706
|
-
? source
|
|
707
|
-
: (() => { throw new CliError("internal", `Display output ${path} has no display source.`); })()
|
|
708
|
-
: emitted.role === "camera-video"
|
|
709
|
-
? source !== null && "cameraId" in source
|
|
710
|
-
? source
|
|
711
|
-
: (() => { throw new CliError("internal", `Camera output ${path} has no camera source.`); })()
|
|
712
|
-
: {
|
|
713
|
-
audioSourceId: this.#manifest.sources.audio.find(({ kind }) =>
|
|
714
|
-
kind === (emitted.role === "system-audio" ? "system" : "microphone")
|
|
715
|
-
)?.audioSourceId
|
|
716
|
-
?? segment.sources.audio.find(({ kind }) =>
|
|
717
|
-
kind === (emitted.role === "system-audio" ? "system" : "microphone")
|
|
718
|
-
)?.audioSourceId
|
|
719
|
-
?? `missing-${emitted.role}`,
|
|
720
|
-
};
|
|
721
|
-
const key = `${emitted.role}:${JSON.stringify(trackSource)}`;
|
|
722
|
-
const trackId = TrackIdSchema.parse(opaque("track", key));
|
|
723
|
-
const helperNativeStartUs = verified.timing.clockAnchors.first.nativeTimeUs;
|
|
724
|
-
const helperNativeEndUs = verified.timing.clockAnchors.end.nativeTimeUs;
|
|
725
|
-
const referenceNativeDurationUs = referenceTiming.clockAnchors.end.nativeTimeUs
|
|
726
|
-
- referenceTiming.clockAnchors.first.nativeTimeUs;
|
|
727
|
-
const placement = deriveCaptureSyncPlacement({
|
|
728
|
-
clockNativeStartUs: segment.clock.start.nativeTimeUs,
|
|
729
|
-
clockSourceEndUs: segmentEndUs,
|
|
730
|
-
clockSourceStartUs: segmentStartUs,
|
|
731
|
-
fileContainerOriginPtsUs: fileOriginPtsUs,
|
|
732
|
-
fileEndPtsUs: verified.filePresentation.endPtsUs,
|
|
733
|
-
fileFirstPtsUs: verified.filePresentation.firstPtsUs,
|
|
734
|
-
helperNativeStartUs,
|
|
735
|
-
});
|
|
736
|
-
if (placement === null) {
|
|
737
|
-
throw new CliError(
|
|
738
|
-
"invalid-data",
|
|
739
|
-
`Capture timing for ${emitted.role} in ${path} does not overlap segment ${segment.index}.`,
|
|
740
|
-
);
|
|
741
|
-
}
|
|
742
|
-
const {
|
|
743
|
-
endUs,
|
|
744
|
-
fileRangeEndUs,
|
|
745
|
-
fileRangeStartUs,
|
|
746
|
-
rawEndUs,
|
|
747
|
-
rawStartUs,
|
|
748
|
-
startUs,
|
|
749
|
-
} = placement;
|
|
750
|
-
const onsetSkewUs = helperNativeStartUs
|
|
751
|
-
- referenceTiming.clockAnchors.first.nativeTimeUs;
|
|
752
|
-
const endSkewUs = helperNativeEndUs - referenceTiming.clockAnchors.end.nativeTimeUs;
|
|
753
|
-
const durationDriftUs = endSkewUs - onsetSkewUs;
|
|
754
|
-
const durationDriftPpm = deriveCaptureSyncDurationDriftPpm(
|
|
755
|
-
durationDriftUs,
|
|
756
|
-
referenceNativeDurationUs,
|
|
757
|
-
);
|
|
758
|
-
const tolerances = deriveCaptureSyncTolerances({
|
|
759
|
-
referenceDurationUs: referenceNativeDurationUs,
|
|
760
|
-
referenceEndUncertaintyUs: referenceTiming.clockAnchors.end.uncertaintyUs,
|
|
761
|
-
referenceFirstUncertaintyUs: referenceTiming.clockAnchors.first.uncertaintyUs,
|
|
762
|
-
referenceMaximumSampleDurationUs: referenceTiming.presentation.maximumSampleDurationUs,
|
|
763
|
-
subjectEndUncertaintyUs: verified.timing.clockAnchors.end.uncertaintyUs,
|
|
764
|
-
subjectFirstUncertaintyUs: verified.timing.clockAnchors.first.uncertaintyUs,
|
|
765
|
-
subjectMaximumSampleDurationUs: verified.timing.presentation.maximumSampleDurationUs,
|
|
766
|
-
});
|
|
767
|
-
if (durationDriftPpm === null || tolerances === null) {
|
|
768
|
-
throw new CliError(
|
|
769
|
-
"invalid-data",
|
|
770
|
-
`Capture synchronization policy for ${emitted.role} in ${path} exceeds the supported range.`,
|
|
771
|
-
);
|
|
772
|
-
}
|
|
773
|
-
const {
|
|
774
|
-
durationDriftUs: durationDriftToleranceUs,
|
|
775
|
-
onsetSkewUs: onsetToleranceUs,
|
|
776
|
-
} = tolerances;
|
|
777
|
-
const status = Math.abs(onsetSkewUs) > onsetToleranceUs
|
|
778
|
-
|| Math.abs(durationDriftUs) > durationDriftToleranceUs
|
|
779
|
-
? "out-of-tolerance"
|
|
780
|
-
: "within-tolerance";
|
|
781
|
-
if (startUs !== rawStartUs || endUs !== rawEndUs) {
|
|
782
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, {
|
|
783
|
-
code: "capture-track-trimmed-to-segment",
|
|
784
|
-
level: "warning",
|
|
785
|
-
message: `Trimmed ${emitted.role} in ${path} to segment ${segment.index}'s native-clock range.`,
|
|
786
|
-
}, startUs);
|
|
787
|
-
}
|
|
788
|
-
if (Math.abs(onsetSkewUs) > onsetToleranceUs) {
|
|
789
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, {
|
|
790
|
-
code: "capture-track-onset-skew",
|
|
791
|
-
level: "warning",
|
|
792
|
-
message: `${emitted.role} in ${path} starts ${onsetSkewUs} microseconds from the primary-display reference.`,
|
|
793
|
-
}, startUs);
|
|
794
|
-
}
|
|
795
|
-
if (Math.abs(durationDriftUs) > durationDriftToleranceUs) {
|
|
796
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, {
|
|
797
|
-
code: "capture-track-duration-drift",
|
|
798
|
-
level: "warning",
|
|
799
|
-
message: `${emitted.role} in ${path} drifted ${durationDriftUs} microseconds against the native capture clock.`,
|
|
800
|
-
}, endUs);
|
|
801
|
-
}
|
|
802
|
-
const mediaSegment = MediaSegmentSchema.parse({
|
|
803
|
-
codec: verified.codec,
|
|
804
|
-
container,
|
|
805
|
-
containerTrackIdentity: verified.containerTrackIdentity,
|
|
806
|
-
endUs,
|
|
807
|
-
fileRange: {
|
|
808
|
-
endUs: fileRangeEndUs,
|
|
809
|
-
startUs: fileRangeStartUs,
|
|
810
|
-
},
|
|
811
|
-
integrity: fileIntegrity,
|
|
812
|
-
path,
|
|
813
|
-
segmentId: opaque("segment", `${trackId}:${segment.index}:${path}:${verified.streamIndex}`),
|
|
814
|
-
startUs,
|
|
815
|
-
streamIndex: verified.streamIndex,
|
|
816
|
-
timing: {
|
|
817
|
-
captureSegmentIndex: segment.index,
|
|
818
|
-
durationDriftPpm,
|
|
819
|
-
durationDriftUs,
|
|
820
|
-
evidence: {
|
|
821
|
-
file: {
|
|
822
|
-
containerOriginPtsUs: fileOriginPtsUs,
|
|
823
|
-
endPtsUs: verified.filePresentation.endPtsUs,
|
|
824
|
-
firstPtsUs: verified.filePresentation.firstPtsUs,
|
|
825
|
-
spanToleranceUs: verified.filePresentation.spanToleranceUs,
|
|
826
|
-
tickUs: verified.filePresentation.tickUs,
|
|
827
|
-
},
|
|
828
|
-
helper: {
|
|
829
|
-
...verified.timing,
|
|
830
|
-
containerDurationUs,
|
|
831
|
-
},
|
|
832
|
-
},
|
|
833
|
-
kind: "measured",
|
|
834
|
-
nativeRange: {
|
|
835
|
-
endUs: helperNativeEndUs,
|
|
836
|
-
startUs: helperNativeStartUs,
|
|
837
|
-
},
|
|
838
|
-
onsetSkewUs,
|
|
839
|
-
policy: "capture-sync-v1",
|
|
840
|
-
presentation: {
|
|
841
|
-
endPtsUs: verified.timing.presentation.endPtsUs,
|
|
842
|
-
firstPtsUs: verified.timing.presentation.firstPtsUs,
|
|
843
|
-
lastPtsUs: verified.timing.presentation.lastPtsUs,
|
|
844
|
-
},
|
|
845
|
-
referenceTrackId,
|
|
846
|
-
status,
|
|
847
|
-
tolerance: {
|
|
848
|
-
durationDriftUs: durationDriftToleranceUs,
|
|
849
|
-
onsetSkewUs: onsetToleranceUs,
|
|
850
|
-
},
|
|
851
|
-
},
|
|
852
|
-
});
|
|
853
|
-
const priorIndex = tracks.findIndex(({ trackId: candidate }) => candidate === trackId);
|
|
854
|
-
if (priorIndex === -1) {
|
|
855
|
-
tracks.push(LogicalTrackSchema.parse({
|
|
856
|
-
enabled: true,
|
|
857
|
-
kind: emitted.role,
|
|
858
|
-
label: emitted.role === "display-video" || emitted.role === "camera-video" ? label : emitted.role,
|
|
859
|
-
segments: [mediaSegment],
|
|
860
|
-
source: trackSource,
|
|
861
|
-
trackId,
|
|
862
|
-
}));
|
|
863
|
-
} else {
|
|
864
|
-
const prior = tracks[priorIndex]!;
|
|
865
|
-
tracks[priorIndex] = { ...prior, segments: [...prior.segments, mediaSegment] };
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
};
|
|
869
|
-
|
|
870
|
-
const sources = mergeSources(this.#manifest.sources, segment.sources);
|
|
871
|
-
for (const display of [...segment.displays].sort((left, right) => Number(right.display.isPrimary) - Number(left.display.isPrimary))) {
|
|
872
|
-
const inventory = sources.displays.find(({ displayId }) => displayId === display.display.displayId);
|
|
873
|
-
await addMedia(
|
|
874
|
-
display.path,
|
|
875
|
-
display.container,
|
|
876
|
-
display.streams,
|
|
877
|
-
{ displayId: display.display.displayId },
|
|
878
|
-
inventory?.label ?? display.display.displayId,
|
|
879
|
-
display.containerDurationUs,
|
|
880
|
-
"display",
|
|
881
|
-
);
|
|
882
|
-
}
|
|
883
|
-
if (segment.camera.availability === "recorded") {
|
|
884
|
-
const cameraRecording = segment.camera;
|
|
885
|
-
const camera = sources.cameras.find(({ cameraId }) => cameraId === cameraRecording.deviceId);
|
|
886
|
-
await addMedia(
|
|
887
|
-
cameraRecording.path,
|
|
888
|
-
cameraRecording.container,
|
|
889
|
-
cameraRecording.streams,
|
|
890
|
-
{ cameraId: cameraRecording.deviceId },
|
|
891
|
-
camera?.label ?? cameraRecording.label,
|
|
892
|
-
cameraRecording.containerDurationUs,
|
|
893
|
-
"camera",
|
|
894
|
-
);
|
|
895
|
-
} else if (segment.camera.reason !== "disabled") {
|
|
896
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, {
|
|
897
|
-
code: `camera-${segment.camera.reason}`,
|
|
898
|
-
level: segment.camera.reason.endsWith("failed") ? "error" : "warning",
|
|
899
|
-
message: `Camera was unavailable for segment ${segment.index}: ${segment.camera.reason}.`,
|
|
900
|
-
}, segmentEndUs);
|
|
901
|
-
}
|
|
902
|
-
if (segment.microphone.availability === "recorded") {
|
|
903
|
-
const microphoneRecording = segment.microphone;
|
|
904
|
-
const microphone = sources.audio.find(({ kind }) => kind === "microphone");
|
|
905
|
-
await addMedia(
|
|
906
|
-
microphoneRecording.path,
|
|
907
|
-
microphoneRecording.container,
|
|
908
|
-
microphoneRecording.streams,
|
|
909
|
-
null,
|
|
910
|
-
microphone?.label ?? microphoneRecording.label,
|
|
911
|
-
microphoneRecording.containerDurationUs,
|
|
912
|
-
"microphone",
|
|
913
|
-
);
|
|
914
|
-
} else if (segment.microphone.reason !== "disabled") {
|
|
915
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, {
|
|
916
|
-
code: `microphone-${segment.microphone.reason}`,
|
|
917
|
-
level: segment.microphone.reason.endsWith("failed") ? "error" : "warning",
|
|
918
|
-
message: `Microphone was unavailable for segment ${segment.index}: ${segment.microphone.reason}.`,
|
|
919
|
-
}, segmentEndUs);
|
|
920
|
-
}
|
|
921
|
-
for (const metadata of segment.metadata) {
|
|
922
|
-
const absolute = await resolveSafePath(this.#bundleRoot, metadata.path);
|
|
923
|
-
eventStreams.push(EventStreamReferenceSchema.parse({
|
|
924
|
-
endUs: segmentEndUs,
|
|
925
|
-
eventKinds: metadata.eventKinds,
|
|
926
|
-
eventStreamId: opaque("event", `${segment.index}:${metadata.path}`),
|
|
927
|
-
integrity: await integrity(absolute),
|
|
928
|
-
path: metadata.path,
|
|
929
|
-
recordCount: metadata.recordCount,
|
|
930
|
-
startUs: segmentStartUs,
|
|
931
|
-
}));
|
|
932
|
-
if (metadata.droppedEvents > 0) {
|
|
933
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, {
|
|
934
|
-
code: "metadata-dropped-events",
|
|
935
|
-
level: "warning",
|
|
936
|
-
message: `${metadata.droppedEvents} metadata event(s) were dropped in ${metadata.path}.`,
|
|
937
|
-
}, segmentEndUs);
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
for (const diagnostic of segment.diagnostics) {
|
|
941
|
-
diagnostics = appendDiagnostic({ ...this.#manifest, diagnostics }, {
|
|
942
|
-
code: diagnostic.code,
|
|
943
|
-
level: diagnostic.recoverable ? "warning" : "error",
|
|
944
|
-
message: `${diagnostic.source}: ${diagnostic.message}`,
|
|
945
|
-
}, segmentEndUs);
|
|
946
|
-
}
|
|
947
|
-
if (interruption !== null) {
|
|
948
|
-
diagnostics = appendDiagnostic(
|
|
949
|
-
{ ...this.#manifest, diagnostics },
|
|
950
|
-
captureInterruptionDiagnostic(interruption),
|
|
951
|
-
interruption.sourceTimeUs,
|
|
952
|
-
);
|
|
953
|
-
}
|
|
954
|
-
const nextManifest = RecordingManifestV3Schema.parse({
|
|
955
|
-
...this.#manifest,
|
|
956
|
-
diagnostics,
|
|
957
|
-
eventStreams,
|
|
958
|
-
interruptions: interruption === null
|
|
959
|
-
? this.#manifest.interruptions
|
|
960
|
-
: [...this.#manifest.interruptions, interruption],
|
|
961
|
-
sources,
|
|
962
|
-
timeline: {
|
|
963
|
-
durationUs: Math.max(this.#manifest.timeline.durationUs, segmentEndUs),
|
|
964
|
-
nativeClock: {
|
|
965
|
-
kind: "mach-continuous-microseconds",
|
|
966
|
-
segments: [
|
|
967
|
-
...this.#manifest.timeline.nativeClock.segments,
|
|
968
|
-
{
|
|
969
|
-
index: segment.index,
|
|
970
|
-
nativeRange: {
|
|
971
|
-
endUs: segment.clock.end.nativeTimeUs,
|
|
972
|
-
startUs: segment.clock.start.nativeTimeUs,
|
|
973
|
-
},
|
|
974
|
-
sourceRange: {
|
|
975
|
-
endUs: segmentEndUs,
|
|
976
|
-
startUs: segmentStartUs,
|
|
977
|
-
},
|
|
978
|
-
},
|
|
979
|
-
],
|
|
980
|
-
},
|
|
981
|
-
timebase: "microseconds",
|
|
982
|
-
},
|
|
983
|
-
tracks,
|
|
984
|
-
updatedAt: this.#now().toISOString(),
|
|
985
|
-
});
|
|
986
|
-
await saveRecordingManifest(this.#fileSystem, nextManifest);
|
|
987
|
-
this.#manifest = nextManifest;
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
async recordFailedSegmentCompletion(
|
|
991
|
-
segment: SegmentCompletion,
|
|
992
|
-
interruption: CaptureInterruption | null,
|
|
993
|
-
failure: unknown,
|
|
994
|
-
): Promise<void> {
|
|
995
|
-
const clockSegment = {
|
|
996
|
-
index: segment.index,
|
|
997
|
-
nativeRange: {
|
|
998
|
-
endUs: segment.clock.end.nativeTimeUs,
|
|
999
|
-
startUs: segment.clock.start.nativeTimeUs,
|
|
1000
|
-
},
|
|
1001
|
-
sourceRange: {
|
|
1002
|
-
endUs: segment.clock.end.sourceTimeUs,
|
|
1003
|
-
startUs: segment.clock.start.sourceTimeUs,
|
|
1004
|
-
},
|
|
1005
|
-
} as const;
|
|
1006
|
-
const existingClock = this.#manifest.timeline.nativeClock.segments.find(
|
|
1007
|
-
candidate => candidate.index === segment.index,
|
|
1008
|
-
);
|
|
1009
|
-
if (
|
|
1010
|
-
existingClock !== undefined
|
|
1011
|
-
&& !sameCaptureClockSegment(existingClock, clockSegment)
|
|
1012
|
-
) {
|
|
1013
|
-
throw new CliError(
|
|
1014
|
-
"invalid-data",
|
|
1015
|
-
`Capture segment ${segment.index} emitted conflicting completion-clock evidence.`,
|
|
1016
|
-
);
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
const existingInterruption = interruption === null
|
|
1020
|
-
? undefined
|
|
1021
|
-
: this.#manifest.interruptions.find(
|
|
1022
|
-
candidate => candidate.segmentIndex === interruption.segmentIndex,
|
|
1023
|
-
);
|
|
1024
|
-
if (
|
|
1025
|
-
interruption !== null
|
|
1026
|
-
&& existingInterruption !== undefined
|
|
1027
|
-
&& !sameCaptureInterruption(existingInterruption, interruption)
|
|
1028
|
-
) {
|
|
1029
|
-
throw new CliError(
|
|
1030
|
-
"invalid-data",
|
|
1031
|
-
`Capture segment ${interruption.segmentIndex} emitted conflicting interruption evidence.`,
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
let diagnostics = this.#manifest.diagnostics;
|
|
1036
|
-
if (interruption !== null && existingInterruption === undefined) {
|
|
1037
|
-
diagnostics = appendDiagnostic(
|
|
1038
|
-
{ ...this.#manifest, diagnostics },
|
|
1039
|
-
captureInterruptionDiagnostic(interruption),
|
|
1040
|
-
interruption.sourceTimeUs,
|
|
1041
|
-
);
|
|
1042
|
-
}
|
|
1043
|
-
const publicationFailure = failedSegmentPublicationDiagnostic(segment, failure);
|
|
1044
|
-
const publicationFailureExists = diagnostics.some(({ code, level, message }) =>
|
|
1045
|
-
code === publicationFailure.code
|
|
1046
|
-
&& level === publicationFailure.level
|
|
1047
|
-
&& message === publicationFailure.message
|
|
1048
|
-
);
|
|
1049
|
-
if (!publicationFailureExists) {
|
|
1050
|
-
diagnostics = appendDiagnostic(
|
|
1051
|
-
{ ...this.#manifest, diagnostics },
|
|
1052
|
-
publicationFailure,
|
|
1053
|
-
segment.clock.end.sourceTimeUs,
|
|
1054
|
-
);
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
const nextManifest = RecordingManifestV3Schema.parse({
|
|
1058
|
-
...this.#manifest,
|
|
1059
|
-
diagnostics,
|
|
1060
|
-
interruptions: interruption !== null && existingInterruption === undefined
|
|
1061
|
-
? [...this.#manifest.interruptions, interruption]
|
|
1062
|
-
: this.#manifest.interruptions,
|
|
1063
|
-
sources: mergeSources(this.#manifest.sources, segment.sources),
|
|
1064
|
-
state: "failed",
|
|
1065
|
-
timeline: {
|
|
1066
|
-
...this.#manifest.timeline,
|
|
1067
|
-
durationUs: Math.max(
|
|
1068
|
-
this.#manifest.timeline.durationUs,
|
|
1069
|
-
segment.clock.end.sourceTimeUs,
|
|
1070
|
-
),
|
|
1071
|
-
nativeClock: {
|
|
1072
|
-
...this.#manifest.timeline.nativeClock,
|
|
1073
|
-
segments: existingClock === undefined
|
|
1074
|
-
? [...this.#manifest.timeline.nativeClock.segments, clockSegment]
|
|
1075
|
-
: this.#manifest.timeline.nativeClock.segments,
|
|
1076
|
-
},
|
|
1077
|
-
},
|
|
1078
|
-
updatedAt: this.#now().toISOString(),
|
|
1079
|
-
});
|
|
1080
|
-
await saveRecordingManifest(this.#fileSystem, nextManifest);
|
|
1081
|
-
this.#manifest = nextManifest;
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
async recordFailureInterruption(
|
|
1085
|
-
interruption: CaptureInterruption,
|
|
1086
|
-
): Promise<void> {
|
|
1087
|
-
const existing = this.#manifest.interruptions.find(
|
|
1088
|
-
candidate => candidate.segmentIndex === interruption.segmentIndex,
|
|
1089
|
-
);
|
|
1090
|
-
if (
|
|
1091
|
-
existing !== undefined
|
|
1092
|
-
&& !sameCaptureInterruption(existing, interruption)
|
|
1093
|
-
) {
|
|
1094
|
-
throw new CliError(
|
|
1095
|
-
"invalid-data",
|
|
1096
|
-
`Capture segment ${interruption.segmentIndex} emitted conflicting interruption evidence.`,
|
|
1097
|
-
);
|
|
1098
|
-
}
|
|
1099
|
-
const diagnostics = existing === undefined
|
|
1100
|
-
? appendDiagnostic(
|
|
1101
|
-
this.#manifest,
|
|
1102
|
-
captureInterruptionDiagnostic(interruption),
|
|
1103
|
-
interruption.sourceTimeUs,
|
|
1104
|
-
)
|
|
1105
|
-
: this.#manifest.diagnostics;
|
|
1106
|
-
this.#manifest = RecordingManifestV3Schema.parse({
|
|
1107
|
-
...this.#manifest,
|
|
1108
|
-
diagnostics,
|
|
1109
|
-
interruptions: existing === undefined
|
|
1110
|
-
? [...this.#manifest.interruptions, interruption]
|
|
1111
|
-
: this.#manifest.interruptions,
|
|
1112
|
-
state: "failed",
|
|
1113
|
-
updatedAt: this.#now().toISOString(),
|
|
1114
|
-
});
|
|
1115
|
-
await saveRecordingManifest(this.#fileSystem, this.#manifest);
|
|
1116
|
-
}
|
|
1117
|
-
}
|