diff --git a/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts index 8b3f28100d..4146dba39d 100644 --- a/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-dispatch.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type DeveloperVideoStore, developerVideo, @@ -19,7 +19,10 @@ const mocks = vi.hoisted(() => ({ endpointQueries: 0, failEndpointQuery: false, hangEndpointQuery: false, + failTransactions: 0, start: vi.fn(), + attempt: vi.fn(), + sleep: vi.fn(), head: vi.fn(), readText: vi.fn(), })); @@ -52,7 +55,13 @@ vi.mock("@cap/database", async () => { : videos.select(fields).from(), }), transaction: (run: (tx: unknown) => Promise) => { - const next = turn.then(() => run(database)); + const next = turn.then(() => { + if (mocks.failTransactions > 0) { + mocks.failTransactions -= 1; + throw new Error("connection lost"); + } + return run(database); + }); turn = next.catch(() => undefined); return next; }, @@ -71,6 +80,10 @@ vi.mock("workflow/api", () => ({ start: mocks.start })); vi.mock("@/workflows/deliver-developer-webhook", () => ({ deliverDeveloperWebhookWorkflow: "deliver-developer-webhook", })); +vi.mock("workflow", () => ({ sleep: mocks.sleep })); +vi.mock("@/lib/developer-webhook-delivery", () => ({ + deliverDeveloperWebhookAttempt: mocks.attempt, +})); vi.mock("@/lib/developer-video-storage", () => ({ headDeveloperVideoObject: mocks.head, readDeveloperVideoText: mocks.readText, @@ -92,8 +105,18 @@ vi.mock("@/lib/media-client", () => ({ })); vi.mock("@/lib/media-container", () => ({ sniffMediaUrl: vi.fn() })); -const { applyDeveloperVideoProgress, failDeveloperVideoProcessing } = - await import("@/lib/developer-video-processing"); +const { + applyDeveloperVideoProgress, + claimDeveloperVideoProcessing, + failDeveloperVideoProcessing, + readDeveloperVideoParts, +} = await import("@/lib/developer-video-processing"); +const { developerVideoEventStep, READY_EVENT_MAX_WAIT_MS } = await import( + "@/lib/developer-webhook-events" +); +const { deliverDeveloperWebhookWorkflow } = await vi.importActual< + typeof import("@/workflows/deliver-developer-webhook") +>("@/workflows/deliver-developer-webhook"); const { enqueueDeveloperVideoEvent } = await import( "@/lib/developer-webhook-dispatch" ); @@ -145,6 +168,11 @@ const hlsDone = () => const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); +function readyData() { + const job = jobs().find((queued) => queued.eventType === "video.ready"); + return JSON.parse(job?.body ?? "{}").data; +} + function video() { if (!mocks.store.video) throw new Error("no video"); return mocks.store.video; @@ -161,7 +189,12 @@ const jobs = () => }, ); +afterEach(() => { + vi.useRealTimers(); +}); + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); vi.clearAllMocks(); mocks.store.video = developerVideo({ processing: processingState({ mp4: running("m1"), hls: running("h1") }), @@ -179,7 +212,9 @@ beforeEach(() => { mocks.endpointQueries = 0; mocks.failEndpointQuery = false; mocks.hangEndpointQuery = false; + mocks.failTransactions = 0; mocks.start.mockResolvedValue({ runId: "run-1" }); + mocks.sleep.mockResolvedValue(undefined); mocks.head.mockResolvedValue({ bytes: 2_000, contentType: "video/mp4" }); mocks.readText.mockResolvedValue(master); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -189,6 +224,7 @@ beforeEach(() => { describe("video.ready", () => { it("queues one delivery carrying the video as GET /videos/:id returns it", async () => { await mp4Done(); + await hlsDone(); await settled(); expect(video().status).toBe("ready"); @@ -210,34 +246,258 @@ describe("video.ready", () => { ); expect(event.data).toMatchObject({ id: "video-1", status: "ready" }); expect(event.data.mp4Url).toContain("https://play.example/"); + expect(event.data.hlsUrl).toContain("https://play.example/"); expect(job?.body).not.toContain("s3Key"); expect(job?.body).not.toContain("developer/app-1"); }); - it("queues nothing more when the encoder's callback arrives twice", async () => { - await mp4Done(); + it("waits for HLS when the MP4 makes the video ready first", async () => { await mp4Done(); + await settled(); + expect(video().status).toBe("ready"); + expect(video().processing?.readyEventDueAt).toEqual(expect.any(Number)); + expect(mocks.start).not.toHaveBeenCalled(); + + await hlsDone(); + await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); + expect(readyData()).toMatchObject({ + hlsUrl: expect.stringContaining("/master.m3u8"), + mp4Url: expect.stringContaining("https://play.example/"), + }); + expect(video().processing?.readyEventDueAt).toBeUndefined(); + }); + + it("waits for the MP4 when HLS makes the video ready first", async () => { + await hlsDone(); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.start).not.toHaveBeenCalled(); + await mp4Done(); await settled(); expect(mocks.start).toHaveBeenCalledTimes(1); + expect(readyData()).toMatchObject({ + hlsUrl: expect.stringContaining("/master.m3u8"), + mp4Url: expect.stringContaining("https://play.example/"), + }); }); - it("queues one event when both outputs finish at once", async () => { + it("queues one event carrying both URLs when both outputs finish at once", async () => { await Promise.all([mp4Done(), hlsDone()]); await settled(); expect(video().status).toBe("ready"); expect(mocks.start).toHaveBeenCalledTimes(1); expect(jobs()[0]?.eventType).toBe("video.ready"); + expect(readyData().hlsUrl).toContain("/master.m3u8"); + expect(readyData().mp4Url).toContain("https://play.example/"); + }); + + it("queues one event when both outputs settle in one update", async () => { + mocks.store.video = developerVideo({ + processing: processingState({ + mp4: { state: "complete", output: "m0" }, + hls: running("h1"), + }), + }); + await hlsDone(); + await settled(); + expect(video().status).toBe("ready"); + expect(mocks.start).toHaveBeenCalledTimes(1); + expect(video().processing?.readyEventDueAt).toBeUndefined(); }); - it("queues nothing when the second output finishes later", async () => { + it("queues nothing more when the encoder's callbacks arrive again", async () => { + await mp4Done(); + await mp4Done(); + await hlsDone(); + await hlsDone(); await mp4Done(); await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); + }); + + it("is sent with hlsUrl null when HLS fails", async () => { + mocks.store.video = developerVideo({ + processing: processingState({ + mp4: running("m1"), + hls: running("h1"), + attempts: { hls: 2, mp4: 1 }, + }), + }); + await mp4Done(); + await settled(); + expect(mocks.start).not.toHaveBeenCalled(); + + await applyDeveloperVideoProgress( + { videoId: "video-1", phase: "error", error: "boom" }, + "hls", + "h1", + ); + await settled(); + expect(video().processing?.hls.state).toBe("failed"); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + expect(readyData()).toMatchObject({ + hlsUrl: null, + mp4Url: expect.stringContaining("https://play.example/"), + }); + }); + + it("is sent once, with hlsUrl null, when HLS never settles", async () => { + const realNow = Date.now(); + vi.setSystemTime(realNow); + await mp4Done(); + await settled(); + expect(mocks.start).not.toHaveBeenCalled(); + + vi.setSystemTime(realNow + READY_EVENT_MAX_WAIT_MS - 1_000); + await readDeveloperVideoParts("video-1", "encode-1"); + await settled(); + expect(mocks.start).not.toHaveBeenCalled(); + + vi.setSystemTime(realNow + READY_EVENT_MAX_WAIT_MS); + const parts = await readDeveloperVideoParts("video-1", "encode-1"); + await settled(); + expect(parts?.hls).toBe("check"); + expect(video().processing?.hls.state).toBe("running"); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + expect(readyData()).toMatchObject({ + hlsUrl: null, + mp4Url: expect.stringContaining("https://play.example/"), + }); + + await readDeveloperVideoParts("video-1", "encode-1"); await hlsDone(); await settled(); + expect(video().processing?.hls.state).toBe("complete"); expect(mocks.start).toHaveBeenCalledTimes(1); }); + it("is sent with hlsUrl null when processing times out on a stuck HLS job", async () => { + await mp4Done(); + await settled(); + await failDeveloperVideoProcessing("video-1", "encode-1", "Too long"); + await settled(); + expect(video().status).toBe("ready"); + expect(video().processing?.hls.state).toBe("failed"); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + expect(readyData().hlsUrl).toBeNull(); + }); + + it("is sent on the next poll when the last output was settled without sending", async () => { + mocks.store.video = developerVideo({ + status: "ready", + processing: processingState({ + mp4: { state: "complete", output: "m1" }, + hls: { state: "complete", output: "h1" }, + readyEventDueAt: Date.now() - 5_000, + }), + }); + const parts = await readDeveloperVideoParts("video-1", "encode-1"); + await readDeveloperVideoParts("video-1", "encode-1"); + await settled(); + expect(parts).toEqual({ hls: "done", mp4: "done" }); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + expect(readyData().hlsUrl).toContain("/master.m3u8"); + expect(video().processing?.readyEventDueAt).toBeUndefined(); + }); + + it("keeps the workflow polling when sending a due event fails", async () => { + mocks.store.video = developerVideo({ + status: "ready", + processing: processingState({ + mp4: { state: "complete", output: "m1" }, + hls: { state: "complete", output: "h1" }, + readyEventDueAt: Date.now() - 5_000, + }), + }); + mocks.failTransactions = 1; + expect(await readDeveloperVideoParts("video-1", "encode-1")).toEqual({ + hls: "wait", + mp4: "wait", + }); + await settled(); + expect(mocks.start).not.toHaveBeenCalled(); + expect(video().processing?.readyEventDueAt).toEqual(expect.any(Number)); + + expect(await readDeveloperVideoParts("video-1", "encode-1")).toEqual({ + hls: "done", + mp4: "done", + }); + await settled(); + expect(jobs().map((job) => job.eventType)).toEqual(["video.ready"]); + }); + + it("sends no second event for a video that was ready before this change", async () => { + mocks.store.video = developerVideo({ + status: "ready", + processing: processingState({ + mp4: { state: "complete", output: "m0" }, + hls: running("h1"), + }), + }); + await hlsDone(); + await settled(); + expect(video().processing?.hls.state).toBe("complete"); + + vi.setSystemTime(Date.now() + 2 * READY_EVENT_MAX_WAIT_MS); + await readDeveloperVideoParts("video-1", "encode-1"); + await settled(); + expect(mocks.endpointQueries).toBe(0); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("sends no event for a video that was ready before webhooks existed", async () => { + mocks.store.video = developerVideo({ + status: "ready", + createdAt: new Date("2026-10-01T00:00:00Z"), + updatedAt: new Date("2026-10-01T00:00:00Z"), + processing: processingState({ + mp4: { state: "complete", output: "m0" }, + hls: { state: "unavailable", reason: "not yet", retryAt: 0 }, + }), + }); + expect(await claimDeveloperVideoProcessing("video-1")).toMatchObject({ + action: "retry-hls", + }); + expect(video().processing?.hls.state).toBe("pending"); + mocks.store.video = { + ...video(), + processing: processingState({ + ...video().processing, + hls: running("h1"), + }), + }; + await hlsDone(); + await settled(); + expect(video().processing?.hls.state).toBe("complete"); + expect(mocks.endpointQueries).toBe(0); + expect(mocks.start).not.toHaveBeenCalled(); + }); + + it("re-sends the same event id and body on every retry", async () => { + await mp4Done(); + await hlsDone(); + await settled(); + const [job] = jobs(); + if (!job) throw new Error("no event was queued"); + mocks.attempt + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("delivered"); + + expect(await deliverDeveloperWebhookWorkflow(job)).toEqual({ + outcome: "delivered", + attempts: 3, + }); + const sent = mocks.attempt.mock.calls.map(([attempt]) => attempt); + expect(sent).toEqual([job, job, job]); + expect(JSON.parse(sent[2]?.body ?? "{}")).toMatchObject({ + id: developerVideoEventId("video-1", "video.ready"), + data: { hlsUrl: expect.stringContaining("/master.m3u8") }, + }); + }); + it("queues one delivery for each endpoint that wants the event", async () => { mocks.endpoints.push( { @@ -269,7 +529,7 @@ describe("video.ready", () => { deletedAt: null, }, ); - await mp4Done(); + await Promise.all([mp4Done(), hlsDone()]); await settled(); expect(jobs().map((job) => job.endpointId)).toEqual(["wh-1", "wh-2"]); expect(new Set(jobs().map((job) => job.eventId)).size).toBe(1); @@ -279,18 +539,20 @@ describe("video.ready", () => { it("reads nothing more and queues nothing for an app with no endpoints", async () => { mocks.endpoints = []; await mp4Done(); + await hlsDone(); await settled(); expect(video().status).toBe("ready"); expect(mocks.endpointQueries).toBe(1); expect(mocks.start).not.toHaveBeenCalled(); }); - it("asks about endpoints only when the status changes", async () => { + it("asks about endpoints only when an event is due", async () => { await applyDeveloperVideoProgress( { videoId: "video-1", phase: "processing", jobId: "job-9" }, "mp4", "m1", ); + await mp4Done(); await settled(); expect(mocks.endpointQueries).toBe(0); }); @@ -317,6 +579,7 @@ describe("video.errored", () => { it("queues nothing for an output that fails after the video is ready", async () => { await mp4Done(); + await hlsDone(); await settled(); await applyDeveloperVideoProgress( { videoId: "video-1", phase: "error", error: "boom" }, @@ -375,26 +638,36 @@ describe("webhooks never hold up a video", () => { const hanging = new Promise(() => {}); mocks.start.mockReturnValue(hanging); - const started = Date.now(); - const result = await mp4Done(); + vi.useRealTimers(); + const started = performance.now(); + expect(await mp4Done()).toEqual({ success: true, applied: true }); + expect(await hlsDone()).toEqual({ success: true, applied: true }); + await settled(); - expect(result).toEqual({ success: true, applied: true }); + expect(mocks.start).toHaveBeenCalledTimes(1); expect(video().status).toBe("ready"); - expect(Date.now() - started).toBeLessThan(1_000); + expect(video().processing?.hls.state).toBe("complete"); + expect(performance.now() - started).toBeLessThan(1_000); }); - it("marks the video ready when queueing the delivery fails", async () => { + it("records the outputs when queueing the delivery fails", async () => { mocks.start.mockRejectedValue(new Error("queue is down")); expect(await mp4Done()).toEqual({ success: true, applied: true }); + expect(await hlsDone()).toEqual({ success: true, applied: true }); await settled(); + expect(mocks.start).toHaveBeenCalledTimes(1); expect(video().status).toBe("ready"); + expect(video().processing?.hls.state).toBe("complete"); }); - it("marks the video ready when the endpoint lookup fails", async () => { + it("records the outputs when the endpoint lookup fails", async () => { mocks.failEndpointQuery = true; expect(await mp4Done()).toEqual({ success: true, applied: true }); + expect(await hlsDone()).toEqual({ success: true, applied: true }); await settled(); + expect(mocks.endpointQueries).toBe(1); expect(video().status).toBe("ready"); + expect(video().processing?.hls.state).toBe("complete"); expect(mocks.start).not.toHaveBeenCalled(); }); @@ -406,3 +679,131 @@ describe("webhooks never hold up a video", () => { expect(mocks.start).not.toHaveBeenCalled(); }); }); + +describe("deciding the event inside the status write", () => { + const complete = { state: "complete" as const, output: "o" }; + const now = 10_000_000; + + it("sends video.ready at once when every output has settled", () => { + const processing = processingState({ + mp4: complete, + hls: { state: "unavailable", reason: "no ladder", retryAt: now + 1 }, + }); + expect( + developerVideoEventStep( + { status: "processing", processing }, + { status: "ready", processing }, + now, + ), + ).toEqual({ type: "video.ready" }); + }); + + it("marks the video and waits while an output is still encoding", () => { + const processing = processingState({ mp4: complete, hls: running("h") }); + expect( + developerVideoEventStep( + { status: "processing", processing }, + { status: "ready", processing }, + now, + ), + ).toEqual({ + type: null, + processing: { ...processing, readyEventDueAt: now }, + }); + }); + + it("sends it once the waiting output settles, and clears the mark", () => { + const waiting = processingState({ + mp4: complete, + hls: running("h"), + readyEventDueAt: now, + }); + const settledState = { ...waiting, hls: complete }; + const step = developerVideoEventStep( + { status: "ready", processing: waiting }, + { status: "ready", processing: settledState }, + now + 5_000, + ); + expect(step.type).toBe("video.ready"); + expect(step.processing).not.toHaveProperty("readyEventDueAt"); + expect( + developerVideoEventStep( + { status: "ready", processing: step.processing ?? null }, + { status: "ready", processing: step.processing ?? null }, + now + 6_000, + ), + ).toEqual({ type: null }); + }); + + it("sends it once the wait runs out, even with an output still encoding", () => { + const waiting = processingState({ + mp4: complete, + hls: running("h"), + readyEventDueAt: now, + }); + const video = { status: "ready", processing: waiting }; + expect( + developerVideoEventStep(video, video, now + READY_EVENT_MAX_WAIT_MS - 1), + ).toEqual({ type: null }); + expect( + developerVideoEventStep(video, video, now + READY_EVENT_MAX_WAIT_MS).type, + ).toBe("video.ready"); + }); + + it("sends nothing for a ready video without the mark", () => { + const before = processingState({ mp4: complete, hls: running("h") }); + const after = { ...before, hls: complete }; + expect( + developerVideoEventStep( + { status: "ready", processing: before }, + { status: "ready", processing: after }, + now + 10 * READY_EVENT_MAX_WAIT_MS, + ), + ).toEqual({ type: null }); + expect( + developerVideoEventStep( + { status: "ready", processing: null }, + { status: "ready", processing: null }, + now, + ), + ).toEqual({ type: null }); + }); + + it("sends video.errored on the change to errored, as before", () => { + const processing = processingState(); + expect( + developerVideoEventStep( + { status: "processing", processing }, + { status: "errored", processing }, + now, + ), + ).toEqual({ type: "video.errored" }); + expect( + developerVideoEventStep( + { status: "errored", processing }, + { status: "errored", processing }, + now, + ), + ).toEqual({ type: null }); + }); +}); + +describe("a rolled back announcement", () => { + it("is not sent while the committed row still carries the mark", async () => { + mocks.store.video = developerVideo({ + status: "ready", + processing: processingState({ + mp4: { state: "complete", output: "m0" }, + hls: { state: "complete", output: "h0" }, + readyEventDueAt: 1, + }), + }); + expect( + await enqueueDeveloperVideoEvent( + { id: "video-1", appId: "app-1" }, + "video.ready", + ), + ).toBe(0); + expect(mocks.start).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/developer-webhook-docs.test.ts b/apps/web/__tests__/unit/developer-webhook-docs.test.ts index 8337172bae..ce7c9a606c 100644 --- a/apps/web/__tests__/unit/developer-webhook-docs.test.ts +++ b/apps/web/__tests__/unit/developer-webhook-docs.test.ts @@ -1,5 +1,6 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { compileMDX } from "next-mdx-remote/rsc"; @@ -16,6 +17,7 @@ const { app } = await import("@/app/api/developer/v1/[...route]/webhooks"); const { developerOpenApiSpec } = await import("@/lib/developer-openapi"); const { MAX_WEBHOOK_ENDPOINTS_PER_APP, + READY_EVENT_MAX_WAIT_MS, WEBHOOK_DISABLE_AFTER_FAILURES, WEBHOOK_DISABLE_AFTER_MS, WEBHOOK_EVENT_TYPES, @@ -143,6 +145,95 @@ describe("the webhooks guide", () => { } }); + it("gives a receiver that answers Cap's request and refuses a forged one", async () => { + const jsBlocks = [...guide.matchAll(/```js\n([\s\S]*?)```/g)].map( + ([, code]) => code ?? "", + ); + const verify = jsBlocks.find((code) => code.includes("timingSafeEqual")); + const receiver = jsBlocks.find((code) => code.includes("createServer")); + expect(receiver).toContain('from "./verify-cap-webhook.mjs"'); + const folder = mkdtempSync(join(tmpdir(), "cap-webhook-receiver-")); + const port = await new Promise((resolve) => { + const probe = createServer().listen(0, () => { + const address = probe.address(); + probe.close(() => + resolve(typeof address === "object" && address ? address.port : 0), + ); + }); + }); + const secret = "cws_0123456789abcdefghjkmnpqrstvwx"; + writeFileSync(join(folder, "verify-cap-webhook.mjs"), verify ?? ""); + writeFileSync(join(folder, "server.mjs"), receiver ?? ""); + const child = spawn(process.execPath, [join(folder, "server.mjs")], { + env: { ...process.env, PORT: String(port), CAP_WEBHOOK_SECRET: secret }, + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + try { + const url = `http://127.0.0.1:${port}/webhooks/cap`; + const body = JSON.stringify({ + id: "evt_1", + type: "video.ready", + data: { id: "video-1", hlsUrl: "https://play/master.m3u8" }, + }); + const post = async (signature: string) => { + for (let tries = 0; ; tries++) { + try { + return await fetch(url, { + method: "POST", + headers: { "Cap-Signature": signature }, + body, + }); + } catch (error) { + if (tries > 50) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + }; + const oversized = await post("").then(() => + fetch(url, { method: "POST", body: "x".repeat(2_000_000) }).then( + (response) => response.status, + () => "reset", + ), + ); + expect([413, "reset"]).toContain(oversized); + const now = Math.floor(Date.now() / 1000); + expect( + (await post(signWebhookBody(`${secret}x`, body, now))).status, + ).toBe(400); + expect((await post(signWebhookBody(secret, body, now))).status).toBe(200); + expect((await post(signWebhookBody(secret, body, now))).status).toBe(200); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(output.trim().split("\n")).toEqual([ + "ready video-1 https://play/master.m3u8", + ]); + } finally { + child.kill(); + rmSync(folder, { recursive: true, force: true }); + } + }); + + it("says when video.ready is sent, matching the code", () => { + expect(guide).not.toContain("No second event is sent"); + expect(guide).toContain( + "`video.ready` arrives once every playback URL is ready, so `hlsUrl` is set unless HLS failed", + ); + expect(READY_EVENT_MAX_WAIT_MS).toBe(30 * 60 * 1000); + expect(guide).toContain("still encoding 30 minutes after"); + expect( + (spec.webhooks as Record)[ + "video.ready" + ]?.post.description, + ).toContain("30 minutes after"); + }); + + it("indents code like the rest of the docs", () => { + const blocks = [...guide.matchAll(/```\w*\n([\s\S]*?)```/g)]; + for (const [, code] of blocks) expect(code).not.toMatch(/^\t/m); + }); + it("teaches the REST API only", () => { expect(guide).not.toMatch(/\bcap (developers|caps|webhooks)\b/); expect(guide).not.toMatch(/\bMCP\b/); diff --git a/apps/web/__tests__/unit/developer-webhooks-api.test.ts b/apps/web/__tests__/unit/developer-webhooks-api.test.ts index c0234edcba..bae4eac7ff 100644 --- a/apps/web/__tests__/unit/developer-webhooks-api.test.ts +++ b/apps/web/__tests__/unit/developer-webhooks-api.test.ts @@ -98,7 +98,9 @@ async function create( body: (await response.json()) as { data: Record & { id: string; secret: string }; code?: string; + message?: string; hint?: string; + docs?: string; }, }; } @@ -175,6 +177,44 @@ describe("creating a webhook endpoint", () => { expect(rows()).toHaveLength(0); }); + it("explains an unknown or missing event in plain words", async () => { + const unknown = await create(A, { + url: "https://a.example/x", + events: ["video.ready", "video.updated"], + }); + expect(unknown.body.message).toBe( + 'Invalid request: events.1: Unknown event "video.updated". Use video.ready or video.errored.', + ); + const none = await create(A, { url: "https://a.example/x", events: [] }); + expect(none.body.message).toBe( + "Invalid request: events: events must list at least one of: video.ready, video.errored", + ); + expect(unknown.body.docs).toBe("https://cap.so/docs/api/rest-api"); + + const objectEvent = await create(A, { + url: "https://a.example/x", + events: [{ toString: null }], + }); + expect(objectEvent.status).toBe(400); + expect(objectEvent.body.message).toBe( + "Invalid request: events.0: Unknown event. Use video.ready or video.errored.", + ); + expect(rows()).toHaveLength(0); + }); + + it("links webhook errors to the webhooks guide", async () => { + const missing = await send("GET", "/wh_missing", A); + expect(await missing.json()).toMatchObject({ + code: "webhook_not_found", + docs: "https://cap.so/docs/api/webhooks", + }); + const refused = await create(A, { url: "http://hooks.example.com/cap" }); + expect(refused.body).toMatchObject({ + code: "invalid_webhook_url", + docs: "https://cap.so/docs/api/webhooks", + }); + }); + it.each([ ["http", "http://hooks.example.com/cap"], ["a private address", "https://10.0.0.8/cap"], diff --git a/apps/web/app/api/developer/v1/[...route]/webhooks.ts b/apps/web/app/api/developer/v1/[...route]/webhooks.ts index ce6d78bae4..1e53c1aeec 100644 --- a/apps/web/app/api/developer/v1/[...route]/webhooks.ts +++ b/apps/web/app/api/developer/v1/[...route]/webhooks.ts @@ -31,8 +31,15 @@ const failureBody = (failure: WebhookFailure) => const endpointFields = { url: z.string().min(1).max(2048), events: z - .array(z.enum(WEBHOOK_EVENT_TYPES)) - .min(1) + .array( + z.enum(WEBHOOK_EVENT_TYPES, { + error: (issue) => + `Unknown event${typeof issue.input === "string" ? ` ${JSON.stringify(issue.input.slice(0, 100))}` : ""}. Use ${WEBHOOK_EVENT_TYPES.join(" or ")}.`, + }), + ) + .min(1, { + error: `events must list at least one of: ${WEBHOOK_EVENT_TYPES.join(", ")}`, + }) .refine((types) => new Set(types).size === types.length, { message: "Each event type can be listed once", }), diff --git a/apps/web/content/docs/api/get-started.mdx b/apps/web/content/docs/api/get-started.mdx index aa16700e07..17ce7aefdd 100644 --- a/apps/web/content/docs/api/get-started.mdx +++ b/apps/web/content/docs/api/get-started.mdx @@ -358,6 +358,8 @@ Save this as `index.html` beside `server.mjs`. It uses no library. Completing the upload starts processing. [Get Video Status](/docs/api/rest-api#get-video-status) moves from `uploading` to `processing`, then to `ready` or `errored`. The page asks your server, and your server asks Cap with the secret key. +Or let Cap tell your server when it's ready: see [Webhooks](/docs/api/webhooks). + Add this to `index.html`, below the upload script: ```html @@ -468,7 +470,7 @@ Safari plays HLS by itself. Every other browser needs [hls.js](https://github.co | `playMp4` on a fatal error | Keep playing from the same position if HLS fails for any other reason | | `stopHls` at the start of `playVideo` | Stop the previous video's player before starting the next one on the same page | -There is no start level to set. The master playlist lists a rendition that starts quickly first, and players begin with the first one listed. +There is no start level to set. Players begin with the first rendition the master playlist lists, and Cap lists the largest one whose peak bitrate is at most 2.6 Mbps first, at 1080p or smaller. On most videos that is 720p. The player steps up or down from there. [Play a Video](/docs/api/rest-api#play-a-video) covers a Content Security Policy, bitrates and what happens when playback stops. @@ -492,7 +494,7 @@ Every upload also returns `shareUrl` and `embedUrl`. They are Cap's hosted playe `loading="lazy"` leaves an embed further down the page unloaded until the viewer scrolls near it. The embed takes options in its URL, such as a start time, autoplay and loop. See [Embed Parameters](/docs/api/rest-api#embed-parameters). -Use the hosted player when you want a video on a page with the least work. Build your own player, as above, when you need to control how it looks or react to what the viewer does. [Share and Embed](/docs/api/rest-api#share-and-embed) has the details. +Use the hosted player for the look and behaviour too: [Embed Options](/docs/api/rest-api#embed-options) set its colour, corners and controls, [Embed Parameters](/docs/api/rest-api#embed-parameters) its start time and autoplay, and [Player Events](/docs/api/rest-api#player-events) tell your page what the viewer does. Build your own player, as above, only when you need full control of the UI. [Share and Embed](/docs/api/rest-api#share-and-embed) has the details. ## 6. Upload from your server diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 37a208a0f8..de84d2ac7e 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -572,7 +572,7 @@ Poll this endpoint every 3 seconds after completing an upload, and stop when `st A video is `ready` as soon as its first output can be played, so playback can start while the rest is still encoding. The playback URLs are `null` until then. `hlsUrl` is an adaptive HLS master playlist, `mp4Url` is a progressive MP4 and `posterUrl` is a poster image. They need no API key and can be loaded from any origin. [Play a Video](/docs/api/rest-api#play-a-video) shows how to use them, and [Delete Video](/docs/api/rest-api#delete-video) says when they stop working. -On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that output finishes encoding. It is filled in later without the video leaving `ready`, and `renditions` is filled in together with `hlsUrl`. Play whichever URL is present. A player that wants HLS should keep polling after `ready` until `hlsUrl` is set, and can play `mp4Url` in the meantime. +On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that output finishes encoding. It is filled in later without the video leaving `ready`, and `renditions` is filled in together with `hlsUrl`. Play whichever URL is present. A player that wants HLS should keep polling after `ready` until `hlsUrl` is set, and can play `mp4Url` in the meantime. A [webhook](/docs/api/webhooks)'s `video.ready` is sent once every playback URL is ready instead. `outputs` says whether a `null` URL is still coming. Each of `outputs.hls` and `outputs.mp4` is `ready` when its URL is set, `processing` while it is being encoded or will be retried, and `failed` when it will not be produced. A video can be `ready` with one output `failed`, so stop waiting for that URL and play the other. `none` only appears on videos uploaded before encoding existed. While `takenDown` is `true` the playback URLs are `null` whatever `outputs` says, and `outputs` keeps describing what was encoded. @@ -1394,7 +1394,7 @@ Replace `HLS_URL`, `MP4_URL` and `POSTER_URL` with the video's `hlsUrl`, `mp4Url - If hls.js hits an error it cannot recover from, the page switches to the MP4 at the same position and keeps playing. - If the browser's own HLS player fails, the page loads the MP4 in its place. A viewer who had already pressed play may have to press it again. - `capLevelToPlayerSize` stops hls.js from loading a rendition larger than the player. Without it, a small player on a fast connection climbs to the largest rendition. -- There is no start level to set. The master playlist lists a rendition that starts quickly first, and players begin with the first one listed. +- There is no start level to set. Players begin with the first rendition the master playlist lists, and Cap lists the largest one whose peak bitrate is at most 2.6 Mbps first, at 1080p or smaller. On most videos that is 720p. ### Play in Safari diff --git a/apps/web/content/docs/api/webhooks.mdx b/apps/web/content/docs/api/webhooks.mdx index 49f0e68a86..873bd4430a 100644 --- a/apps/web/content/docs/api/webhooks.mdx +++ b/apps/web/content/docs/api/webhooks.mdx @@ -18,7 +18,7 @@ Webhooks belong to an app in [Cap Infra](/dashboard/infra) and are managed with A video sends `video.ready` or `video.errored` once. Handlers should ignore a type they do not know, so a new event type never breaks them. -On a ready video, one of `hlsUrl` and `mp4Url` can still be `null` while that output finishes encoding. No second event is sent when it arrives. Read it with [Get Video](/docs/api/rest-api#get-video). +`video.ready` arrives once every playback URL is ready, so `hlsUrl` is set unless HLS failed. In the rare case that an output is still encoding 30 minutes after the video could first be played, the event is sent without it, and [Get Video](/docs/api/rest-api#get-video) returns it once it is ready. ## Create an endpoint @@ -88,7 +88,7 @@ Anything else returns `400` with code `invalid_webhook_url`. An app can have 5 e | `POST /api/developer/v1/webhooks/:id/rotate-secret` | [Rotate the signing secret](#rotate-the-signing-secret) | | `POST /api/developer/v1/webhooks/:id/test` | [Send a test event](#send-a-test-event) | -An ID that does not belong to the app returns `404` with code `webhook_not_found`. +An ID that does not belong to the app returns `404` with code `webhook_not_found`. Deleting an endpoint that is already deleted returns `200`, the same as deleting a video twice, so a retried delete is safe. Every endpoint is returned with these fields: @@ -143,7 +143,7 @@ Cap-Event-Type: video.ready "width": 1920, "height": 1080, "playbackId": "Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G", - "hlsUrl": null, + "hlsUrl": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/master.m3u8", "mp4Url": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/video.mp4", "posterUrl": "https://cap.so/api/developer/playback/Zk3vQ9s2Lm8Rt1Xy7Bc4Df6G/poster.jpg" } @@ -178,21 +178,21 @@ Sign the body exactly as it arrived, byte for byte. Parsing the JSON and seriali import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyCapWebhook(rawBody, signatureHeader, secret) { - const parts = Object.fromEntries( - String(signatureHeader ?? "") - .split(",") - .map((part) => part.split("=")), - ); - const timestamp = Number(parts.t); - if (!Number.isInteger(timestamp) || !parts.v1) return false; - if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; - const expected = createHmac("sha256", secret) - .update(`${parts.t}.${rawBody}`) - .digest(); - const received = Buffer.from(parts.v1, "hex"); - return ( - received.length === expected.length && timingSafeEqual(received, expected) - ); + const parts = Object.fromEntries( + String(signatureHeader ?? "") + .split(",") + .map((part) => part.split("=")), + ); + const timestamp = Number(parts.t); + if (!Number.isInteger(timestamp) || !parts.v1) return false; + if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; + const expected = createHmac("sha256", secret) + .update(`${parts.t}.${rawBody}`) + .digest(); + const received = Buffer.from(parts.v1, "hex"); + return ( + received.length === expected.length && timingSafeEqual(received, expected) + ); } ``` @@ -223,6 +223,57 @@ def verify_cap_webhook(raw_body: bytes, signature_header: str, secret: str) -> b return hmac.compare_digest(expected, received) ``` +### A complete receiver + +This server uses only Node.js. Save the function above as `verify-cap-webhook.mjs` next to it and start it with your signing secret in `CAP_WEBHOOK_SECRET`. It reads the raw body, refuses one over 1 MB (Cap's events are far smaller), checks the signature, answers `200` straight away, then handles the event. + +```js +import { createServer } from "node:http"; +import { verifyCapWebhook } from "./verify-cap-webhook.mjs"; + +const secret = process.env.CAP_WEBHOOK_SECRET; +const handled = new Set(); + +function handleEvent(event) { + if (handled.has(event.id)) return; + handled.add(event.id); + if (event.type === "video.ready") { + console.log("ready", event.data.id, event.data.hlsUrl ?? event.data.mp4Url); + } else if (event.type === "video.errored") { + console.log("errored", event.data.id, event.data.errorReason); + } +} + +createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/webhooks/cap") { + res.writeHead(404).end(); + return; + } + const chunks = []; + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + if (size > 1_000_000) { + res.writeHead(413).end(); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + const rawBody = Buffer.concat(chunks).toString("utf8"); + if (!verifyCapWebhook(rawBody, req.headers["cap-signature"], secret)) { + res.writeHead(400).end(); + return; + } + res.writeHead(200).end(); + handleEvent(JSON.parse(rawBody)); + }); +}).listen(Number(process.env.PORT ?? 3000)); +``` + +With Express, read this route's body with `express.raw({ type: "application/json" })`, not `express.json()`: a parsed and re-serialised body no longer matches the signature. Keep the ids you have handled in your database rather than in memory, so a restart does not forget them. + ## Respond Answer with any `2xx` status within 10 seconds. Do the slow work after you have answered: store the event or put it on a queue, respond, then process it. diff --git a/apps/web/lib/api-errors.ts b/apps/web/lib/api-errors.ts index 59ab790604..b296759efd 100644 --- a/apps/web/lib/api-errors.ts +++ b/apps/web/lib/api-errors.ts @@ -1,4 +1,5 @@ export const API_DOCS_URL = "https://cap.so/docs/api/rest-api"; +export const WEBHOOK_DOCS_URL = "https://cap.so/docs/api/webhooks"; export const OPENAPI_SPEC_PATH = "/openapi.json"; export type ApiErrorBody = { @@ -18,7 +19,7 @@ export const apiErrorBody = ( code, message, hint, - docs: API_DOCS_URL, + docs: code.includes("webhook") ? WEBHOOK_DOCS_URL : API_DOCS_URL, }); export const apiNotFoundBody = (pathname: string) => diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index b2fe049f7d..ba2c51d5ed 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -19,6 +19,7 @@ import { } from "@/lib/developer-transcript-errors"; import { MAX_WEBHOOK_ENDPOINTS_PER_APP, + READY_EVENT_MAX_WAIT_MS, WEBHOOK_DISABLE_AFTER_FAILURES, WEBHOOK_EVENT_TYPES, WEBHOOK_MAX_ATTEMPTS, @@ -602,6 +603,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ "video.ready", "A video is ready to play", ref("DeveloperVideo"), + `Sent once every playback URL is ready, so hlsUrl is set unless HLS failed. An output still encoding ${READY_EVENT_MAX_WAIT_MS / 60_000} minutes after the video could first be played is left out, and Get Video returns it once it is ready. ${videoEventDescription("video.ready")}`, ), "video.errored": webhookDelivery( "video.errored", diff --git a/apps/web/lib/developer-video-processing.ts b/apps/web/lib/developer-video-processing.ts index 9c5bf48835..3d85885ca0 100644 --- a/apps/web/lib/developer-video-processing.ts +++ b/apps/web/lib/developer-video-processing.ts @@ -61,7 +61,11 @@ import { internalDeveloperVideoUrl, readDeveloperVideoText, } from "./developer-video-storage"; -import { announceDeveloperVideoStatus } from "./developer-webhook-dispatch"; +import { announceDeveloperVideoEvent } from "./developer-webhook-dispatch"; +import { + developerVideoEventStep, + developerVideoReadyEventDue, +} from "./developer-webhook-events"; import { MEDIA_SERVER_SHUTDOWN_MESSAGE, readMediaServerVideoJob, @@ -123,14 +127,28 @@ async function mutateDeveloperVideo( .where(eq(developerVideos.id, videoId)) .for("update"); if (!video) return null; - const updates = mutate(video); - if (!updates) return null; - becameReady = video.status !== "ready" && updates.status === "ready"; + const changes = mutate(video); + if (!changes) return null; + becameReady = video.status !== "ready" && changes.status === "ready"; + const event = developerVideoEventStep( + video, + { + status: changes.status ?? video.status, + processing: + "processing" in changes + ? (changes.processing ?? null) + : video.processing, + }, + Date.now(), + ); + const updates = event.processing + ? { ...changes, processing: event.processing } + : changes; await tx .update(developerVideos) .set(updates) .where(eq(developerVideos.id, videoId)); - announceDeveloperVideoStatus(video, updates); + announceDeveloperVideoEvent(video, event); return { ...video, ...updates } as DeveloperVideoRow; }; const updated = await (executor ? run(executor) : db().transaction(run)); @@ -591,6 +609,22 @@ export async function prepareDeveloperVideoProcessing( } } +// The processing workflow polls a ready video until its outputs settle, and +// each poll sends a due video.ready. A stuck output holds it back for at most +// READY_EVENT_MAX_WAIT_MS and one poll, and a marked video whose last output +// was settled by a write that did not send still gets it on the final poll. +export async function sendDueDeveloperVideoReady( + videoId: string, +): Promise { + await mutateDeveloperVideo(videoId, (video) => + !video.deletedAt && + video.processing && + developerVideoReadyEventDue(video, Date.now()) + ? { processing: video.processing } + : null, + ); +} + export async function readDeveloperVideoParts( videoId: string, encodeId: string, @@ -598,10 +632,26 @@ export async function readDeveloperVideoParts( const encode = await findEncode(videoId, encodeId); if (!encode) return null; const now = Date.now(); - return { + const parts: DeveloperVideoPartSnapshot = { hls: developerVideoPartAction(encode.processing.hls, now), mp4: developerVideoPartAction(encode.processing.mp4, now), }; + if (!developerVideoReadyEventDue(encode.video, now)) return parts; + // Thrown out of this step, a failure here would fail the video's + // processing. Finished parts are reported as waiting instead, so the + // workflow polls again rather than ending with the event unsent. + try { + await sendDueDeveloperVideoReady(videoId); + return parts; + } catch (error) { + console.error("[developer-webhook] Failed to send a due event", { + videoId, + message: error instanceof Error ? error.message : String(error), + }); + const waiting = (action: DeveloperVideoPartAction) => + action === "done" ? "wait" : action; + return { hls: waiting(parts.hls), mp4: waiting(parts.mp4) }; + } } function webhookUrl(part: DeveloperVideoPart, token: string): string { diff --git a/apps/web/lib/developer-webhook-dispatch.ts b/apps/web/lib/developer-webhook-dispatch.ts index fc2393a894..d4da803251 100644 --- a/apps/web/lib/developer-webhook-dispatch.ts +++ b/apps/web/lib/developer-webhook-dispatch.ts @@ -7,9 +7,9 @@ import { and, eq, isNull } from "drizzle-orm"; import { after } from "next/server"; import { start } from "workflow/api"; import { serializeDeveloperVideo } from "@/lib/developer-video-api"; -import { - developerVideoEventType, - type WebhookEventType, +import type { + DeveloperVideoEventStep, + WebhookEventType, } from "@/lib/developer-webhook-events"; import { developerVideoEventId } from "@/lib/developer-webhook-signature"; import { deliverDeveloperWebhookWorkflow } from "@/workflows/deliver-developer-webhook"; @@ -19,6 +19,16 @@ const STATUS_OF_EVENT: Record = { "video.errored": "errored", }; +// A ready video still carrying the marker has not been announced, which is +// also what a rolled back announcement leaves behind. +function eventIsCommitted( + video: typeof developerVideos.$inferSelect, + type: WebhookEventType, +): boolean { + if (video.deletedAt || video.status !== STATUS_OF_EVENT[type]) return false; + return type !== "video.ready" || video.processing?.readyEventDueAt == null; +} + async function subscribedEndpointIds( appId: string, type: WebhookEventType, @@ -64,9 +74,7 @@ export async function enqueueDeveloperVideoEvent( if (endpointIds.length === 0) return 0; const video = await committedVideo(target.id); - if (!video || video.deletedAt || video.status !== STATUS_OF_EVENT[type]) { - return 0; - } + if (!video || !eventIsCommitted(video, type)) return 0; const eventId = developerVideoEventId(video.id, type); const body = JSON.stringify({ @@ -95,11 +103,11 @@ export async function enqueueDeveloperVideoEvent( // Never awaited: a video's status change must not wait for, or fail on, // anything to do with webhooks. -export function announceDeveloperVideoStatus( - video: { id: string; appId: string; status: string }, - updates: { status?: string }, +export function announceDeveloperVideoEvent( + video: { id: string; appId: string }, + step: DeveloperVideoEventStep, ): void { - const type = developerVideoEventType(video.status, updates.status); + const type = step.type; if (!type) return; const queued = enqueueDeveloperVideoEvent(video, type).catch((error) => { console.error("[developer-webhook] Failed to queue an event", { diff --git a/apps/web/lib/developer-webhook-events.ts b/apps/web/lib/developer-webhook-events.ts index 76d73fca7b..fdc9c78201 100644 --- a/apps/web/lib/developer-webhook-events.ts +++ b/apps/web/lib/developer-webhook-events.ts @@ -1,3 +1,9 @@ +import type { + DeveloperVideoPartState, + DeveloperVideoProcessingState, +} from "@cap/database/types"; +import { DEVELOPER_VIDEO_PARTS } from "@/lib/developer-video-state"; + export const WEBHOOK_EVENT_TYPES = ["video.ready", "video.errored"] as const; export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; @@ -40,12 +46,67 @@ export function isWebhookEventType(value: string): value is WebhookEventType { return WEBHOOK_EVENT_TYPES.some((type) => type === value); } -export function developerVideoEventType( - previous: string, - next: string | undefined, -): WebhookEventType | null { - if (next === undefined || next === previous) return null; - if (next === "ready") return "video.ready"; - if (next === "errored") return "video.errored"; - return null; +// video.ready waits for every output so it carries every playback URL. An +// output still encoding this long after the video became ready is treated as +// stuck, and the event goes out with the URLs that exist. +export const READY_EVENT_MAX_WAIT_MS = 30 * 60 * 1000; + +const SETTLED_PART_STATES: ReadonlySet = + new Set(["complete", "failed", "unavailable"]); + +interface EventVideo { + status: string; + processing: DeveloperVideoProcessingState | null; +} + +export interface DeveloperVideoEventStep { + type: WebhookEventType | null; + processing?: DeveloperVideoProcessingState; +} + +export function developerVideoOutputsSettled( + processing: DeveloperVideoProcessingState, +): boolean { + return DEVELOPER_VIDEO_PARTS.every((part) => + SETTLED_PART_STATES.has(processing[part].state), + ); +} + +// Due once every output has settled or the wait has run out. Also true for a +// marked video whose last output was settled by a write that did not send, +// such as one from the previous deployment while this one rolls out. +export function developerVideoReadyEventDue( + video: EventVideo, + now: number, +): boolean { + const processing = video.processing; + const dueAt = processing?.readyEventDueAt; + if (video.status !== "ready" || !processing || dueAt == null) return false; + return ( + developerVideoOutputsSettled(processing) || + now - dueAt >= READY_EVENT_MAX_WAIT_MS + ); +} + +// Runs inside the transaction that writes `next`, under the video's row lock, +// so concurrent writers see each other's marker and only one of them sends. +export function developerVideoEventStep( + previous: EventVideo, + next: EventVideo, + now: number, +): DeveloperVideoEventStep { + const processing = next.processing; + if (next.status !== previous.status) { + if (next.status === "errored") return { type: "video.errored" }; + if (next.status !== "ready") return { type: null }; + if (!processing || developerVideoOutputsSettled(processing)) { + return { type: "video.ready" }; + } + return { type: null, processing: { ...processing, readyEventDueAt: now } }; + } + if (!processing || !developerVideoReadyEventDue(next, now)) { + return { type: null }; + } + const { readyEventDueAt: _readyEventDueAt, ...announced } = processing; + return { type: "video.ready", processing: announced }; } diff --git a/packages/database/types/developer-video.ts b/packages/database/types/developer-video.ts index 353ee492b3..b22b84f50d 100644 --- a/packages/database/types/developer-video.ts +++ b/packages/database/types/developer-video.ts @@ -59,6 +59,10 @@ export interface DeveloperVideoProcessingState { lostJobs?: Partial>; // When every worker last answered busy to a start of this encode. busyAt?: number; + // When the video became ready with an output still encoding. video.ready + // is sent, and this removed, once the outputs settle. A video that became + // ready without it was already announced, by this code or by older code. + readyEventDueAt?: number; unavailableChecks: number; poster: { output: string } | null; bytes: {