Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
46
apps/api/src/production/journal.ts
Normal file
46
apps/api/src/production/journal.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { PoolClient } from "pg";
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DesiredChannel } from "../media-control/wire.js";
|
||||
import type { DesiredSpec } from "../media-control/spec.js";
|
||||
|
||||
export type OverrideMode = "until_released" | "five_minutes" | "until_source_offline" | "until_next_event";
|
||||
export type DirectorOperation =
|
||||
| { type: "desired"; spec: DesiredSpec }
|
||||
| { type: "take" | "cut" }
|
||||
| { type: "pst-select"; sceneId: string }
|
||||
| { type: "lock"; enabled: boolean }
|
||||
| { type: "override"; mode: OverrideMode | null }
|
||||
| { type: "overlay"; slotId: string; revisionId: string };
|
||||
export interface DirectorState { locked: boolean; override: { mode: OverrideMode; expiresAt: string | null } | null }
|
||||
export interface DirectorReceipt {
|
||||
operation: DirectorOperation;
|
||||
requestId: string;
|
||||
fingerprint: string;
|
||||
snapshot: DesiredChannel;
|
||||
state: DirectorState;
|
||||
}
|
||||
export interface DirectorActor { userId: string | null; role: string }
|
||||
export type TenantTransaction = <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
|
||||
|
||||
export function requestIdentity(workspaceId: string, channelId: string, idempotencyKey: string): string {
|
||||
if (!/^[\x21-\x7e]{1,128}$/.test(idempotencyKey)) throw new BadRequestException("Idempotency-Key must contain 1–128 printable characters");
|
||||
return createHash("sha256").update(JSON.stringify([workspaceId, channelId, idempotencyKey])).digest("hex");
|
||||
}
|
||||
export function fingerprint(operation: DirectorOperation): string {
|
||||
// Rebuild a closed shape: field insertion order cannot change request identity.
|
||||
const value = operation.type === "pst-select" ? [operation.type, operation.sceneId]
|
||||
: operation.type === "lock" ? [operation.type, operation.enabled]
|
||||
: operation.type === "override" ? [operation.type, operation.mode]
|
||||
: operation.type === "overlay" ? [operation.type, operation.slotId, operation.revisionId]
|
||||
: operation.type === "desired" ? [operation.type, operation.spec] : [operation.type];
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
export async function findReceipt(client: PoolClient, workspaceId: string, channelId: string, requestId: string, expected: string): Promise<DirectorReceipt | null> {
|
||||
const result = await client.query<{ diff: DirectorReceipt }>(
|
||||
`SELECT diff FROM audit_log WHERE workspace_id=$1 AND target_type='production_command'
|
||||
AND target_id=$2 AND diff->>'requestId'=$3 ORDER BY seq DESC LIMIT 1`, [workspaceId, channelId, requestId]);
|
||||
const receipt = result.rows[0]?.diff;
|
||||
if (receipt && receipt.fingerprint !== expected) throw new ConflictException("Idempotency-Key was used with another operation");
|
||||
return receipt ?? null;
|
||||
}
|
||||
33
apps/api/src/production/preview.controller.ts
Normal file
33
apps/api/src/production/preview.controller.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Controller, ForbiddenException, Get, Inject, Param, Query, Req, Res } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { parseId } from "../http/validation.js";
|
||||
import { PreviewService } from "./preview.js";
|
||||
|
||||
/** Capability URLs are minted by the authenticated media-control endpoint.
|
||||
* Proxy plaintext through this origin; encrypted S3 objects are never redirected. */
|
||||
@Controller("media-control/preview")
|
||||
export class PreviewController {
|
||||
constructor(@Inject(PreviewService) private readonly preview: PreviewService) {}
|
||||
@Get(":workspaceId/:channelId/:tap/:file")
|
||||
async read(@Req() request: Request, @Res() response: Response,
|
||||
@Param("workspaceId") ws: string, @Param("channelId") ch: string, @Param("tap") tap: string, @Param("file") file: string,
|
||||
@Query("expires") expires: string, @Query("signature") signature: string): Promise<void> {
|
||||
const origin = request.headers.origin;
|
||||
const ownOrigin = process.env.PUBLIC_ORIGIN ?? `${request.protocol}://${request.get("host")}`;
|
||||
if (origin && origin !== ownOrigin) throw new ForbiddenException("cross-origin preview denied");
|
||||
parseId(ws, "workspaceId"); parseId(ch, "channelId");
|
||||
if (tap !== "program") parseId(tap, "tap");
|
||||
this.preview.verify(ws, ch, tap, file, expires, signature);
|
||||
const object = await this.preview.read(ws, ch, tap, file);
|
||||
response.setHeader("ETag", object.etag);
|
||||
response.setHeader("Cache-Control", "private, max-age=1");
|
||||
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Vary", "Origin");
|
||||
response.removeHeader("Access-Control-Allow-Origin");
|
||||
if (request.headers["if-none-match"]?.split(/,\s*/).some(tag => tag === object.etag || tag === `W/${object.etag}` || tag === "*")) {
|
||||
response.status(304).end(); return;
|
||||
}
|
||||
response.type(object.contentType).send(object.body);
|
||||
}
|
||||
}
|
||||
54
apps/api/src/production/preview.test.ts
Normal file
54
apps/api/src/production/preview.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRedis } from "./testing/memory-redis.js";
|
||||
import { PreviewService } from "./preview.js";
|
||||
import { keyForHash, sha256, type StorageService } from "../storage/storage.js";
|
||||
const WS = randomUUID(), CH = randomUUID(), TAP = randomUUID();
|
||||
function fixture(): { preview: PreviewService; storage: StorageService; redis: MemoryRedis; seed: (file: string, body: string, type?: string) => void; advance: (ms: number) => void } {
|
||||
const redis = new MemoryRedis(), objects = new Map<string, Buffer>(); let now = Date.now();
|
||||
const storage: StorageService = { putContent: async () => { throw new Error("unused"); }, getObject: vi.fn(async key => { await new Promise(r => setTimeout(r, 1)); return objects.get(key) ?? Buffer.alloc(0); }), hasContent: async () => false, presign: async () => { throw new Error("must not redirect to encrypted S3"); } };
|
||||
const preview = new PreviewService({ redis, storage, secret: "test", now: () => now, assertTap: async () => undefined });
|
||||
const seed = (file: string, body: string, type = "video/mp2t"): void => {
|
||||
const buffer = Buffer.from(body), key = keyForHash(sha256(buffer), type); objects.set(key, buffer);
|
||||
redis.data.set(`preview:${WS}:${CH}:${TAP}:${file}`, JSON.stringify({ key, contentType: type }));
|
||||
};
|
||||
return { preview, storage, redis, seed, advance: ms => { now += ms; } };
|
||||
}
|
||||
describe("same-origin short-lived preview capabilities and shared cache", () => {
|
||||
it("single-flight concurrent requests fill once and reuse an ETag", async () => {
|
||||
const f = fixture(); f.seed("thumbnail.jpg", "jpeg", "image/jpeg");
|
||||
const objects = await Promise.all(Array.from({ length: 20 }, () => f.preview.read(WS, CH, TAP, "thumbnail.jpg")));
|
||||
expect(f.storage.getObject).toHaveBeenCalledTimes(1); expect(new Set(objects.map(o => o.etag)).size).toBe(1);
|
||||
expect(objects[0]?.body.toString()).toBe("jpeg");
|
||||
f.advance(1001); await f.preview.read(WS, CH, TAP, "thumbnail.jpg"); expect(f.storage.getObject).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("keeps at most six segments per tap and fills evicted segments", async () => {
|
||||
const f = fixture(); for (let i = 0; i < 7; i++) { f.seed(`s${i}.ts`, `segment-${i}`); await f.preview.read(WS, CH, TAP, `s${i}.ts`); }
|
||||
await f.preview.read(WS, CH, TAP, "s0.ts"); expect(f.storage.getObject).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
it("signs path-bound URLs that expire after 60 seconds", async () => {
|
||||
const f = fixture(); const urls = await f.preview.urls(WS, CH, TAP);
|
||||
const url = new URL(urls.thumbnail ?? "", "https://same-origin.example");
|
||||
const expiry = url.searchParams.get("expires") ?? "", signature = url.searchParams.get("signature") ?? "";
|
||||
expect(url.pathname.startsWith("/media-control/preview/")).toBe(true);
|
||||
expect(() => f.preview.verify(WS, CH, TAP, "thumbnail.jpg", expiry, signature)).not.toThrow();
|
||||
expect(() => f.preview.verify(randomUUID(), CH, TAP, "thumbnail.jpg", expiry, signature)).toThrow();
|
||||
expect(() => f.preview.verify(WS, CH, TAP, "audio.m3u8", expiry, signature)).toThrow();
|
||||
f.advance(60_000); expect(() => f.preview.verify(WS, CH, TAP, "thumbnail.jpg", expiry, signature)).toThrow("expired");
|
||||
});
|
||||
it("rewrites HLS media and init URLs to signed same-origin paths", async () => {
|
||||
const f = fixture(); f.seed("video.m3u8", '#EXTM3U\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:1,\ns1.m4s\n', "application/vnd.apple.mpegurl");
|
||||
const object = await f.preview.read(WS, CH, TAP, "video.m3u8");
|
||||
expect(object.body.toString()).toContain(`/media-control/preview/${WS}/${CH}/${TAP}/init.mp4?expires=`);
|
||||
expect(object.body.toString()).toContain(`/media-control/preview/${WS}/${CH}/${TAP}/s1.m4s?expires=`);
|
||||
expect(object.body.toString()).not.toContain("https://");
|
||||
});
|
||||
it.each(["https://evil.example/s.ts", "../escape.ts", "//evil/s.ts", "file:///etc/passwd"])("rejects external/traversal HLS URI %s", async uri => {
|
||||
const f = fixture(); f.seed("video.m3u8", `#EXTM3U\n${uri}\n`, "application/vnd.apple.mpegurl");
|
||||
await expect(f.preview.read(WS, CH, TAP, "video.m3u8")).rejects.toThrow("invalid preview file");
|
||||
});
|
||||
it("failed fill does not poison the single-flight cache", async () => {
|
||||
const f = fixture(); await expect(f.preview.read(WS, CH, TAP, "s.ts")).rejects.toThrow("unavailable");
|
||||
f.seed("s.ts", "fixed"); expect((await f.preview.read(WS, CH, TAP, "s.ts")).body.toString()).toBe("fixed");
|
||||
});
|
||||
});
|
||||
99
apps/api/src/production/preview.ts
Normal file
99
apps/api/src/production/preview.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, OnModuleDestroy, Optional } from "@nestjs/common";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { RespControlRedis, type ControlRedis } from "../media-control/redis.js";
|
||||
import { S3Storage, s3ConfigFromEnv, sha256, type StorageService } from "../storage/storage.js";
|
||||
|
||||
export const PREVIEW_DEPENDENCIES = Symbol("PREVIEW_DEPENDENCIES");
|
||||
export const PREVIEW_TTL_SECONDS = 60;
|
||||
interface PreviewManifest { key: string; contentType: string }
|
||||
interface PreviewObject { body: Buffer; contentType: string; etag: string }
|
||||
interface PreviewDependencies {
|
||||
redis: ControlRedis;
|
||||
storage: StorageService;
|
||||
secret: string;
|
||||
now: () => number;
|
||||
assertTap: (workspaceId: string, channelId: string, tap: string) => Promise<void>;
|
||||
}
|
||||
const FILE = /^(?:[A-Za-z0-9_-]+\.(?:m3u8|m4s|ts|aac|jpg|jpeg|mp4))$/;
|
||||
const TYPES = new Set(["image/jpeg", "application/vnd.apple.mpegurl", "application/x-mpegURL", "video/mp2t", "video/mp4", "audio/mp4", "audio/aac"]);
|
||||
|
||||
@Injectable()
|
||||
export class PreviewService implements OnModuleDestroy {
|
||||
private readonly deps: PreviewDependencies;
|
||||
private readonly sources = new Map<string, Map<string, { expiresAt: number; object: PreviewObject }>>();
|
||||
private readonly flights = new Map<string, Promise<PreviewObject>>();
|
||||
constructor(@Optional() @Inject(PREVIEW_DEPENDENCIES) dependencies?: Partial<PreviewDependencies>) {
|
||||
const secret = dependencies?.secret ?? process.env.PREVIEW_SIGNING_SECRET ?? process.env.SESSION_SECRET;
|
||||
if (!secret && process.env.NODE_ENV === "production") throw new Error("PREVIEW_SIGNING_SECRET is required");
|
||||
this.deps = { redis: dependencies?.redis ?? new RespControlRedis(process.env.REDIS_URL ?? "redis://localhost:6379"),
|
||||
storage: dependencies?.storage ?? new S3Storage(s3ConfigFromEnv()), secret: secret ?? "dev-preview-secret",
|
||||
now: dependencies?.now ?? Date.now,
|
||||
assertTap: dependencies?.assertTap ?? (async (ws, ch, tap) => {
|
||||
const result = await withTenant(ws, client => client.query(tap === "program"
|
||||
? "SELECT id FROM virtual_channels WHERE workspace_id=$1 AND id=$2"
|
||||
: "SELECT id FROM virtual_channel_sources WHERE workspace_id=$1 AND virtual_channel_id=$2 AND id=$3", tap === "program" ? [ws, ch] : [ws, ch, tap]));
|
||||
if (!result.rowCount) throw new NotFoundException("preview tap not found");
|
||||
}),
|
||||
};
|
||||
}
|
||||
onModuleDestroy(): void { this.deps.redis.close(); this.sources.clear(); }
|
||||
async urls(ws: string, ch: string, tap: string): Promise<Record<string, string>> {
|
||||
await this.deps.assertTap(ws, ch, tap);
|
||||
return { thumbnail: this.url(ws, ch, tap, "thumbnail.jpg"), video: this.url(ws, ch, tap, "video.m3u8"), audio: this.url(ws, ch, tap, "audio.m3u8") };
|
||||
}
|
||||
private mac(path: string, expires: number): string { return createHmac("sha256", this.deps.secret).update(`${path}\n${expires}`).digest("hex"); }
|
||||
private path(ws: string, ch: string, tap: string, file: string): string {
|
||||
return `/media-control/preview/${ws}/${ch}/${tap}/${file}`;
|
||||
}
|
||||
url(ws: string, ch: string, tap: string, file: string): string {
|
||||
if (!FILE.test(file)) throw new BadRequestException("invalid preview file");
|
||||
const path = this.path(ws, ch, tap, file);
|
||||
const expires = Math.floor(this.deps.now() / 1000) + PREVIEW_TTL_SECONDS;
|
||||
return `${path}?expires=${expires}&signature=${this.mac(path, expires)}`;
|
||||
}
|
||||
verify(ws: string, ch: string, tap: string, file: string, expires: string, signature: string): void {
|
||||
if (!FILE.test(file) || !/^\d{1,12}$/.test(expires) || !/^[0-9a-f]{64}$/.test(signature)) throw new ForbiddenException("invalid preview signature");
|
||||
const expiry = Number(expires), now = Math.floor(this.deps.now() / 1000);
|
||||
const expected = this.mac(this.path(ws, ch, tap, file), expiry);
|
||||
if (expiry <= now || expiry > now + PREVIEW_TTL_SECONDS || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) throw new ForbiddenException("expired or invalid preview signature");
|
||||
}
|
||||
async read(ws: string, ch: string, tap: string, file: string): Promise<PreviewObject> {
|
||||
const source = `${ws}:${ch}:${tap}`, key = `${source}:${file}`;
|
||||
const cache = this.sources.get(source);
|
||||
const hit = cache?.get(file);
|
||||
if (hit && hit.expiresAt > this.deps.now()) { cache?.delete(file); cache?.set(file, hit); return hit.object; }
|
||||
const existing = this.flights.get(key);
|
||||
if (existing) return existing;
|
||||
if (this.flights.size >= 128) throw new BadRequestException("preview fill capacity exceeded");
|
||||
const flight = this.fill(ws, ch, tap, file).then(object => {
|
||||
const entries = this.sources.get(source) ?? new Map<string, { expiresAt: number; object: PreviewObject }>();
|
||||
entries.delete(file); entries.set(file, { expiresAt: this.deps.now() + 1000, object });
|
||||
while (entries.size > 6) entries.delete(entries.keys().next().value ?? "");
|
||||
this.sources.delete(source); this.sources.set(source, entries);
|
||||
while (this.sources.size > 128) this.sources.delete(this.sources.keys().next().value ?? "");
|
||||
return object;
|
||||
}).finally(() => this.flights.delete(key));
|
||||
this.flights.set(key, flight);
|
||||
return flight;
|
||||
}
|
||||
private async fill(ws: string, ch: string, tap: string, file: string): Promise<PreviewObject> {
|
||||
if (!FILE.test(file)) throw new BadRequestException("invalid preview file");
|
||||
const raw = await this.deps.redis.get(`preview:${ws}:${ch}:${tap}:${file}`);
|
||||
if (!raw) throw new NotFoundException("preview unavailable");
|
||||
const manifest = JSON.parse(raw) as PreviewManifest;
|
||||
if (!/^sha256\/[0-9a-f]{64}\.[a-z0-9]+$/.test(manifest.key) || !TYPES.has(manifest.contentType)) throw new BadRequestException("invalid preview manifest");
|
||||
let body = await this.deps.storage.getObject(manifest.key);
|
||||
if (body.length > 4_000_000) throw new BadRequestException("preview object too large");
|
||||
if (file.endsWith(".m3u8")) {
|
||||
const rewrite = (uri: string): string => this.url(ws, ch, tap, uri);
|
||||
const playlist = body.toString("utf8");
|
||||
if (!playlist.startsWith("#EXTM3U")) throw new BadRequestException("invalid preview playlist");
|
||||
body = Buffer.from(playlist.split(/\r?\n/).map(line => {
|
||||
if (line.startsWith("#")) return line.replace(/URI="([^"]+)"/g, (_match: string, uri: string) => `URI="${rewrite(uri)}"`);
|
||||
return line.trim() === "" ? line : rewrite(line.trim());
|
||||
}).join("\n"));
|
||||
}
|
||||
return { body, contentType: manifest.contentType, etag: `"${sha256(body)}"` };
|
||||
}
|
||||
}
|
||||
18
apps/api/src/production/production.controller.ts
Normal file
18
apps/api/src/production/production.controller.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Body, Controller, Headers, Inject, Param, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { parseId, requireSession } from "../http/validation.js";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import type { IssuedCommand } from "../media-control/media-control.service.js";
|
||||
import { ProductionService } from "./production.service.js";
|
||||
@Controller("production")
|
||||
@UseGuards(RbacGuard)
|
||||
export class ProductionController {
|
||||
constructor(@Inject(ProductionService) private readonly production: ProductionService) {}
|
||||
@Post("overlays/:overlayId")
|
||||
@RequirePermission("director.change_layout")
|
||||
render(@Req() request: Request & AuthenticatedRequest, @Param("overlayId") overlayId: string,
|
||||
@Body() body: unknown, @Headers("idempotency-key") key?: string): Promise<IssuedCommand> {
|
||||
const session = requireSession(request);
|
||||
return this.production.submit(session.workspaceId, parseId(overlayId, "overlayId"), body, key ?? "", { userId: session.userId, role: session.role });
|
||||
}
|
||||
}
|
||||
6
apps/api/src/production/production.module.ts
Normal file
6
apps/api/src/production/production.module.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Module } from "@nestjs/common";
|
||||
import { MediaControlModule } from "../media-control/media-control.module.js";
|
||||
import { ProductionService } from "./production.service.js";
|
||||
import { ProductionController } from "./production.controller.js";
|
||||
@Module({ imports: [MediaControlModule], controllers: [ProductionController], providers: [ProductionService], exports: [ProductionService] })
|
||||
export class ProductionModule {}
|
||||
94
apps/api/src/production/production.service.ts
Normal file
94
apps/api/src/production/production.service.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { ConflictException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from "@nestjs/common";
|
||||
import { withTenant, pool } from "../db/client.js";
|
||||
import { MediaControlService, type IssuedCommand } from "../media-control/media-control.service.js";
|
||||
import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
|
||||
import { requestIdentity, type DirectorActor, type TenantTransaction } from "./journal.js";
|
||||
import { renderOverlay, validateRenderInput, type RenderInput } from "./render.js";
|
||||
export const PRODUCTION_DEPENDENCIES = Symbol("PRODUCTION_DEPENDENCIES");
|
||||
interface RevisionFields { input: RenderInput; actor: DirectorActor; requestId: string; fingerprint: string }
|
||||
interface Revision { id: string; virtual_channel_id: string; overlay_slot_id: string; render_state: string; fields: RevisionFields }
|
||||
interface Dependencies { transaction: TenantTransaction; storage: StorageService; render: (input: unknown) => Promise<Buffer> }
|
||||
|
||||
@Injectable()
|
||||
export class ProductionService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly deps: Dependencies;
|
||||
private readonly flights = new Map<string, Promise<IssuedCommand>>();
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private recovering = false;
|
||||
constructor(@Inject(MediaControlService) private readonly control: MediaControlService,
|
||||
@Optional() @Inject(PRODUCTION_DEPENDENCIES) dependencies?: Partial<Dependencies>) {
|
||||
this.deps = { transaction: dependencies?.transaction ?? withTenant, storage: dependencies?.storage ?? new S3Storage(s3ConfigFromEnv()),
|
||||
render: dependencies?.render ?? renderOverlay };
|
||||
}
|
||||
onModuleInit(): void {
|
||||
this.timer = setInterval(() => { void this.recover(); }, 1000); this.timer.unref();
|
||||
void this.recover();
|
||||
}
|
||||
onModuleDestroy(): void { if (this.timer) clearInterval(this.timer); }
|
||||
async submit(ws: string, slotId: string, value: unknown, idempotencyKey: string, actor: DirectorActor): Promise<IssuedCommand> {
|
||||
const input = validateRenderInput(value);
|
||||
const expected = createHash("sha256").update(JSON.stringify(input)).digest("hex");
|
||||
const revision = await this.deps.transaction(ws, async client => {
|
||||
const slot = await client.query<{ virtual_channel_id: string; kind: string }>("SELECT virtual_channel_id,kind FROM overlay_slots WHERE workspace_id=$1 AND id=$2 FOR UPDATE", [ws, slotId]);
|
||||
const row = slot.rows[0];
|
||||
if (!row) throw new NotFoundException("overlay slot not found");
|
||||
if (row.kind !== input.templateId) throw new ConflictException("template must match overlay kind");
|
||||
const requestId = requestIdentity(ws, slotId, idempotencyKey);
|
||||
const found = await client.query<Revision>("SELECT * FROM overlay_revisions WHERE workspace_id=$1 AND overlay_slot_id=$2 AND fields->>'requestId'=$3", [ws, slotId, requestId]);
|
||||
const previous = found.rows[0];
|
||||
if (previous) {
|
||||
if (previous.fields.fingerprint !== expected) throw new ConflictException("Idempotency-Key was used with different overlay fields");
|
||||
return previous;
|
||||
}
|
||||
const id = randomUUID();
|
||||
const fields: RevisionFields = { input, actor, requestId, fingerprint: expected };
|
||||
await client.query("INSERT INTO overlay_revisions(id,workspace_id,virtual_channel_id,overlay_slot_id,template_id,fields) VALUES($1,$2,$3,$4,$5,$6)", [id, ws, row.virtual_channel_id, slotId, input.templateId, fields]);
|
||||
return { id, virtual_channel_id: row.virtual_channel_id, overlay_slot_id: slotId, render_state: "pending", fields };
|
||||
});
|
||||
return this.run(ws, revision);
|
||||
}
|
||||
private run(ws: string, revision: Revision): Promise<IssuedCommand> {
|
||||
const key = `${ws}:${revision.id}`;
|
||||
const previous = this.flights.get(key);
|
||||
if (previous) return previous;
|
||||
const flight = (async () => {
|
||||
if (revision.render_state !== "rendered") {
|
||||
try {
|
||||
const png = await this.deps.render(revision.fields.input);
|
||||
const stored = await this.deps.storage.putContent(png, "image/png");
|
||||
await this.deps.transaction(ws, client => client.query("UPDATE overlay_revisions SET render_state='rendered',asset_id=$3,updated_at=now() WHERE workspace_id=$1 AND id=$2", [ws, revision.id, stored.hash]));
|
||||
} catch (error) {
|
||||
await this.deps.transaction(ws, client => client.query("UPDATE overlay_revisions SET render_state='failed',asset_id=NULL,updated_at=now() WHERE workspace_id=$1 AND id=$2 AND render_state<>'rendered'", [ws, revision.id]));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return this.control.director(ws, revision.virtual_channel_id, { type: "overlay", slotId: revision.overlay_slot_id, revisionId: revision.id },
|
||||
`overlay-render:${revision.id}`, revision.fields.actor);
|
||||
})().finally(() => this.flights.delete(key));
|
||||
this.flights.set(key, flight); return flight;
|
||||
}
|
||||
private async recover(): Promise<void> {
|
||||
if (this.recovering) return;
|
||||
this.recovering = true;
|
||||
try {
|
||||
const workspaces = await pool.query<{ id: string }>("SELECT id FROM workspaces");
|
||||
for (const ws of workspaces.rows) {
|
||||
const revisions = await this.deps.transaction(ws.id, async client => {
|
||||
const result = await client.query<Revision>(`SELECT r.* FROM overlay_revisions r WHERE r.workspace_id=$1
|
||||
AND r.render_state <> 'failed' AND r.fields ? 'input' AND r.fields ? 'actor'
|
||||
AND NOT EXISTS(SELECT 1 FROM overlay_slots s JOIN overlay_revisions active_revision ON active_revision.id=s.current_revision_id
|
||||
AND active_revision.workspace_id=s.workspace_id WHERE s.workspace_id=r.workspace_id AND s.id=r.overlay_slot_id AND active_revision.created_at > r.created_at)
|
||||
AND NOT EXISTS(SELECT 1 FROM audit_log a WHERE a.workspace_id=$1
|
||||
AND a.target_type='production_command' AND a.diff->'operation'->>'revisionId'=r.id::text) ORDER BY r.created_at LIMIT 100`, [ws.id]);
|
||||
return result.rows;
|
||||
});
|
||||
for (const revision of revisions) {
|
||||
// Rendered rows are retried too: a crash may happen between storing PNG and swapping the slot.
|
||||
await this.run(ws.id, revision).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} catch { /* Retry after transient database/storage failures. */ }
|
||||
finally { this.recovering = false; }
|
||||
}
|
||||
}
|
||||
67
apps/api/src/production/production.test.ts
Normal file
67
apps/api/src/production/production.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { PoolClient } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MediaControlService, type IssuedCommand } from "../media-control/media-control.service.js";
|
||||
import { sha256, keyForHash, type StorageService } from "../storage/storage.js";
|
||||
import { ProductionService } from "./production.service.js";
|
||||
import type { TenantTransaction } from "./journal.js";
|
||||
const WS = randomUUID(), CH = randomUUID(), SLOT = randomUUID();
|
||||
const ACTOR = { userId: randomUUID(), role: "Owner" };
|
||||
const ISSUED: IssuedCommand = { commandId: randomUUID(), workerId: "worker", assignmentEpoch: 1, desiredVersion: 1, issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 30000).toISOString(), receivers: 1 };
|
||||
interface Revision { id: string; virtual_channel_id: string; overlay_slot_id: string; fields: unknown; render_state: string; asset_id?: string }
|
||||
function fixture(render?: (input: unknown) => Promise<Buffer>): { production: ProductionService; rows: Revision[]; storage: StorageService; control: MediaControlService } {
|
||||
const rows: Revision[] = []; let tail = Promise.resolve();
|
||||
const transaction: TenantTransaction = async <T>(_ws: string, fn: (client: PoolClient) => Promise<T>): Promise<T> => {
|
||||
const wait = tail; let unlock = (): void => undefined; tail = new Promise<void>(resolve => { unlock = resolve; }); await wait;
|
||||
const query = async (sql: string, args: unknown[]): Promise<{ rows: unknown[]; rowCount: number }> => {
|
||||
if (sql.startsWith("SELECT virtual_channel_id")) return { rows: [{ virtual_channel_id: CH, kind: "lower_third" }], rowCount: 1 };
|
||||
if (sql.startsWith("SELECT * FROM overlay_revisions")) {
|
||||
const found = rows.filter(r => r.fields && typeof r.fields === "object" && "requestId" in r.fields && r.fields.requestId === args[2]);
|
||||
return { rows: found, rowCount: found.length };
|
||||
}
|
||||
if (sql.startsWith("INSERT")) rows.push({ id: String(args[0]), virtual_channel_id: String(args[2]), overlay_slot_id: String(args[3]), fields: args[5], render_state: "pending" });
|
||||
if (sql.startsWith("UPDATE")) {
|
||||
const row = rows.find(r => r.id === args[1]); if (!row) throw new Error("missing revision");
|
||||
row.render_state = sql.includes("render_state='rendered'") ? "rendered" : "failed"; if (args[2]) row.asset_id = String(args[2]);
|
||||
}
|
||||
return { rows: [], rowCount: 1 };
|
||||
};
|
||||
try { return await fn({ query } as unknown as PoolClient); } finally { unlock(); }
|
||||
};
|
||||
const control = new MediaControlService({ signer: null, assignments: [] }); vi.spyOn(control, "director").mockResolvedValue(ISSUED);
|
||||
const storage: StorageService = { putContent: vi.fn(async (buffer, contentType) => { const hash = sha256(buffer); return { hash, key: keyForHash(hash, contentType), sizeBytes: buffer.length, contentType, deduplicated: false }; }), getObject: async () => Buffer.alloc(0), hasContent: async () => false, presign: async () => "" };
|
||||
return { rows, control, storage, production: new ProductionService(control, { transaction, storage, ...(render ? { render } : {}) }) };
|
||||
}
|
||||
describe("revision → render job → content-addressed storage → desired-state pipeline", () => {
|
||||
it("stores a real alpha PNG and swaps only a revision/asset reference through media-control", async () => {
|
||||
const f = fixture(); const input = { templateId: "lower_third", fields: { title: '<script>bad</script>', subtitle: 'Name; [movie=hostile]' } };
|
||||
await expect(f.production.submit(WS, SLOT, input, "render-1", ACTOR)).resolves.toEqual(ISSUED);
|
||||
expect(f.rows).toHaveLength(1); expect(f.rows[0]?.render_state).toBe("rendered"); expect(f.rows[0]?.asset_id).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(f.storage.putContent).toHaveBeenCalledWith(expect.any(Buffer), "image/png");
|
||||
expect(f.control.director).toHaveBeenCalledWith(WS, CH, { type: "overlay", slotId: SLOT, revisionId: f.rows[0]?.id }, `overlay-render:${f.rows[0]?.id}`, ACTOR);
|
||||
await f.production.submit(WS, SLOT, input, "render-1", ACTOR); expect(f.storage.putContent).toHaveBeenCalledTimes(1);
|
||||
await expect(f.production.submit(WS, SLOT, { ...input, fields: { title: "changed" } }, "render-1", ACTOR)).rejects.toThrow("different overlay fields");
|
||||
});
|
||||
it("concurrent duplicate overlay requests share a single render job/revision", async () => {
|
||||
const renderer = vi.fn(async () => { await new Promise(r => setTimeout(r, 10)); return Buffer.from("test-png"); }); const f = fixture(renderer);
|
||||
await Promise.all(Array.from({ length: 10 }, () => f.production.submit(WS, SLOT, { templateId: "lower_third", fields: { title: "hello" } }, "parallel", ACTOR)));
|
||||
expect(renderer).toHaveBeenCalledTimes(1); expect(f.rows).toHaveLength(1); expect(f.control.director).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("render failure records failed revision; retry resumes the same revision", async () => {
|
||||
let fail = true; const f = fixture(async () => { if (fail) { fail = false; throw new Error("render failed"); } return Buffer.from("png"); });
|
||||
const input = { templateId: "lower_third", fields: { title: "hello" } };
|
||||
await expect(f.production.submit(WS, SLOT, input, "retry", ACTOR)).rejects.toThrow("render failed"); expect(f.rows[0]?.render_state).toBe("failed"); expect(f.control.director).not.toHaveBeenCalled();
|
||||
await f.production.submit(WS, SLOT, input, "retry", ACTOR); expect(f.rows).toHaveLength(1); expect(f.rows[0]?.render_state).toBe("rendered");
|
||||
});
|
||||
it("crash after PNG persistence retries only the desired-state swap", async () => {
|
||||
const render = vi.fn(async () => Buffer.from("png")), f = fixture(render);
|
||||
vi.mocked(f.control.director).mockRejectedValueOnce(new Error("publish outage"));
|
||||
const input = { templateId: "lower_third", fields: { title: "hello" } };
|
||||
await expect(f.production.submit(WS, SLOT, input, "resume", ACTOR)).rejects.toThrow("publish outage"); expect(f.rows[0]?.render_state).toBe("rendered");
|
||||
await f.production.submit(WS, SLOT, input, "resume", ACTOR); expect(render).toHaveBeenCalledTimes(1); expect(f.storage.putContent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("rejects hostile uploaded SVG before creating a revision or invoking storage", async () => {
|
||||
const f = fixture(); await expect(f.production.submit(WS, SLOT, { templateId: "logo", fields: { svg: '<!DOCTYPE svg [<!ENTITY x SYSTEM "https://evil.example">]><svg>&x;</svg>' } }, "hostile", ACTOR)).rejects.toThrow("unsafe SVG");
|
||||
expect(f.rows).toHaveLength(0); expect(f.storage.putContent).not.toHaveBeenCalled(); expect(f.control.director).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
33
apps/api/src/production/render.test.ts
Normal file
33
apps/api/src/production/render.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { renderOverlay, validateRenderInput, overlaySvg } from "./render.js";
|
||||
const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
describe("overlay render job injection gates (installed sharp, isolated worker)", () => {
|
||||
it.each(["lower_third", "chyron"])("renders %s hostile text as escaped text into an actual PNG", async templateId => {
|
||||
const input = { templateId, fields: { title: '<script>alert("x")</script> & $(touch /tmp/owned)', subtitle: 'evil; [movie=/etc/passwd]; "', style: "red" } };
|
||||
const svg = overlaySvg(validateRenderInput(input)).svg;
|
||||
expect(svg).toContain("<script>"); expect(svg).not.toContain("<script>");
|
||||
const rendered = await renderOverlay(input); expect(rendered.subarray(0, 8)).toEqual(png);
|
||||
expect(rendered.readUInt32BE(16)).toBe(templateId === "chyron" ? 4096 : 1920);
|
||||
});
|
||||
it("rasterizes a safe tenant geometric logo", async () => {
|
||||
const rendered = await renderOverlay({ templateId: "logo", fields: { svg: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#FF1610"/></svg>' } });
|
||||
expect(rendered.subarray(0, 8)).toEqual(png); expect(rendered.readUInt32BE(16)).toBe(512);
|
||||
});
|
||||
it.each([
|
||||
'<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><svg>&xxe;</svg>',
|
||||
'<svg><script>alert(1)</script></svg>', '<svg onload="alert(1)"></svg>',
|
||||
'<svg><image href="https://attacker.example/tracker"/></svg>',
|
||||
'<svg><style>@import "https://attacker.example/a.css";</style></svg>',
|
||||
'<svg><foreignObject><div>html</div></foreignObject></svg>',
|
||||
'<svg><path fill="url(https://evil.example/x)"/></svg>',
|
||||
'<svg xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="file:///etc/passwd"/></svg>',
|
||||
'<?xml-stylesheet href="https://evil.example/a"?><svg/>',
|
||||
'<svg width="999999999" height="999999999"/>',
|
||||
'<svg><g></svg></g>', '<svg/><svg/>', '<svg><path d="M0 0" onclick="evil()"/></svg>',
|
||||
])("rejects hostile SVG through the render job: %s", async svg => {
|
||||
await expect(renderOverlay({ templateId: "logo", fields: { svg } })).rejects.toThrow();
|
||||
});
|
||||
it.each(["\u0000", "\n", "\u202e", "a".repeat(81)])("rejects invalid text %s before sharp", async title => {
|
||||
await expect(renderOverlay({ templateId: "lower_third", fields: { title } })).rejects.toThrow("invalid overlay text");
|
||||
});
|
||||
});
|
||||
118
apps/api/src/production/render.ts
Normal file
118
apps/api/src/production/render.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// eslint-disable-next-line no-restricted-syntax -- Node worker_threads is a runtime builtin, not a provider identifier.
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { createRequire } from "node:module";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
export type OverlayTemplate = "lower_third" | "chyron" | "logo";
|
||||
export interface OverlayFields { title?: string; subtitle?: string; style?: "dark" | "red"; svg?: string }
|
||||
export interface RenderInput { templateId: OverlayTemplate; fields: OverlayFields }
|
||||
function text(value: unknown, max: number): string {
|
||||
if (typeof value !== "string" || value.length > max || !/^[\p{L}\p{N}\p{P}\p{Zs}\p{M}\p{S}]*$/u.test(value)) throw new BadRequestException("invalid overlay text");
|
||||
return value;
|
||||
}
|
||||
function escape(value: string): string {
|
||||
return value.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c);
|
||||
}
|
||||
export function validateRenderInput(value: unknown): RenderInput {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new BadRequestException("invalid overlay input");
|
||||
const input = value as Record<string, unknown>;
|
||||
if (Object.keys(input).some(k => !["templateId", "fields"].includes(k)) || !["lower_third", "chyron", "logo"].includes(String(input.templateId))) throw new BadRequestException("invalid overlay template");
|
||||
if (!input.fields || typeof input.fields !== "object" || Array.isArray(input.fields)) throw new BadRequestException("invalid overlay fields");
|
||||
const fields = input.fields as Record<string, unknown>;
|
||||
if (Object.keys(fields).some(k => !["title", "subtitle", "style", "svg"].includes(k))) throw new BadRequestException("unknown overlay field");
|
||||
if (input.templateId === "logo") {
|
||||
if (Object.keys(fields).some(k => k !== "svg") || typeof fields.svg !== "string") throw new BadRequestException("logo requires SVG only");
|
||||
return { templateId: "logo", fields: { svg: sanitizeLogo(fields.svg) } };
|
||||
}
|
||||
if (fields.svg !== undefined || (fields.style !== undefined && fields.style !== "dark" && fields.style !== "red")) throw new BadRequestException("invalid overlay style");
|
||||
return { templateId: input.templateId as "lower_third" | "chyron", fields: { title: text(fields.title, 80),
|
||||
subtitle: text(fields.subtitle ?? "", 120), style: fields.style === "red" ? "red" : "dark" } };
|
||||
}
|
||||
|
||||
/** Closed geometric SVG language. No declarations, entities, CSS, URLs, script,
|
||||
* animation, image/font loading, foreignObject or namespaced attributes. */
|
||||
export function sanitizeLogo(svg: string): string {
|
||||
if (svg.length > 64_000 || /<!|<\?|&|url\s*\(|[\u0000-\u001f]/i.test(svg)) throw new BadRequestException("unsafe SVG");
|
||||
const tags = new Set(["svg", "g", "path", "rect", "circle", "ellipse", "polygon", "polyline", "line"]);
|
||||
const attrs = new Set(["xmlns", "width", "height", "viewBox", "fill", "stroke", "stroke-width", "opacity", "fill-opacity", "stroke-opacity", "d", "points", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry", "transform", "fill-rule", "stroke-linecap", "stroke-linejoin"]);
|
||||
const stack: string[] = [];
|
||||
let offset = 0, roots = 0;
|
||||
for (const match of svg.matchAll(/<([^<>]+)>/g)) {
|
||||
if (svg.slice(offset, match.index).trim() !== "") throw new BadRequestException("SVG text is not allowed");
|
||||
offset = (match.index ?? 0) + match[0].length;
|
||||
const token = match[1] ?? "";
|
||||
const head = /^(\/)?([A-Za-z]+)([\s\S]*)$/.exec(token);
|
||||
if (!head || !tags.has(head[2] ?? "")) throw new BadRequestException("unsafe SVG element");
|
||||
const tag = head[2] ?? "", tail = head[3] ?? "";
|
||||
if (head[1]) {
|
||||
if (tail.trim() || stack.pop() !== tag) throw new BadRequestException("invalid SVG nesting");
|
||||
continue;
|
||||
}
|
||||
if (stack.length === 0 && (tag !== "svg" || ++roots !== 1)) throw new BadRequestException("invalid SVG root");
|
||||
if (stack.length > 32 || (tag === "svg" && stack.length > 0)) throw new BadRequestException("invalid SVG nesting");
|
||||
const selfClosing = /\/\s*$/.test(tail);
|
||||
const attributeText = tail.replace(/\/\s*$/, "");
|
||||
let position = 0;
|
||||
const names = new Set<string>();
|
||||
for (const attr of attributeText.matchAll(/\s+([A-Za-z][A-Za-z0-9-]*)\s*=\s*(?:"([^"<>]*)"|'([^'<>]*)')/g)) {
|
||||
if (attributeText.slice(position, attr.index).trim()) throw new BadRequestException("invalid SVG attribute");
|
||||
position = (attr.index ?? 0) + attr[0].length;
|
||||
const name = attr[1] ?? "", value = attr[2] ?? attr[3] ?? "";
|
||||
if (!attrs.has(name) || names.has(name)) throw new BadRequestException("unsafe SVG attribute");
|
||||
names.add(name);
|
||||
if (name === "xmlns" ? value !== "http://www.w3.org/2000/svg" : !/^[A-Za-z0-9#.,() +\-]*$/.test(value)) throw new BadRequestException("unsafe SVG attribute value");
|
||||
if (tag === "svg" && (name === "width" || name === "height") && (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 1920)) throw new BadRequestException("SVG dimensions exceeded");
|
||||
}
|
||||
if (attributeText.slice(position).trim()) throw new BadRequestException("invalid SVG attributes");
|
||||
if (!selfClosing) stack.push(tag);
|
||||
}
|
||||
if (roots !== 1 || stack.length || svg.slice(offset).trim()) throw new BadRequestException("invalid SVG");
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function overlaySvg(input: RenderInput): { svg: string; width: number; height: number } {
|
||||
if (input.templateId === "logo") return { svg: sanitizeLogo(input.fields.svg ?? ""), width: 512, height: 512 };
|
||||
const width = input.templateId === "chyron" ? 4096 : 1920;
|
||||
const height = input.templateId === "chyron" ? 128 : 240;
|
||||
const fill = input.fields.style === "red" ? "#FF1610" : "#171717";
|
||||
return { width, height, svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect x="0" y="0" width="${width}" height="${height}" rx="12" fill="${fill}"/><rect width="14" height="${height}" fill="#FF1610"/><text x="48" y="80" font-family="DejaVu Sans" font-size="52" fill="white">${escape(input.fields.title ?? "")}</text><text x="48" y="156" font-family="DejaVu Sans" font-size="36" fill="white">${escape(input.fields.subtitle ?? "")}</text></svg>` };
|
||||
}
|
||||
|
||||
function sharpPath(): string {
|
||||
const require = createRequire(__filename);
|
||||
if (process.env.SHARP_MODULE_PATH) return require.resolve(process.env.SHARP_MODULE_PATH);
|
||||
try { return require.resolve("sharp"); } catch {
|
||||
throw new Error("sharp not found — install it or point SHARP_MODULE_PATH at a sharp build");
|
||||
}
|
||||
}
|
||||
const RENDER_SCRIPT = `const {parentPort,workerData}=require('node:worker_threads');const sharp=require(workerData.modulePath);sharp.cache(false);sharp.concurrency(1);sharp(Buffer.from(workerData.svg),{limitInputPixels:16777216}).resize(workerData.width,workerData.height,{fit:'contain',background:{r:0,g:0,b:0,alpha:0}}).png().toBuffer().then(png=>parentPort.postMessage(png)).catch(()=>{throw new Error('PNG rasterization failed')})`;
|
||||
let activeJobs = 0;
|
||||
|
||||
/** Isolated job: closed SVG language, bounded pixels/output, V8 heap and wall time.
|
||||
* The thread receives sanitized pixels/geometry only, with no credentials in its environment. */
|
||||
export async function renderOverlay(value: unknown): Promise<Buffer> {
|
||||
const input = validateRenderInput(value);
|
||||
const rendered = overlaySvg(input);
|
||||
const modulePath = sharpPath();
|
||||
if (activeJobs >= 4) throw new BadRequestException("overlay render capacity exceeded");
|
||||
activeJobs += 1;
|
||||
try {
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const worker = new Worker(RENDER_SCRIPT, { eval: true, workerData: { ...rendered, modulePath },
|
||||
resourceLimits: { maxOldGenerationSizeMb: 128, maxYoungGenerationSizeMb: 16, stackSizeMb: 4 },
|
||||
env: { PATH: process.env.PATH, HOME: process.env.HOME, LANG: "C.UTF-8" } });
|
||||
let settled = false;
|
||||
const finish = (error: Error | null, body?: Buffer): void => {
|
||||
if (settled) return; settled = true; clearTimeout(timer); void worker.terminate();
|
||||
if (error || !body) reject(error ?? new Error("overlay render failed")); else resolve(body);
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error("overlay render timed out")), 5000);
|
||||
worker.once("message", (message: unknown) => {
|
||||
if (!(message instanceof Uint8Array) || message.byteLength > 8_000_000 || message.byteLength < 8) finish(new Error("invalid overlay render output"));
|
||||
else finish(null, Buffer.from(message));
|
||||
});
|
||||
worker.once("error", error => finish(error));
|
||||
worker.once("exit", () => finish(new Error("overlay render exited before producing PNG")));
|
||||
});
|
||||
} finally { activeJobs -= 1; }
|
||||
}
|
||||
33
apps/api/src/production/snapshot.ts
Normal file
33
apps/api/src/production/snapshot.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { PoolClient } from "pg";
|
||||
import { parseProductionSpec, type ProductionSpec } from "../media-control/production.js";
|
||||
|
||||
/** Read the aggregate only while virtual_channels is locked by the writer. */
|
||||
export async function productionSnapshot(client: PoolClient, workspaceId: string, channelId: string): Promise<ProductionSpec> {
|
||||
const args = [workspaceId, channelId];
|
||||
const scenes = await client.query<{ id: string; label: string; layout: string }>("SELECT id,label,layout FROM production_scenes WHERE workspace_id=$1 AND virtual_channel_id=$2 ORDER BY id", args);
|
||||
const slots = await client.query<{ scene_id: string; slot_index: number; source_id: string | null; x: number | null; y: number | null; w: number | null; h: number | null; fit: string; audio_mode: string; gain_db: number }>("SELECT * FROM scene_slots WHERE workspace_id=$1 AND virtual_channel_id=$2 ORDER BY slot_index", args);
|
||||
const sources = await client.query<{ id: string; label: string; kind: string; hls_url: string | null; playlist_loop: boolean | null; asset_ids: string[] }>(
|
||||
`SELECT s.*, COALESCE((SELECT array_agg(substring(v.storage_key from 'sha256/([0-9a-f]{64})') ORDER BY i.position) FROM playlist_items i
|
||||
JOIN video_assets v ON v.id=i.video_asset_id AND v.workspace_id=i.workspace_id
|
||||
WHERE i.workspace_id=s.workspace_id AND i.source_id=s.id), '{}') AS asset_ids
|
||||
FROM virtual_channel_sources s WHERE workspace_id=$1 AND virtual_channel_id=$2 ORDER BY id`, args);
|
||||
const buses = await client.query<{ bus: "preview" | "program"; scene_id: string | null }>("SELECT bus,scene_id FROM production_buses WHERE workspace_id=$1 AND virtual_channel_id=$2", args);
|
||||
const overlays = await client.query<{ id: string; scene_id: string; kind: string; enabled: boolean; layer: number; pos_x: number; pos_y: number; current_revision_id: string | null; asset_id: string | null; chyron_speed_px_s: number | null; chyron_strip_width_px: number | null; clock_format: string | null }>(
|
||||
`SELECT s.*, r.asset_id FROM overlay_slots s LEFT JOIN overlay_revisions r
|
||||
ON r.workspace_id=s.workspace_id AND r.id=s.current_revision_id AND r.render_state='rendered'
|
||||
WHERE s.workspace_id=$1 AND s.virtual_channel_id=$2 AND (s.kind='clock' OR r.asset_id IS NOT NULL) ORDER BY s.layer,s.id`, args);
|
||||
return parseProductionSpec({
|
||||
sources: sources.rows.map(s => s.kind === "hls" ? { id: s.id, label: s.label, kind: s.kind, url: s.hls_url }
|
||||
: { id: s.id, label: s.label, kind: s.kind, assetIds: s.asset_ids, loop: s.playlist_loop }),
|
||||
scenes: scenes.rows.map(s => ({ ...s, slots: slots.rows.filter(x => x.scene_id === s.id).map(x => ({
|
||||
index: x.slot_index, sourceId: x.source_id, rect: x.x === null ? null : { x: x.x, y: x.y, w: x.w, h: x.h },
|
||||
fit: x.fit, audioMode: x.audio_mode, gainDb: x.gain_db,
|
||||
})) })),
|
||||
buses: { preview: buses.rows.find(b => b.bus === "preview")?.scene_id ?? null, program: buses.rows.find(b => b.bus === "program")?.scene_id ?? null },
|
||||
overlays: overlays.rows.map(o => ({ id: o.id, sceneId: o.scene_id, kind: o.kind, enabled: o.enabled, layer: o.layer,
|
||||
position: { x: o.pos_x, y: o.pos_y }, revisionId: o.current_revision_id, assetId: o.asset_id,
|
||||
chyron: o.kind === "chyron" ? { speedPxPerSec: o.chyron_speed_px_s, stripWidthPx: o.chyron_strip_width_px } : null,
|
||||
clock: o.kind === "clock" ? { format: o.clock_format } : null })),
|
||||
transition: { type: "cut" }, audio: { masterGainDb: 0 },
|
||||
});
|
||||
}
|
||||
34
apps/api/src/production/testing/memory-redis.ts
Normal file
34
apps/api/src/production/testing/memory-redis.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { ASSIGN_SCRIPT, COMMIT_DESIRED_SCRIPT, DELIVER_RECEIPT_SCRIPT } from "../../media-control/media-control.service.js";
|
||||
import type { ControlRedis, RedisReply } from "../../media-control/redis.js";
|
||||
import type { SignedCommand } from "../../media-control/wire.js";
|
||||
export class MemoryRedis implements ControlRedis {
|
||||
readonly data = new Map<string, string>();
|
||||
readonly sets = new Map<string, Set<string>>();
|
||||
readonly published: Array<{ channel: string; message: string }> = [];
|
||||
failDelivery = false;
|
||||
echo: ((command: SignedCommand) => void) | null = null;
|
||||
async get(k: string): Promise<string | null> { return this.data.get(k) ?? null; }
|
||||
async mget(ks: string[]): Promise<Array<string | null>> { return ks.map(k => this.data.get(k) ?? null); }
|
||||
async set(k: string, v: string, options?: { nx?: boolean }): Promise<boolean> { if (options?.nx && this.data.has(k)) return false; this.data.set(k, v); return true; }
|
||||
async incr(k: string): Promise<number> { const n = Number(this.data.get(k) ?? 0) + 1; this.data.set(k, String(n)); return n; }
|
||||
async sadd(k: string, v: string): Promise<void> { const s = this.sets.get(k) ?? new Set(); s.add(v); this.sets.set(k, s); }
|
||||
async smembers(k: string): Promise<string[]> { return [...(this.sets.get(k) ?? [])]; }
|
||||
async publish(channel: string, message: string): Promise<number> { this.published.push({ channel, message }); if (channel.startsWith("cmd:")) this.echo?.(JSON.parse(message) as SignedCommand); return 1; }
|
||||
async eval(script: string, keys: string[], args: string[]): Promise<RedisReply> {
|
||||
const k = (i: number): string => keys[i] ?? "", a = (i: number): string => args[i] ?? "";
|
||||
if (script === ASSIGN_SCRIPT) { this.data.set(k(0), a(0)); await this.sadd(k(1), a(3)); return 1; }
|
||||
if (script === COMMIT_DESIRED_SCRIPT) { this.data.set(k(0), a(0)); return 1; }
|
||||
if (script !== DELIVER_RECEIPT_SCRIPT) throw new Error("unknown script");
|
||||
if (this.failDelivery) { this.failDelivery = false; throw new Error("crash before delivery"); }
|
||||
const prior = this.data.get(k(0)); if (prior) return prior;
|
||||
if (this.data.get(k(3)) !== a(3)) return -1;
|
||||
const current = JSON.parse(this.data.get(k(1)) ?? "null") as { desiredVersion: number } | null;
|
||||
if (!current || current.desiredVersion < Number(a(2))) { this.data.set(k(1), a(1)); this.data.set(k(2), a(2)); this.data.set(k(7), JSON.stringify((JSON.parse(a(6)) as { state: unknown }).state)); }
|
||||
this.data.set(k(5), a(4));
|
||||
const result = JSON.parse(a(0)) as Record<string, unknown>; result.receivers = 1;
|
||||
const receipt = JSON.stringify(result);
|
||||
this.data.set(k(0), receipt);
|
||||
await this.publish(k(4), a(4)); await this.publish(k(6), a(6)); return receipt;
|
||||
}
|
||||
close(): void {}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue