feat: produce worker deployment + web producer/collective/branding UI + mobile CSS (wave-2c)
Deployment (t4g2): media-worker gains font-dejavu (drawtext lower-thirds) and a dedicated produce-worker compose service running src/produce.ts — claims video_processing_jobs compose|mix by lane, renders assembled/ overlaid/voiced cuts, registers derived library assets. Deliberately its own process so a remote-lane box needs only Postgres + S3 + ffmpeg. Web: compose media attachments UI (t2), Producer panel in the video drawer (t5), collective creation + branding settings + shell branding (brandweb), mobile CSS (mcss: touch targets, icon sizing, safe areas).
This commit is contained in:
parent
1183d69849
commit
d9e8cdf789
18 changed files with 2384 additions and 20 deletions
196
apps/media-worker/src/producer/runner.test.ts
Normal file
196
apps/media-worker/src/producer/runner.test.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Produce runner: one job end to end against a real ffmpeg, plus the loop's
|
||||
* lane/claim/failure behaviour, using store/storage doubles — no Postgres, no S3.
|
||||
*
|
||||
* The encode itself is exercised only when the system ffmpeg exists (the same
|
||||
* guard cpu-smoke/golden-frame tests lean on); everything else is double-driven.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseProduceRequest } from "./command-builder";
|
||||
import { ProduceRunner } from "./runner";
|
||||
import type { ClaimedProduceJob, ProduceJobStore, ProduceObjectStorage } from "./store";
|
||||
|
||||
const UUID = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
|
||||
const WS = "8f0c5a8e-3b1d-4c3e-9a5b-0c1d2e3f4a5b";
|
||||
const SCRATCH = mkdtempSync(join(tmpdir(), "produce-runner-"));
|
||||
const FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
|
||||
|
||||
function hasFfmpeg(): boolean {
|
||||
return spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
/** ffmpeg 5.1 actually loads the font (drawtext) — a plain file probe is not enough. */
|
||||
function hasFont(): boolean {
|
||||
const out = join(SCRATCH, "font-probe.png");
|
||||
return spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "color=c=black:s=64x32:d=1",
|
||||
"-vf", `drawtext=fontfile=${FONT}:text='ok':fontsize=12`, "-frames:v", "1", out], { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
/** A tiny 2 s 320x180 clip with tone audio — the produce preset rescales it. */
|
||||
function makeClip(path: string): void {
|
||||
const r = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc2=s=320x180:r=30:d=2",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest",
|
||||
"-movflags", "+faststart", path]);
|
||||
if (r.status !== 0) throw new Error(`fixture encode failed: ${r.stderr?.toString() ?? ""}`);
|
||||
}
|
||||
|
||||
function key(bytes: Buffer): string { return `sha256/${createHash("sha256").update(bytes).digest("hex")}.mp4`; }
|
||||
|
||||
function doubles() {
|
||||
const events: Array<{ type: string; jobId?: string; detail?: string }> = [];
|
||||
const jobs: Array<{ jobId: string; state: string; reason?: string; output?: { storageKey: string; producedAssetId: string } }> = [];
|
||||
const objects = new Map<string, Buffer>();
|
||||
let published: { key: string } | null = null;
|
||||
let queue: ClaimedProduceJob[] = [];
|
||||
let lane: string | null = null;
|
||||
const assetKeys = new Map<string, string>();
|
||||
const store: ProduceJobStore = {
|
||||
async claim(requestedLane) {
|
||||
lane = requestedLane;
|
||||
const next = queue.shift();
|
||||
if (!next) return null;
|
||||
jobs.push({ jobId: next.jobId, state: "transcoding" });
|
||||
return next;
|
||||
},
|
||||
async progress() { /* the runner reports checkpoints; not asserted here */ },
|
||||
async complete(jobId, output) {
|
||||
const producedAssetId = UUID(99);
|
||||
const row = jobs.find((j) => j.jobId === jobId);
|
||||
if (!row) throw new Error("job not claimed");
|
||||
row.state = "complete";
|
||||
row.output = { storageKey: output.storageKey, producedAssetId };
|
||||
return producedAssetId;
|
||||
},
|
||||
async fail(jobId, reason) {
|
||||
const row = jobs.find((j) => j.jobId === jobId);
|
||||
if (row) { row.state = "failed"; row.reason = reason; }
|
||||
},
|
||||
async asset(_workspaceId, assetId) {
|
||||
const storageKey = assetKeys.get(assetId);
|
||||
return storageKey === undefined ? null : { storageKey, mimeType: "audio/mp4" };
|
||||
},
|
||||
};
|
||||
const storage: ProduceObjectStorage = {
|
||||
async getObject(k) {
|
||||
const body = objects.get(k);
|
||||
if (body === undefined) throw new Error("not found");
|
||||
return body;
|
||||
},
|
||||
async putContent(body) { published = { key: key(body) }; objects.set(published.key, body); return published; },
|
||||
};
|
||||
return { events, jobs, objects, assetKeys, published: () => published, setQueue: (items: ClaimedProduceJob[]) => { queue = items; }, laneOf: () => lane, store, storage };
|
||||
}
|
||||
|
||||
function job(input: string, kind: "compose" | "mix", request: unknown, source: Partial<ClaimedProduceJob["source"]> = {}): ClaimedProduceJob {
|
||||
return {
|
||||
jobId: UUID(7),
|
||||
workspaceId: WS,
|
||||
kind,
|
||||
request: parseProduceRequest(request, kind),
|
||||
source: {
|
||||
id: UUID(1), storageKey: input, mimeType: "video/mp4", sizeBytes: 1,
|
||||
width: 320, height: 180, durationMs: 2000, frameRate: 30, hasAudio: true, ...source,
|
||||
},
|
||||
claimedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function runner(d: ReturnType<typeof doubles>, overrides: Partial<ConstructorParameters<typeof ProduceRunner>[0]> = {}): ProduceRunner {
|
||||
return new ProduceRunner({
|
||||
jobs: d.store, storage: d.storage, fontFile: FONT, scratchRoot: SCRATCH, lane: "local",
|
||||
redactor: { redact: (s) => s.replace(/\/[A-Za-z0-9._/-]+/g, "[path]") },
|
||||
onStderr: () => undefined,
|
||||
onEvent: (e) => d.events.push(e),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("compose job: encode, publish, derived asset, complete", { skip: !hasFfmpeg() || !hasFont() ? "needs ffmpeg + a ttf font" : false }, async () => {
|
||||
const d = doubles();
|
||||
const clip = join(SCRATCH, "source.mp4");
|
||||
makeClip(clip);
|
||||
const bytes = await import("node:fs/promises").then((fs) => fs.readFile(clip));
|
||||
const inputKey = key(bytes);
|
||||
d.objects.set(inputKey, bytes);
|
||||
d.setQueue([job(inputKey, "compose", { overlays: [{ kind: "lower_third", text: "Ada Lovelace", position: { x: 8, y: 85 } }], lane: "local" })]);
|
||||
|
||||
await runner(d).pollOnce();
|
||||
|
||||
assert.equal(d.jobs[0]?.state, "complete");
|
||||
assert.ok(d.jobs[0]?.output?.storageKey.startsWith("sha256/"));
|
||||
assert.ok(d.published()?.key.startsWith("sha256/"));
|
||||
assert.ok(d.events.some((e) => e.type === "complete"));
|
||||
// The publish is a real mp4 body (faststart box at the front).
|
||||
const body = d.objects.get(d.published()!.key);
|
||||
assert.ok(body !== undefined && body.subarray(4, 8).toString("latin1") === "ftyp");
|
||||
});
|
||||
|
||||
test("a mix job with a voiceover runs the ducking path end to end", { skip: !hasFfmpeg() || !hasFont() ? "needs ffmpeg + a ttf font" : false }, async () => {
|
||||
const d = doubles();
|
||||
const clip = join(SCRATCH, "source-mix.mp4");
|
||||
makeClip(clip);
|
||||
const bytes = await import("node:fs/promises").then((fs) => fs.readFile(clip));
|
||||
const inputKey = key(bytes);
|
||||
d.objects.set(inputKey, bytes);
|
||||
const voPath = join(SCRATCH, "vo.m4a");
|
||||
const vo = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "sine=frequency=880:sample_rate=48000:d=2", "-c:a", "aac", voPath]);
|
||||
if (vo.status !== 0) throw new Error("voiceover fixture failed");
|
||||
const voBytes = await import("node:fs/promises").then((fs) => fs.readFile(voPath));
|
||||
const voKey = `sha256/${createHash("sha256").update(voBytes).digest("hex")}.m4a`;
|
||||
d.objects.set(voKey, voBytes);
|
||||
d.setQueue([job(inputKey, "mix", { voiceover: { assetId: UUID(5), gainDb: -6, duckOriginal: true } })]);
|
||||
d.assetKeys.set(UUID(5), voKey);
|
||||
|
||||
await runner(d).pollOnce();
|
||||
assert.equal(d.jobs[0]?.state, "complete", d.jobs[0]?.reason);
|
||||
});
|
||||
|
||||
test("an unresolvable voiceover fails the job with a bounded reason", async () => {
|
||||
const d = doubles();
|
||||
d.setQueue([job("sha256/" + "0".repeat(64) + ".mp4", "mix", { voiceover: { assetId: UUID(5), gainDb: -6, duckOriginal: false } })]);
|
||||
await runner(d).pollOnce();
|
||||
assert.equal(d.jobs[0]?.state, "failed");
|
||||
assert.equal(d.jobs[0]?.reason, "produce download failed");
|
||||
assert.ok(!d.events.some((e) => e.type === "complete"));
|
||||
});
|
||||
|
||||
test("the loop claims for its lane only, idles when empty, and survives store errors", async () => {
|
||||
const d = doubles();
|
||||
let failures = 0;
|
||||
const flaky: ProduceJobStore = {
|
||||
...d.store,
|
||||
claim: async (lane) => { if (failures++ < 2) throw new Error("connection refused"); return d.store.claim(lane); },
|
||||
};
|
||||
const r = new ProduceRunner({
|
||||
jobs: flaky, storage: d.storage, fontFile: FONT, scratchRoot: SCRATCH, lane: "remote",
|
||||
redactor: { redact: (s) => s }, onStderr: () => undefined, onEvent: (e) => d.events.push(e),
|
||||
pollIntervalMs: 5, scheduler: { setTimeout: (_ms, action) => { const t = setTimeout(action, 0); return { cancel: () => clearTimeout(t) }; } },
|
||||
});
|
||||
r.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await r.stop();
|
||||
assert.equal(d.laneOf(), "remote");
|
||||
assert.ok(d.events.some((e) => e.type === "store-error"));
|
||||
assert.ok(d.events.some((e) => e.type === "idle"));
|
||||
});
|
||||
|
||||
test("scratch is removed after a job, success or failure", async () => {
|
||||
const d = doubles();
|
||||
const before = join(SCRATCH, "before.txt");
|
||||
writeFileSync(before, "x");
|
||||
d.setQueue([job("sha256/" + "0".repeat(64) + ".mp4", "compose", {})]);
|
||||
await runner(d).pollOnce();
|
||||
assert.equal(d.jobs[0]?.state, "failed"); // the input object does not exist
|
||||
assert.ok(statSync(before).isFile(), "the scratch root itself must survive");
|
||||
const leftovers = spawnSync("find", [SCRATCH, "-mindepth", "1", "-maxdepth", "1", "-name", "produce-*"], { encoding: "utf8" });
|
||||
assert.equal(leftovers.stdout.trim(), "");
|
||||
});
|
||||
|
||||
test.after(() => { rmSync(SCRATCH, { recursive: true, force: true }); });
|
||||
Loading…
Add table
Add a link
Reference in a new issue