mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(studio,studio-server,cli): render cancel end-to-end + renders/nle/storyboard UX (#1963)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user