360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
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 });
|
||
}
|
||
}
|