crew-runner 0.1.0 → 0.2.2

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,87 @@
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
+ // A recording of up to three hours and 6 GiB (a raw 1080p webinar is about 1 to 3 GB an hour). Only a hosted job's own
14
+ // deadline can afford that much work; the local limits stop long before.
15
+ export const SOURCE_SECONDS = 10_800;
16
+ export const SOURCE_BYTES = 6 * 1024 ** 3;
17
+ // Every other media file the engine reads (an intro, a supporting asset).
18
+ export const MEDIA_BYTES = 100 * 1024 * 1024;
19
+ export const EXPORT_BYTES = 512 * 1024 ** 2;
20
+ // A hosted export of a long programme: under one object PUT (5 GiB), and the encoder's peak rate is sized to fit it.
21
+ export const HOSTED_EXPORT_BYTES = 4 * 1024 ** 3;
22
+ // A hosted job's clock and scratch budget (studio.ts checks hosted limits against them; the worker passes them).
23
+ export const HOSTED_MAX_JOB_MS = 4 * 3_600_000;
24
+ export const HOSTED_INTERMEDIATE_BYTES = 16 * 1024 ** 3;
25
+ // Every 30-second unit of a three-hour programme (360), one more for each of up to 193 kept intervals between edit
26
+ // boundaries (32 cuts, mutes and holds each), and an intro's units.
27
+ export const SEGMENT_BUDGET = 576;
28
+ function fields(v: unknown, keys: string[], required = keys): Record<string, unknown> {
29
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("Invalid source plan object");
30
+ const o = v as Record<string, unknown>;
31
+ if (Object.keys(o).some((k) => !keys.includes(k)) || required.some((k) => !(k in o))) throw new Error("Unknown or missing source plan field");
32
+ return o;
33
+ }
34
+ function time(v: unknown): number {
35
+ 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 3 hours");
36
+ return v;
37
+ }
38
+ export function validateSourcePlan(value: unknown, mediaName: (v: unknown) => string): SourcePlan {
39
+ const o = fields(value, ["version", "source", "sourceDuration", "remove", "mute", "holds", "crop", "intro"], ["version", "source", "sourceDuration", "remove", "mute", "holds"]);
40
+ if (o.version !== 2) throw new Error("Unsupported plan version");
41
+ const duration = time(o.sourceDuration);
42
+ if (!duration) throw new Error("Empty source duration");
43
+ const spans = (value: unknown, hold = false) => {
44
+ if (!Array.isArray(value) || value.length > 32) throw new Error("Source edit entry limit");
45
+ let previous = 0;
46
+ return value.map((entry) => {
47
+ const s = fields(entry, hold ? ["start", "end", "frame"] : ["start", "end"]);
48
+ const start = time(s.start), end = time(s.end);
49
+ if (end <= start || start < previous || end > duration) throw new Error("Invalid, overlapping or unsorted source interval");
50
+ previous = end;
51
+ if (!hold) return { start, end };
52
+ const frame = time(s.frame);
53
+ if (frame >= duration) throw new Error("Hold frame outside source");
54
+ return { start, end, frame };
55
+ });
56
+ };
57
+ const remove = spans(o.remove), mute = spans(o.mute), holds = spans(o.holds, true) as SourcePlan["holds"];
58
+ if (remove.reduce((n, s) => n + s.end - s.start, 0) >= duration) throw new Error("Empty output timeline");
59
+ let crop: SourcePlan["crop"];
60
+ if (o.crop !== undefined) {
61
+ const c = fields(o.crop, ["x", "y", "width", "height"]);
62
+ 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");
63
+ crop = { x: c.x as number, y: c.y as number, width: c.width as number, height: c.height as number };
64
+ if (!crop!.width || !crop!.height) throw new Error("Empty crop");
65
+ }
66
+ const plan: SourcePlan = { version: 2, source: mediaName(o.source), sourceDuration: duration, remove, mute, holds, ...(crop ? { crop } : {}), ...(o.intro !== undefined ? { intro: mediaName(o.intro) } : {}) };
67
+ compileSourcePlan(plan); // admission includes segment count, before any media work
68
+ return plan;
69
+ }
70
+ // introFrames: whole 30fps output frames of a prepended intro; source mapping is unchanged.
71
+ export function compileSourcePlan(p: SourcePlan, introFrames = 0): SourceSegment[] {
72
+ const boundaries = [...new Set([0, p.sourceDuration, ...[...p.remove, ...p.mute, ...p.holds].flatMap((s) => [s.start, s.end])])].sort((a,b) => a-b);
73
+ const result: SourceSegment[] = [];
74
+ let outputFrames = introFrames;
75
+ for (let i=0; i<boundaries.length-1; i++) {
76
+ const start = boundaries[i]!, end = boundaries[i+1]!;
77
+ if (p.remove.some((s) => s.start <= start && s.end >= end)) continue;
78
+ const hold = p.holds.find((s) => s.start <= start && s.end >= end);
79
+ for (let from = start; from < end; from += 30) {
80
+ const to = Math.min(end, from+30);
81
+ result.push({ start: from, end: to, outputStart: outputFrames/30, mute: p.mute.some((s) => s.start <= from && s.end >= to), ...(hold ? { frame: hold.frame } : {}) });
82
+ outputFrames += Math.round((to-from)*30);
83
+ }
84
+ }
85
+ if (result.length > SEGMENT_BUDGET) throw new Error("Source plan segment budget exceeded");
86
+ return result;
87
+ }
package/src/studio.ts ADDED
@@ -0,0 +1,411 @@
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, HOSTED_EXPORT_BYTES, HOSTED_INTERMEDIATE_BYTES, HOSTED_MAX_JOB_MS, MEDIA_BYTES, SEGMENT_BUDGET, 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, HOSTED_EXPORT_BYTES or
13
+ // HOSTED_INTERMEDIATE_BYTES.
14
+ export type StudioLimits = Readonly<{ jobMs: number; unitMs: number; threads: number; height: 720 | 1080; intermediateBytes: number; exportBytes: number }>;
15
+ export const LOCAL_LIMITS: StudioLimits = Object.freeze({ jobMs: 900_000, unitMs: 90_000, threads: 2, height: 720, intermediateBytes: EXPORT_BYTES, exportBytes: EXPORT_BYTES });
16
+ // Sized from a measured hosted render (75 minutes of 1080p30 took 37 minutes, about half real time, on 4 dedicated CPUs): a
17
+ // three-hour recording plus its download fits with room for slower footage.
18
+ export { HOSTED_MAX_JOB_MS, HOSTED_INTERMEDIATE_BYTES };
19
+ const int = (v: unknown, min: number, max: number) => typeof v === "number" && Number.isSafeInteger(v) && v >= min && v <= max;
20
+ function checkLimits(l: StudioLimits): StudioLimits {
21
+ 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)
22
+ || !int(l.intermediateBytes, 1, HOSTED_INTERMEDIATE_BYTES) || !int(l.exportBytes, 1, HOSTED_EXPORT_BYTES)) throw new Error("Invalid studio limits");
23
+ return Object.freeze({ jobMs: l.jobMs, unitMs: l.unitMs, threads: l.threads, height: l.height, intermediateBytes: l.intermediateBytes, exportBytes: l.exportBytes });
24
+ }
25
+ type Span = { start: number; end: number };
26
+ type TextSpan = Span & { text: string };
27
+ export type EditPlan = { source: string; cuts: Span[]; captions: TextSpan[]; callouts: TextSpan[]; supportingAsset?: Span & { id: string } };
28
+ type Media = { duration: number; videoDuration: number; width: number; height: number; hasAudio: boolean; fps: number };
29
+ type Asset = { id: string; path: string; origin: "higgsfield" | "local"; reference: string; sha256: string; verifiedByProvider: false; charged: false };
30
+ const MAX_BYTES = MEDIA_BYTES;
31
+ const MAX_SECONDS = 120;
32
+ const TIMEOUT = 90_000;
33
+ const BASE = ".crew-studio";
34
+
35
+ // maxMs is set only by openStudio from checked limits: one hour, or a hosted job's longer deadline.
36
+ export function assertGrant(g: StudioGrant & { maxMs?: number }): void {
37
+ if (g.enabled !== true) throw new Error("Studio disabled: explicit local opt-in required");
38
+ 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");
39
+ }
40
+ function objectFields(v: unknown, keys: string[], required = keys): Record<string, unknown> {
41
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("Invalid object");
42
+ const o = v as Record<string, unknown>;
43
+ if (Object.keys(o).some((k) => !keys.includes(k)) || required.some((k) => !(k in o))) throw new Error("Unknown or missing field");
44
+ return o;
45
+ }
46
+ function number(v: unknown): number {
47
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > MAX_SECONDS) throw new Error("Invalid time");
48
+ return v;
49
+ }
50
+ function span(v: unknown, text = false): Span | TextSpan {
51
+ const o = objectFields(v, text ? ["start", "end", "text"] : ["start", "end"]);
52
+ const start = number(o.start), end = number(o.end);
53
+ if (end <= start || end - start < 0.1) throw new Error("Invalid span");
54
+ if (!text) return { start, end };
55
+ 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)");
56
+ return { start, end, text: o.text };
57
+ }
58
+ function mediaName(v: unknown): string {
59
+ 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");
60
+ return v;
61
+ }
62
+ export function validatePlan(v: unknown): EditPlan | SourcePlan {
63
+ if (v && typeof v === "object" && "version" in v) return validateSourcePlan(v, mediaName);
64
+ const o = objectFields(v, ["source", "cuts", "captions", "callouts", "supportingAsset"], ["source", "cuts", "captions", "callouts"]);
65
+ const list = (x: unknown, max: number) => { if (!Array.isArray(x) || x.length > max) throw new Error("Too many edit entries"); return x; };
66
+ const cuts = list(o.cuts, 12).map((c) => span(c) as Span);
67
+ if (!cuts.length) throw new Error("At least one original cut required");
68
+ let total = cuts.reduce((n, c) => n + c.end - c.start, 0);
69
+ let supportingAsset: EditPlan["supportingAsset"];
70
+ if (o.supportingAsset !== undefined) {
71
+ const a = objectFields(o.supportingAsset, ["id", "start", "end"]);
72
+ if (typeof a.id !== "string" || !/^[a-f0-9-]{36}$/.test(a.id)) throw new Error("Invalid asset ID");
73
+ supportingAsset = { ...span({ start: a.start, end: a.end }), id: a.id };
74
+ total += supportingAsset.end - supportingAsset.start;
75
+ }
76
+ if (total > MAX_SECONDS) throw new Error("Timeline exceeds 120 seconds");
77
+ 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; });
78
+ return { source: mediaName(o.source), cuts, captions: texts(o.captions), callouts: texts(o.callouts), ...(supportingAsset ? { supportingAsset } : {}) };
79
+ }
80
+
81
+ // All executable names, filters, codecs and resource arguments are owned here, never by the model.
82
+ async function command(bin: "ffmpeg" | "ffprobe", args: string[], cwd: string, g: StudioGrant, signal?: AbortSignal, timeout = TIMEOUT, stderrLine?: (line: string) => void): Promise<string> {
83
+ assertGrant(g);
84
+ if (signal?.aborted) throw new Error("Studio cancelled");
85
+ return new Promise((resolvePromise, reject) => {
86
+ // Inherit the adapter/bridge group so an abrupt owner shutdown cannot orphan an encoder.
87
+ // FFmpeg/ffprobe do not spawn children; killTree falls back to killing this owned PID.
88
+ const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"], detached: false, windowsHide: true });
89
+ let stdout = "", stderr = "", reason = "", pending = "", stderrBytes = 0;
90
+ let force: NodeJS.Timeout | undefined;
91
+ const stop = (why: string) => {
92
+ if (reason) return;
93
+ reason = why;
94
+ // Kill only this owned media process. Wait for close before deleting files.
95
+ killTree(child);
96
+ force = setTimeout(() => killTree(child), 500);
97
+ };
98
+ const abort = () => stop("Studio cancelled");
99
+ signal?.addEventListener("abort", abort, { once: true });
100
+ const timer = setTimeout(() => stop("Studio timeout or expired consent"), Math.min(timeout, g.expiresAt - Date.now()));
101
+ child.stdout.on("data", (b: Buffer) => { if (reason) return; stdout += b.toString(); if (stdout.length > 512_000) stop("Media output limit"); });
102
+ child.stderr.on("data", (b: Buffer) => {
103
+ if (reason) return;
104
+ stderr = (stderr + b.toString()).slice(-16_000);
105
+ if (!stderrLine) return;
106
+ stderrBytes += b.length;
107
+ if (stderrBytes > AUDIO_SCAN_BYTES) return stop("Source audio scan byte limit");
108
+ pending += b.toString();
109
+ try {
110
+ let newline: number;
111
+ while ((newline = pending.indexOf("\n")) >= 0) {
112
+ if (newline > 8192) throw new Error("Source audio scan line limit");
113
+ stderrLine(pending.slice(0, newline)); pending = pending.slice(newline + 1);
114
+ }
115
+ if (pending.length > 8192) throw new Error("Source audio scan line limit");
116
+ } catch (e) { stop((e as Error).message); }
117
+ });
118
+ child.on("error", () => { reason = `${bin} unavailable: install FFmpeg/ffprobe locally; no automatic download`; });
119
+ child.on("close", (code) => {
120
+ clearTimeout(timer); clearTimeout(force); signal?.removeEventListener("abort", abort);
121
+ if (!reason && stderrLine && pending) { try { stderrLine(pending); } catch (e) { reason = (e as Error).message; } }
122
+ if (reason || code !== 0) reject(new Error(reason || `Media process failed: ${stderr.slice(-4000)}`));
123
+ else resolvePromise(stdout);
124
+ });
125
+ });
126
+ }
127
+
128
+ export async function openStudio(input: StudioGrant, limitsInput: StudioLimits = LOCAL_LIMITS) {
129
+ // Copy authority once, never retain mutable caller/model-owned objects.
130
+ const limits = checkLimits(limitsInput);
131
+ const t = String(limits.threads);
132
+ // Source decode and the final encode may use the job deadline less five minutes (10 of the local 15).
133
+ const longMs = limits.jobMs - 300_000;
134
+ const g = Object.freeze({ root: input.root, enabled: input.enabled, expiresAt: input.expiresAt, maxMs: Math.max(3_600_000, limits.jobMs) });
135
+ assertGrant(g);
136
+ if (!isAbsolute(g.root) || resolve(g.root) !== g.root) throw new Error("Select a canonical absolute project root explicitly");
137
+ const canonical = await realpath(g.root);
138
+ if (canonical !== g.root || !(await lstat(g.root)).isDirectory()) throw new Error("Project root must not use symlinks");
139
+ const safe = async (name: string, directory = false, maxBytes = MAX_BYTES) => {
140
+ assertGrant(g);
141
+ if (isAbsolute(name) || name.split("/").some((s) => !s || s === "." || s === "..") || !/^[a-zA-Z0-9_./ -]+$/.test(name)) throw new Error("Unsafe project path");
142
+ if (await realpath(g.root) !== canonical) throw new Error("Project root changed");
143
+ let path = g.root;
144
+ for (const part of name.split("/")) {
145
+ path = join(path, part);
146
+ const info = await lstat(path);
147
+ if (info.isSymbolicLink()) throw new Error("Symlink denied");
148
+ }
149
+ const info = await lstat(path);
150
+ if (directory ? !info.isDirectory() : !info.isFile()) throw new Error("Not a regular project file/directory");
151
+ if (!directory && info.size > maxBytes) throw new Error("Media size limit");
152
+ return path;
153
+ };
154
+ const data = async (name: string): Promise<Buffer> => {
155
+ const path = await safe(name);
156
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
157
+ try {
158
+ const s = await file.stat();
159
+ if (!s.isFile() || s.size > MAX_BYTES) throw new Error("Media size limit");
160
+ const b = await file.readFile();
161
+ if (b.length > MAX_BYTES) throw new Error("Media size limit");
162
+ return b;
163
+ } finally { await file.close(); }
164
+ };
165
+ const privateDir = async () => {
166
+ await mkdir(join(g.root, BASE), { mode: 0o700 }).catch((e: NodeJS.ErrnoException) => { if (e.code !== "EEXIST") throw e; });
167
+ return safe(BASE, true);
168
+ };
169
+ const probeFile = async (path: string, signal?: AbortSignal, expected?: number, long = false, grant = g, expectedAudio = true): Promise<Media> => {
170
+ const maxBytes = long ? (expected === undefined ? SOURCE_BYTES : Math.max(EXPORT_BYTES, limits.exportBytes)) : MAX_BYTES;
171
+ const maxSeconds = long ? SOURCE_SECONDS : MAX_SECONDS;
172
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
173
+ try {
174
+ const info = await file.stat();
175
+ if (!info.isFile() || info.size <= 0 || info.size > maxBytes) throw new Error("Media size limit");
176
+ const header = Buffer.alloc(12); await file.read(header, 0, 12, 0);
177
+ if (header.toString("ascii", 4, 8) !== "ftyp") throw new Error("Invalid MP4 media bytes");
178
+ } finally { await file.close(); }
179
+ 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);
180
+ 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 };
181
+ const parsed = JSON.parse(raw) as { format?: { duration?: string }; streams?: Stream[] };
182
+ const duration = Number(parsed.format?.duration);
183
+ const video = parsed.streams?.find((s) => s.codec_type === "video");
184
+ const audio = parsed.streams?.filter((s) => s.codec_type === "audio") ?? [];
185
+ 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");
186
+ const [num, den] = (video.avg_frame_rate ?? "").split("/").map(Number);
187
+ const fps = (num ?? NaN) / (den ?? NaN);
188
+ if (long && (!Number.isFinite(fps) || fps < 1 || fps > 120)) throw new Error("Unsupported source frame cadence (1-120 fps)");
189
+ // Never turn low/malformed source frame rates into an unbounded duration allowance.
190
+ const frame = Number.isFinite(fps) && fps >= 24 ? 1 / fps : 0;
191
+ const audioSlack = (s: Stream) => {
192
+ const rate = Number(s.sample_rate);
193
+ return Number.isFinite(rate) && rate >= 8000 ? Math.min(1024 / rate, 1024 / 48000) : 0;
194
+ };
195
+ const videoDuration = Number(video.duration);
196
+ if (!Number.isFinite(videoDuration) || videoDuration <= 0 || videoDuration > maxSeconds) throw new Error("Unsupported video duration");
197
+ for (const s of [video, ...audio]) {
198
+ const length = Number(s.duration), start = Number(s.start_time ?? 0);
199
+ const slack = s.codec_type === "video" ? frame : frame + 2 * audioSlack(s);
200
+ 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");
201
+ 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}`);
202
+ }
203
+ const muxSlack = frame + Math.max(0, ...audio.map(audioSlack));
204
+ const streamEnd = Math.max(...[video, ...audio].map((s) => Number(s.duration) + Number(s.start_time ?? 0)));
205
+ 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");
206
+ 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");
207
+ // A valid header and probeable index do not establish that packets can actually decode.
208
+ const admission = long && expected === undefined && audio.length ? sourceAudioAdmission(audio) : undefined;
209
+ // This is the existing strict full decode, with bounded streamed metadata
210
+ // only. No async/resampling here: inspect the actual decoder clock before
211
+ // aresample can allocate gap-sized silence. Same input origin as final pass.
212
+ 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);
213
+ admission?.finish();
214
+ return { duration, videoDuration, width: video.width, height: video.height, hasAudio: audio.length > 0, fps };
215
+ };
216
+ const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex");
217
+ const probe = async (name: string) => probeFile(await safe(mediaName(name)));
218
+ const readPlan = async (name: string) => {
219
+ if (!name.endsWith(".json")) throw new Error("Select a JSON edit plan");
220
+ const b = await data(name); if (b.length > 64_000) throw new Error("Plan size limit");
221
+ return validatePlan(JSON.parse(b.toString("utf8")));
222
+ };
223
+ const importAsset = async (value: unknown): Promise<Asset> => {
224
+ const o = objectFields(value, ["path", "origin", "reference"]);
225
+ const path = mediaName(o.path);
226
+ if (o.origin !== "higgsfield" && o.origin !== "local") throw new Error("Unknown asset origin");
227
+ if (typeof o.reference !== "string" || !/^[a-zA-Z0-9 _.:/-]{1,160}$/.test(o.reference)) throw new Error("Include a plain provenance reference");
228
+ await probe(path);
229
+ const bytes = await data(path);
230
+ const asset: Asset = { id: randomUUID(), path, origin: o.origin, reference: o.reference, sha256: sha(bytes), verifiedByProvider: false, charged: false };
231
+ const dir = await privateDir();
232
+ await writeFile(join(dir, `asset-${asset.id}.json`), JSON.stringify(asset, null, 2), { flag: "wx", mode: 0o600 });
233
+ return asset;
234
+ };
235
+ const render = async (value: unknown, options: { signal?: AbortSignal; preview?: boolean } = {}) => {
236
+ assertGrant(g);
237
+ if (options.signal?.aborted) throw new Error("Studio cancelled");
238
+ const validated = validatePlan(value);
239
+ const sourcePlan = "version" in validated ? validated : undefined;
240
+ const mapping = sourcePlan ? compileSourcePlan(sourcePlan) : undefined;
241
+ const p: EditPlan = sourcePlan ? { source: sourcePlan.source, cuts: mapping!, captions: [], callouts: [] } : validated as EditPlan;
242
+ const jobGrant = Object.freeze({ ...g, expiresAt: Math.min(g.expiresAt, Date.now() + limits.jobMs) });
243
+ const budget = sourcePlan ? limits.intermediateBytes : MAX_BYTES, exportBudget = sourcePlan ? limits.exportBytes : MAX_BYTES;
244
+ const base = await privateDir();
245
+ const lock = join(base, "lock");
246
+ try { await mkdir(lock, { mode: 0o700 }); } catch { throw new Error("Studio busy: inspect the owned lock after a crash before removing it"); }
247
+ const job = `job-${randomUUID()}`;
248
+ const dir = join(base, job);
249
+ try {
250
+ await mkdir(dir, { mode: 0o700 });
251
+ // The member's selected intro: snapshot it like a supporting asset, then admit the
252
+ // copy with the same strict v2 decode and audio-clock rules as the source, before
253
+ // spending time on the long source. Only whole 30fps frames of its picture are used.
254
+ let intro: { path: string; sha256: string; frames: number; media: Media } | undefined;
255
+ if (sourcePlan?.intro) {
256
+ const bytes = await data(sourcePlan.intro);
257
+ const disk = await statfs(base);
258
+ if (disk.bavail * disk.bsize < bytes.length + 256 * 1024 ** 2) throw new Error("Insufficient local disk budget for the intro");
259
+ await writeFile(join(dir, "intro.mp4"), bytes, { flag: "wx", mode: 0o600 });
260
+ // Short-form metadata limits (<=120s) refuse a long intro before any full decode.
261
+ 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}`); });
262
+ const media = await probeFile(join(dir, "intro.mp4"), options.signal, undefined, true, jobGrant);
263
+ const frames = Math.floor(media.videoDuration * 30 + 1e-6);
264
+ if (!frames) throw new Error("Intro has no whole output frame");
265
+ if (mapping!.reduce((n, s) => n + s.end - s.start, 0) + frames / 30 > SOURCE_SECONDS) throw new Error("Edited programme plus intro exceeds 3 hours");
266
+ intro = { path: sourcePlan.intro, sha256: sha(bytes), frames, media };
267
+ }
268
+ // Snapshot in bounded chunks, hash exactly the bytes the encoder will read.
269
+ const sourcePath = await safe(p.source, false, sourcePlan ? SOURCE_BYTES : MAX_BYTES);
270
+ const input = await open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
271
+ let sourceHash: string;
272
+ let sourceMedia: Media | undefined;
273
+ try {
274
+ const info = await input.stat();
275
+ if (!info.isFile() || info.size > (sourcePlan ? SOURCE_BYTES : MAX_BYTES)) throw new Error("Media size limit");
276
+ const disk = await statfs(base);
277
+ if (disk.bavail * disk.bsize < info.size + budget + exportBudget + 256 * 1024 ** 2) throw new Error("Insufficient local disk budget for snapshot, intermediates and export");
278
+ if (sourcePlan) sourceMedia = await probeFile(sourcePath, options.signal, undefined, true, jobGrant);
279
+ const copy = await open(join(dir, "source.mp4"), "wx", 0o600);
280
+ const hash = createHash("sha256");
281
+ try {
282
+ const buffer = Buffer.alloc(1024 * 1024);
283
+ let total = 0;
284
+ for (;;) {
285
+ assertGrant(jobGrant);
286
+ if (options.signal?.aborted) throw new Error("Studio cancelled");
287
+ const { bytesRead } = await input.read(buffer, 0, buffer.length, null);
288
+ if (!bytesRead) break;
289
+ total += bytesRead;
290
+ if (total > info.size) throw new Error("Source changed during snapshot");
291
+ const chunk = buffer.subarray(0, bytesRead);
292
+ hash.update(chunk);
293
+ let written = 0;
294
+ while (written < bytesRead) written += (await copy.write(chunk, written, bytesRead-written)).bytesWritten;
295
+ }
296
+ const after = await input.stat();
297
+ const selected = await lstat(sourcePath);
298
+ 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");
299
+ sourceHash = hash.digest("hex");
300
+ } finally { await copy.close(); }
301
+ } finally { await input.close(); }
302
+ sourceMedia ??= await probeFile(join(dir, "source.mp4"), options.signal, undefined, false, jobGrant);
303
+ if (sourcePlan && Math.abs(sourceMedia.duration-sourcePlan.sourceDuration) > 0.05) throw new Error("Declared source duration does not match selected recording");
304
+ 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");
305
+ let asset: Asset | undefined;
306
+ if (p.supportingAsset) {
307
+ const b = await data(`${BASE}/asset-${p.supportingAsset.id}.json`);
308
+ asset = JSON.parse(b.toString()) as Asset;
309
+ const extra = await data(mediaName(asset.path));
310
+ if (sha(extra) !== asset.sha256) throw new Error("Imported asset changed: reimport explicitly");
311
+ await writeFile(join(dir, "asset.mp4"), extra, { flag: "wx" });
312
+ }
313
+ 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 })) : [];
314
+ 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 }))];
315
+ if (segments.length > SEGMENT_BUDGET) throw new Error("Source plan segment budget exceeded");
316
+ 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 });
317
+ const height = options.preview ? 360 : limits.height, width = height * 16 / 9;
318
+ let intermediateBytes = 0;
319
+ for (const [i, s] of segments.entries()) {
320
+ if (s.end > s.media.duration + 0.03) throw new Error("Cut exceeds original media duration");
321
+ const length = s.end - s.start;
322
+ 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];
323
+ let audioMap = "0:a:0";
324
+ if (!sourcePlan && (!s.media.hasAudio || s.mute)) { args.push("-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"); audioMap = "1:a:0"; }
325
+ 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"; }
326
+ const crop = s.intro ? undefined : sourcePlan?.crop;
327
+ // fps needs the final source frame's display interval at EOF (e.g. 0.5s at 2fps).
328
+ // Source has already strictly decoded; never use padding to accept corrupt bytes.
329
+ 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(",");
330
+ 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`);
331
+ await command("ffmpeg", args, dir, jobGrant, options.signal, limits.unitMs);
332
+ await probeFile(join(dir, `seg-${i}.mp4`), options.signal, length, !!sourcePlan, jobGrant, !sourcePlan);
333
+ intermediateBytes += (await lstat(join(dir, `seg-${i}.mp4`))).size;
334
+ if (intermediateBytes >= budget) throw new Error("Intermediate storage budget exceeded");
335
+ await writeFile(join(dir, "progress.json"), JSON.stringify({ completedSegments: i+1, totalSegments: segments.length, intermediateBytes, expiresAt: jobGrant.expiresAt }), { mode: 0o600 });
336
+ }
337
+ // Pin concat offsets to requested cuts, not each muxer's rounded frame/AAC duration.
338
+ // Otherwise per-cut rounding accumulates beyond the final single-frame budget.
339
+ const timeline = segments.reduce((sum, s) => sum + s.end - s.start, 0);
340
+ await writeFile(join(dir, "concat.txt"), segments.map((s, i) => `file 'seg-${i}.mp4'\nduration ${s.end - s.start}`).join("\n"), { flag: "wx" });
341
+ const filters = ["setpts=PTS-STARTPTS,fps=30:start_time=0", ...(sourcePlan ? [] : [`drawbox=x=0:y=0:w=iw:h=8:color=0x55F5BD:t=fill`])];
342
+ for (const [i, t] of p.captions.entries()) {
343
+ await writeFile(join(dir, `caption-${i}.txt`), t.text, { flag: "wx" });
344
+ 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})'`);
345
+ }
346
+ for (const [i, t] of p.callouts.entries()) {
347
+ await writeFile(join(dir, `callout-${i}.txt`), t.text, { flag: "wx" });
348
+ const y = Math.round(height * 0.24);
349
+ 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})'`);
350
+ 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})'`);
351
+ }
352
+ // V2 never joins encoded audio. Admission bounds positive decoded-clock
353
+ // gaps (including start) to 2s before snapshot; swresample may buffer that
354
+ // silence, not a whole unbounded source gap. Decode one source audio input,
355
+ // place it by source PTS (fill gaps/drop overlaps), then select exact
356
+ // 1600-sample (1/30s at 48kHz) blocks in SOURCE coordinates,
357
+ // then renumber samples. No split/concat queues or full-recording PCM cache.
358
+ // All expression numbers come from the copied, grid-validated SourcePlan.
359
+ const audioInput: string[] = [];
360
+ let audioFilter = "aresample=async=1:first_pts=0";
361
+ if (sourcePlan) {
362
+ audioInput.push(...(sourceMedia.hasAudio
363
+ ? ["-threads", t, "-protocol_whitelist", "file", "-format_whitelist", "mov", "-i", "source.mp4"]
364
+ : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"]));
365
+ 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("+");
366
+ const last = mapping![mapping!.length-1]!.end;
367
+ 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",
368
+ ...(sourcePlan.mute.length ? [`volume=0:enable='${intervals(sourcePlan.mute)}'`] : []),
369
+ ...(sourcePlan.remove.length ? [`aselect='not(${intervals(sourcePlan.remove)})'`] : []),
370
+ "asetpts=N/SR/TB"].join(",");
371
+ }
372
+ let graph = ["-af", audioFilter, "-vf", filters.join(","), "-map", "0:v:0", "-map", sourcePlan ? "1:a:0" : "0:a:0"];
373
+ if (intro) {
374
+ // Intro audio (or fixed silence) is placed by PTS, converted to 48kHz stereo,
375
+ // padded/trimmed to exactly its picture's samples and joined once before the
376
+ // unchanged source chain. No music or generated sound is ever added.
377
+ const samples = intro.frames * 1600, stereo = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo";
378
+ audioInput.push(...(intro.media.hasAudio ? ["-threads", t, "-protocol_whitelist", "file", "-format_whitelist", "mov", "-i", "intro.mp4"] : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"]));
379
+ 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]"];
380
+ }
381
+ // A long v2 programme gets a peak video rate that keeps the whole export inside its budget (128k audio and container
382
+ // overhead set aside), so a dense hour cannot run into -fs and be refused at the end. Where the budget cannot bind at
383
+ // any rate CRF 20 reaches (short and local exports) the argv is unchanged.
384
+ const peak = sourcePlan ? Math.floor(exportBudget * 8 * 0.9 / timeline) - 200_000 : Infinity;
385
+ if (peak < 600_000) throw new Error("The export budget is too small for this programme length");
386
+ const peakRate = peak < 12_000_000 ? ["-maxrate", String(peak), "-bufsize", String(2 * peak)] : [];
387
+ 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", ...peakRate, "-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);
388
+ const output = await probeFile(join(dir, "export.mp4"), options.signal, timeline, !!sourcePlan, jobGrant);
389
+ const outputFile = await open(join(dir, "export.mp4"), constants.O_RDONLY | constants.O_NOFOLLOW);
390
+ const outputHash = createHash("sha256");
391
+ try { for await (const chunk of outputFile.createReadStream({ autoClose: false })) outputHash.update(chunk); } finally { await outputFile.close(); }
392
+ assertGrant(jobGrant);
393
+ if (options.signal?.aborted) throw new Error("Studio cancelled");
394
+ 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() };
395
+ await writeFile(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2), { flag: "wx", mode: 0o600 });
396
+ // Keep finished output and provenance only, not temporary copies of member recordings.
397
+ 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 });
398
+ return { path: relative(g.root, join(dir, "export.mp4")).split("\\").join("/"), manifest: `${BASE}/${job}/manifest.json`, ...output };
399
+ } catch (e) {
400
+ await rm(dir, { force: true, recursive: true }); throw e;
401
+ } finally { await rm(lock, { force: true, recursive: true }); }
402
+ };
403
+ return Object.freeze({ probe, importAsset, render, readPlan,
404
+ 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."); },
405
+ capabilities: async () => {
406
+ const encoders = await command("ffmpeg", ["-hide_banner", "-encoders"], g.root, g);
407
+ if (!encoders.includes("libx264")) throw new Error("FFmpeg libx264 encoder unavailable");
408
+ 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 };
409
+ },
410
+ });
411
+ }