import { randomUUID } from "node:crypto"; import { mkdir, readFile, rm } from "node:fs/promises"; import path from "node:path"; import type { BrandKit, BrandKitRecord, IntroVariant, MotionTemplateInfo, MotionTemplateRef, } from "@rakazo/contracts"; import { BrandKitSchema, IntroVariantSchema } from "@rakazo/contracts"; import { InvalidMediaInputError } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { proposeIntroVariants, validateBrandKit } from "@rakazo/media"; import { resolveInside } from "@rakazo/media/node"; import type { MediaActor, MediaServiceDeps } from "./media-context.js"; import type { MediaToolPaths } from "./media-folder-source-probe.js"; import { runMediaTool } from "./media-folder-source-probe.js"; import { materializeArtifact, mediaAdapterContext, storeMediaArtifact, } from "./media-marketing-files.js"; /* * Brand kits and the signature intro (spec 9). * * Saving a kit checks what the schema cannot: every referenced file is an * artifact of this space with a usable type (PNG/SVG logos and watermark, TTF/ * OTF/TTC fonts, a parseable .cube LUT, audio music beds), and intro, outro and * end card refer to motion templates of the right kind within their length * limits (intro 1.5–4 s). Softer findings (contrast, missing licence notes, * non-commercial music) come back as warnings. * * Intro variants are proposed deterministically from the kit's tone and * audience, get a sound-design cue from the commercially licensed music beds, * and are rendered as small MP4 previews over the kit background. A preview * that cannot be rendered stays null; the variant itself is still valid. */ export const INTRO_MIN_MS = 1500; export const INTRO_MAX_MS = 4000; const PREVIEW_WIDTH = 640; const PREVIEW_HEIGHT = 360; const PREVIEW_FPS = 24; const LUT_MAX_BYTES = 8 * 1024 * 1024; const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".ttc"]); const IMAGE_EXTENSIONS = new Set([".png", ".svg"]); const AUDIO_EXTENSIONS = new Set([".mp3", ".m4a", ".aac", ".wav", ".flac", ".ogg", ".opus"]); export interface BrandKitSaveResult { record: BrandKitRecord; warnings: string[]; } interface ArtifactRow { id: string; name: string; mimeType: string; size: number; storageKey: string; } function extensionOf(row: ArtifactRow): string { return path.extname(row.name).toLowerCase(); } function isImage(row: ArtifactRow): boolean { return IMAGE_EXTENSIONS.has(extensionOf(row)); } function isAudio(row: ArtifactRow): boolean { return AUDIO_EXTENSIONS.has(extensionOf(row)) || row.mimeType.startsWith("audio/"); } /** Whether `text` is an Adobe/Resolve .cube 3D LUT with a complete table. */ export function isCubeLut(text: string): boolean { let size = 0; let rows = 0; for (const raw of text.split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; const sized = /^LUT_3D_SIZE\s+(\d+)$/.exec(line); if (sized) { size = Number(sized[1]); continue; } if (/^(TITLE|DOMAIN_MIN|DOMAIN_MAX|LUT_1D_SIZE|LUT_3D_INPUT_RANGE)\b/.test(line)) { if (line.startsWith("LUT_1D_SIZE")) return false; continue; } const values = line.split(/\s+/).map(Number); if (values.length !== 3 || values.some((value) => !Number.isFinite(value))) return false; rows += 1; } return size >= 2 && size <= 65 && rows === size ** 3; } function checkTemplate( ref: MotionTemplateRef | undefined, kind: MotionTemplateInfo["kind"], field: string, templates: Map, errors: string[], ): void { if (!ref) return; const template = templates.get(ref.templateId); if (!template || template.kind !== kind) { errors.push(`${field}: "${ref.templateId}" is not a ${kind.replace("_", " ")} template`); return; } if (ref.durationMs < template.minDurationMs || ref.durationMs > template.maxDurationMs) { errors.push( `${field}: duration must be ${template.minDurationMs}–${template.maxDurationMs} ms`, ); } for (const param of template.params) { if (param.required && ref.params[param.name] === undefined) { errors.push(`${field}: parameter "${param.name}" is required`); } } } /** Validate and store a brand kit of the actor's space. */ export async function saveBrandKit( deps: Pick, actor: MediaActor, input: { id?: string; kit: unknown }, ): Promise { const parsed = BrandKitSchema.safeParse(input.kit); if (!parsed.success) { throw new InvalidMediaInputError( `Invalid brand kit: ${parsed.error.issues .map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`) .join("; ")}`, ); } const kit = parsed.data; const errors: string[] = []; const referenced = [ ...kit.fonts.map((font) => font.artifactId), ...kit.logoArtifactIds, ...(kit.lutArtifactId ? [kit.lutArtifactId] : []), ...(kit.watermark?.artifactId ? [kit.watermark.artifactId] : []), ...kit.musicBeds.map((bed) => bed.artifactId), ]; const rows = new Map( ( await deps.prisma.artifact.findMany({ where: { id: { in: [...new Set(referenced)] }, spaceId: actor.spaceId }, select: { id: true, name: true, mimeType: true, size: true, storageKey: true }, }) ).map((row) => [row.id, row]), ); const expectFile = ( id: string, field: string, accepts: (row: ArtifactRow) => boolean, what: string, ) => { const row = rows.get(id); if (!row) errors.push(`${field}: file not found in this space`); else if (!accepts(row)) errors.push(`${field}: must be ${what}`); }; const isFont = (row: ArtifactRow) => FONT_EXTENSIONS.has(extensionOf(row)); for (const [index, font] of kit.fonts.entries()) { expectFile(font.artifactId, `fonts.${index}`, isFont, "a TTF, OTF or TTC font"); } for (const [index, id] of kit.logoArtifactIds.entries()) { expectFile(id, `logoArtifactIds.${index}`, isImage, "a PNG or SVG image"); } if (kit.watermark?.artifactId) { expectFile(kit.watermark.artifactId, "watermark", isImage, "a PNG or SVG image"); } for (const [index, bed] of kit.musicBeds.entries()) { expectFile(bed.artifactId, `musicBeds.${index}`, isAudio, "an audio file"); } if (kit.lutArtifactId) { const row = rows.get(kit.lutArtifactId); if (!row) errors.push("lutArtifactId: file not found in this space"); else if (extensionOf(row) !== ".cube" || row.size > LUT_MAX_BYTES) { errors.push("lutArtifactId: must be a .cube LUT"); } else { const bytes = await deps.artifacts .get(row.storageKey, mediaAdapterContext(actor, `media-brand-lut:${row.id}`)) .catch(() => null); if (!bytes || !isCubeLut(new TextDecoder().decode(bytes))) { errors.push("lutArtifactId: not a complete 3D .cube LUT"); } } } const templates = new Map(deps.motion.templates().map((template) => [template.id, template])); checkTemplate(kit.intro, "intro", "intro", templates, errors); checkTemplate(kit.outro, "outro", "outro", templates, errors); checkTemplate(kit.endCard, "end_card", "endCard", templates, errors); if (kit.intro && (kit.intro.durationMs < INTRO_MIN_MS || kit.intro.durationMs > INTRO_MAX_MS)) { errors.push(`intro: duration must be ${INTRO_MIN_MS}–${INTRO_MAX_MS} ms`); } if (errors.length > 0) throw new InvalidMediaInputError(`Invalid brand kit: ${errors.join("; ")}`); const record = await deps.repos.upsertBrandKit(actor.spaceId, { ...(input.id ? { id: input.id } : {}), kit, }); return { record, warnings: validateBrandKit(kit) }; } function previewColor(kit: BrandKit): string { return /^#[0-9a-fA-F]{6}$/.test(kit.colors.background) ? `0x${kit.colors.background.slice(1)}` : "black"; } /** Flatten an alpha overlay clip onto the kit background as a small MP4. */ async function encodePreview( overlayPath: string, outPath: string, kit: BrandKit, durationMs: number, tools: MediaToolPaths, signal?: AbortSignal, ): Promise { await runMediaTool( tools.ffmpegPath ?? "ffmpeg", [ "-v", "error", "-y", "-f", "lavfi", "-i", `color=c=${previewColor(kit)}:s=${PREVIEW_WIDTH}x${PREVIEW_HEIGHT}:r=${PREVIEW_FPS}`, "-i", overlayPath, "-filter_complex", "[0:v][1:v]overlay=shortest=1,format=yuv420p[v]", "-map", "[v]", "-t", (durationMs / 1000).toFixed(3), "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-map_metadata", "-1", "-fflags", "+bitexact", "-movflags", "+faststart", outPath, ], { signal }, ); } /** * Propose `count` signature intros for a brand kit and render a preview of * each (spec 9). Choosing one is saving it as the kit's `intro`. */ export async function introVariants( deps: MediaServiceDeps, actor: MediaActor, input: { brandKitId: string; count: number; signal?: AbortSignal }, tools: MediaToolPaths = {}, ): Promise { const { kit } = await deps.repos.getBrandKit(actor.spaceId, input.brandKitId); const count = Math.max(1, Math.min(5, Math.floor(input.count))); const proposals = proposeIntroVariants(kit, deps.motion.templates(), count); if (proposals.length === 0) return []; const beds = kit.musicBeds.filter((bed) => bed.commercialUse); const workDir = await resolveInside( deps.mediaRoot, path.join(actor.spaceId, "tmp", `intro-${randomUUID()}`), ); await mkdir(workDir, { recursive: true }); try { let assets: { fonts: Array<{ family: string; path: string }>; logoPaths: string[] } | null; try { const fontDir = path.join(workDir, "assets"); assets = { fonts: await Promise.all( kit.fonts.map(async (font) => ({ family: font.family, path: await materializeArtifact(deps, actor, font.artifactId, fontDir), })), ), logoPaths: await Promise.all( kit.logoArtifactIds.map((id) => materializeArtifact(deps, actor, id, fontDir)), ), }; } catch (error) { getLogger().warn("media intro preview assets unavailable", { reason: error instanceof Error ? error.message : String(error), }); assets = null; } const variants: IntroVariant[] = []; for (const [index, proposal] of proposals.entries()) { const bed = beds.length > 0 ? beds[index % beds.length] : undefined; const rationale = bed ? `${proposal.rationale}, sound: ${bed.title}` : proposal.rationale; let previewArtifactId: string | null = null; if (assets) { try { const overlay = await deps.motion.render({ template: proposal.template, brandKit: kit, assets, width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, fps: PREVIEW_FPS, outPath: path.join(workDir, `intro-${index}.mov`), ...(input.signal ? { signal: input.signal } : {}), }); const mp4 = path.join(workDir, `intro-${index}.mp4`); await encodePreview( overlay.path, mp4, kit, proposal.template.durationMs, tools, input.signal, ); previewArtifactId = await storeMediaArtifact(deps, actor, { name: `intro-preview-${index + 1}.mp4`, mimeType: "video/mp4", bytes: new Uint8Array(await readFile(mp4)), }); } catch (error) { input.signal?.throwIfAborted(); getLogger().warn("media intro preview failed", { templateId: proposal.template.templateId, reason: error instanceof Error ? error.message : String(error), }); } } variants.push( IntroVariantSchema.parse({ id: randomUUID(), template: proposal.template, rationale, previewArtifactId, }), ); } return variants; } finally { await rm(workDir, { recursive: true, force: true }); } }