evOo-local/packages/adapters/src/media-brand.ts

360 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, MotionTemplateInfo>,
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<MediaServiceDeps, "prisma" | "repos" | "motion" | "artifacts">,
actor: MediaActor,
input: { id?: string; kit: unknown },
): Promise<BrandKitSaveResult> {
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<string, ArtifactRow>(
(
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<void> {
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<IntroVariant[]> {
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 });
}
}