crew-runner 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { openStudio, type StudioGrant } from "./studio.ts";
5
+
6
+ export const MEDIA_TOOLS = ["probe", "capabilities", "render", "preview", "cancel"] as const;
7
+ export async function createStudioMcp(grant: StudioGrant) {
8
+ const studio = await openStudio(grant);
9
+ const server = new McpServer({ name: "crew-studio", version: "0.1.0" });
10
+ let current: AbortController | undefined;
11
+ const text = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
12
+ const span = z.object({ start: z.number(), end: z.number() }).strict();
13
+ const timedText = span.extend({ text: z.string() }).strict();
14
+ const plan = z.object({ source: z.string(), cuts: z.array(span).max(12), captions: z.array(timedText).max(24), callouts: z.array(timedText).max(24), supportingAsset: span.extend({ id: z.string() }).strict().optional() }).strict();
15
+ server.registerTool("probe", { description: "Inspect a project-relative MP4. No file contents or network access.", inputSchema: { path: z.string() } }, async ({ path }) => text(await studio.probe(path)));
16
+ server.registerTool("capabilities", { description: "Report installed CPU encoder and unverified advertised hardware encoders.", inputSchema: {} }, async () => text(await studio.capabilities()));
17
+ for (const name of ["render", "preview"] as const) {
18
+ server.registerTool(name, { description: "Versioned local MP4 with original cuts/audio, timed captions and animated branded callouts. Never uploads.", inputSchema: { plan } }, async ({ plan: edit }, extra) => {
19
+ if (current) throw new Error("Studio busy");
20
+ const controller = new AbortController(); current = controller;
21
+ const cancel = () => controller.abort();
22
+ extra.signal.addEventListener("abort", cancel, { once: true });
23
+ if (extra.signal.aborted) cancel();
24
+ try { return text(await studio.render(edit, { preview: name === "preview", signal: controller.signal })); }
25
+ finally { extra.signal.removeEventListener("abort", cancel); current = undefined; }
26
+ });
27
+ }
28
+ server.registerTool("cancel", { description: "Cancel only this local studio's active render.", inputSchema: {} }, async () => { current?.abort(); return text({ cancellationRequested: !!current }); });
29
+ return { server, close: async () => { current?.abort(); await server.close(); } };
30
+ }
31
+ export async function serveStudio(grant: StudioGrant): Promise<void> {
32
+ const bridge = await createStudioMcp(grant);
33
+ const stop = () => { void bridge.close(); };
34
+ process.once("SIGINT", stop); process.once("SIGTERM", stop);
35
+ process.stdin.once("end", stop);
36
+ const timer = setTimeout(stop, Math.max(1, grant.expiresAt - Date.now())); timer.unref();
37
+ await bridge.server.connect(new StdioServerTransport());
38
+ }
@@ -0,0 +1,77 @@
1
+ // Source-space contract only. Execution stays in the existing Studio owner.
2
+ export type SourceSpan = { start: number; end: number };
3
+ export type SourcePlan = {
4
+ version: 2; source: string; sourceDuration: number;
5
+ remove: SourceSpan[]; mute: SourceSpan[];
6
+ holds: (SourceSpan & { frame: number })[];
7
+ crop?: { x: number; y: number; width: number; height: number };
8
+ // One existing project MP4 chosen by the member, prepended whole. Never generated.
9
+ intro?: string;
10
+ };
11
+ export type SourceSegment = SourceSpan & { outputStart: number; mute: boolean; frame?: number };
12
+ export const SOURCE_FPS = 30;
13
+ export const SOURCE_SECONDS = 5400;
14
+ export const SOURCE_BYTES = 2 * 1024 ** 3;
15
+ // Every other media file the engine reads (an intro, a supporting asset).
16
+ export const MEDIA_BYTES = 100 * 1024 * 1024;
17
+ export const EXPORT_BYTES = 512 * 1024 ** 2;
18
+ function fields(v: unknown, keys: string[], required = keys): Record<string, unknown> {
19
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("Invalid source plan object");
20
+ const o = v as Record<string, unknown>;
21
+ if (Object.keys(o).some((k) => !keys.includes(k)) || required.some((k) => !(k in o))) throw new Error("Unknown or missing source plan field");
22
+ return o;
23
+ }
24
+ function time(v: unknown): number {
25
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > SOURCE_SECONDS || Math.abs(v * SOURCE_FPS - Math.round(v * SOURCE_FPS)) > 0.000001) throw new Error("Source times must lie on the 30fps output grid within 90 minutes");
26
+ return v;
27
+ }
28
+ export function validateSourcePlan(value: unknown, mediaName: (v: unknown) => string): SourcePlan {
29
+ const o = fields(value, ["version", "source", "sourceDuration", "remove", "mute", "holds", "crop", "intro"], ["version", "source", "sourceDuration", "remove", "mute", "holds"]);
30
+ if (o.version !== 2) throw new Error("Unsupported plan version");
31
+ const duration = time(o.sourceDuration);
32
+ if (!duration) throw new Error("Empty source duration");
33
+ const spans = (value: unknown, hold = false) => {
34
+ if (!Array.isArray(value) || value.length > 32) throw new Error("Source edit entry limit");
35
+ let previous = 0;
36
+ return value.map((entry) => {
37
+ const s = fields(entry, hold ? ["start", "end", "frame"] : ["start", "end"]);
38
+ const start = time(s.start), end = time(s.end);
39
+ if (end <= start || start < previous || end > duration) throw new Error("Invalid, overlapping or unsorted source interval");
40
+ previous = end;
41
+ if (!hold) return { start, end };
42
+ const frame = time(s.frame);
43
+ if (frame >= duration) throw new Error("Hold frame outside source");
44
+ return { start, end, frame };
45
+ });
46
+ };
47
+ const remove = spans(o.remove), mute = spans(o.mute), holds = spans(o.holds, true) as SourcePlan["holds"];
48
+ if (remove.reduce((n, s) => n + s.end - s.start, 0) >= duration) throw new Error("Empty output timeline");
49
+ let crop: SourcePlan["crop"];
50
+ if (o.crop !== undefined) {
51
+ const c = fields(o.crop, ["x", "y", "width", "height"]);
52
+ for (const v of Object.values(c)) if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0 || v > 3840 || v % 2) throw new Error("Crop requires bounded even source pixels");
53
+ crop = { x: c.x as number, y: c.y as number, width: c.width as number, height: c.height as number };
54
+ if (!crop!.width || !crop!.height) throw new Error("Empty crop");
55
+ }
56
+ const plan: SourcePlan = { version: 2, source: mediaName(o.source), sourceDuration: duration, remove, mute, holds, ...(crop ? { crop } : {}), ...(o.intro !== undefined ? { intro: mediaName(o.intro) } : {}) };
57
+ compileSourcePlan(plan); // admission includes segment count, before any media work
58
+ return plan;
59
+ }
60
+ // introFrames: whole 30fps output frames of a prepended intro; source mapping is unchanged.
61
+ export function compileSourcePlan(p: SourcePlan, introFrames = 0): SourceSegment[] {
62
+ const boundaries = [...new Set([0, p.sourceDuration, ...[...p.remove, ...p.mute, ...p.holds].flatMap((s) => [s.start, s.end])])].sort((a,b) => a-b);
63
+ const result: SourceSegment[] = [];
64
+ let outputFrames = introFrames;
65
+ for (let i=0; i<boundaries.length-1; i++) {
66
+ const start = boundaries[i]!, end = boundaries[i+1]!;
67
+ if (p.remove.some((s) => s.start <= start && s.end >= end)) continue;
68
+ const hold = p.holds.find((s) => s.start <= start && s.end >= end);
69
+ for (let from = start; from < end; from += 30) {
70
+ const to = Math.min(end, from+30);
71
+ result.push({ start: from, end: to, outputStart: outputFrames/30, mute: p.mute.some((s) => s.start <= from && s.end >= to), ...(hold ? { frame: hold.frame } : {}) });
72
+ outputFrames += Math.round((to-from)*30);
73
+ }
74
+ }
75
+ if (result.length > 384) throw new Error("Source plan segment budget exceeded");
76
+ return result;
77
+ }
package/src/studio.ts ADDED
@@ -0,0 +1,402 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, realpath, rm, writeFile, statfs } from "node:fs/promises";
4
+ import { isAbsolute, join, resolve, relative } from "node:path";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { killTree } from "./spawn.ts";
7
+ import { sourceAudioAdmission, AUDIO_CLOCK_HZ, AUDIO_SCAN_BYTES } from "./studio-audio-admission.ts";
8
+ import { validateSourcePlan, compileSourcePlan, SOURCE_SECONDS, SOURCE_BYTES, EXPORT_BYTES, MEDIA_BYTES, type SourcePlan } from "./studio-source.ts";
9
+
10
+ export type StudioGrant = Readonly<{ root: string; enabled: boolean; expiresAt: number }>;
11
+ // The local CLI and MCP bridge always use LOCAL_LIMITS. A hosted edit runs alone on a disposable machine that is
12
+ // destroyed after the job, so it may pass larger limits, never beyond HOSTED_MAX_JOB_MS or the 512 MiB export cap.
13
+ export type StudioLimits = Readonly<{ jobMs: number; unitMs: number; threads: number; height: 720 | 1080; intermediateBytes: number; exportBytes: number }>;
14
+ export const LOCAL_LIMITS: StudioLimits = Object.freeze({ jobMs: 900_000, unitMs: 90_000, threads: 2, height: 720, intermediateBytes: EXPORT_BYTES, exportBytes: EXPORT_BYTES });
15
+ export const HOSTED_MAX_JOB_MS = 2 * 3_600_000;
16
+ const int = (v: unknown, min: number, max: number) => typeof v === "number" && Number.isSafeInteger(v) && v >= min && v <= max;
17
+ function checkLimits(l: StudioLimits): StudioLimits {
18
+ if (!int(l.jobMs, 60_000, HOSTED_MAX_JOB_MS) || !int(l.unitMs, 10_000, 600_000) || !int(l.threads, 1, 8) || (l.height !== 720 && l.height !== 1080)
19
+ || !int(l.intermediateBytes, 1, 4 * 1024 ** 3) || !int(l.exportBytes, 1, EXPORT_BYTES)) throw new Error("Invalid studio limits");
20
+ return Object.freeze({ jobMs: l.jobMs, unitMs: l.unitMs, threads: l.threads, height: l.height, intermediateBytes: l.intermediateBytes, exportBytes: l.exportBytes });
21
+ }
22
+ type Span = { start: number; end: number };
23
+ type TextSpan = Span & { text: string };
24
+ export type EditPlan = { source: string; cuts: Span[]; captions: TextSpan[]; callouts: TextSpan[]; supportingAsset?: Span & { id: string } };
25
+ type Media = { duration: number; videoDuration: number; width: number; height: number; hasAudio: boolean; fps: number };
26
+ type Asset = { id: string; path: string; origin: "higgsfield" | "local"; reference: string; sha256: string; verifiedByProvider: false; charged: false };
27
+ const MAX_BYTES = MEDIA_BYTES;
28
+ const MAX_SECONDS = 120;
29
+ const TIMEOUT = 90_000;
30
+ const BASE = ".crew-studio";
31
+
32
+ // maxMs is set only by openStudio from checked limits: one hour, or a hosted job's longer deadline.
33
+ export function assertGrant(g: StudioGrant & { maxMs?: number }): void {
34
+ if (g.enabled !== true) throw new Error("Studio disabled: explicit local opt-in required");
35
+ if (!Number.isFinite(g.expiresAt) || g.expiresAt <= Date.now() || g.expiresAt > Date.now() + (g.maxMs ?? 3_600_000)) throw new Error("Studio consent expired or exceeds its allowed window");
36
+ }
37
+ function objectFields(v: unknown, keys: string[], required = keys): Record<string, unknown> {
38
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("Invalid object");
39
+ const o = v as Record<string, unknown>;
40
+ if (Object.keys(o).some((k) => !keys.includes(k)) || required.some((k) => !(k in o))) throw new Error("Unknown or missing field");
41
+ return o;
42
+ }
43
+ function number(v: unknown): number {
44
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > MAX_SECONDS) throw new Error("Invalid time");
45
+ return v;
46
+ }
47
+ function span(v: unknown, text = false): Span | TextSpan {
48
+ const o = objectFields(v, text ? ["start", "end", "text"] : ["start", "end"]);
49
+ const start = number(o.start), end = number(o.end);
50
+ if (end <= start || end - start < 0.1) throw new Error("Invalid span");
51
+ if (!text) return { start, end };
52
+ if (typeof o.text !== "string" || !/^[\p{L}\p{N} .,!?/:+&()%-]{1,80}$/u.test(o.text)) throw new Error("Use plain caption/callout text (80 characters maximum)");
53
+ return { start, end, text: o.text };
54
+ }
55
+ function mediaName(v: unknown): string {
56
+ if (typeof v !== "string" || isAbsolute(v) || !/^[a-zA-Z0-9_./ -]+\.mp4$/.test(v) || v.split("/").some((s) => !s || s === "." || s === "..") || v.length > 240) throw new Error("Only project-relative MP4 media paths allowed");
57
+ return v;
58
+ }
59
+ export function validatePlan(v: unknown): EditPlan | SourcePlan {
60
+ if (v && typeof v === "object" && "version" in v) return validateSourcePlan(v, mediaName);
61
+ const o = objectFields(v, ["source", "cuts", "captions", "callouts", "supportingAsset"], ["source", "cuts", "captions", "callouts"]);
62
+ const list = (x: unknown, max: number) => { if (!Array.isArray(x) || x.length > max) throw new Error("Too many edit entries"); return x; };
63
+ const cuts = list(o.cuts, 12).map((c) => span(c) as Span);
64
+ if (!cuts.length) throw new Error("At least one original cut required");
65
+ let total = cuts.reduce((n, c) => n + c.end - c.start, 0);
66
+ let supportingAsset: EditPlan["supportingAsset"];
67
+ if (o.supportingAsset !== undefined) {
68
+ const a = objectFields(o.supportingAsset, ["id", "start", "end"]);
69
+ if (typeof a.id !== "string" || !/^[a-f0-9-]{36}$/.test(a.id)) throw new Error("Invalid asset ID");
70
+ supportingAsset = { ...span({ start: a.start, end: a.end }), id: a.id };
71
+ total += supportingAsset.end - supportingAsset.start;
72
+ }
73
+ if (total > MAX_SECONDS) throw new Error("Timeline exceeds 120 seconds");
74
+ const texts = (v: unknown) => list(v, 24).map((x) => { const s = span(x, true) as TextSpan; if (s.end > total) throw new Error("Text exceeds timeline"); return s; });
75
+ return { source: mediaName(o.source), cuts, captions: texts(o.captions), callouts: texts(o.callouts), ...(supportingAsset ? { supportingAsset } : {}) };
76
+ }
77
+
78
+ // All executable names, filters, codecs and resource arguments are owned here, never by the model.
79
+ async function command(bin: "ffmpeg" | "ffprobe", args: string[], cwd: string, g: StudioGrant, signal?: AbortSignal, timeout = TIMEOUT, stderrLine?: (line: string) => void): Promise<string> {
80
+ assertGrant(g);
81
+ if (signal?.aborted) throw new Error("Studio cancelled");
82
+ return new Promise((resolvePromise, reject) => {
83
+ // Inherit the adapter/bridge group so an abrupt owner shutdown cannot orphan an encoder.
84
+ // FFmpeg/ffprobe do not spawn children; killTree falls back to killing this owned PID.
85
+ const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"], detached: false, windowsHide: true });
86
+ let stdout = "", stderr = "", reason = "", pending = "", stderrBytes = 0;
87
+ let force: NodeJS.Timeout | undefined;
88
+ const stop = (why: string) => {
89
+ if (reason) return;
90
+ reason = why;
91
+ // Kill only this owned media process. Wait for close before deleting files.
92
+ killTree(child);
93
+ force = setTimeout(() => killTree(child), 500);
94
+ };
95
+ const abort = () => stop("Studio cancelled");
96
+ signal?.addEventListener("abort", abort, { once: true });
97
+ const timer = setTimeout(() => stop("Studio timeout or expired consent"), Math.min(timeout, g.expiresAt - Date.now()));
98
+ child.stdout.on("data", (b: Buffer) => { if (reason) return; stdout += b.toString(); if (stdout.length > 512_000) stop("Media output limit"); });
99
+ child.stderr.on("data", (b: Buffer) => {
100
+ if (reason) return;
101
+ stderr = (stderr + b.toString()).slice(-16_000);
102
+ if (!stderrLine) return;
103
+ stderrBytes += b.length;
104
+ if (stderrBytes > AUDIO_SCAN_BYTES) return stop("Source audio scan byte limit");
105
+ pending += b.toString();
106
+ try {
107
+ let newline: number;
108
+ while ((newline = pending.indexOf("\n")) >= 0) {
109
+ if (newline > 8192) throw new Error("Source audio scan line limit");
110
+ stderrLine(pending.slice(0, newline)); pending = pending.slice(newline + 1);
111
+ }
112
+ if (pending.length > 8192) throw new Error("Source audio scan line limit");
113
+ } catch (e) { stop((e as Error).message); }
114
+ });
115
+ child.on("error", () => { reason = `${bin} unavailable: install FFmpeg/ffprobe locally; no automatic download`; });
116
+ child.on("close", (code) => {
117
+ clearTimeout(timer); clearTimeout(force); signal?.removeEventListener("abort", abort);
118
+ if (!reason && stderrLine && pending) { try { stderrLine(pending); } catch (e) { reason = (e as Error).message; } }
119
+ if (reason || code !== 0) reject(new Error(reason || `Media process failed: ${stderr.slice(-4000)}`));
120
+ else resolvePromise(stdout);
121
+ });
122
+ });
123
+ }
124
+
125
+ export async function openStudio(input: StudioGrant, limitsInput: StudioLimits = LOCAL_LIMITS) {
126
+ // Copy authority once, never retain mutable caller/model-owned objects.
127
+ const limits = checkLimits(limitsInput);
128
+ const t = String(limits.threads);
129
+ // Source decode and the final encode may use the job deadline less five minutes (10 of the local 15).
130
+ const longMs = limits.jobMs - 300_000;
131
+ const g = Object.freeze({ root: input.root, enabled: input.enabled, expiresAt: input.expiresAt, maxMs: Math.max(3_600_000, limits.jobMs) });
132
+ assertGrant(g);
133
+ if (!isAbsolute(g.root) || resolve(g.root) !== g.root) throw new Error("Select a canonical absolute project root explicitly");
134
+ const canonical = await realpath(g.root);
135
+ if (canonical !== g.root || !(await lstat(g.root)).isDirectory()) throw new Error("Project root must not use symlinks");
136
+ const safe = async (name: string, directory = false, maxBytes = MAX_BYTES) => {
137
+ assertGrant(g);
138
+ if (isAbsolute(name) || name.split("/").some((s) => !s || s === "." || s === "..") || !/^[a-zA-Z0-9_./ -]+$/.test(name)) throw new Error("Unsafe project path");
139
+ if (await realpath(g.root) !== canonical) throw new Error("Project root changed");
140
+ let path = g.root;
141
+ for (const part of name.split("/")) {
142
+ path = join(path, part);
143
+ const info = await lstat(path);
144
+ if (info.isSymbolicLink()) throw new Error("Symlink denied");
145
+ }
146
+ const info = await lstat(path);
147
+ if (directory ? !info.isDirectory() : !info.isFile()) throw new Error("Not a regular project file/directory");
148
+ if (!directory && info.size > maxBytes) throw new Error("Media size limit");
149
+ return path;
150
+ };
151
+ const data = async (name: string): Promise<Buffer> => {
152
+ const path = await safe(name);
153
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
154
+ try {
155
+ const s = await file.stat();
156
+ if (!s.isFile() || s.size > MAX_BYTES) throw new Error("Media size limit");
157
+ const b = await file.readFile();
158
+ if (b.length > MAX_BYTES) throw new Error("Media size limit");
159
+ return b;
160
+ } finally { await file.close(); }
161
+ };
162
+ const privateDir = async () => {
163
+ await mkdir(join(g.root, BASE), { mode: 0o700 }).catch((e: NodeJS.ErrnoException) => { if (e.code !== "EEXIST") throw e; });
164
+ return safe(BASE, true);
165
+ };
166
+ const probeFile = async (path: string, signal?: AbortSignal, expected?: number, long = false, grant = g, expectedAudio = true): Promise<Media> => {
167
+ const maxBytes = long ? (expected === undefined ? SOURCE_BYTES : EXPORT_BYTES) : MAX_BYTES;
168
+ const maxSeconds = long ? SOURCE_SECONDS : MAX_SECONDS;
169
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
170
+ try {
171
+ const info = await file.stat();
172
+ if (!info.isFile() || info.size <= 0 || info.size > maxBytes) throw new Error("Media size limit");
173
+ const header = Buffer.alloc(12); await file.read(header, 0, 12, 0);
174
+ if (header.toString("ascii", 4, 8) !== "ftyp") throw new Error("Invalid MP4 media bytes");
175
+ } finally { await file.close(); }
176
+ const raw = await command("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov", "-show_entries", "format=duration,format_name:stream=codec_type,codec_name,profile,channels,width,height,duration,start_time,avg_frame_rate,sample_rate,nb_frames", "-of", "json", path], g.root, grant, signal);
177
+ type Stream = { codec_type: string; codec_name?: string; profile?: string; channels?: number; width?: number; height?: number; duration?: string; start_time?: string; avg_frame_rate?: string; sample_rate?: string; nb_frames?: string };
178
+ const parsed = JSON.parse(raw) as { format?: { duration?: string }; streams?: Stream[] };
179
+ const duration = Number(parsed.format?.duration);
180
+ const video = parsed.streams?.find((s) => s.codec_type === "video");
181
+ const audio = parsed.streams?.filter((s) => s.codec_type === "audio") ?? [];
182
+ if (!video?.width || !video.height || video.width > 3840 || video.height > 2160 || !Number.isFinite(duration) || duration <= 0 || (parsed.streams?.length ?? 0) > 8) throw new Error("Unsupported media metadata/size/duration");
183
+ const [num, den] = (video.avg_frame_rate ?? "").split("/").map(Number);
184
+ const fps = (num ?? NaN) / (den ?? NaN);
185
+ if (long && (!Number.isFinite(fps) || fps < 1 || fps > 120)) throw new Error("Unsupported source frame cadence (1-120 fps)");
186
+ // Never turn low/malformed source frame rates into an unbounded duration allowance.
187
+ const frame = Number.isFinite(fps) && fps >= 24 ? 1 / fps : 0;
188
+ const audioSlack = (s: Stream) => {
189
+ const rate = Number(s.sample_rate);
190
+ return Number.isFinite(rate) && rate >= 8000 ? Math.min(1024 / rate, 1024 / 48000) : 0;
191
+ };
192
+ const videoDuration = Number(video.duration);
193
+ if (!Number.isFinite(videoDuration) || videoDuration <= 0 || videoDuration > maxSeconds) throw new Error("Unsupported video duration");
194
+ for (const s of [video, ...audio]) {
195
+ const length = Number(s.duration), start = Number(s.start_time ?? 0);
196
+ const slack = s.codec_type === "video" ? frame : frame + 2 * audioSlack(s);
197
+ if (!Number.isFinite(length) || length <= 0 || !Number.isFinite(start) || Math.abs(start) > slack + 0.001 || length > maxSeconds + slack + 0.001) throw new Error("Unsupported stream duration");
198
+ if (expected !== undefined && Math.abs(length - expected) > slack + 0.001) throw new Error(`Incomplete media: ${path.split("/").at(-1)} ${s.codec_type} duration ${length} does not match timeline ${expected}`);
199
+ }
200
+ const muxSlack = frame + Math.max(0, ...audio.map(audioSlack));
201
+ const streamEnd = Math.max(...[video, ...audio].map((s) => Number(s.duration) + Number(s.start_time ?? 0)));
202
+ if (duration > maxSeconds + muxSlack + 0.001 || Math.abs(duration - streamEnd) > muxSlack + 0.001 || (expected !== undefined && audio.length !== (expectedAudio ? 1 : 0))) throw new Error("Incomplete media: unsupported container duration/completion");
203
+ if (long && expected !== undefined && (video.avg_frame_rate !== "30/1" || Number(video.nb_frames) !== Math.round(expected * 30))) throw new Error("Incomplete media: frame cadence/count mismatch");
204
+ // A valid header and probeable index do not establish that packets can actually decode.
205
+ const admission = long && expected === undefined && audio.length ? sourceAudioAdmission(audio) : undefined;
206
+ // This is the existing strict full decode, with bounded streamed metadata
207
+ // only. No async/resampling here: inspect the actual decoder clock before
208
+ // aresample can allocate gap-sized silence. Same input origin as final pass.
209
+ await command("ffmpeg", ["-hide_banner", "-loglevel", admission ? "info" : "error", "-nostats", "-nostdin", "-xerror", "-err_detect", "explode", "-threads", t, "-filter_threads", t, "-protocol_whitelist", "file", "-format_whitelist", "mov", "-i", path, "-map", "0:v:0", "-map", "0:a?", ...(admission ? ["-af", `asettb=1/${AUDIO_CLOCK_HZ},ashowinfo`] : []), "-f", "null", "-"], g.root, grant, signal, long ? longMs : TIMEOUT, admission?.line);
210
+ admission?.finish();
211
+ return { duration, videoDuration, width: video.width, height: video.height, hasAudio: audio.length > 0, fps };
212
+ };
213
+ const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex");
214
+ const probe = async (name: string) => probeFile(await safe(mediaName(name)));
215
+ const readPlan = async (name: string) => {
216
+ if (!name.endsWith(".json")) throw new Error("Select a JSON edit plan");
217
+ const b = await data(name); if (b.length > 64_000) throw new Error("Plan size limit");
218
+ return validatePlan(JSON.parse(b.toString("utf8")));
219
+ };
220
+ const importAsset = async (value: unknown): Promise<Asset> => {
221
+ const o = objectFields(value, ["path", "origin", "reference"]);
222
+ const path = mediaName(o.path);
223
+ if (o.origin !== "higgsfield" && o.origin !== "local") throw new Error("Unknown asset origin");
224
+ if (typeof o.reference !== "string" || !/^[a-zA-Z0-9 _.:/-]{1,160}$/.test(o.reference)) throw new Error("Include a plain provenance reference");
225
+ await probe(path);
226
+ const bytes = await data(path);
227
+ const asset: Asset = { id: randomUUID(), path, origin: o.origin, reference: o.reference, sha256: sha(bytes), verifiedByProvider: false, charged: false };
228
+ const dir = await privateDir();
229
+ await writeFile(join(dir, `asset-${asset.id}.json`), JSON.stringify(asset, null, 2), { flag: "wx", mode: 0o600 });
230
+ return asset;
231
+ };
232
+ const render = async (value: unknown, options: { signal?: AbortSignal; preview?: boolean } = {}) => {
233
+ assertGrant(g);
234
+ if (options.signal?.aborted) throw new Error("Studio cancelled");
235
+ const validated = validatePlan(value);
236
+ const sourcePlan = "version" in validated ? validated : undefined;
237
+ const mapping = sourcePlan ? compileSourcePlan(sourcePlan) : undefined;
238
+ const p: EditPlan = sourcePlan ? { source: sourcePlan.source, cuts: mapping!, captions: [], callouts: [] } : validated as EditPlan;
239
+ const jobGrant = Object.freeze({ ...g, expiresAt: Math.min(g.expiresAt, Date.now() + limits.jobMs) });
240
+ const budget = sourcePlan ? limits.intermediateBytes : MAX_BYTES, exportBudget = sourcePlan ? limits.exportBytes : MAX_BYTES;
241
+ const base = await privateDir();
242
+ const lock = join(base, "lock");
243
+ try { await mkdir(lock, { mode: 0o700 }); } catch { throw new Error("Studio busy: inspect the owned lock after a crash before removing it"); }
244
+ const job = `job-${randomUUID()}`;
245
+ const dir = join(base, job);
246
+ try {
247
+ await mkdir(dir, { mode: 0o700 });
248
+ // The member's selected intro: snapshot it like a supporting asset, then admit the
249
+ // copy with the same strict v2 decode and audio-clock rules as the source, before
250
+ // spending time on the long source. Only whole 30fps frames of its picture are used.
251
+ let intro: { path: string; sha256: string; frames: number; media: Media } | undefined;
252
+ if (sourcePlan?.intro) {
253
+ const bytes = await data(sourcePlan.intro);
254
+ const disk = await statfs(base);
255
+ if (disk.bavail * disk.bsize < bytes.length + 256 * 1024 ** 2) throw new Error("Insufficient local disk budget for the intro");
256
+ await writeFile(join(dir, "intro.mp4"), bytes, { flag: "wx", mode: 0o600 });
257
+ // Short-form metadata limits (<=120s) refuse a long intro before any full decode.
258
+ await probeFile(join(dir, "intro.mp4"), options.signal, undefined, false, jobGrant).catch((e: Error) => { throw new Error(`Intro must be an MP4 of at most 120 seconds: ${e.message}`); });
259
+ const media = await probeFile(join(dir, "intro.mp4"), options.signal, undefined, true, jobGrant);
260
+ const frames = Math.floor(media.videoDuration * 30 + 1e-6);
261
+ if (!frames) throw new Error("Intro has no whole output frame");
262
+ if (mapping!.reduce((n, s) => n + s.end - s.start, 0) + frames / 30 > SOURCE_SECONDS) throw new Error("Edited programme plus intro exceeds 90 minutes");
263
+ intro = { path: sourcePlan.intro, sha256: sha(bytes), frames, media };
264
+ }
265
+ // Snapshot in bounded chunks, hash exactly the bytes the encoder will read.
266
+ const sourcePath = await safe(p.source, false, sourcePlan ? SOURCE_BYTES : MAX_BYTES);
267
+ const input = await open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
268
+ let sourceHash: string;
269
+ let sourceMedia: Media | undefined;
270
+ try {
271
+ const info = await input.stat();
272
+ if (!info.isFile() || info.size > (sourcePlan ? SOURCE_BYTES : MAX_BYTES)) throw new Error("Media size limit");
273
+ const disk = await statfs(base);
274
+ if (disk.bavail * disk.bsize < info.size + budget + exportBudget + 256 * 1024 ** 2) throw new Error("Insufficient local disk budget for snapshot, intermediates and export");
275
+ if (sourcePlan) sourceMedia = await probeFile(sourcePath, options.signal, undefined, true, jobGrant);
276
+ const copy = await open(join(dir, "source.mp4"), "wx", 0o600);
277
+ const hash = createHash("sha256");
278
+ try {
279
+ const buffer = Buffer.alloc(1024 * 1024);
280
+ let total = 0;
281
+ for (;;) {
282
+ assertGrant(jobGrant);
283
+ if (options.signal?.aborted) throw new Error("Studio cancelled");
284
+ const { bytesRead } = await input.read(buffer, 0, buffer.length, null);
285
+ if (!bytesRead) break;
286
+ total += bytesRead;
287
+ if (total > info.size) throw new Error("Source changed during snapshot");
288
+ const chunk = buffer.subarray(0, bytesRead);
289
+ hash.update(chunk);
290
+ let written = 0;
291
+ while (written < bytesRead) written += (await copy.write(chunk, written, bytesRead-written)).bytesWritten;
292
+ }
293
+ const after = await input.stat();
294
+ const selected = await lstat(sourcePath);
295
+ if (selected.isSymbolicLink() || selected.dev !== info.dev || selected.ino !== info.ino || total !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) throw new Error("Source changed during admission/snapshot");
296
+ sourceHash = hash.digest("hex");
297
+ } finally { await copy.close(); }
298
+ } finally { await input.close(); }
299
+ sourceMedia ??= await probeFile(join(dir, "source.mp4"), options.signal, undefined, false, jobGrant);
300
+ if (sourcePlan && Math.abs(sourceMedia.duration-sourcePlan.sourceDuration) > 0.05) throw new Error("Declared source duration does not match selected recording");
301
+ if (sourcePlan?.crop && (sourcePlan.crop.x + sourcePlan.crop.width > sourceMedia.width || sourcePlan.crop.y + sourcePlan.crop.height > sourceMedia.height)) throw new Error("Crop outside source picture");
302
+ let asset: Asset | undefined;
303
+ if (p.supportingAsset) {
304
+ const b = await data(`${BASE}/asset-${p.supportingAsset.id}.json`);
305
+ asset = JSON.parse(b.toString()) as Asset;
306
+ const extra = await data(mediaName(asset.path));
307
+ if (sha(extra) !== asset.sha256) throw new Error("Imported asset changed: reimport explicitly");
308
+ await writeFile(join(dir, "asset.mp4"), extra, { flag: "wx" });
309
+ }
310
+ const introUnits = intro ? Array.from({ length: Math.ceil(intro.frames / 900) }, (_, i) => ({ start: i * 30, end: Math.min(intro!.frames, (i + 1) * 900) / 30, mute: false, frame: undefined, file: "intro.mp4", media: intro!.media, intro: true })) : [];
311
+ const segments = [...introUnits, ...p.cuts.map((cut, i) => ({ ...cut, mute: mapping?.[i]?.mute ?? false, frame: mapping?.[i]?.frame, file: "source.mp4", media: sourceMedia, intro: false }))];
312
+ if (segments.length > 384) throw new Error("Source plan segment budget exceeded");
313
+ if (p.supportingAsset) segments.push({ start: p.supportingAsset.start, end: p.supportingAsset.end, mute: false, frame: undefined, file: "asset.mp4", media: await probeFile(join(dir, "asset.mp4"), options.signal), intro: false });
314
+ const height = options.preview ? 360 : limits.height, width = height * 16 / 9;
315
+ let intermediateBytes = 0;
316
+ for (const [i, s] of segments.entries()) {
317
+ if (s.end > s.media.duration + 0.03) throw new Error("Cut exceeds original media duration");
318
+ const length = s.end - s.start;
319
+ const args = ["-hide_banner", "-loglevel", "error", "-nostdin", "-n", "-xerror", "-threads", t, "-filter_threads", t, "-protocol_whitelist", "file", "-format_whitelist", "mov", "-ss", String(s.frame ?? s.start), "-i", s.file];
320
+ let audioMap = "0:a:0";
321
+ if (!sourcePlan && (!s.media.hasAudio || s.mute)) { args.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"); audioMap = "1:a:0"; }
322
+ else if (!sourcePlan && s.frame !== undefined) { args.push("-threads", t, "-protocol_whitelist", "file", "-format_whitelist", "mov", "-ss", String(s.start), "-i", s.file); audioMap = "1:a:0"; }
323
+ const crop = s.intro ? undefined : sourcePlan?.crop;
324
+ // fps needs the final source frame's display interval at EOF (e.g. 0.5s at 2fps).
325
+ // Source has already strictly decoded; never use padding to accept corrupt bytes.
326
+ const picture = [s.frame !== undefined ? "trim=end_frame=1,loop=loop=-1:size=1:start=0,setpts=N/(30*TB)" : "", crop ? `crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}` : "", `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1`, ...(sourcePlan ? [`tpad=stop_mode=clone:stop_duration=${1/s.media.fps},fps=30,setpts=N/(30*TB)`] : [])].filter(Boolean).join(",");
327
+ args.push("-t", String(length), "-map", "0:v:0", ...(sourcePlan ? ["-an"] : ["-map", audioMap]), "-vf", picture, "-r", "30", "-fps_mode", "cfr", "-c:v", "libx264", "-threads", t, "-preset", "veryfast", "-crf", "22", "-pix_fmt", "yuv420p", ...(sourcePlan ? [] : ["-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k"]), "-fs", String(budget-intermediateBytes), `seg-${i}.mp4`);
328
+ await command("ffmpeg", args, dir, jobGrant, options.signal, limits.unitMs);
329
+ await probeFile(join(dir, `seg-${i}.mp4`), options.signal, length, !!sourcePlan, jobGrant, !sourcePlan);
330
+ intermediateBytes += (await lstat(join(dir, `seg-${i}.mp4`))).size;
331
+ if (intermediateBytes >= budget) throw new Error("Intermediate storage budget exceeded");
332
+ await writeFile(join(dir, "progress.json"), JSON.stringify({ completedSegments: i+1, totalSegments: segments.length, intermediateBytes, expiresAt: jobGrant.expiresAt }), { mode: 0o600 });
333
+ }
334
+ // Pin concat offsets to requested cuts, not each muxer's rounded frame/AAC duration.
335
+ // Otherwise per-cut rounding accumulates beyond the final single-frame budget.
336
+ const timeline = segments.reduce((sum, s) => sum + s.end - s.start, 0);
337
+ await writeFile(join(dir, "concat.txt"), segments.map((s, i) => `file 'seg-${i}.mp4'\nduration ${s.end - s.start}`).join("\n"), { flag: "wx" });
338
+ const filters = ["setpts=PTS-STARTPTS,fps=30:start_time=0", ...(sourcePlan ? [] : [`drawbox=x=0:y=0:w=iw:h=8:color=0x55F5BD:t=fill`])];
339
+ for (const [i, t] of p.captions.entries()) {
340
+ await writeFile(join(dir, `caption-${i}.txt`), t.text, { flag: "wx" });
341
+ filters.push(`drawtext=textfile=caption-${i}.txt:expansion=none:fontcolor=white:fontsize=${Math.round(height * 0.045)}:box=1:boxcolor=black@0.8:boxborderw=14:x=(w-tw)/2:y=h-th-40:enable='between(t,${t.start},${t.end})'`);
342
+ }
343
+ for (const [i, t] of p.callouts.entries()) {
344
+ await writeFile(join(dir, `callout-${i}.txt`), t.text, { flag: "wx" });
345
+ const y = Math.round(height * 0.24);
346
+ filters.push(`drawbox=x=0:y=${y - 20}:w=iw*0.92:h=${Math.round(height * 0.19)}:color=0x091C28@0.92:t=fill:enable='between(t,${t.start},${t.end})'`);
347
+ filters.push(`drawtext=textfile=callout-${i}.txt:expansion=none:fontcolor=0x55F5BD:fontsize=${Math.round(height * 0.065)}:x='40-w*pow(max(0,1-(t-${t.start})/0.45),3)':y=${y}:alpha='min(1,(t-${t.start})/0.2)*min(1,(${t.end}-t)/0.2)':enable='between(t,${t.start},${t.end})'`);
348
+ }
349
+ // V2 never joins encoded audio. Admission bounds positive decoded-clock
350
+ // gaps (including start) to 2s before snapshot; swresample may buffer that
351
+ // silence, not a whole unbounded source gap. Decode one source audio input,
352
+ // place it by source PTS (fill gaps/drop overlaps), then select exact
353
+ // 1600-sample (1/30s at 48kHz) blocks in SOURCE coordinates,
354
+ // then renumber samples. No split/concat queues or full-recording PCM cache.
355
+ // All expression numbers come from the copied, grid-validated SourcePlan.
356
+ const audioInput: string[] = [];
357
+ let audioFilter = "aresample=async=1:first_pts=0";
358
+ if (sourcePlan) {
359
+ audioInput.push(...(sourceMedia.hasAudio
360
+ ? ["-threads", t, "-protocol_whitelist", "file", "-format_whitelist", "mov", "-i", "source.mp4"]
361
+ : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"]));
362
+ const intervals = (spans: { start: number; end: number }[]) => spans.map(s => `gte(n,${Math.round(s.start*30)})*lt(n,${Math.round(s.end*30)})`).join("+");
363
+ const last = mapping![mapping!.length-1]!.end;
364
+ audioFilter = ["aresample=48000:async=1:min_hard_comp=0.001:first_pts=0", `atrim=end_sample=${Math.round(last*48000)}`, "asetpts=N/SR/TB", "asetnsamples=n=1600:p=0",
365
+ ...(sourcePlan.mute.length ? [`volume=0:enable='${intervals(sourcePlan.mute)}'`] : []),
366
+ ...(sourcePlan.remove.length ? [`aselect='not(${intervals(sourcePlan.remove)})'`] : []),
367
+ "asetpts=N/SR/TB"].join(",");
368
+ }
369
+ let graph = ["-af", audioFilter, "-vf", filters.join(","), "-map", "0:v:0", "-map", sourcePlan ? "1:a:0" : "0:a:0"];
370
+ if (intro) {
371
+ // Intro audio (or fixed silence) is placed by PTS, converted to 48kHz stereo,
372
+ // padded/trimmed to exactly its picture's samples and joined once before the
373
+ // unchanged source chain. No music or generated sound is ever added.
374
+ const samples = intro.frames * 1600, stereo = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo";
375
+ audioInput.push(...(intro.media.hasAudio ? ["-threads", t, "-protocol_whitelist", "file", "-format_whitelist", "mov", "-i", "intro.mp4"] : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"]));
376
+ graph = ["-filter_complex_threads", t, "-filter_complex", `[0:v]${filters.join(",")}[v];[2:a]aresample=48000:async=1:min_hard_comp=0.001:first_pts=0,${stereo},apad=whole_len=${samples},atrim=end_sample=${samples},asetpts=N/SR/TB[i];[1:a]${audioFilter},${stereo}[s];[i][s]concat=n=2:v=0:a=1[a]`, "-map", "[v]", "-map", "[a]"];
377
+ }
378
+ await command("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-n", "-threads", t, "-filter_threads", t, "-protocol_whitelist", "file", "-f", "concat", "-safe", "1", "-reinit_filter", "0", "-i", "concat.txt", ...audioInput, ...(sourcePlan ? [] : ["-t", String(timeline)]), ...graph, "-r", "30", "-fps_mode", "cfr", "-c:v", "libx264", "-threads", t, "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k", "-movflags", "+faststart", "-fs", String(exportBudget), "export.mp4"], dir, jobGrant, options.signal, sourcePlan ? longMs : TIMEOUT);
379
+ const output = await probeFile(join(dir, "export.mp4"), options.signal, timeline, !!sourcePlan, jobGrant);
380
+ const outputFile = await open(join(dir, "export.mp4"), constants.O_RDONLY | constants.O_NOFOLLOW);
381
+ const outputHash = createHash("sha256");
382
+ try { for await (const chunk of outputFile.createReadStream({ autoClose: false })) outputHash.update(chunk); } finally { await outputFile.close(); }
383
+ assertGrant(jobGrant);
384
+ if (options.signal?.aborted) throw new Error("Studio cancelled");
385
+ const manifest = { version: sourcePlan ? 2 : 1, source: { path: p.source, sha256: sourceHash }, supportingAsset: asset ?? null, plan: validated, ...(intro ? { intro: { path: intro.path, sha256: intro.sha256, frames: intro.frames, outputStart: 0, outputEnd: intro.frames / 30 } } : {}), ...(mapping ? { mapping: intro ? compileSourcePlan(sourcePlan!, intro.frames) : mapping } : {}), output: { ...output, sha256: outputHash.digest("hex") }, encoder: "libx264", acceleration: "cpu", gpuVerified: false, createdAt: new Date().toISOString() };
386
+ await writeFile(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2), { flag: "wx", mode: 0o600 });
387
+ // Keep finished output and provenance only, not temporary copies of member recordings.
388
+ for (const file of ["source.mp4", "intro.mp4", "asset.mp4", "concat.txt", ...segments.map((_, i) => `seg-${i}.mp4`), ...p.captions.map((_, i) => `caption-${i}.txt`), ...p.callouts.map((_, i) => `callout-${i}.txt`)]) await rm(join(dir, file), { force: true });
389
+ return { path: relative(g.root, join(dir, "export.mp4")).split("\\").join("/"), manifest: `${BASE}/${job}/manifest.json`, ...output };
390
+ } catch (e) {
391
+ await rm(dir, { force: true, recursive: true }); throw e;
392
+ } finally { await rm(lock, { force: true, recursive: true }); }
393
+ };
394
+ return Object.freeze({ probe, importAsset, render, readPlan,
395
+ transcribe: async (name: string): Promise<never> => { void name; assertGrant(g); throw new Error("Speech model not installed. Supply a timed transcript in the edit plan; no automatic model download."); },
396
+ capabilities: async () => {
397
+ const encoders = await command("ffmpeg", ["-hide_banner", "-encoders"], g.root, g);
398
+ if (!encoders.includes("libx264")) throw new Error("FFmpeg libx264 encoder unavailable");
399
+ return { encoder: "libx264", acceleration: "cpu", gpuVerified: false, hardwareEncodersAdvertised: ["h264_nvenc", "h264_qsv", "h264_videotoolbox"].filter((e) => encoders.includes(e)), maxDuration: MAX_SECONDS, maxBytes: MAX_BYTES, threads: limits.threads };
400
+ },
401
+ });
402
+ }