diff --git a/apps/editor-web/package.json b/apps/editor-web/package.json
index 1170cc2..f256c39 100644
--- a/apps/editor-web/package.json
+++ b/apps/editor-web/package.json
@@ -14,6 +14,9 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2",
"vite": "^5.4.8"
diff --git a/apps/editor-web/src/hooks/useJobPolling.ts b/apps/editor-web/src/hooks/useJobPolling.ts
index 68a51a3..982f3bb 100644
--- a/apps/editor-web/src/hooks/useJobPolling.ts
+++ b/apps/editor-web/src/hooks/useJobPolling.ts
@@ -3,14 +3,14 @@ import { fetchJsonSafe, normalizeRunningStatus, startPolling } from "../utils/ap
type TranscribeStateLike = {
jobId: string | null;
- status: string;
+ status: "idle" | "starting" | "running" | "done" | "error";
log: string[];
error: string | null;
transcriptRelPath: string | null;
startedAt?: number;
mediaDurationSec?: number;
transcribedSec?: number;
- phase?: string;
+ phase?: "queued" | "extracting" | "transcribing" | "finalizing" | "done" | "error";
percent?: number | null;
etaSec?: number | null;
speedLabel?: string | null;
@@ -18,7 +18,7 @@ type TranscribeStateLike = {
type ExportStateLike = {
jobId: string | null;
- status: string;
+ status: "idle" | "starting" | "running" | "done" | "error";
outputPath: string | null;
error: string | null;
log: string[];
diff --git a/apps/editor-web/src/vite-env.d.ts b/apps/editor-web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/editor-web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/editor-web/vite.config.ts b/apps/editor-web/vite.config.ts
index ed6c602..f55f801 100644
--- a/apps/editor-web/vite.config.ts
+++ b/apps/editor-web/vite.config.ts
@@ -130,6 +130,10 @@ type SubtitleTokenInput = {
const jobs = new Map();
const exportJobs = new Map();
const fcpxmlJobs = new Map();
+const edlJobs = new Map();
+const premiereXmlJobs = new Map();
+const aeMarkerJobs = new Map();
+const aafBridgeJobs = new Map();
const RENDER_STATUS_PATH = path.resolve(REPO_ROOT, "data", "render-status.json");
let latestRenderStatus: RenderStatusSnapshot = (() => {
@@ -383,6 +387,11 @@ function sanitizeProjectName(raw: string): string {
return name.replace(/[^a-zA-Z0-9 _.-]/g, "_").slice(0, 80) || "Project";
}
+function projectsDir(): string {
+ return path.resolve(studioSettings.projectsDir || DEFAULT_SETTINGS.projectsDir || "data/projects");
+}
+
+
function normalizeRange(input: RangeInput): { startSec: number; endSec: number } | null {
const start = Number(input.sourceStartSec ?? input.startSec);
const end = Number(input.sourceEndSec ?? input.endSec);
@@ -1192,9 +1201,10 @@ function studioApiPlugin(): Plugin {
fs.writeFileSync(out, JSON.stringify(payload, null, 2), "utf-8");
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true, projectId: id, projectName, outputPath: out }));
- } catch {
+ } catch (err) {
+ console.error("Failed to save project:", err);
res.statusCode = 500;
- res.end(JSON.stringify({ error: "Failed to save project" }));
+ res.end(JSON.stringify({ error: `Failed to save project: ${err instanceof Error ? err.message : String(err)}` }));
}
});
@@ -1882,6 +1892,7 @@ function studioApiPlugin(): Plugin {
}
pushLog(job, `Running Whisper (${model}, ${device}, ${computeType}, beam=${beamSize}${vadFilter ? ", vad" : ""})`);
+ let whisperStderr = "";
const tr = spawn("bash", ["-lc", `${command} "${wavPath}" --model "${model}" --device "${device}" --compute-type "${computeType}" --beam-size "${beamSize}" ${vadFilter ? "--vad-filter" : ""} --language "${language}" --out "${transcriptAbsPath}"`], {
cwd: REPO_ROOT,
});
@@ -1896,6 +1907,7 @@ function studioApiPlugin(): Plugin {
});
tr.stderr.on("data", (d) => {
const text = String(d);
+ whisperStderr += text;
const sec = parseWhisperProgressSec(text);
if (sec !== undefined) {
job.transcribedSec = Math.max(job.transcribedSec ?? 0, sec);
@@ -1913,7 +1925,8 @@ function studioApiPlugin(): Plugin {
} else {
job.status = "error";
job.phase = "error";
- job.error = `Whisper failed (${trCode})`;
+ const tail = whisperStderr.trim().split("\n").slice(-5).join(" | ");
+ job.error = `Whisper failed (${trCode}): ${tail || "no stderr"}`;
}
});
});
@@ -2607,7 +2620,7 @@ function studioApiPlugin(): Plugin {
return;
}
- const sourceMetadata = probeSourceMetadata(absMedia);
+ const sourceMetadata = probeFcpxmlMetadata(absMedia);
const source = {
path: absMedia,
name: path.basename(absMedia),
@@ -2616,17 +2629,18 @@ function studioApiPlugin(): Plugin {
durationSec: sourceMetadata.durationSec,
};
- const manifest = buildAafBridgeManifest(keepRanges as KeepRange[], source);
- const fcpxml = exportFcpxmlV1(keepRanges as KeepRange[], source, {
+ const timelineRanges = toTimelineKeepRanges(keepRanges);
+ const manifest = buildAafBridgeManifest(timelineRanges, source);
+ const fcpxml = exportFcpxmlV1(timelineRanges, source, {
projectName: outputName.replace(/-aaf-bridge\.zip$/, ""),
sequenceName: outputName.replace(/-aaf-bridge\.zip$/, ""),
eventName: "prune",
});
- const edl = exportEdlCmx3600(keepRanges as KeepRange[], source, {
+ const edl = exportEdlCmx3600(timelineRanges, source, {
title: outputName.replace(/\.zip$/, "").toUpperCase().slice(0, 64),
reel: source.name,
});
- const premiereXml = exportPremiereXml(keepRanges as KeepRange[], source, {
+ const premiereXml = exportPremiereXml(timelineRanges, source, {
projectName: outputName.replace(/-aaf-bridge\.zip$/, ""),
sequenceName: outputName.replace(/-aaf-bridge\.zip$/, ""),
});
diff --git a/package-lock.json b/package-lock.json
index 2c3eced..79c14da 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,11 @@
"workspaces": [
"apps/*",
"packages/*"
- ]
+ ],
+ "engines": {
+ "node": ">=20 <21",
+ "npm": ">=9 <10"
+ }
},
"apps/editor-web": {
"name": "@prune/editor-web",
@@ -21,6 +25,9 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2",
"vite": "^5.4.8"
@@ -1100,6 +1107,36 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
+ "node_modules/@types/node": {
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.19.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1191,6 +1228,13 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1512,6 +1556,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",