feat: media attachments backend + producer job intake (t1+t3)

Rich media (t1): media_assets (migration 038) + /media module — multipart
upload with magic-byte sniffing, size caps, auth-scoped serve with
ETag/same-origin; the publishing 'text only' gate lifted to accept
0..4 validated mediaAssetIds with optional positional altTexts.

Producer intake (t3): video_processing_jobs gains kind compose|mix +
input_json + produced_asset_id (migration 039), video_assets.derived_from;
POST /video/:id/produce validates overlays/voiceover strictly and queues
the job for the media-worker runner (landing separately).
This commit is contained in:
glass beadian 2026-10-14 20:00:31 +00:00
parent abb343284a
commit 71db0a08b2
15 changed files with 1276 additions and 9 deletions

View file

@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
MAX_PRODUCE_OVERLAY_TEXT,
MAX_PRODUCE_POSITION,
parseProduceRequest,
} from "./produce-request.js";
const voiceoverAssetId = "20000000-0000-4000-8000-000000000002";
describe("produce request validation", () => {
it("accepts a full request and canonicalizes it (fixed key order, defaults dropped)", () => {
const parsed = parseProduceRequest({
overlays: [
{ kind: "lower_third", text: "Live from the occupation", position: { x: 0, y: 100 } },
{ kind: "logo", logoStorageKey: "sha256/9f2b1c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d.png", position: { x: 100, y: 0 } },
{ kind: "clock" },
],
voiceover: { assetId: voiceoverAssetId, gainDb: -6, duckOriginal: true },
outputLabel: "Riot recap v2",
lane: "remote",
});
expect(parsed).toEqual({
overlays: [
{ kind: "lower_third", text: "Live from the occupation", position: { x: 0, y: 100 } },
{ kind: "logo", logoStorageKey: "sha256/9f2b1c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d.png", position: { x: 100, y: 0 } },
{ kind: "clock", position: undefined },
],
voiceover: { assetId: voiceoverAssetId, gainDb: -6, duckOriginal: true },
outputLabel: "Riot recap v2",
lane: "remote",
});
// Same input always serializes to the same input_json bytes.
expect(JSON.stringify(parsed)).toBe(JSON.stringify(parseProduceRequest(JSON.parse(JSON.stringify({
lane: "remote",
outputLabel: "Riot recap v2",
voiceover: { duckOriginal: true, gainDb: -6, assetId: voiceoverAssetId },
overlays: [
{ position: { y: 100, x: 0 }, text: "Live from the occupation", kind: "lower_third" },
{ position: { y: 0, x: 100 }, logoStorageKey: "sha256/9f2b1c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d.png", kind: "logo" },
{ kind: "clock" },
],
})))));
});
it("accepts an empty body and treats every field as optional", () => {
expect(parseProduceRequest({})).toEqual({});
expect(parseProduceRequest({ overlays: null, voiceover: null, outputLabel: null, lane: null })).toEqual({});
});
it("rejects an unknown key on the body, an overlay and a voiceover (never ignores one)", () => {
expect(() => parseProduceRequest({ outputLabel: "x", filter: "drawtext" })).toThrow(/body\.filter is not allowed/);
expect(() => parseProduceRequest({ apiKey: "x" })).toThrow(/secrets are never accepted/);
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", layer: 0 }] })).toThrow(/overlays\[0\]\.layer is not allowed/);
expect(() => parseProduceRequest({ voiceover: { assetId: voiceoverAssetId, streamKey: "x" } })).toThrow(/secrets are never accepted/);
});
it("rejects filtergraph and shell metacharacters in overlay text", () => {
for (const hostile of [
"text';drawtext=1",
"text[0]",
"text,volume=9",
"text'quote",
'text"quote',
"text`cmd`",
"text$HOME",
"text\\n",
"text{a}",
"text=x",
]) {
expect(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: hostile }] }), hostile).toThrow(/metacharacter|control characters|must be a string/);
}
// Newlines are control characters too.
expect(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: "a\nb" }] })).toThrow(/control characters/);
expect(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: "\u0000" }] })).toThrow(/control characters/);
});
it("bounds overlay text length and rejects empty text", () => {
expect(parseProduceRequest({ overlays: [{ kind: "lower_third", text: "x".repeat(MAX_PRODUCE_OVERLAY_TEXT) }] }).overlays?.[0]?.text).toHaveLength(200);
expect(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: "x".repeat(201) }] })).toThrow(/at most 200 characters/);
expect(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: "" }] })).toThrow(/must not be empty/);
expect(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: " " }] })).toThrow(/must not be empty/);
});
it("restricts position to finite numbers 0-100 on both axes", () => {
expect(parseProduceRequest({ overlays: [{ kind: "clock", position: { x: 0, y: MAX_PRODUCE_POSITION } }] }).overlays?.[0]?.position).toEqual({ x: 0, y: 100 });
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", position: { x: -1, y: 0 } }] })).toThrow(/position\.x must be between 0 and 100/);
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", position: { x: 101, y: 0 } }] })).toThrow(/position\.y|position\.x must be between/);
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", position: { x: Number.NaN, y: 0 } }] })).toThrow(/position\.x must be a number/);
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", position: { x: "50", y: 0 } }] })).toThrow(/position\.x must be a number/);
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", position: { x: 50 } }] })).toThrow(/position\.y/);
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", position: { x: 50, y: 0, w: 10 } }] })).toThrow(/position\.w is not allowed/);
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", position: { x: Number.POSITIVE_INFINITY, y: 0 } }] })).toThrow(/position\.x must be a number|between 0 and 100/);
});
it("keeps overlay kinds closed and per-kind fields exclusive", () => {
expect(() => parseProduceRequest({ overlays: [{ kind: "chyron" }] })).toThrow(/overlays\[0\]\.kind must be one of lower_third, logo, clock/);
expect(() => parseProduceRequest({ overlays: [{ kind: "lower_third" }] })).toThrow(/overlays\[0\]\.text must be a string/);
expect(() => parseProduceRequest({ overlays: [{ kind: "logo" }] })).toThrow(/overlays\[0\]\.logoStorageKey must be a content-addressed storage key/);
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", text: "nope" }] })).toThrow(/overlays\[0\]\.text is not allowed/);
expect(() => parseProduceRequest({ overlays: [{ kind: "logo", text: "nope" }] })).toThrow(/overlays\[0\]\.text is not allowed/);
// A storage key riding on a clock is rejected; the "key"-shaped name trips the
// secrets branch first (same precedence as production.ts) — either way, refused.
expect(() => parseProduceRequest({ overlays: [{ kind: "clock", logoStorageKey: "sha256/9f2b1c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d.png" }] })).toThrow(/logoStorageKey/);
expect(() => parseProduceRequest({ overlays: [{ kind: "lower_third", text: "ok", logoStorageKey: "sha256/9f2b1c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d0e0f0a0b0c0d.png" }] })).toThrow(/logoStorageKey/);
expect(() => parseProduceRequest({ overlays: [{}] })).toThrow(/overlays\[0\]\.kind must be one of/);
});
it("accepts only content-addressed storage keys for logos (no traversal, no separators)", () => {
expect(() => parseProduceRequest({ overlays: [{ kind: "logo", logoStorageKey: "../../etc/passwd" }] })).toThrow(/content-addressed storage key/);
expect(() => parseProduceRequest({ overlays: [{ kind: "logo", logoStorageKey: "sha256/../x.png" }] })).toThrow(/content-addressed storage key/);
expect(() => parseProduceRequest({ overlays: [{ kind: "logo", logoStorageKey: "C:\\evil.png" }] })).toThrow(/content-addressed storage key/);
expect(() => parseProduceRequest({ overlays: [{ kind: "logo", logoStorageKey: "file:///tmp/x.png" }] })).toThrow(/content-addressed storage key/);
expect(() => parseProduceRequest({ overlays: [{ kind: "logo", logoStorageKey: "png" }] })).toThrow(/content-addressed storage key/);
});
it("validates the voiceover: UUID asset, bounded gain, boolean duck", () => {
expect(() => parseProduceRequest({ voiceover: { assetId: "not-a-uuid" } })).toThrow(/voiceover\.assetId must be a UUID/);
expect(() => parseProduceRequest({ voiceover: { assetId: voiceoverAssetId, gainDb: -49 } })).toThrow(/gainDb must be a number between -48 and 12 dB/);
expect(() => parseProduceRequest({ voiceover: { assetId: voiceoverAssetId, gainDb: 13 } })).toThrow(/gainDb must be a number between/);
expect(() => parseProduceRequest({ voiceover: { assetId: voiceoverAssetId, gainDb: "loud" } })).toThrow(/gainDb/);
expect(() => parseProduceRequest({ voiceover: { assetId: voiceoverAssetId, duckOriginal: "yes" } })).toThrow(/duckOriginal must be a boolean/);
expect(parseProduceRequest({ voiceover: { assetId: voiceoverAssetId } }).voiceover).toEqual({ assetId: voiceoverAssetId });
});
it("bounds outputLabel and the overlay list, and closes the lane enum", () => {
expect(() => parseProduceRequest({ outputLabel: "x".repeat(65) })).toThrow(/outputLabel must be 1-64/);
expect(() => parseProduceRequest({ outputLabel: "has;semicolon" })).toThrow(/outputLabel must be 1-64/);
expect(() => parseProduceRequest({ outputLabel: "=cmd" })).toThrow(/outputLabel must be 1-64/);
expect(() => parseProduceRequest({ lane: "cloud" })).toThrow(/lane must be one of local, remote/);
expect(() => parseProduceRequest({ overlays: new Array(65).fill({ kind: "clock" }) })).toThrow(/at most 64 entries/);
expect(() => parseProduceRequest({ overlays: "clock" })).toThrow(/overlays must be an array/);
expect(() => parseProduceRequest("nope")).toThrow(/body must be an object/);
expect(() => parseProduceRequest([{ kind: "clock" }])).toThrow(/body must be an object/);
});
it("returns a serializable, JSON-round-trippable request for input_json", () => {
const parsed = parseProduceRequest({ overlays: [{ kind: "lower_third", text: "ok", position: { x: 1, y: 2 } }], voiceover: { assetId: voiceoverAssetId, gainDb: 0 } });
expect(JSON.parse(JSON.stringify(parsed))).toEqual({
overlays: [{ kind: "lower_third", text: "ok", position: { x: 1, y: 2 } }],
voiceover: { assetId: voiceoverAssetId, gainDb: 0 },
});
});
});

View file

@ -0,0 +1,178 @@
import { BadRequestException } from "@nestjs/common";
import { MAX_OVERLAYS } from "../media-control/production.js";
import { isUuid } from "../http/validation.js";
/**
* Producer job intake: the request shape for POST /video/:id/produce.
*
* Everything accepted here is persisted verbatim into
* video_processing_jobs.input_json and later reaches an ffmpeg filtergraph in
* the media-worker compositor, so the rules follow
* media-control/production.ts: closed enums, exhaustive key allowlists (an
* unknown key is rejected, never ignored — that is what keeps a stray
* `movie=`/`enable=` field from riding into a filtergraph), bounded geometry,
* bounded text, and no filtergraph or shell metacharacter inside operator text.
*
* The parse is total: it either returns a canonicalized request (fixed key
* order, so the stored input_json bytes are deterministic) or throws
* BadRequestException with a field path.
*/
export type ProduceOverlayKind = "lower_third" | "logo" | "clock";
export type ProduceLane = "local" | "remote";
export const PRODUCE_OVERLAY_KINDS: readonly ProduceOverlayKind[] = ["lower_third", "logo", "clock"];
export const PRODUCE_LANES: readonly ProduceLane[] = ["local", "remote"];
export const MAX_PRODUCE_OVERLAY_TEXT = 200;
export const MAX_PRODUCE_LABEL = 64;
export const PRODUCE_POSITION_MIN = 0;
export const MAX_PRODUCE_POSITION = 100;
export const PRODUCE_GAIN_DB_MIN = -48;
export const PRODUCE_GAIN_DB_MAX = 12;
export interface ProduceOverlay {
kind: ProduceOverlayKind;
/** lower_third only: the burned-in caption. */
text?: string;
/** Percent of the output frame, 0-100 on each axis. */
position?: { x: number; y: number };
/** logo only: storage key of the rendered PNG. */
logoStorageKey?: string;
}
export interface ProduceVoiceover {
assetId: string;
gainDb?: number;
duckOriginal?: boolean;
}
export interface ProduceRequest {
overlays?: ProduceOverlay[];
voiceover?: ProduceVoiceover;
outputLabel?: string;
lane?: ProduceLane;
}
/** Keys nobody may post, mirroring production.ts: secrets are never accepted. */
const SECRETISH = /key|secret|token|password|passwd|credential(?!HandleId)|auth/i;
/**
* Characters that change an ffmpeg filtergraph (`;` splits, `[`/`]` label pads,
* `,` separates args, `'` quotes, `=` sets an option) or a shell string. A lower
* third is operator prose, so the check is a blocklist of these rather than
* production.ts's tight label allowlist — prose keeps its punctuation and no
* metacharacter survives.
*/
const TEXT_FORBIDDEN = new Set([';', "[", "]", "'", '"', "`", "$", "\\", "{", "}", "=", ","]);
/** Storage keys are content-addressed, flat and extension-suffixed; no traversal, no separators. */
const STORAGE_KEY = /^[a-z0-9][a-z0-9/._-]{7,255}$/;
const LABEL = /^[\p{L}\p{N}][\p{L}\p{N} _.-]{0,63}$/u;
function fail(message: string): never {
throw new BadRequestException(message);
}
function record(value: unknown, field: string, allowed: readonly string[]): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
for (const key of Object.keys(value)) {
if (allowed.includes(key)) continue;
if (SECRETISH.test(key)) fail(`${field}.${key}: secrets are never accepted; reference a vault credentialHandleId`);
fail(`${field}.${key} is not allowed`);
}
return value as Record<string, unknown>;
}
function optional<T>(value: unknown, field: string, parse: (v: unknown, f: string) => T): T | undefined {
return value === undefined || value === null ? undefined : parse(value, field);
}
function text(value: unknown, field: string): string {
if (typeof value !== "string") fail(`${field} must be a string`);
if (value.trim() === "") fail(`${field} must not be empty`);
if (value.length > MAX_PRODUCE_OVERLAY_TEXT) fail(`${field} must be at most ${MAX_PRODUCE_OVERLAY_TEXT} characters`);
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) fail(`${field} must not contain control characters`);
if (TEXT_FORBIDDEN.has(character)) fail(`${field} must not contain the filtergraph metacharacter ${JSON.stringify(character)}`);
}
return value;
}
function position(value: unknown, field: string): { x: number; y: number } {
const p = record(value, field, ["x", "y"]);
return { x: coordinate(p.x, `${field}.x`), y: coordinate(p.y, `${field}.y`) };
}
function coordinate(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) fail(`${field} must be a number`);
if (value < PRODUCE_POSITION_MIN || value > MAX_PRODUCE_POSITION) {
fail(`${field} must be between ${PRODUCE_POSITION_MIN} and ${MAX_PRODUCE_POSITION} (percent of frame)`);
}
return value;
}
function storageKey(value: unknown, field: string): string {
if (typeof value !== "string" || !STORAGE_KEY.test(value) || value.includes("..")) {
fail(`${field} must be a content-addressed storage key`);
}
return value;
}
function overlay(value: unknown, field: string): ProduceOverlay {
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${field} must be an object`);
const kind = (value as Record<string, unknown>).kind;
if (kind !== "lower_third" && kind !== "logo" && kind !== "clock") {
fail(`${field}.kind must be one of ${PRODUCE_OVERLAY_KINDS.join(", ")}`);
}
// Per-kind allowlist: a clock has no text and no asset, a logo has no caption.
const allowed = kind === "lower_third" ? ["kind", "text", "position"] : kind === "logo" ? ["kind", "logoStorageKey", "position"] : ["kind", "position"];
const o = record(value, field, allowed);
return {
kind,
...(kind === "lower_third"
? { text: text(o.text, `${field}.text`) }
: kind === "logo"
? { logoStorageKey: storageKey(o.logoStorageKey, `${field}.logoStorageKey`) }
: {}),
...(o.position === undefined ? {} : { position: position(o.position, `${field}.position`) }),
};
}
function voiceover(value: unknown, field: string): ProduceVoiceover {
const v = record(value, field, ["assetId", "gainDb", "duckOriginal"]);
if (typeof v.assetId !== "string" || !isUuid(v.assetId)) fail(`${field}.assetId must be a UUID`);
const gainDb = optional(v.gainDb, `${field}.gainDb`, (g, f) => {
if (typeof g !== "number" || !Number.isFinite(g) || g < PRODUCE_GAIN_DB_MIN || g > PRODUCE_GAIN_DB_MAX) {
fail(`${f} must be a number between ${PRODUCE_GAIN_DB_MIN} and ${PRODUCE_GAIN_DB_MAX} dB`);
}
return g;
});
const duckOriginal = optional(v.duckOriginal, `${field}.duckOriginal`, (d, f) => {
if (typeof d !== "boolean") fail(`${f} must be a boolean`);
return d;
});
return { assetId: v.assetId, ...(gainDb === undefined ? {} : { gainDb }), ...(duckOriginal === undefined ? {} : { duckOriginal }) };
}
/** Parse and canonicalize a produce request; throws BadRequestException on the first problem. */
export function parseProduceRequest(value: unknown, field = "body"): ProduceRequest {
const b = record(value, field, ["overlays", "voiceover", "outputLabel", "lane"]);
if (b.overlays !== undefined && b.overlays !== null) {
if (!Array.isArray(b.overlays)) fail(`${field}.overlays must be an array`);
if (b.overlays.length > MAX_OVERLAYS) fail(`${field}.overlays allows at most ${MAX_OVERLAYS} entries`);
}
if (b.lane !== undefined && b.lane !== null && b.lane !== "local" && b.lane !== "remote") {
fail(`${field}.lane must be one of ${PRODUCE_LANES.join(", ")}`);
}
const outputLabel = optional(b.outputLabel, `${field}.outputLabel`, (l, f) => {
if (typeof l !== "string" || !LABEL.test(l)) fail(`${f} must be 1-${MAX_PRODUCE_LABEL} letters, digits, spaces, '_', '.', '-'`);
return l;
});
const overlays = (b.overlays ?? []).map((item, index) => overlay(item, `${field}.overlays[${index}]`));
return {
...(overlays.length === 0 ? {} : { overlays }),
...(b.voiceover === undefined || b.voiceover === null ? {} : { voiceover: voiceover(b.voiceover, `${field}.voiceover`) }),
...(outputLabel === undefined ? {} : { outputLabel }),
...(b.lane === undefined || b.lane === null ? {} : { lane: b.lane }),
};
}

View file

@ -14,6 +14,8 @@ export interface FakeVideoDatabase {
asset?: Record<string, unknown>;
job?: Record<string, unknown>;
transitions?: string[];
/** Raw INSERT statements seen, for asserting what intake wrote. */
statements?: Array<{ sql: string; values: readonly unknown[] }>;
}
function result(rows: Array<Record<string, unknown>>): QueryResult<Record<string, unknown>> {
@ -24,7 +26,7 @@ export function serviceWithDatabase(
database: FakeVideoDatabase,
probe: VideoServiceDependencies["probe"],
storageOverride?: StorageService,
): { service: VideoService; appendAudit: VideoServiceDependencies["appendAudit"]; storage: StorageService; objects: Map<string, Buffer> } {
): { service: VideoService; appendAudit: VideoServiceDependencies["appendAudit"] & { mockClear(): void }; storage: StorageService; objects: Map<string, Buffer> } {
const objects = new Map<string, Buffer>();
const storage: StorageService = storageOverride ?? {
putContent: vi.fn(async (bytes: Buffer, contentType: string) => {
@ -43,6 +45,7 @@ export function serviceWithDatabase(
presign: async () => { throw new Error("Encrypted source must be decrypted before probing"); },
};
database.transitions = [];
database.statements = [];
const client = {
async query(sql: string, values: readonly unknown[] = []): Promise<QueryResult<Record<string, unknown>>> {
if (sql.includes("INSERT INTO video_assets")) {
@ -64,11 +67,17 @@ export function serviceWithDatabase(
return result([database.asset]);
}
if (sql.includes("INSERT INTO video_processing_jobs")) {
database.statements?.push({ sql, values });
const produce = sql.includes("input_json");
database.job = {
id: jobId,
workspace_id: values[0],
video_asset_id: values[1],
state: "queued",
// kind/input_json exist only since migration 039; produce rows carry them.
// pg returns jsonb already parsed, not as the JSON text we sent.
...(produce ? { kind: values[2], input_json: JSON.parse(String(values[3])) } : {}),
produced_asset_id: null,
progress_percent: 0,
output_key: null,
error: null,

View file

@ -19,10 +19,12 @@ import { parseId, parseLimit, requireObject, requireSession, requireString } fro
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import {
VideoService,
type ProduceRequestResult,
type VideoLibraryPage,
type VideoStatus,
type VideoUploadResult,
} from "./video.service.js";
import { parseProduceRequest } from "./produce-request.js";
import { MAX_VIDEO_UPLOAD_BYTES } from "./upload-limits.js";
import { loadDriveBytes, loadMatrixBytes, parseMxcUri } from "./import-sources.js";
@ -91,6 +93,20 @@ export class VideoController {
return this.video.listVideos(requireSession(request).workspaceId, cursor, parseLimit(limit, 50, 200));
}
@Post(":id/produce")
@RequirePermission("media.manage")
async produce(
@Req() request: Request & AuthenticatedRequest,
@Param("id") id: string,
@Body() body: unknown,
): Promise<ProduceRequestResult> {
const session = requireSession(request);
// Strict allowlist parse: unknown keys are rejected, never ignored, so no
// field can ride into input_json (and on into a worker filtergraph).
const produce = parseProduceRequest(body);
return this.video.produceVideo(parseId(id), session.workspaceId, produce, session.userId);
}
@Get(":id/status")
@RequirePermission("media.view")
async status(

View file

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { VideoService } from "./video.service.js";
import { assetId, serviceWithDatabase, workspaceId, type FakeVideoDatabase } from "./test-fixtures.js";
import { MAX_VIDEO_UPLOAD_BYTES } from "./upload-limits.js";
import type { ProduceRequest } from "./produce-request.js";
afterEach(() => vi.unstubAllEnvs());
@ -26,6 +27,7 @@ describe("video byte ingest and processing-job state machine", () => {
expect(uploaded.videoAsset).toMatchObject({ sizeBytes: bytes.length, width: 1280, height: 720, durationMs: 42_000, codec: "h264" });
expect(uploaded.processingJob).toMatchObject({ state: "complete", progressPercent: 100, outputKey: uploaded.videoAsset.storageKey });
expect(uploaded.processingJob.processingNote).toContain("Transcode skipped");
// ...and the produce INSERT is the only thing that followed it.
expect(database.transitions).toEqual(["queued", "probing", "transcoding", "complete"]);
await expect(stat(inputPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(appendAudit).toHaveBeenCalledWith(workspaceId, expect.objectContaining({ action: "video.uploaded", targetId: assetId }));
@ -98,6 +100,59 @@ describe("video byte ingest and processing-job state machine", () => {
expect(objects.get(uploaded.videoAsset.storageKey)).toEqual(bytes);
});
it("queues a compose job with the canonicalized request in input_json and audits it", async () => {
const database: FakeVideoDatabase = {};
const { service, appendAudit } = serviceWithDatabase(database, async () => ({}));
await service.uploadVideoBytes(workspaceId, Buffer.from("source"), "video/mp4");
appendAudit.mockClear();
const request: ProduceRequest = {
overlays: [{ kind: "lower_third", text: "Live now", position: { x: 0, y: 100 } }],
outputLabel: "Recap",
};
const produced = await service.produceVideo(assetId, workspaceId, request, "10000000-0000-4000-8000-000000000009");
// kind is compose unless a voiceover is present, then mix.
expect(produced.job).toMatchObject({ state: "queued", kind: "compose", videoAssetId: assetId });
// Nothing produced yet: the job points at no output asset until the worker finishes.
expect("producedAssetId" in produced.job).toBe(false);
expect(produced.videoAsset.id).toBe(assetId);
expect(database.statements?.at(-1)).toMatchObject({
sql: expect.stringContaining("INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json)"),
values: [workspaceId, assetId, "compose", JSON.stringify({ ...request, requestedBy: "10000000-0000-4000-8000-000000000009" })],
});
// input_json is passed as jsonb text and never leaves the tenant transaction.
expect(JSON.parse(String(database.statements?.at(-1)?.values[3]))).toEqual({ overlays: [{ kind: "lower_third", text: "Live now", position: { x: 0, y: 100 } }], outputLabel: "Recap", requestedBy: "10000000-0000-4000-8000-000000000009" });
expect(appendAudit).toHaveBeenCalledWith(workspaceId, expect.objectContaining({
action: "video.produce_requested",
targetId: assetId,
diff: expect.objectContaining({ kind: "compose", overlayCount: 1, voiceover: null, outputLabel: "Recap" }),
}));
// The produce row never disturbs the probe state machine of the source:
// the fake holds one job slot, so the probe transitions are all still there.
// ...and the produce INSERT is the only thing that followed it.
expect(database.transitions).toEqual(["queued", "probing", "transcoding", "complete", "queued"]);
});
it("queues a mix job when a voiceover is present and does not touch the source asset", async () => {
const database: FakeVideoDatabase = {};
const { service } = serviceWithDatabase(database, async () => ({}));
await service.uploadVideoBytes(workspaceId, Buffer.from("source"), "video/mp4");
await service.produceVideo(assetId, workspaceId, {
voiceover: { assetId: "20000000-0000-4000-8000-000000000004", gainDb: -6, duckOriginal: true },
lane: "remote",
}, "10000000-0000-4000-8000-000000000009");
expect(database.statements?.at(-1)?.values[2]).toBe("mix");
expect(JSON.parse(String(database.statements?.at(-1)?.values[3]))).toEqual({
voiceover: { assetId: "20000000-0000-4000-8000-000000000004", gainDb: -6, duckOriginal: true },
lane: "remote",
requestedBy: "10000000-0000-4000-8000-000000000009",
});
});
it("refuses to produce from an unknown asset", async () => {
const { service } = serviceWithDatabase({}, async () => ({}));
await expect(service.produceVideo("30000000-0000-4000-8000-000000000005", workspaceId, {}, "u")).rejects.toMatchObject({ status: 404 });
});
it("rejects empty or non-video uploads before storage", async () => {
const { service, storage } = serviceWithDatabase({}, async () => ({}));
await expect(service.uploadVideoBytes(workspaceId, Buffer.alloc(0), "video/mp4")).rejects.toMatchObject({ status: 400 });

View file

@ -7,6 +7,7 @@ import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js";
import { withTenant } from "../db/client.js";
import { probeVideoFile, type VideoProbeResult } from "./ffprobe.js";
import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
import { type ProduceRequest } from "./produce-request.js";
import { MAX_VIDEO_UPLOAD_BYTES } from "./upload-limits.js";
const NO_OP_TRANSCODE_NOTE = "Transcode skipped: original stored object retained; rendering belongs to the media-worker compositor.";
@ -28,17 +29,35 @@ export interface VideoAssetRecord {
thumbnailKey?: string;
}
export type VideoProcessingJobKind = "probe" | "compose" | "mix";
export interface VideoProcessingJobRecord {
id: string;
workspaceId: string;
videoAssetId: string;
state: VideoProcessingJobState;
/** What the media-worker should do; 'probe' is the ingest default. */
kind: VideoProcessingJobKind;
/** Producer request (kind compose/mix): { ...request, requestedBy }. */
input?: ProduceJobInput;
/** The video_assets row this job writes when it finishes. */
producedAssetId?: string;
progressPercent?: number;
outputKey?: string;
error?: string;
processingNote?: string;
}
export interface ProduceJobInput extends ProduceRequest {
requestedBy: string;
}
export interface ProduceRequestResult {
/** The newly queued compose/mix job. */
job: VideoProcessingJobRecord;
videoAsset: VideoAssetRecord;
}
export interface VideoPublicationSummary {
connectionId: string;
remoteId: string;
@ -176,7 +195,8 @@ export class VideoService {
`SELECT va.*,
j.id AS job_id, j.video_asset_id AS job_video_asset_id,
j.state AS job_state, j.progress_percent AS job_progress_percent,
j.output_key AS job_output_key, j.error AS job_error
j.output_key AS job_output_key, j.error AS job_error,
j.kind AS job_kind, j.input_json AS job_input_json, j.produced_asset_id AS job_produced_asset_id
FROM video_assets va
LEFT JOIN LATERAL (
SELECT * FROM video_processing_jobs
@ -345,6 +365,43 @@ export class VideoService {
});
}
/**
* Queue a derived asset: burn overlays (and optionally mix a voiceover) over an
* existing asset. Validates nothing itself — the controller parses the request
* with parseProduceRequest before calling, and the media-worker re-validates.
*/
async produceVideo(
assetId: string,
workspaceId: string,
request: ProduceRequest,
requestedBy: string,
): Promise<ProduceRequestResult> {
const input: ProduceJobInput = { ...request, requestedBy };
const kind: VideoProcessingJobKind = request.voiceover === undefined ? "compose" : "mix";
const created = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const source = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]);
if (source.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
const jobResult = await client.query(
`INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json)
VALUES ($1, $2, 'queued', $3, $4::jsonb) RETURNING *`,
[workspaceId, assetId, kind, JSON.stringify(input)],
);
return {
videoAsset: assetFromRow(requiredRow(source.rows[0], "source video asset")),
job: jobFromRow(requiredRow(jobResult.rows[0], "created produce job")),
};
});
await this.audit(workspaceId, "video.produce_requested", assetId, {
kind,
jobId: created.job.id,
overlayCount: request.overlays?.length ?? 0,
voiceover: request.voiceover === undefined ? null : { assetId: request.voiceover.assetId, duckOriginal: request.voiceover.duckOriginal ?? false },
outputLabel: request.outputLabel ?? null,
lane: request.lane ?? null,
});
return created;
}
async deleteVideo(assetId: string, workspaceId: string): Promise<void> {
// Objects are shared by content hash across assets/workspaces. Retain for future
// reference-aware GC; deleting here could destroy another asset's source.
@ -373,7 +430,7 @@ export class VideoService {
private async audit(
workspaceId: string,
action: "video.uploaded" | "video.probed" | "video.deleted" | "video.imported",
action: "video.uploaded" | "video.probed" | "video.deleted" | "video.imported" | "video.produce_requested",
targetId: string,
diff: Record<string, unknown>,
): Promise<void> {
@ -420,6 +477,7 @@ function assetFromRow(row: Record<string, unknown>): VideoAssetRecord {
...optionalString("codec", row.codec),
...optionalString("audioCodec", row.audio_codec),
...optionalString("thumbnailKey", row.thumbnail_key),
...optionalString("derivedFrom", row.derived_from),
};
}
@ -429,6 +487,9 @@ function jobFromRow(row: Record<string, unknown>): VideoProcessingJobRecord {
workspaceId: String(row.workspace_id),
videoAssetId: String(row.video_asset_id),
state: processingState(row.state),
kind: processingKind(row.kind),
...(row.input_json === null || row.input_json === undefined ? {} : { input: requiredRow(row.input_json, "job input") as unknown as ProduceJobInput }),
...optionalString("producedAssetId", row.produced_asset_id),
...optionalNumber("progressPercent", row.progress_percent),
...optionalString("outputKey", row.output_key),
...optionalString("error", row.error),
@ -444,6 +505,9 @@ function joinedJobFromRow(row: Record<string, unknown>): VideoProcessingJobRecor
progress_percent: row.job_progress_percent,
output_key: row.job_output_key,
error: row.job_error,
kind: row.job_kind,
input_json: row.job_input_json,
produced_asset_id: row.job_produced_asset_id,
});
return {
...job,
@ -469,6 +533,13 @@ function publicationFromJson(row: Record<string, unknown>): VideoPublicationSumm
};
}
function processingKind(value: unknown): VideoProcessingJobKind {
if (value === "probe" || value === "compose" || value === "mix") return value;
// Rows created before migration 039 have no kind and are probe jobs.
if (value === null || value === undefined) return "probe";
throw new Error(`invalid video processing job kind: ${String(value)}`);
}
function processingState(value: unknown): VideoProcessingJobState {
if (
value === "queued" ||