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
245
apps/media-worker/src/producer/pg-store.ts
Normal file
245
apps/media-worker/src/producer/pg-store.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
/**
|
||||
* Postgres produce job store: the poll/claim/complete half of the produce worker.
|
||||
*
|
||||
* - Claim: `UPDATE ... SET state='transcoding' WHERE id IN (SELECT id ... FOR UPDATE
|
||||
* SKIP LOCKED) RETURNING` — one row, one statement, lane-filtered, so concurrent
|
||||
* workers never race and a claimed row is invisible to everyone else until its
|
||||
* owner finishes it. The `queued → transcoding` bump inside SKIP LOCKED is the
|
||||
* lease: there is no separate lease column to renew, because a claimed row is
|
||||
* owned until its owner writes complete/failed.
|
||||
* - Every write happens inside the job's tenant transaction (`app.workspace_id`),
|
||||
* because both tables are FORCE ROW LEVEL SECURITY with a `tenant_isolation`
|
||||
* policy (009). The claim reads the workspace off the row first, then re-claims
|
||||
* inside that tenant's scope.
|
||||
* - A row whose `input_json` or source asset fails re-validation is FAILED by THE
|
||||
* CLAIM ITSELF (never thrown out of `claim`), so an undecodable row can never sit
|
||||
* in `transcoding` forever holding its lease.
|
||||
*/
|
||||
import type { Pool, PoolClient } from "pg";
|
||||
import type { Lane, ProduceJobKind } from "./command-builder";
|
||||
import { parseProduceRequest } from "./command-builder";
|
||||
import type { ClaimedProduceJob, ProduceJobStore, ProduceOutput, ProduceSourceAsset } from "./store";
|
||||
|
||||
const KINDS: readonly ProduceJobKind[] = ["compose", "mix"];
|
||||
|
||||
/**
|
||||
* Lane → which rows this worker may claim (039 lanes: default worker = local or NULL,
|
||||
* i.e. anything but an explicit `lane:'remote'`; a remote worker takes only remote).
|
||||
*/
|
||||
export function lanePredicate(lane: Lane): { laneSql: string; params: [boolean] } {
|
||||
// $1 binds the boolean flag: a local/default worker skips remote rows, a remote
|
||||
// worker claims ONLY remote rows. The predicate never matches a local row to a
|
||||
// remote worker, so nothing is locked and then released.
|
||||
return {
|
||||
laneSql: lane === "remote"
|
||||
? "(j.input_json->>'lane' = 'remote')"
|
||||
: "(j.input_json->>'lane' IS DISTINCT FROM 'remote')",
|
||||
params: [lane === "remote"],
|
||||
};
|
||||
}
|
||||
|
||||
function uuid(value: unknown, field: string): string {
|
||||
const text = typeof value === "string" ? value : undefined;
|
||||
if (text === undefined || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(text)) {
|
||||
throw new Error(`produce job store: row has an invalid ${field}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function sourceFromRow(row: Record<string, unknown>): ProduceSourceAsset {
|
||||
return {
|
||||
id: uuid(row.id, "video_asset id"),
|
||||
storageKey: String(row.storage_key ?? ""),
|
||||
mimeType: String(row.mime_type ?? ""),
|
||||
sizeBytes: Number(row.size_bytes ?? 0),
|
||||
width: nullableNumber(row.width),
|
||||
height: nullableNumber(row.height),
|
||||
durationMs: row.duration_ms === null || row.duration_ms === undefined ? null : Math.round(Number(row.duration_ms)),
|
||||
frameRate: nullableNumber(row.frame_rate),
|
||||
// Probed "no audio track" (009 stores an empty codec) → a silent bed, so the
|
||||
// output always has an audio stream.
|
||||
hasAudio: row.audio_codec === null || row.audio_codec === undefined ? null : String(row.audio_codec) !== "",
|
||||
};
|
||||
}
|
||||
|
||||
/** The store's own reads are RLS-scoped to the job's workspace (009 tenant_isolation). */
|
||||
export class PostgresProduceJobStore implements ProduceJobStore {
|
||||
constructor(private readonly pool: Pool, private readonly now: () => Date = () => new Date()) {}
|
||||
|
||||
private async tenant<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async claim(lane: Lane): Promise<ClaimedProduceJob | null> {
|
||||
// Lane filter in the sub-select, so a worker never locks a row it would have to
|
||||
// release: a remote worker only ever locks remote rows, the default worker only
|
||||
// ever locks non-remote rows.
|
||||
const { laneSql, params } = lanePredicate(lane);
|
||||
const claimed = await this.pool.query<{ id: string; workspace_id: string }>(
|
||||
`UPDATE video_processing_jobs j
|
||||
SET state = 'transcoding', progress_percent = 5, error = NULL, updated_at = now()
|
||||
WHERE j.id IN (
|
||||
SELECT id FROM video_processing_jobs
|
||||
WHERE kind IN ('compose', 'mix')
|
||||
AND state = 'queued'
|
||||
AND ${laneSql}
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING j.id, j.workspace_id`,
|
||||
params,
|
||||
);
|
||||
if (claimed.rowCount === 0) return null;
|
||||
const row = claimed.rows[0]!;
|
||||
const workspaceId = uuid(row.workspace_id, "workspace id");
|
||||
|
||||
return this.tenant<ClaimedProduceJob | null>(workspaceId, async (client) => {
|
||||
// Re-claim inside the tenant's RLS scope (the pool-level row above is outside
|
||||
// it; claiming again is the cheap way to stay correct under FORCE RLS).
|
||||
const mine = await client.query(
|
||||
`UPDATE video_processing_jobs
|
||||
SET state = 'transcoding', progress_percent = 5, error = NULL, updated_at = now()
|
||||
WHERE id = $1 AND state IN ('queued', 'transcoding')
|
||||
RETURNING id, kind, input_json, video_asset_id`,
|
||||
[row.id],
|
||||
);
|
||||
if (mine.rowCount === 0) return null;
|
||||
const job = mine.rows[0]! as Record<string, unknown>;
|
||||
const kind = job.kind === "mix" ? "mix" : job.kind === "compose" ? "compose" : null;
|
||||
let request = null;
|
||||
if (kind !== null) {
|
||||
try {
|
||||
request = parseProduceRequest(job.input_json, kind);
|
||||
} catch {
|
||||
request = null;
|
||||
}
|
||||
}
|
||||
if (kind === null || request === null) {
|
||||
// A row the worker cannot honour must FAIL here — never strand in `transcoding`.
|
||||
// Written inside this transaction and COMMITTED by returning null (not thrown:
|
||||
// a throw would roll the failure back and re-strand the row).
|
||||
await this.failInvalid(client, job, kind === null ? "produce job store: unknown kind" : "produce invalid request");
|
||||
return null;
|
||||
}
|
||||
|
||||
const assets = await client.query(
|
||||
`SELECT id, storage_key, mime_type, size_bytes, width, height, duration_ms, frame_rate, audio_codec
|
||||
FROM video_assets WHERE id = $1`,
|
||||
[job.video_asset_id],
|
||||
);
|
||||
if (assets.rowCount === 0) {
|
||||
await this.failInvalid(client, job, "produce asset unavailable");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
jobId: uuid(job.id, "job id"),
|
||||
workspaceId,
|
||||
kind,
|
||||
request,
|
||||
source: sourceFromRow(assets.rows[0] as Record<string, unknown>),
|
||||
claimedAt: this.now().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail a just-claimed row whose payload cannot be honoured, inside the claim's
|
||||
* own transaction so the failure commits with it. Callers then return `null`:
|
||||
* the row is done (failed), there is simply nothing for this tick to encode.
|
||||
*/
|
||||
private async failInvalid(client: PoolClient, job: Record<string, unknown>, reason: string): Promise<void> {
|
||||
await client.query(
|
||||
`UPDATE video_processing_jobs
|
||||
SET state = 'failed', progress_percent = 100, error = $2, updated_at = now()
|
||||
WHERE id = $1 AND state = 'transcoding'`,
|
||||
[job.id, reason],
|
||||
);
|
||||
}
|
||||
|
||||
async progress(jobId: string, percent: number): Promise<void> {
|
||||
await this.tenant(await this.workspaceOf(jobId), async (client) => {
|
||||
await client.query(
|
||||
`UPDATE video_processing_jobs SET progress_percent = $2, updated_at = now()
|
||||
WHERE id = $1 AND state = 'transcoding'`,
|
||||
[jobId, percent],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async complete(jobId: string, output: ProduceOutput, derivedFrom: string): Promise<string> {
|
||||
const workspaceId = await this.workspaceOf(jobId);
|
||||
return this.tenant(workspaceId, async (client) => {
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`INSERT INTO video_assets
|
||||
(workspace_id, storage_key, mime_type, size_bytes, width, height, duration_ms, frame_rate, codec, audio_codec, derived_from)
|
||||
VALUES ($1, $2, 'video/mp4', $3, $4, $5, $6, $7, 'h264', 'aac', $8)
|
||||
RETURNING id`,
|
||||
[workspaceId, output.storageKey, output.sizeBytes, output.width, output.height, output.durationMs, output.frameRate, derivedFrom],
|
||||
);
|
||||
const producedAssetId = inserted.rows[0]!.id;
|
||||
const updated = await client.query(
|
||||
`UPDATE video_processing_jobs
|
||||
SET state = 'complete', progress_percent = 100, output_key = $2,
|
||||
produced_asset_id = $3, error = NULL, updated_at = now()
|
||||
WHERE id = $1 AND state = 'transcoding'
|
||||
RETURNING id`,
|
||||
[jobId, output.storageKey, producedAssetId],
|
||||
);
|
||||
if (updated.rowCount === 0) throw new Error(`produce job store: job ${jobId} lost its claim`);
|
||||
return producedAssetId;
|
||||
});
|
||||
}
|
||||
|
||||
async fail(jobId: string, reason: string): Promise<void> {
|
||||
await this.tenant(await this.workspaceOf(jobId), async (client) => {
|
||||
await client.query(
|
||||
`UPDATE video_processing_jobs
|
||||
SET state = 'failed', error = $2, updated_at = now()
|
||||
WHERE id = $1 AND state IN ('queued', 'transcoding')`,
|
||||
[jobId, reason],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async asset(workspaceId: string, assetId: string): Promise<{ storageKey: string; mimeType: string } | null> {
|
||||
return this.tenant(workspaceId, async (client) => {
|
||||
const rows = await client.query<{ storage_key: string; mime_type: string }>(
|
||||
`SELECT storage_key, mime_type FROM video_assets WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
if (rows.rowCount === 0) return null;
|
||||
return { storageKey: rows.rows[0]!.storage_key, mimeType: rows.rows[0]!.mime_type };
|
||||
});
|
||||
}
|
||||
|
||||
/** A claimed job's workspace, for the tenant-scoped follow-up writes. */
|
||||
private async workspaceOf(jobId: string): Promise<string> {
|
||||
const rows = await this.pool.query<{ workspace_id: string }>(
|
||||
`SELECT workspace_id FROM video_processing_jobs WHERE id = $1`,
|
||||
[jobId],
|
||||
);
|
||||
if (rows.rowCount === 0) throw new Error(`produce job store: job ${jobId} not found`);
|
||||
return uuid(rows.rows[0]!.workspace_id, "workspace id");
|
||||
}
|
||||
}
|
||||
|
||||
export const PRODUCE_JOB_KINDS = KINDS;
|
||||
Loading…
Add table
Add a link
Reference in a new issue