feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX (#1963)

This commit is contained in:
Vance Ingalls
2026-07-06 16:43:48 -07:00
committed by GitHub
parent ec06f4bf89
commit 241f9d683e
19 changed files with 866 additions and 201 deletions
@@ -429,6 +429,81 @@ describe("GET /projects/:id/renders/file/* — path safety", () => {
});
});
describe("POST /render/:jobId/cancel", () => {
async function startJob(app: Hono): Promise<string> {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
return ((await res.json()) as { jobId: string }).jobId;
}
it("marks a rendering job cancelled and invokes the adapter abort hook", async () => {
const spy = vi.fn();
let aborted = false;
const { adapter, rendersDir } = createAdapter(spy);
const baseStartRender = adapter.startRender.bind(adapter);
adapter.startRender = (opts) => {
const state = baseStartRender(opts);
state.cancel = () => {
aborted = true;
};
return state;
};
const app = new Hono();
registerRenderRoutes(app, adapter);
try {
const jobId = await startJob(app);
const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" });
expect(res.status).toBe(200);
expect(((await res.json()) as { status: string }).status).toBe("cancelled");
expect(aborted).toBe(true);
// SSE progress for a cancelled job must terminate (status is terminal).
const progress = await app.request(`http://localhost/render/${jobId}/progress`);
expect(progress.status).toBe(200);
} finally {
rmSync(rendersDir, { recursive: true, force: true });
}
});
it("does not cancel a job that already completed", async () => {
const spy = vi.fn();
const states: Array<{ status: string }> = [];
const { adapter, rendersDir } = createAdapter(spy);
const baseStartRender = adapter.startRender.bind(adapter);
adapter.startRender = (opts) => {
const state = baseStartRender(opts);
states.push(state);
return state;
};
const app = new Hono();
registerRenderRoutes(app, adapter);
try {
const jobId = await startJob(app);
const [state] = states;
if (state) state.status = "complete";
const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" });
expect(res.status).toBe(200);
expect(((await res.json()) as { status: string }).status).toBe("complete");
} finally {
rmSync(rendersDir, { recursive: true, force: true });
}
});
it("404s for unknown jobs", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/render/nope/cancel", { method: "POST" });
expect(res.status).toBe(404);
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => {
it("forwards the browser telemetryDistinctId to the adapter as distinctId", async () => {
const spy = vi.fn();
+15 -2
View File
@@ -26,7 +26,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
const cleanupFinishedJobs = () => {
const now = Date.now();
for (const [key, job] of renderJobs) {
if ((job.status === "complete" || job.status === "failed") && now - job.createdAt > TTL_MS) {
if (job.status !== "rendering" && now - job.createdAt > TTL_MS) {
renderJobs.delete(key);
}
}
@@ -142,12 +142,25 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
error: current.error,
}),
});
if (current.status === "complete" || current.status === "failed") break;
if (current.status !== "rendering") break;
await stream.sleep(500);
}
});
});
// Cancel an in-flight render. Marks the job cancelled immediately (so the
// SSE stream terminates) and invokes the adapter's abort hook when present.
api.post("/render/:jobId/cancel", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
if (job.status === "rendering") {
job.status = "cancelled";
job.cancel?.();
}
return c.json({ status: job.status });
});
const RENDER_MIME: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
+7 -1
View File
@@ -12,11 +12,17 @@ export interface ResolvedProject {
/** Observable render job state, polled by the SSE progress handler. */
export interface RenderJobState {
id: string;
status: "rendering" | "complete" | "failed";
status: "rendering" | "complete" | "failed" | "cancelled";
progress: number;
stage?: string;
outputPath: string;
error?: string;
/**
* Optional abort hook set by the adapter. The cancel route calls this to
* stop an in-flight render; adapters that can't abort may omit it (the
* route still marks the job cancelled so the SSE stream terminates).
*/
cancel?: () => void;
}
export interface MediaProcessingJobState {