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:
glass beadian 2026-10-14 20:40:49 +00:00
parent 1183d69849
commit d9e8cdf789
18 changed files with 2384 additions and 20 deletions

View file

@ -0,0 +1,79 @@
/**
* Produce job store ports (migrations/009_video.sql + 039_producer.sql).
*
* The worker owns the same two tables the API's video service owns: it claims
* `video_processing_jobs` rows by kind, writes the derived `video_assets` row a
* job produces, and drives the state machine to `complete`/`failed`. Ports, not
* a concrete client, so tests and the (single-process) e2e path can double them –
* the same standing as `ObservedStore`, `AssetStore` and `PreviewObjectStore`.
*/
import type { Lane, ProduceJobKind, ProduceRequest } from "./command-builder";
export type { Lane, ProduceJobKind, ProduceRequest } from "./command-builder";
/** The source `video_assets` row a claimed job renders from. */
export interface ProduceSourceAsset {
id: string;
storageKey: string;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
durationMs: number | null;
frameRate: number | null;
/** True when the asset is known to carry an audio track (probed by 009's probe job). */
hasAudio: boolean | null;
}
export interface ClaimedProduceJob {
jobId: string;
workspaceId: string;
kind: ProduceJobKind;
request: ProduceRequest;
source: ProduceSourceAsset;
claimedAt: string;
}
export interface ProduceOutput {
/** Storage key of the produced mp4. */
storageKey: string;
sizeBytes: number;
durationMs: number | null;
width: number;
height: number;
frameRate: number;
}
export interface ProduceJobStore {
/**
* Claim exactly one queued compose/mix row for this worker's lane, with lease
* semantics: the `queued → transcoding` state bump inside
* `FOR UPDATE SKIP LOCKED` is the lease, so two workers can never take the
* same row and a row stays claimed until this worker finishes it.
*
* Returns `null` when nothing is claimable — including when a row was claimed
* but could not be honoured (an undecodable `input_json`, a missing source
* asset): such a row is failed inside the claim, never thrown, so no row can
* strand in `transcoding` holding its lease. Store/transport errors still throw
* (the poll loop's store-error path).
*/
claim(lane: Lane): Promise<ClaimedProduceJob | null>;
/** Report progress on a claimed job (never changes state). */
progress(jobId: string, percent: number): Promise<void>;
/** Insert the derived asset and complete the job pointing at it. */
complete(jobId: string, output: ProduceOutput, derivedFrom: string): Promise<string>;
/** Fail a claimed job; `reason` must already be redacted/bounded. */
fail(jobId: string, reason: string): Promise<void>;
/** Resolve a voiceover asset id to its storage key. */
asset(workspaceId: string, assetId: string): Promise<{ storageKey: string; mimeType: string } | null>;
}
/**
* Object storage, i.e. the API's `StorageService` (content-addressed, envelope
* encrypted): the worker downloads source bytes and publishes the produced mp4
* through exactly this pair. The adapter lives in `s3-storage.ts`.
*/
export interface ProduceObjectStorage {
getObject(key: string): Promise<Buffer>;
putContent(body: Buffer, contentType: "video/mp4"): Promise<{ key: string }>;
}