merge: W2 worktree-agent-a78be97f0347925a3

This commit is contained in:
Claude 2026-10-03 18:47:30 +00:00
commit f29e6379cb
No known key found for this signature in database
13 changed files with 3944 additions and 0 deletions

View File

@ -0,0 +1,359 @@
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 });
}
}

View File

@ -0,0 +1,178 @@
import type { Edl, MarketingCopy, MediaSegment, PlatformProfile } from "@rakazo/contracts";
import { MarketingCopySchema } from "@rakazo/contracts";
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
import { checkCopy, edlDurationMs, isShortForm, normalizeHashtags } from "@rakazo/media";
import { z } from "zod";
import type { MediaActor, MediaServiceDeps } from "./media-context.js";
/*
* Platform copy for a render (spec 10.2). The model writes from the plan's
* metadata and the transcript of the clips actually used, under the
* profile's limits; it is told to state nothing the material does not show.
* Without a model (or consent) the copy comes from the plan metadata and the
* project title only. Either way hashtags are normalized and capped, long
* fields are cut at a word boundary to the profile limits, and the result is
* checked like the gate's text check; findings are returned as `issues`.
*/
const TRANSCRIPT_CHARS = 1500;
const CHAPTER_MIN_PROGRAM_MS = 60_000;
const CopyReplySchema = z.object({
title: z.string().max(200),
hook: z.string().max(200),
caption: z.string().max(5000),
hashtags: z.array(z.string().max(60)).max(50),
description: z.string().max(5000).optional(),
chapters: z
.array(z.object({ atMs: z.number().int().min(0), title: z.string().max(100) }))
.max(100)
.optional(),
altText: z.string().max(500).optional(),
});
type CopyReply = z.infer<typeof CopyReplySchema>;
const LANGUAGE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
/** Cut to `max` characters, at the last word boundary when there is one. */
export function truncateText(value: string, max: number): string {
const chars = [...value.trim()];
if (chars.length <= max) return value.trim();
const cut = chars.slice(0, Math.max(0, max)).join("");
const space = cut.lastIndexOf(" ");
return (space > max * 0.6 ? cut.slice(0, space) : cut).trim();
}
function transcriptOf(edl: Edl, segments: MediaSegment[]): string {
const byId = new Map(segments.map((segment) => [segment.id, segment]));
const parts: string[] = [];
for (const clip of [...edl.tracks.video].sort((a, b) => a.atMs - b.atMs)) {
const linked = clip.segmentId ? byId.get(clip.segmentId) : undefined;
const overlapping = linked
? [linked]
: segments.filter(
(s) => s.assetId === clip.assetId && s.startMs < clip.outMs && s.endMs > clip.inMs,
);
for (const segment of overlapping) {
const text = segment.transcriptExcerpt?.replace(/\s+/g, " ").trim();
if (text && !parts.includes(text)) parts.push(text);
}
}
return parts.join(" ").slice(0, TRANSCRIPT_CHARS);
}
function wantsChapters(profile: PlatformProfile, edl: Edl): boolean {
return !isShortForm(profile) && edlDurationMs(edl) >= CHAPTER_MIN_PROGRAM_MS;
}
function fallbackCopy(edl: Edl, projectTitle: string, transcript: string): CopyReply {
const title = edl.metadata.title?.trim() || projectTitle;
const sentence = transcript.split(/(?<=[.!?])\s+/)[0]?.trim() ?? "";
return {
title,
hook: sentence && sentence.length <= 120 ? sentence : title,
caption: edl.metadata.description?.trim() || title,
hashtags: edl.metadata.tags,
...(edl.metadata.description ? { description: edl.metadata.description } : {}),
};
}
function instructions(profile: PlatformProfile, language: string, chapters: boolean): string {
const { text } = profile;
const link =
text.linkPolicy === "allowed"
? "Links are allowed."
: text.linkPolicy === "bio_only"
? 'Never put a link in the text; say "link in bio" if a call to action needs one.'
: "Never mention or include links.";
return [
`You write social video copy in the language "${language}" for ${profile.name}.`,
"Use only facts from the given title, description, tags and transcript. Never invent claims, prices, results, quotes, guarantees or urgency.",
"No clickbait that misrepresents the video, no engagement bait, no promises the video does not keep.",
`Limits: title ${text.titleMaxChars ?? 100} characters, hook 120 characters, caption ${text.captionMaxChars} characters, at most ${text.hashtagLimit} hashtags without the # sign.`,
link,
chapters
? "Add chapters (atMs from 0, at least 10 s apart) only where the transcript shows topic changes."
: "Do not add chapters.",
"altText describes what is visible for screen readers, factually.",
'Reply with one JSON object: {"title","hook","caption","hashtags":[],"description","chapters":[{"atMs","title"}],"altText"}.',
].join("\n");
}
export async function generateCopy(
deps: MediaServiceDeps,
actor: MediaActor,
input: { renderId: string; language: string },
): Promise<MarketingCopy> {
const language = input.language.trim();
if (!LANGUAGE.test(language)) throw new InvalidMediaInputError("Invalid language code");
const { spaceId } = actor;
const render = await deps.repos.getRender(spaceId, input.renderId);
const [plan, profileSet, project] = await Promise.all([
deps.repos.getPlan(spaceId, render.planId),
deps.profiles(),
deps.repos.getProjectRecord(spaceId, render.projectId),
]);
const profile = profileSet.profiles.get(render.profileId)?.profile;
if (!profile) throw new MediaStateConflictError(`Profile ${render.profileId} is not available`);
const edl = plan.edl;
const segments = await deps.repos.listSegments(spaceId, [
...new Set(edl.tracks.video.map((clip) => clip.assetId)),
]);
const transcript = transcriptOf(edl, segments);
const chapters = wantsChapters(profile, edl);
const durationMs = edlDurationMs(edl);
const reply = await deps.model.generate({
spaceId,
userId: actor.userId,
instructions: instructions(profile, language, chapters),
prompt: JSON.stringify({
projectTitle: project.title,
title: edl.metadata.title ?? null,
description: edl.metadata.description ?? null,
tags: edl.metadata.tags,
durationMs,
transcript: transcript || null,
}),
schema: CopyReplySchema,
});
const draft = reply ?? fallbackCopy(edl, project.title, transcript);
const title = truncateText(draft.title, Math.min(200, profile.text.titleMaxChars ?? 200));
const caption = truncateText(draft.caption, Math.min(5000, profile.text.captionMaxChars));
const hashtags = normalizeHashtags(draft.hashtags, profile.text.hashtagLimit).map((tag) =>
tag.slice(0, 60),
);
const chapterList = chapters
? (draft.chapters ?? [])
.filter((chapter) => chapter.atMs < durationMs && chapter.title.trim())
.sort((a, b) => a.atMs - b.atMs)
: [];
const hook = truncateText(draft.hook, 200);
const description = draft.description ? truncateText(draft.description, 5000) : undefined;
const altText = draft.altText?.trim() ? truncateText(draft.altText, 500) : undefined;
// The other fields get the same banned-pattern and link checks as the caption.
const extraIssues = checkCopy(profile, {
title: null,
caption: [hook, description, altText].filter(Boolean).join("\n"),
tags: [],
})
.filter((issue) => issue.code === "banned_pattern" || issue.code.startsWith("link_"))
.map((issue) => ({
...issue,
message: issue.message.replace(/^caption/, "hook or description"),
}));
return MarketingCopySchema.parse({
profileId: profile.id,
language,
title,
hook,
caption,
hashtags,
...(description ? { description } : {}),
...(chapterList.length > 0 ? { chapters: chapterList } : {}),
...(altText ? { altText } : {}),
issues: [...checkCopy(profile, { title, caption, tags: hashtags }), ...extraIssues],
});
}

View File

@ -0,0 +1,134 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AdapterContext } from "@rakazo/adapter-kit";
import type { MediaRenderOutput } from "@rakazo/contracts";
import { MediaRenderOutputSchema } from "@rakazo/contracts";
import { IsolationError } from "@rakazo/db";
import { resolveInside } from "@rakazo/media/node";
import type { MediaActor, MediaServiceDeps } from "./media-context.js";
/*
* File and artifact plumbing shared by the marketing services. Media artifacts
* (thumbnails, intro posters, small bundles) are space artifacts without a bot,
* owned by the acting user, like chat attachments.
*/
/** A stored render output: the public fields plus its path relative to the media root. */
export type StoredRenderOutput = MediaRenderOutput & { path?: string };
export function mediaAdapterContext(
actor: MediaActor,
operationId: string,
signal: AbortSignal = new AbortController().signal,
): AdapterContext {
return {
operationId,
traceId: operationId,
spaceId: actor.spaceId,
userId: actor.userId,
signal,
};
}
export async function storeMediaArtifact(
deps: Pick<MediaServiceDeps, "prisma" | "artifacts">,
actor: MediaActor,
input: { name: string; mimeType: string; bytes: Uint8Array },
): Promise<string> {
const context = mediaAdapterContext(actor, `media-artifact:${input.name}`);
const stored = await deps.artifacts.put(input, context);
const row = await deps.prisma.artifact
.create({
data: {
spaceId: actor.spaceId,
userId: actor.userId,
name: input.name,
mimeType: input.mimeType,
size: input.bytes.byteLength,
hash: createHash("sha256").update(input.bytes).digest("hex"),
storageKey: stored.id,
},
select: { id: true },
})
.catch(async (error) => {
await deps.artifacts.remove(stored.id, context).catch(() => undefined);
throw error;
});
return row.id;
}
/**
* Copy a space artifact (brand font, logo) into `dir` so a local renderer can
* read it. The file name is the artifact id plus its sanitized extension.
*/
export async function materializeArtifact(
deps: Pick<MediaServiceDeps, "prisma" | "artifacts">,
actor: MediaActor,
artifactId: string,
dir: string,
): Promise<string> {
const row = await deps.prisma.artifact.findFirst({
where: { id: artifactId, spaceId: actor.spaceId },
select: { id: true, name: true, storageKey: true },
});
if (!row) throw new IsolationError();
const bytes = await deps.artifacts.get(
row.storageKey,
mediaAdapterContext(actor, `media-artifact-get:${row.id}`),
);
const ext = path.extname(row.name).toLowerCase();
const safeId = row.id.replace(/[^A-Za-z0-9_-]/g, "");
const file = path.join(dir, `${safeId}${/^\.[a-z0-9]{1,5}$/.test(ext) ? ext : ""}`);
await mkdir(dir, { recursive: true });
await writeFile(file, bytes);
return file;
}
/** Outputs of a render row, dropping anything that does not parse. */
export function storedRenderOutputs(outputs: unknown): StoredRenderOutput[] {
if (!Array.isArray(outputs)) return [];
return outputs.flatMap((raw) => {
const parsed = MediaRenderOutputSchema.safeParse(raw);
if (!parsed.success) return [];
const internal = (raw as { path?: unknown }).path;
return [{ ...parsed.data, ...(typeof internal === "string" ? { path: internal } : {}) }];
});
}
export function publicRenderOutput(output: StoredRenderOutput): MediaRenderOutput {
return {
kind: output.kind,
artifactId: output.artifactId,
fileName: output.fileName,
sizeBytes: output.sizeBytes,
};
}
/**
* Absolute path of a render output: its stored relative path, or
* `<spaceId>/renders/<renderId>/<fileName>` when the renderer stored none.
*/
export function renderOutputPath(
deps: Pick<MediaServiceDeps, "mediaRoot">,
spaceId: string,
renderId: string,
output: StoredRenderOutput,
): Promise<string> {
return resolveInside(
deps.mediaRoot,
output.path ?? path.join(spaceId, "renders", renderId, path.basename(output.fileName)),
);
}
/** Lowercase ASCII slug for UTM values and file names; empty when nothing is left. */
export function slugify(value: string, max = 100): string {
return value
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, max)
.replace(/-+$/g, "");
}

View File

@ -0,0 +1,578 @@
import { mkdir, readFile, rm } from "node:fs/promises";
import path from "node:path";
import type { AgentInputImage } from "@rakazo/adapter-kit";
import type {
BrandKit,
ContentCategory,
Edl,
EdlError,
MediaEditPlan,
MediaProjectGoal,
MediaSegment,
PlatformProfile,
TeaserWeights,
} from "@rakazo/contracts";
import { TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts";
import type { MediaProjectRecord, MediaRepos } from "@rakazo/db";
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { EdlContext, ScoredSegmentInput, SegmentFeatures } from "@rakazo/media";
import {
autoPlanEdl,
defaultTeaserWeights,
introMs,
scoreSegment,
speechHookScore,
validateEdl,
} from "@rakazo/media";
import { resolveInside } from "@rakazo/media/node";
import { z } from "zod";
import type { MediaActor, MediaServiceDeps } from "./media-context.js";
/*
* Edit plans for a project: the deterministic first cut per profile, an
* optional model ranking of the teaser candidates, and validation of plans the
* agent or user submits.
*
* Model judge (spec 8): only the top JUDGE_TOP_K candidates by deterministic
* score go to the model, and only those whose stored classifier scores already
* pass the target profile thresholds and that show no face of a protected
* person (or an unassigned face next to one). The model receives one still
* frame per candidate, taken from the proxy, plus the transcript excerpt.
* Face crops, embeddings and person data never leave the host. The model's
* order re-weights the candidates: rank r of n scales every feature by
* 1 - r / (2n); every usable segment it did not rank keeps half its weight
* (paid-only segments stay as they are: they only mark the payoff). Without a
* model, consent, or a usable reply the deterministic order stands.
*/
export const JUDGE_TOP_K = 12;
const JUDGE_FRAME_WIDTH = 320;
const TRANSCRIPT_EXCERPT_CHARS = 300;
const RATIONALE_MAX_CHARS = 2000;
const RANKING_GOALS: ReadonlySet<MediaProjectGoal> = new Set([
"teaser",
"trailer",
"highlight",
"story",
]);
const JudgeReplySchema = z.object({
ranking: z
.array(z.object({ segmentId: z.string().min(1).max(100), reason: z.string().max(300) }))
.max(50),
});
type AssetRow = Awaited<ReturnType<MediaRepos["listAssetRecords"]>>[number];
type FaceTrackRow = Awaited<ReturnType<MediaRepos["faceTrackRecords"]>>[number];
type PersonRow = Awaited<ReturnType<MediaRepos["listPersonRecords"]>>[number];
type ClassifierFrames = Array<{ tMs: number; scores: Partial<Record<ContentCategory, number>> }>;
export interface PlanContext {
project: MediaProjectRecord;
kit: BrandKit | null;
assets: Map<string, AssetRow>;
segments: MediaSegment[];
faceTracks: FaceTrackRow[];
/** Every person of the space; plans may reference any of them. */
people: PersonRow[];
markIds: Set<string>;
marks: Array<{ id: string; treatment: string; assetId: string | null; personId: string | null }>;
profiles: Map<string, PlatformProfile>;
}
export async function loadPlanContext(
deps: MediaServiceDeps,
actor: MediaActor,
projectId: string,
): Promise<PlanContext> {
const { repos } = deps;
const { spaceId } = actor;
const project = await repos.getProjectRecord(spaceId, projectId);
const fromCollections = await repos.collectionAssetIds(spaceId, project.collectionIds);
const assetIds = [...new Set([...project.assetIds, ...fromCollections])];
const [assetRows, segments, faceTracks, people, marks, kit, profileSet] = await Promise.all([
repos.listAssetRecords(spaceId, assetIds),
repos.listSegments(spaceId, assetIds),
repos.faceTrackRecords(spaceId, assetIds),
repos.listPersonRecords(spaceId),
repos.listMarks(spaceId),
project.brandKitId ? repos.getBrandKit(spaceId, project.brandKitId) : Promise.resolve(null),
deps.profiles(),
]);
return {
project,
kit: kit?.kit ?? null,
assets: new Map(assetRows.map((asset) => [asset.id, asset])),
segments,
faceTracks,
people,
markIds: new Set(marks.map((mark) => mark.id)),
marks: marks.map((mark) => ({
id: mark.id,
treatment: mark.treatment,
assetId: mark.assetId,
personId: mark.personId,
})),
profiles: new Map([...profileSet.profiles.entries()].map(([id, entry]) => [id, entry.profile])),
};
}
function kitArtifactIds(kit: BrandKit | null): string[] {
if (!kit) return [];
return [
...kit.fonts.map((font) => font.artifactId),
...kit.logoArtifactIds,
...(kit.lutArtifactId ? [kit.lutArtifactId] : []),
...(kit.watermark?.artifactId ? [kit.watermark.artifactId] : []),
...kit.musicBeds.map((bed) => bed.artifactId),
];
}
export function buildEdlContext(
deps: Pick<MediaServiceDeps, "motion">,
ctx: PlanContext,
profile: PlatformProfile,
): EdlContext {
return {
profile,
brandKit: ctx.kit,
assets: new Map(
[...ctx.assets.values()].map((asset) => [
asset.id,
{
kind: asset.kind as "video" | "photo" | "live_photo",
durationMs: asset.durationMs,
width: asset.width,
height: asset.height,
},
]),
),
artifactIds: new Set(kitArtifactIds(ctx.kit)),
music: new Map(
(ctx.kit?.musicBeds ?? []).map((bed) => [
bed.artifactId,
{ commercialUse: bed.commercialUse },
]),
),
markIds: ctx.markIds,
personIds: new Set(ctx.people.map((person) => person.id)),
faceTrackIds: new Set(ctx.faceTracks.map((track) => track.id)),
templateIds: new Set(deps.motion.templates().map((template) => template.id)),
paidOnly: ctx.segments
.filter((segment) => segment.paidOnly)
.map((segment) => ({
assetId: segment.assetId,
startMs: segment.startMs,
endMs: segment.endMs,
})),
goal: ctx.project.goal,
};
}
function segmentFeatures(segment: MediaSegment): SegmentFeatures {
const features: SegmentFeatures = { ...segment.scores };
if (features.speechHook === undefined && segment.transcriptExcerpt) {
features.speechHook = speechHookScore(segment.transcriptExcerpt);
}
return features;
}
function scoredInputs(ctx: PlanContext): ScoredSegmentInput[] {
return ctx.segments.map((segment) => ({
id: segment.id,
assetId: segment.assetId,
startMs: segment.startMs,
endMs: segment.endMs,
capturedAt: ctx.assets.get(segment.assetId)?.capturedAt ?? null,
features: segmentFeatures(segment),
paidOnly: segment.paidOnly,
transcript: segment.transcriptExcerpt,
}));
}
/** Scale features so the model's order counts as much as the deterministic score. */
export function applyRanking(
segments: ScoredSegmentInput[],
order: string[],
): ScoredSegmentInput[] {
const rank = new Map(order.map((id, index) => [id, index]));
return segments.map((segment) => {
// Paid-only segments are never used; they only mark the payoff, so they keep their score.
if (segment.paidOnly) return segment;
const r = rank.get(segment.id);
const factor = r === undefined ? 0.5 : 1 - r / (2 * order.length);
const features = Object.fromEntries(
Object.entries(segment.features).map(([feature, value]) => [feature, (value ?? 0) * factor]),
) as SegmentFeatures;
return { ...segment, features };
});
}
async function classifierFrames(
deps: MediaServiceDeps,
spaceId: string,
assetIds: Set<string>,
): Promise<Map<string, ClassifierFrames>> {
const analyses = await deps.repos.latestAnalysesByKind(spaceId, "classifier");
const out = new Map<string, ClassifierFrames>();
for (const [assetId, row] of analyses) {
if (!assetIds.has(assetId)) continue;
let data: unknown = row.data;
if ((data === null || data === undefined) && row.dataPath) {
try {
data = JSON.parse(
await readFile(await resolveInside(deps.mediaRoot, row.dataPath), "utf8"),
);
} catch {
continue;
}
}
const frames = (data as { frames?: unknown } | null)?.frames;
if (!Array.isArray(frames)) continue;
out.set(
assetId,
frames.filter(
(frame): frame is ClassifierFrames[number] =>
typeof frame?.tMs === "number" &&
typeof frame.scores === "object" &&
frame.scores !== null,
),
);
}
return out;
}
/** Fail closed: a segment without classifier frames in its range does not pass. */
function classifierPasses(
frames: ClassifierFrames | undefined,
segment: { startMs: number; endMs: number },
profile: PlatformProfile,
): boolean {
const inRange = (frames ?? []).filter(
(frame) => frame.tMs >= segment.startMs && frame.tMs <= segment.endMs,
);
if (inRange.length === 0) return false;
return inRange.every((frame) =>
Object.entries(profile.content.thresholds).every(([category, threshold]) => {
const score = frame.scores[category as ContentCategory];
return score === undefined || threshold === undefined || score <= threshold;
}),
);
}
function protectedPersonIds(ctx: PlanContext): Set<string> {
return new Set(
ctx.people.filter((person) => person.anonymizeByDefault !== "none").map((p) => p.id),
);
}
/**
* Whether a segment may show something the user protects: a face of a
* protected person (or an unassigned face while anyone is protected), or a
* treated identifying mark of its asset or of a person visible in it. Such
* segments never go to the model, even with consent.
*/
export function showsProtectedDetail(
ctx: Pick<PlanContext, "faceTracks" | "marks">,
protectedIds: Set<string>,
segment: { assetId: string; startMs: number; endMs: number },
): boolean {
const tracks = ctx.faceTracks.filter(
(track) =>
track.assetId === segment.assetId &&
track.startMs < segment.endMs &&
track.endMs > segment.startMs,
);
if (
protectedIds.size > 0 &&
tracks.some((track) => track.personId === null || protectedIds.has(track.personId))
) {
return true;
}
const visible = new Set(tracks.flatMap((track) => (track.personId ? [track.personId] : [])));
return ctx.marks.some(
(mark) =>
mark.treatment !== "none" &&
(mark.assetId === segment.assetId || (mark.personId !== null && visible.has(mark.personId))),
);
}
interface Judgement {
order: string[];
reasons: Map<string, string>;
}
async function judgeCandidates(
deps: MediaServiceDeps,
actor: MediaActor,
ctx: PlanContext,
profile: PlatformProfile,
segments: ScoredSegmentInput[],
weights: TeaserWeights,
): Promise<Judgement | null> {
const top = segments
.filter((segment) => !segment.paidOnly && segment.endMs > segment.startMs)
.map((segment) => ({ segment, score: scoreSegment(segment.features, weights) }))
.sort((a, b) => b.score - a.score || a.segment.id.localeCompare(b.segment.id))
.slice(0, JUDGE_TOP_K);
if (top.length < 2) return null;
const classifier = await classifierFrames(
deps,
actor.spaceId,
new Set(top.map((c) => c.segment.assetId)),
);
const protectedIds = protectedPersonIds(ctx);
const eligible = top.filter(
({ segment }) =>
classifierPasses(classifier.get(segment.assetId), segment, profile) &&
!showsProtectedDetail(ctx, protectedIds, segment),
);
if (eligible.length < 2) return null;
const workRelative = path.join(actor.spaceId, "tmp", `judge-${crypto.randomUUID()}`);
const workDir = await resolveInside(deps.mediaRoot, workRelative);
await mkdir(workDir, { recursive: true });
try {
const images: AgentInputImage[] = [];
const sent: typeof eligible = [];
for (const candidate of eligible) {
const asset = ctx.assets.get(candidate.segment.assetId);
const isVideo = asset?.kind === "video";
const source = isVideo ? asset?.proxyPath : (asset?.thumbPath ?? asset?.proxyPath);
if (!source) continue;
const outPath = path.join(workDir, `${images.length}.png`);
try {
await deps.renderer.extractFrame({
path: await resolveInside(deps.mediaRoot, source),
tMs: isVideo ? Math.round((candidate.segment.startMs + candidate.segment.endMs) / 2) : 0,
outPath,
width: JUDGE_FRAME_WIDTH,
});
images.push({
name: `${candidate.segment.id}.png`,
mimeType: "image/png",
data: new Uint8Array(await readFile(outPath)),
});
sent.push(candidate);
} catch (error) {
getLogger().warn("media judge frame failed", {
reason: error instanceof Error ? error.message : String(error),
});
}
}
if (sent.length < 2) return null;
const lines = sent.map(({ segment, score }, index) => {
const transcript = (segment.transcript ?? "").replace(/\s+/g, " ").trim();
return [
`${index + 1}. id=${segment.id} image=${segment.id}.png`,
`duration=${segment.endMs - segment.startMs}ms score=${score.toFixed(3)}`,
transcript ? `transcript="${transcript.slice(0, TRANSCRIPT_EXCERPT_CHARS)}"` : "",
]
.filter(Boolean)
.join(" ");
});
const reply = await deps.model.generate({
spaceId: actor.spaceId,
userId: actor.userId,
instructions: [
"You rank candidate segments for a short promotional video.",
`Goal: ${ctx.project.goal}. Platform: ${profile.name}. The first ${profile.duration.hookWindowMs} ms must hook the viewer.`,
"Judge only what the frames and transcripts show. Prefer clear, engaging openers that keep the payoff hidden.",
'Reply with one JSON object: {"ranking":[{"segmentId":"...","reason":"..."}]}, best first, using only the given ids.',
].join("\n"),
prompt: `Candidates:\n${lines.join("\n")}`,
images,
schema: JudgeReplySchema,
});
if (!reply) return null;
const ids = new Set(sent.map(({ segment }) => segment.id));
const order: string[] = [];
const reasons = new Map<string, string>();
for (const entry of reply.ranking) {
if (!ids.has(entry.segmentId) || reasons.has(entry.segmentId)) continue;
order.push(entry.segmentId);
reasons.set(entry.segmentId, entry.reason.replace(/\s+/g, " ").trim());
}
return order.length > 0 ? { order, reasons } : null;
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
function rationaleFor(input: {
edl: Edl;
profile: PlatformProfile;
ctx: PlanContext;
learned: boolean;
judgement: Judgement | null;
}): string {
const { edl, profile, ctx } = input;
const lines = [
`Automatic ${ctx.project.goal} plan for ${profile.name}.`,
input.learned
? "Weights learned from this space's published results."
: `Default ${ctx.project.goal} weights.`,
];
const first = [...edl.tracks.video].sort((a, b) => a.atMs - b.atMs)[0];
if (first) {
lines.push(
`Hook: clip ${first.id}${first.segmentId ? ` (segment ${first.segmentId})` : ""} starts at ${introMs(edl) + first.atMs} ms, inside the ${profile.duration.hookWindowMs} ms hook window.`,
);
}
if (edl.intro) lines.push("Brand intro opens the video.");
else if (ctx.kit?.intro) lines.push("Brand intro left out so the hook comes first.");
const paidOnly = new Set(ctx.segments.filter((s) => s.paidOnly).map((s) => s.id));
if (
paidOnly.size > 0 &&
!edl.tracks.video.some((c) => c.segmentId && paidOnly.has(c.segmentId))
) {
lines.push("Paid-only segments excluded.");
}
if (input.judgement) {
const reasons = input.judgement.order
.slice(0, 3)
.map((id) => `${id}: ${input.judgement?.reasons.get(id) ?? ""}`.trim());
lines.push(
`Model ranking applied (${input.judgement.order.length} ranked): ${reasons.join("; ")}`,
);
}
return lines.join("\n").slice(0, RATIONALE_MAX_CHARS);
}
/**
* Teasers end on a call to action that follows the profile's link policy
* (spec 8). The tracking link itself goes into the caption or bio, never into
* the picture: it only exists once the render is published.
*/
export function defaultCtaText(goal: MediaProjectGoal, profile: PlatformProfile): string | null {
if (goal !== "teaser") return null;
if (profile.text.linkPolicy === "allowed") return "Link below";
if (profile.text.linkPolicy === "bio_only") return "Link in bio";
return "Full video on my profile";
}
function requireProfile(ctx: PlanContext, profileId: string): PlatformProfile {
if (!ctx.project.profileIds.includes(profileId)) {
throw new InvalidMediaInputError(`Profile ${profileId} is not part of this project`);
}
const profile = ctx.profiles.get(profileId);
if (!profile) throw new InvalidMediaInputError(`Unknown platform profile ${profileId}`);
return profile;
}
/** First cut per profile, validated and stored as a new plan version by the agent. */
export async function autoPlan(
deps: MediaServiceDeps,
actor: MediaActor,
input: { projectId: string; profileIds?: string[] },
): Promise<MediaEditPlan[]> {
const ctx = await loadPlanContext(deps, actor, input.projectId);
const profileIds = [...new Set(input.profileIds ?? ctx.project.profileIds)];
const profiles = profileIds.map((id) => requireProfile(ctx, id));
const model = await deps.repos.getTeaserModel(actor.spaceId);
const learned = Boolean(model && model.sampleCount >= TEASER_LEARNING_MIN_SAMPLES);
const weights = learned && model ? model.weights : defaultTeaserWeights(ctx.project.goal);
const base = scoredInputs(ctx);
const assets = new Map(
[...ctx.assets.values()].map((asset) => [
asset.id,
{
durationMs: asset.durationMs,
width: asset.width,
height: asset.height,
hasSpeech: ctx.segments.some(
(segment) => segment.assetId === asset.id && Boolean(segment.transcriptExcerpt?.trim()),
),
},
]),
);
const projectPeople = new Set(
ctx.faceTracks.flatMap((track) => (track.personId ? [track.personId] : [])),
);
const people = ctx.people
.filter((person) => projectPeople.has(person.id))
.map((person) => ({ id: person.id, anonymizeByDefault: person.anonymizeByDefault }));
const endCardCta = ctx.kit?.endCard?.params.cta;
const plans: MediaEditPlan[] = [];
for (const profile of profiles) {
const judgement = RANKING_GOALS.has(ctx.project.goal)
? await judgeCandidates(deps, actor, ctx, profile, base, weights)
: null;
const segments = judgement ? applyRanking(base, judgement.order) : base;
let edl: Edl;
try {
edl = autoPlanEdl({
goal: ctx.project.goal,
profile,
brandKit: ctx.kit,
segments,
weights,
assets,
people,
marks: ctx.marks,
ctaText:
typeof endCardCta === "string" ? endCardCta : defaultCtaText(ctx.project.goal, profile),
});
} catch (error) {
throw new MediaStateConflictError(
`No automatic plan for ${profile.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
const validation = validateEdl(edl, buildEdlContext(deps, ctx, profile));
if (!validation.ok) {
throw new InvalidMediaInputError(
`Automatic plan for ${profile.id} is invalid: ${validation.errors
.map((error) => `${error.path} ${error.message}`)
.join("; ")}`,
);
}
plans.push(
await deps.repos.createPlanVersion(actor.spaceId, {
projectId: ctx.project.id,
profileId: profile.id,
edl: validation.edl,
createdBy: "agent",
rationale: rationaleFor({ edl: validation.edl, profile, ctx, learned, judgement }),
}),
);
}
return plans;
}
export async function submitPlan(
deps: MediaServiceDeps,
actor: MediaActor,
input: { projectId: string; edl: unknown; rationale: string | null; createdBy: "agent" | "user" },
): Promise<{ ok: true; plan: MediaEditPlan } | { ok: false; errors: EdlError[] }> {
const ctx = await loadPlanContext(deps, actor, input.projectId);
const profileId = (input.edl as { output?: { profileId?: unknown } } | null)?.output?.profileId;
const profile =
typeof profileId === "string" && ctx.project.profileIds.includes(profileId)
? ctx.profiles.get(profileId)
: undefined;
if (!profile) {
return {
ok: false,
errors: [
{
code: "profile_mismatch",
path: "output.profileId",
message: `plan must target one of the project's profiles: ${ctx.project.profileIds.join(", ")}`,
},
],
};
}
const validation = validateEdl(input.edl, buildEdlContext(deps, ctx, profile));
if (!validation.ok) return { ok: false, errors: validation.errors };
const plan = await deps.repos.createPlanVersion(actor.spaceId, {
projectId: ctx.project.id,
profileId: profile.id,
edl: validation.edl,
createdBy: input.createdBy,
rationale: input.rationale,
});
return { ok: true, plan };
}

View File

@ -0,0 +1,365 @@
import { access, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import type {
BrandKit,
ContentCategory,
Edl,
MediaRenderOutput,
PlatformProfile,
} from "@rakazo/contracts";
import { MediaStateConflictError } from "@rakazo/db";
import {
clipLengthMs,
escapeFilterText,
escapeFilterValue,
introMs,
locateOutputTime,
mainProgramMs,
rankThumbnailFrames,
} from "@rakazo/media";
import { resolveInside } from "@rakazo/media/node";
import sharp from "sharp";
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 type { StoredRenderOutput } from "./media-marketing-files.js";
import {
materializeArtifact,
publicRenderOutput,
renderOutputPath,
storedRenderOutputs,
storeMediaArtifact,
} from "./media-marketing-files.js";
/*
* Thumbnail and poster variants for a finished render (spec 10.1).
*
* Candidates are 24 evenly spaced frames of the main program plus the frame
* just after every cut. Each is decoded small and scored: sharpness is the
* variance of a 4-neighbour Laplacian, contrast the luminance standard
* deviation (both relative to the best candidate), thirds the gradient energy
* near the rule-of-thirds points against the frame average, and face 1 when a
* stored face track of an unprotected person covers the source time.
* rankThumbnailFrames orders them; the best frames are extracted at full size
* as PNG (no EXIF or text chunks) with the plan title drawn in the brand
* colours inside the safe zone. Every variant passes the content thresholds of
* the profile and, when the project protects a person, must show no detectable
* face; failing frames are dropped and the next candidate takes their place.
*/
export const THUMBNAIL_CANDIDATES = 24;
const MAX_VARIANTS = 10;
const ANALYSIS_WIDTH = 160;
const MAX_THUMB_WIDTH = 1920;
const CUT_OFFSET_MS = 200;
const TITLE_MAX_CHARS = 80;
const DEJAVU_BOLD = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
];
export function thumbnailVariantName(index: number): string {
return `thumb-${String.fromCharCode(97 + index)}`;
}
export interface FrameMeasure {
/** Variance of the 4-neighbour Laplacian over the grey frame. */
laplacianVariance: number;
/** Standard deviation of the grey levels. */
contrast: number;
/** 0..1: gradient energy near the third intersections relative to the frame. */
thirds: number;
}
/** Sharpness, contrast and thirds of a packed 8-bit grey frame. */
export function measureGrayFrame(data: Uint8Array, width: number, height: number): FrameMeasure {
let sum = 0;
let sumSq = 0;
for (let i = 0; i < width * height; i++) {
const value = data[i] ?? 0;
sum += value;
sumSq += value * value;
}
const pixels = Math.max(1, width * height);
const mean = sum / pixels;
const contrast = Math.sqrt(Math.max(0, sumSq / pixels - mean * mean));
let lapSum = 0;
let lapSq = 0;
let lapCount = 0;
let energyAll = 0;
let energyThirds = 0;
let thirdsCount = 0;
const radiusX = width / 8;
const radiusY = height / 8;
const points = [1 / 3, 2 / 3].flatMap((fx) =>
[1 / 3, 2 / 3].map((fy) => [fx * width, fy * height] as const),
);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const at = (dx: number, dy: number) => data[(y + dy) * width + x + dx] ?? 0;
const center = at(0, 0);
const lap = 4 * center - at(-1, 0) - at(1, 0) - at(0, -1) - at(0, 1);
lapSum += lap;
lapSq += lap * lap;
lapCount += 1;
const gradient = Math.abs(at(1, 0) - at(-1, 0)) + Math.abs(at(0, 1) - at(0, -1));
energyAll += gradient;
if (points.some(([px, py]) => Math.abs(x - px) <= radiusX && Math.abs(y - py) <= radiusY)) {
energyThirds += gradient;
thirdsCount += 1;
}
}
}
const lapMean = lapCount ? lapSum / lapCount : 0;
const laplacianVariance = lapCount ? Math.max(0, lapSq / lapCount - lapMean * lapMean) : 0;
const meanAll = lapCount ? energyAll / lapCount : 0;
const meanThirds = thirdsCount ? energyThirds / thirdsCount : 0;
const thirds = meanAll > 0 ? Math.min(1, meanThirds / meanAll / 2) : 0;
return { laplacianVariance, contrast, thirds };
}
/** Candidate output times: evenly spaced plus just after each cut, inside the main program. */
export function thumbnailCandidateTimes(edl: Edl, durationMs: number): number[] {
const start = introMs(edl);
const end = Math.min(durationMs, start + mainProgramMs(edl));
if (end <= start) return [];
const span = end - start;
const times = new Set<number>();
for (let i = 0; i < THUMBNAIL_CANDIDATES; i++) {
times.add(Math.round(start + ((i + 0.5) * span) / THUMBNAIL_CANDIDATES));
}
for (const clip of edl.tracks.video) {
const t = start + clip.atMs + Math.min(CUT_OFFSET_MS, clipLengthMs(clip) / 2);
if (t > start && t < end) times.add(Math.round(t));
}
return [...times].sort((a, b) => a - b);
}
async function existingFile(candidates: string[]): Promise<string | null> {
for (const file of candidates) {
try {
await access(file);
return file;
} catch {
// try the next location
}
}
return null;
}
function ffmpegColor(hex: string | undefined, fallback: string): string {
return hex && /^#[0-9a-fA-F]{6}$/.test(hex) ? `0x${hex.slice(1)}` : fallback;
}
/** drawtext title band inside the top safe zone; every value is escaped. */
export function titleOverlayFilter(input: {
text: string;
width: number;
height: number;
profile: PlatformProfile;
kit: BrandKit | null;
fontFile: string | null;
}): string {
const text = input.text.replace(/\s+/g, " ").trim().slice(0, TITLE_MAX_CHARS);
const fontSize = Math.max(
12,
Math.round(Math.min(input.height / 14, (input.width * 0.9) / Math.max(1, text.length * 0.6))),
);
const y = Math.round(input.height * (input.profile.safeZones.topPct / 100 + 0.04));
const font = input.fontFile
? `fontfile=${escapeFilterValue(input.fontFile)}`
: `font=${escapeFilterValue("DejaVu Sans")}`;
return [
`drawtext=${font}`,
`text=${escapeFilterText(text)}`,
`fontsize=${fontSize}`,
`fontcolor=${ffmpegColor(input.kit?.colors.text, "white")}`,
"box=1",
`boxcolor=${ffmpegColor(input.kit?.colors.background, "black")}@0.6`,
`boxborderw=${Math.max(4, Math.round(fontSize / 3))}`,
"x=(w-text_w)/2",
`y=${y}`,
].join(":");
}
function exceedsThresholds(
frames: Array<{ scores: Partial<Record<ContentCategory, number>> }>,
profile: PlatformProfile,
): boolean {
return frames.some((frame) =>
Object.entries(profile.content.thresholds).some(([category, threshold]) => {
const score = frame.scores[category as ContentCategory];
return score !== undefined && threshold !== undefined && score > threshold;
}),
);
}
export async function createThumbnails(
deps: MediaServiceDeps,
actor: MediaActor,
input: { renderId: string; count: number },
tools: MediaToolPaths = {},
): Promise<MediaRenderOutput[]> {
const { spaceId } = actor;
// Fail closed: every variant is re-checked against the profile, which needs inference.
const inference = deps.inference;
if (!inference) throw new MediaStateConflictError("Thumbnails need the media worker");
const row = await deps.repos.getRenderRecord(spaceId, input.renderId);
if (row.status !== "done") throw new MediaStateConflictError("Thumbnails need a finished render");
const outputs = storedRenderOutputs(row.outputs);
const video = outputs.find((output) => output.kind === "video");
if (!video) throw new MediaStateConflictError("Render has no video output");
const videoPath = await renderOutputPath(deps, spaceId, row.id, video);
const [plan, profileSet, project] = await Promise.all([
deps.repos.getPlan(spaceId, row.planId),
deps.profiles(),
deps.repos.getProjectRecord(spaceId, row.projectId),
]);
const profile = profileSet.profiles.get(row.profileId)?.profile;
if (!profile) throw new MediaStateConflictError(`Profile ${row.profileId} is not available`);
const edl = plan.edl;
const kit = project.brandKitId
? (await deps.repos.getBrandKit(spaceId, project.brandKitId)).kit
: null;
const stored = row.ffprobe as { durationMs?: unknown } | null;
const durationMs =
typeof stored?.durationMs === "number"
? stored.durationMs
: (await deps.renderer.probe(videoPath)).durationMs;
const assetIds = [...new Set(edl.tracks.video.map((clip) => clip.assetId))];
const tracks = await deps.repos.faceTrackRecords(spaceId, assetIds);
const personIds = [...new Set(tracks.flatMap((t) => (t.personId ? [t.personId] : [])))];
const people = personIds.length ? await deps.repos.listPersonRecords(spaceId, personIds) : [];
const protectedIds = new Set(
people.filter((person) => person.anonymizeByDefault !== "none").map((person) => person.id),
);
const protectsFaces = protectedIds.size > 0 || edl.redactions.faces.length > 0;
const relativeDir = path.join(spaceId, "renders", row.id, "thumbs");
const dir = await resolveInside(deps.mediaRoot, relativeDir);
const candidateDir = path.join(dir, "candidates");
await rm(candidateDir, { recursive: true, force: true });
await mkdir(candidateDir, { recursive: true });
try {
const measured: Array<{ tMs: number } & FrameMeasure & { face: number }> = [];
for (const tMs of thumbnailCandidateTimes(edl, durationMs)) {
const file = path.join(candidateDir, `${tMs}.png`);
await deps.renderer.extractFrame({
path: videoPath,
tMs,
outPath: file,
width: ANALYSIS_WIDTH,
});
const { data, info } = await sharp(file)
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
const at = locateOutputTime(edl, tMs);
const face =
at &&
tracks.some(
(track) =>
track.assetId === at.assetId &&
track.startMs <= at.sourceMs &&
track.endMs >= at.sourceMs &&
track.personId !== null &&
!protectedIds.has(track.personId),
)
? 1
: 0;
measured.push({ tMs, face, ...measureGrayFrame(data, info.width, info.height) });
}
const maxSharp = Math.max(1e-9, ...measured.map((m) => m.laplacianVariance));
const maxContrast = Math.max(1e-9, ...measured.map((m) => m.contrast));
const order = rankThumbnailFrames(
measured.map((m) => ({
tMs: m.tMs,
sharpness: m.laplacianVariance / maxSharp,
face: m.face,
thirds: m.thirds,
contrast: m.contrast / maxContrast,
})),
measured.length,
);
const title = edl.metadata.title?.trim() || null;
let fontFile: string | null = null;
if (title) {
const heading = kit?.fonts.find((font) => font.role === "heading") ?? kit?.fonts[0];
fontFile = heading
? await materializeArtifact(deps, actor, heading.artifactId, path.join(dir, "fonts"))
: await existingFile(DEJAVU_BOLD);
}
const width = Math.min(edl.output.width, MAX_THUMB_WIDTH);
const height = Math.round((width * edl.output.height) / edl.output.width / 2) * 2;
const wanted = Math.max(0, Math.min(MAX_VARIANTS, Math.floor(input.count)));
const results: StoredRenderOutput[] = [];
for (const tMs of order) {
if (results.length >= wanted) break;
const name = thumbnailVariantName(results.length);
const out = path.join(dir, `${name}.png`);
const frame = path.join(candidateDir, `full-${tMs}.png`);
await deps.renderer.extractFrame({ path: videoPath, tMs, outPath: frame, width });
if (title) {
await runMediaTool(tools.ffmpegPath ?? "ffmpeg", [
"-v",
"error",
"-y",
"-i",
frame,
"-vf",
titleOverlayFilter({ text: title, width, height, profile, kit, fontFile }),
"-frames:v",
"1",
"-map_metadata",
"-1",
"-bitexact",
"-c:v",
"png",
out,
]);
} else {
await rename(frame, out);
}
const content = await inference.classifyContent({
path: out,
sampleFps: 1,
extraTimesMs: [0],
});
let rejected = content.frames.length === 0 || exceedsThresholds(content.frames, profile);
if (!rejected && protectsFaces) {
const faces = await inference.detectFaces({ path: out, sampleFps: 1, timesMs: [0] });
rejected = faces.frames.length === 0 || faces.frames.some((f) => f.faces.length > 0);
}
if (rejected) {
await rm(out, { force: true });
continue;
}
const bytes = await readFile(out);
const artifactId = await storeMediaArtifact(deps, actor, {
name: `${name}.png`,
mimeType: "image/png",
bytes,
});
results.push({
kind: "thumbnail",
artifactId,
fileName: `${name}.png`,
sizeBytes: (await stat(out)).size,
path: path.join(relativeDir, `${name}.png`),
});
}
await deps.repos.updateRender(
spaceId,
row.id,
{ outputs: [...outputs.filter((output) => output.kind !== "thumbnail"), ...results] },
["done"],
);
return results.map(publicRenderOutput);
} finally {
await rm(candidateDir, { recursive: true, force: true });
}
}

View File

@ -0,0 +1,950 @@
import { execFile, execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { BackgroundJob, JobPublisher, MediaRenderer } from "@rakazo/adapter-kit";
import type { BrandKit, FfprobeReport, PlatformProfile, TeaserWeights } from "@rakazo/contracts";
import { PlatformProfileSchema, TEASER_FEATURES } from "@rakazo/contracts";
import type { MediaRepos, PrismaClient } from "@rakazo/db";
import { bootstrapUserSpace, createDb, createMediaRepos } from "@rakazo/db";
import type { ProfileSet } from "@rakazo/media";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { LocalArtifactStore } from "./artifacts.js";
import { isCubeLut, saveBrandKit } from "./media-brand.js";
import type {
MediaActor,
MediaModelClient,
MediaRealtimeEvent,
MediaServiceDeps,
} from "./media-context.js";
import { EmulatorMediaInferenceProvider } from "./media-emulator.js";
import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js";
import { storeMediaArtifact } from "./media-marketing-files.js";
import {
recordMetric,
refitTeaserModel,
resetTeaserModel,
segmentFunnel,
} from "./media-metrics.js";
import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js";
import { bundleRelativeDir } from "./media-publish.js";
import { BundleMediaPublisher } from "./media-publisher-bundle.js";
/*
* Marketing slice against real repositories: plans with the teaser judge,
* brand kits and intro variants, thumbnails, publications, bundles, the
* scheduled publish job, and results import with teaser learning. All media is
* synthetic (ffmpeg testsrc and sine); the model is a recording fake.
*/
const run = promisify(execFile);
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
function onPath(bin: string): boolean {
try {
execFileSync(bin, ["-version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
const hasFfmpeg = onPath("ffmpeg") && onPath("ffprobe");
async function ffmpeg(args: string[]): Promise<void> {
await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args]);
}
function profile(overrides: Record<string, unknown>): PlatformProfile {
return PlatformProfileSchema.parse({
id: "example-short-vertical",
name: "Example short vertical",
kind: "free_social",
reviewedAt: "2026-09-01",
video: {
aspectRatios: ["9:16"],
width: 1080,
height: 1920,
fps: 30,
codec: "h264",
maxBitrateKbps: 8000,
maxFileSizeMb: 500,
},
duration: { hardMinMs: 1000, hardMaxMs: 60_000, recommended: [7000, 15_000, 30_000] },
safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 },
captions: { recommended: true, maxLines: 2 },
audio: {},
text: { titleMaxChars: 100, captionMaxChars: 150, hashtagLimit: 3, linkPolicy: "bio_only" },
content: { thresholds: { explicit_nudity: 0.2 } },
disclosure: { aiAlteredLabelRequired: true },
consent: { requirePerformerConsent: false, requireAgeVerification: false },
export: {},
...overrides,
});
}
const SHORT = profile({});
const LANDSCAPE = profile({
id: "example-landscape",
name: "Example landscape",
video: {
aspectRatios: ["16:9"],
width: 1920,
height: 1080,
fps: 30,
codec: "h264",
maxBitrateKbps: 12_000,
maxFileSizeMb: 2000,
},
duration: { hardMinMs: 1000, hardMaxMs: 600_000, recommended: [30_000], hookWindowMs: 5000 },
text: { titleMaxChars: 100, captionMaxChars: 5000, hashtagLimit: 15, linkPolicy: "allowed" },
});
const PAID = profile({
id: "example-paid-video",
name: "Example paid video",
kind: "paid_video",
video: LANDSCAPE.video,
duration: { hardMinMs: 1000, hardMaxMs: 3_600_000, recommended: [] },
text: { captionMaxChars: 2000, hashtagLimit: 5, linkPolicy: "forbidden" },
disclosure: { aiAlteredLabelRequired: true, ageRestrictedFlag: true },
consent: { requirePerformerConsent: true, requireAgeVerification: true },
export: { previewClipMs: 2000 },
});
const profileSet: ProfileSet = {
profiles: new Map(
[SHORT, LANDSCAPE, PAID].map((p) => [p.id, { profile: p, origin: "builtin" as const }]),
),
errors: [],
};
const KIT: BrandKit = {
name: "Lake Studio",
colors: {
primary: "#1a1a1a",
secondary: "#333333",
accent: "#777777",
text: "#f5f5f5",
background: "#0a0a0a",
},
fonts: [],
logoArtifactIds: [],
captionPresets: [],
musicBeds: [],
tone: "calm and premium",
intro: { templateId: "intro_logo_reveal", params: {}, durationMs: 2500 },
};
function gatePassed(profileId: string) {
return {
profileId,
passed: true,
checkedAt: new Date().toISOString(),
sampledFrames: 8,
issues: [],
autoFixRound: 0,
};
}
describePostgres("media marketing on Postgres", () => {
let prisma: PrismaClient;
let pool: { end(): Promise<void> };
let repos: MediaRepos;
let root: string;
let mediaRoot: string;
const userIds: string[] = [];
const jobs: BackgroundJob[] = [];
const canceledJobs: string[] = [];
const events: MediaRealtimeEvent[] = [];
let modelReply: ((input: Parameters<MediaModelClient["generate"]>[0]) => unknown) | null = null;
const modelCalls: Array<Parameters<MediaModelClient["generate"]>[0]> = [];
let deps: MediaServiceDeps;
async function newActor(): Promise<MediaActor> {
const id = `mk${randomUUID().replaceAll("-", "")}`;
userIds.push(id);
await prisma.user.create({ data: { id, name: "Test", email: `${id}@rakazo.test` } });
const { spaceId } = await bootstrapUserSpace(
prisma,
{ id },
{ signupsEnabled: "true", signupAllowlist: "" },
{ claimDeploymentOwner: false },
);
return { spaceId, userId: id };
}
const renderer: MediaRenderer = {
id: "test-ffmpeg",
hardwareEncoders: async () => [],
render: async () => {
throw new Error("not used by the marketing slice");
},
probe: async () => ({ durationMs: 4000 }) as FfprobeReport,
async extractFrame(input) {
await ffmpeg([
"-ss",
(input.tMs / 1000).toFixed(3),
"-i",
input.path,
"-frames:v",
"1",
...(input.width ? ["-vf", `scale=${input.width}:-2`] : []),
"-map_metadata",
"-1",
input.outPath,
]);
},
};
beforeAll(async () => {
const db = createDb(databaseUrl as string, { poolMax: 4 });
prisma = db.prisma;
pool = db.pool;
repos = createMediaRepos(prisma);
root = await mkdtemp(path.join(tmpdir(), "media-marketing-"));
mediaRoot = path.join(root, "media");
await mkdir(mediaRoot, { recursive: true });
const jobPublisher: JobPublisher = {
enqueue: async (job) => {
jobs.push(job);
},
cancel: async (key) => {
canceledJobs.push(key);
},
close: async () => {},
};
deps = {
prisma,
repos,
jobs: jobPublisher,
inference: new EmulatorMediaInferenceProvider({ root: mediaRoot }),
renderer,
motion: new ResvgMotionGraphicsRenderer({ root: mediaRoot }),
publisher: new BundleMediaPublisher(),
shortener: null,
sources: {},
mediaRoot,
profiles: async () => profileSet,
artifacts: new LocalArtifactStore(path.join(root, "artifacts")),
model: {
async generate(input) {
modelCalls.push(input);
return modelReply ? input.schema.parse(modelReply(input)) : null;
},
},
publish: async (_spaceId, event) => {
events.push(event);
},
now: () => new Date(),
};
});
afterAll(async () => {
if (!prisma) return;
const memberships = await prisma.member.findMany({
where: { userId: { in: userIds } },
select: { organizationId: true },
});
await prisma.organization.deleteMany({
where: { id: { in: memberships.map((member) => member.organizationId) } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.$disconnect();
await pool.end();
await rm(root, { recursive: true, force: true });
});
/** A space with two 20 s landscape clips, scored segments, and a project. */
async function seedLibrary(actor: MediaActor, options: { kit?: BrandKit } = {}) {
const source = await repos.createSource(actor.spaceId, {
kind: "folder",
label: "Footage",
ownerUserId: actor.userId,
config: { path: "/media/footage" },
});
const item = (index: number) => ({
externalId: `clip-${index}.mp4`,
kind: "video" as const,
capturedAt: new Date(Date.UTC(2026, 0, 1, 8, index)),
durationMs: 20_000,
width: 1920,
height: 1080,
fps: 30,
gpsLat: null,
gpsLon: null,
deviceModel: null,
albumNames: [],
isFavorite: false,
fileName: `clip-${index}.mp4`,
sizeBytes: 1000,
revision: "r1",
});
const synced = await repos.upsertSourceItems(actor.spaceId, source.id, [item(1), item(2)]);
const [assetA, assetB] = synced.changedAssetIds as [string, string];
const scores = (motion: number, speechHook = 0.2) => ({
motion,
sharpness: 0.7,
faces: 0.2,
audioPeak: 0.4,
speechHook,
novelty: 0.5,
aesthetics: 0.6,
});
await repos.replaceSegments(actor.spaceId, assetA, 1, [
{
kind: "shot",
startMs: 0,
endMs: 3000,
scores: scores(0.9, 0.9),
transcriptExcerpt: "Did you know these 3 tricks?",
},
{ kind: "shot", startMs: 3000, endMs: 8000, scores: scores(0.6) },
{ kind: "shot", startMs: 8000, endMs: 12_000, scores: scores(1, 1), paidOnly: true },
{ kind: "shot", startMs: 12_000, endMs: 16_000, scores: scores(0.95) },
{ kind: "shot", startMs: 16_000, endMs: 20_000, scores: scores(0.5) },
]);
await repos.replaceSegments(actor.spaceId, assetB, 1, [
{ kind: "shot", startMs: 0, endMs: 5000, scores: scores(0.85) },
{ kind: "shot", startMs: 5000, endMs: 10_000, scores: scores(0.4) },
]);
// Frame 12.5 s of clip A exceeds the short profile's nudity threshold.
const frames = Array.from({ length: 20 }, (_, i) => ({
tMs: i * 1000 + 500,
scores: { explicit_nudity: i === 12 ? 0.9 : 0.01 },
}));
for (const assetId of [assetA, assetB]) {
await repos.upsertAnalysis(actor.spaceId, assetId, {
kind: "classifier",
modelId: "emulator",
modelVersion: "1",
data: { frames },
});
}
// A treated tattoo on clip B keeps every frame of it away from the model.
await repos.createMark(actor.spaceId, {
kind: "tattoo",
label: "arm",
personId: null,
assetId: assetB,
prompt: { tMs: 1000, point: { x: 0.5, y: 0.5 } },
treatment: "blur",
});
if (hasFfmpeg) {
for (const assetId of [assetA, assetB]) {
const relative = path.join(actor.spaceId, assetId, "proxy.mp4");
await mkdir(path.join(mediaRoot, path.dirname(relative)), { recursive: true });
await ffmpeg([
"-f",
"lavfi",
"-i",
"testsrc2=s=160x90:r=10:d=20",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
path.join(mediaRoot, relative),
]);
await repos.recordProxy(actor.spaceId, assetId, {
contentHash: assetId.padEnd(64, "0").slice(0, 64),
proxyPath: relative,
thumbPath: null,
});
}
}
const kit = options.kit ? (await saveBrandKit(deps, actor, { kit: options.kit })).record : null;
const project = await repos.createProject(actor.spaceId, actor.userId, {
title: "Lake Day Launch",
goal: "teaser",
profileIds: [SHORT.id, LANDSCAPE.id],
assetIds: [assetA, assetB],
brandKitId: kit?.id ?? null,
});
const segments = await repos.listSegments(actor.spaceId, [assetA, assetB]);
return { assetA, assetB, project, segments };
}
describe("edit plans", () => {
it("puts the hook first on shorts, keeps paid-only segments out, and ends on a CTA", async () => {
const actor = await newActor();
const { project, segments, assetA } = await seedLibrary(actor, { kit: KIT });
modelReply = null;
const marketing = createMediaMarketingService(deps);
const plans = await marketing.autoPlan(actor, { projectId: project.id });
expect(plans.map((plan) => plan.profileId).sort()).toEqual([LANDSCAPE.id, SHORT.id]);
const short = plans.find((plan) => plan.profileId === SHORT.id)!;
const landscape = plans.find((plan) => plan.profileId === LANDSCAPE.id)!;
// Shorts: hook before branding; the long-form profile may open with the intro.
expect(short.edl.intro).toBeUndefined();
expect(landscape.edl.intro?.templateId).toBe("intro_logo_reveal");
const firstShort = [...short.edl.tracks.video].sort((a, b) => a.atMs - b.atMs)[0]!;
expect(firstShort.atMs).toBeLessThan(SHORT.duration.hookWindowMs);
expect(short.rationale).toContain("Brand intro left out so the hook comes first.");
const paid = segments.find((segment) => segment.paidOnly)!;
for (const plan of plans) {
for (const clip of plan.edl.tracks.video) {
expect(clip.segmentId).not.toBe(paid.id);
if (clip.assetId === assetA) {
expect(clip.outMs <= paid.startMs || clip.inMs >= paid.endMs).toBe(true);
}
}
}
const cta = short.edl.overlays.find((overlay) => overlay.kind === "cta_end_card");
expect(cta && "text" in cta ? cta.text : null).toBe("Link in bio");
expect(short.rationale).toContain("Default teaser weights.");
});
it("sends only gate-safe candidates to the model and applies its ranking", async () => {
if (!hasFfmpeg) return;
const actor = await newActor();
const { project, segments, assetB } = await seedLibrary(actor);
modelCalls.length = 0;
modelReply = (input) => {
const ids = [...input.prompt.matchAll(/id=(\S+)/g)].map((match) => match[1]!);
return {
ranking: ids.reverse().map((segmentId) => ({ segmentId, reason: "clear opener" })),
};
};
const plans = await createMediaMarketingService(deps).autoPlan(actor, {
projectId: project.id,
profileIds: [SHORT.id],
});
modelReply = null;
expect(modelCalls).toHaveLength(1);
const call = modelCalls[0]!;
const sentIds = [...call.prompt.matchAll(/id=(\S+)/g)].map((match) => match[1]!);
const byId = new Map(segments.map((segment) => [segment.id, segment]));
expect(sentIds.length).toBeGreaterThanOrEqual(2);
expect(call.images?.map((image) => image.name)).toEqual(sentIds.map((id) => `${id}.png`));
for (const id of sentIds) {
const segment = byId.get(id)!;
expect(segment.paidOnly).toBe(false);
expect(segment.assetId).not.toBe(assetB);
expect(segment.startMs).not.toBe(12_000);
}
expect(call.prompt).not.toContain(actor.userId);
expect(plans[0]?.rationale).toContain("Model ranking applied");
});
it("uses learned weights only from 20 publications and validates submitted plans", async () => {
const actor = await newActor();
const { project, assetA } = await seedLibrary(actor);
const marketing = createMediaMarketingService(deps);
const weights = Object.fromEntries(TEASER_FEATURES.map((f) => [f, 1])) as TeaserWeights;
await repos.putTeaserModel(actor.spaceId, weights, 5);
const [few] = await marketing.autoPlan(actor, {
projectId: project.id,
profileIds: [SHORT.id],
});
expect(few?.rationale).toContain("Default teaser weights.");
await repos.putTeaserModel(actor.spaceId, weights, 20);
const [many] = await marketing.autoPlan(actor, {
projectId: project.id,
profileIds: [SHORT.id],
});
expect(many?.rationale).toContain("Weights learned");
await resetTeaserModel(deps, actor);
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
const wrongProfile = await marketing.submitPlan(actor, {
projectId: project.id,
edl: { ...many!.edl, output: { ...many!.edl.output, profileId: PAID.id } },
rationale: null,
createdBy: "agent",
});
expect(wrongProfile.ok).toBe(false);
const outOfRange = await marketing.submitPlan(actor, {
projectId: project.id,
edl: {
...many!.edl,
tracks: {
...many!.edl.tracks,
video: [
{ id: "c1", assetId: assetA, inMs: 0, outMs: 90_000, atMs: 0, reframe: "none" },
],
},
},
rationale: null,
createdBy: "user",
});
expect(outOfRange.ok).toBe(false);
const resubmitted = await marketing.submitPlan(actor, {
projectId: project.id,
edl: many!.edl,
rationale: "kept",
createdBy: "user",
});
expect(resubmitted.ok && resubmitted.plan.version).toBe(3);
});
});
describe("brand kits and intros", () => {
it("accepts only files of the space with usable types and real templates", async () => {
const actor = await newActor();
const other = await newActor();
const png = await sharp({
create: {
width: 64,
height: 64,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 },
},
})
.png()
.toBuffer();
const logo = await storeMediaArtifact(deps, actor, {
name: "logo.png",
mimeType: "image/png",
bytes: new Uint8Array(png),
});
const lutText = `LUT_3D_SIZE 2\n${Array.from({ length: 8 }, () => "0 0.5 1").join("\n")}\n`;
expect(isCubeLut(lutText)).toBe(true);
expect(isCubeLut("LUT_3D_SIZE 2\n0 0 0\n")).toBe(false);
const lut = await storeMediaArtifact(deps, actor, {
name: "look.cube",
mimeType: "text/plain",
bytes: new TextEncoder().encode(lutText),
});
const foreign = await storeMediaArtifact(deps, other, {
name: "logo.png",
mimeType: "image/png",
bytes: new Uint8Array(png),
});
const saved = await saveBrandKit(deps, actor, {
kit: {
...KIT,
logoArtifactIds: [logo],
lutArtifactId: lut,
colors: { ...KIT.colors, text: "#111111" },
},
});
expect(saved.record.kit.logoArtifactIds).toEqual([logo]);
expect(saved.warnings.some((warning) => warning.includes("contrast"))).toBe(true);
await expect(
saveBrandKit(deps, actor, { kit: { ...KIT, logoArtifactIds: [foreign] } }),
).rejects.toThrow(/not found in this space/);
await expect(
saveBrandKit(deps, actor, { kit: { ...KIT, logoArtifactIds: [lut] } }),
).rejects.toThrow(/PNG or SVG/);
await expect(
saveBrandKit(deps, actor, {
kit: { ...KIT, intro: { templateId: "endcard_cta", params: {}, durationMs: 3000 } },
}),
).rejects.toThrow(/not a intro template/);
await expect(
saveBrandKit(deps, actor, {
kit: { ...KIT, intro: { templateId: "intro_glitch", params: {}, durationMs: 5000 } },
}),
).rejects.toThrow(/duration/);
});
it("proposes three intro variants with previews", async () => {
const actor = await newActor();
const { record } = await saveBrandKit(deps, actor, { kit: KIT });
const variants = await createMediaMarketingService(deps).introVariants(actor, {
brandKitId: record.id,
count: 3,
});
expect(variants).toHaveLength(3);
expect(new Set(variants.map((v) => v.template.templateId)).size).toBe(3);
for (const variant of variants) {
expect(variant.template.durationMs).toBeGreaterThanOrEqual(1500);
expect(variant.template.durationMs).toBeLessThanOrEqual(4000);
if (hasFfmpeg) {
const artifact = await prisma.artifact.findFirst({
where: { id: variant.previewArtifactId ?? "", spaceId: actor.spaceId },
});
expect(artifact?.mimeType).toBe("video/mp4");
}
}
expect(variants[0]?.template.templateId).toBe("intro_elegant_fade");
});
});
describe.skipIf(!hasFfmpeg)("thumbnails, publications and bundles", () => {
async function finishedRender(
actor: MediaActor,
input: { profile: PlatformProfile; quality?: "draft" | "final"; projectId?: string },
) {
const assetIds = input.projectId ? [] : [(await seedLibrary(actor)).assetA];
const projectId =
input.projectId ??
(
await repos.createProject(actor.spaceId, actor.userId, {
title: "Lake Day Launch",
goal: "teaser",
profileIds: [input.profile.id],
assetIds,
})
).id;
const vertical = input.profile.video.aspectRatios.includes("9:16");
const plan = await repos.createPlanVersion(actor.spaceId, {
projectId,
profileId: input.profile.id,
edl: {
edlVersion: 1,
output: {
profileId: input.profile.id,
width: input.profile.video.width,
height: input.profile.video.height,
fps: 30,
durationTargetMs: 4000,
durationHardMinMs: 1000,
durationHardMaxMs: 60_000,
},
tracks: {
video: [
{
id: "c1",
assetId: assetIds[0] ?? "a",
inMs: 0,
outMs: 4000,
atMs: 0,
reframe: "none",
},
],
},
metadata: { title: "Lake day", aiAltered: true, aiAlterations: ["inpaint"] },
},
createdBy: "agent",
rationale: null,
});
const render = await repos.createRender(actor.spaceId, {
planId: plan.id,
quality: input.quality ?? "final",
createdBy: actor.userId,
});
const dir = path.join(mediaRoot, actor.spaceId, "renders", render.id);
await mkdir(dir, { recursive: true });
await ffmpeg([
"-f",
"lavfi",
"-i",
`testsrc2=s=${vertical ? "180x320" : "320x180"}:r=30:d=4`,
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=4",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-g",
"30",
"-c:a",
"aac",
"-shortest",
path.join(dir, "video.mp4"),
]);
await ffmpeg([
"-i",
path.join(dir, "video.mp4"),
"-frames:v",
"1",
path.join(dir, "poster.png"),
]);
const srt = "1\n00:00:00,000 --> 00:00:02,000\nHello\n";
await writeFile(path.join(dir, "subtitles.srt"), srt);
await repos.claimRender(actor.spaceId, render.id);
await repos.updateRender(actor.spaceId, render.id, {
status: "done",
progress: 1,
gateReport: gatePassed(input.profile.id),
outputs: [
{
kind: "video",
artifactId: null,
fileName: "video.mp4",
sizeBytes: (await stat(path.join(dir, "video.mp4"))).size,
},
{ kind: "poster", artifactId: null, fileName: "poster.png", sizeBytes: 1 },
{
kind: "subtitles_srt",
artifactId: null,
fileName: "subtitles.srt",
sizeBytes: srt.length,
},
],
});
return { render, projectId };
}
it("creates three EXIF-free thumbnail variants", async () => {
const actor = await newActor();
const { render } = await finishedRender(actor, { profile: SHORT });
const thumbs = await createMediaMarketingService(deps).thumbnails(actor, {
renderId: render.id,
count: 3,
});
expect(thumbs.map((thumb) => thumb.fileName)).toEqual([
"thumb-a.png",
"thumb-b.png",
"thumb-c.png",
]);
const stored = await repos.getRender(actor.spaceId, render.id);
expect(stored.outputs.filter((output) => output.kind === "thumbnail")).toHaveLength(3);
const file = await readFile(
path.join(mediaRoot, actor.spaceId, "renders", render.id, "thumbs", "thumb-a.png"),
);
for (const chunk of ["eXIf", "tEXt", "iTXt", "zTXt"]) {
expect(file.includes(Buffer.from(chunk, "ascii"))).toBe(false);
}
});
it("refuses drafts and builds a complete bundle with tracking and checklist", async () => {
const actor = await newActor();
const marketing = createMediaMarketingService(deps);
const draft = await finishedRender(actor, { profile: SHORT, quality: "draft" });
const base = {
destination: "bundle",
targetUrl: "https://example.com/full-video",
utm: null,
title: "Lake day",
caption: "A calm morning at the lake",
tags: ["lake", "#morning", "calm", "extra"],
thumbnailVariant: null,
scheduledAt: null,
};
await expect(
marketing.createPublication(actor, { ...base, renderId: draft.render.id }),
).rejects.toThrow(/final render/);
const { render } = await finishedRender(actor, {
profile: SHORT,
projectId: draft.projectId,
});
await marketing.thumbnails(actor, { renderId: render.id, count: 3 });
const publication = await marketing.createPublication(actor, {
...base,
renderId: render.id,
thumbnailVariant: "thumb-b",
});
const tracking = new URL(publication.trackingUrl ?? "");
expect(tracking.searchParams.get("utm_source")).toBe(SHORT.id);
expect(tracking.searchParams.get("utm_medium")).toBe("video-9x16");
expect(tracking.searchParams.get("utm_campaign")).toBe("lake-day-launch");
expect(tracking.searchParams.get("utm_content")).toBe("thumb-b");
const bundled = await marketing.bundle(actor, publication.id);
expect(bundled.status).toBe("bundled");
expect(bundled.bundleArtifactId).not.toBeNull();
const dir = path.join(mediaRoot, bundleRelativeDir(actor.spaceId, publication.id));
expect((await readdir(dir)).sort()).toEqual(
[
"AI-DISCLOSURE.txt",
"UPLOAD-CHECKLIST.md",
"bundle.zip",
"caption.txt",
"metadata.json",
"poster.png",
"subtitles.srt",
"thumb-a.png",
"thumb-b.png",
"thumb-c.png",
"video.mp4",
].sort(),
);
const checklist = await readFile(path.join(dir, "UPLOAD-CHECKLIST.md"), "utf8");
expect(checklist).toContain("AI-generated or altered content label");
expect(checklist).toContain("profile bio");
const caption = await readFile(path.join(dir, "caption.txt"), "utf8");
expect(caption).not.toContain("https://");
expect(caption.match(/#/g)).toHaveLength(3);
const metadata = JSON.parse(await readFile(path.join(dir, "metadata.json"), "utf8"));
expect(metadata).toMatchObject({ aiAltered: true, aiAlterations: ["inpaint"] });
expect(
(await readFile(path.join(dir, "poster.png"))).equals(
await readFile(path.join(dir, "thumb-b.png")),
),
).toBe(true);
});
it("schedules the publish job, bundles at the time, and reminds instead of uploading paid videos", async () => {
const actor = await newActor();
const marketing = createMediaMarketingService(deps);
const { render } = await finishedRender(actor, { profile: PAID });
const at = new Date(Date.now() + 3_600_000).toISOString();
const publication = await marketing.createPublication(actor, {
renderId: render.id,
destination: "bundle",
targetUrl: null,
utm: null,
title: "Full release",
caption: null,
tags: [],
thumbnailVariant: null,
scheduledAt: at,
});
expect(publication.status).toBe("scheduled");
const job = jobs.find((entry) => entry.replaceKey === `media.publish:${publication.id}`);
expect(job?.availableAt?.toISOString()).toBe(at);
const publish = vi.spyOn(deps.publisher, "publish");
const remind = vi.fn(async () => {});
const handlers = createMediaMarketingJobHandlers(deps, marketing, { remind });
await handlers["media.publish"]({ publicationId: publication.id });
expect(publish).not.toHaveBeenCalled();
expect(remind).toHaveBeenCalledTimes(1);
const after = await repos.getPublication(actor.spaceId, publication.id);
expect(after.status).toBe("bundled");
const dir = path.join(mediaRoot, bundleRelativeDir(actor.spaceId, publication.id));
const files = await readdir(dir);
expect(files).toContain("preview.mp4");
const checklist = await readFile(path.join(dir, "UPLOAD-CHECKLIST.md"), "utf8");
expect(checklist).toContain("age-restricted");
const canceled = await marketing.cancelPublication(actor, publication.id);
expect(canceled.status).toBe("canceled");
expect(canceledJobs).toContain(`media.publish:${publication.id}`);
await handlers["media.publish"]({ publicationId: publication.id });
expect(remind).toHaveBeenCalledTimes(1);
publish.mockRestore();
});
});
describe("results", () => {
it("imports CSV through a saved mapping without double counting and learns from 20 publications", async () => {
const actor = await newActor();
const { project, segments, assetA } = await seedLibrary(actor);
const marketing = createMediaMarketingService(deps);
const strong = segments.find((s) => s.assetId === assetA && s.startMs === 0)!;
const weak = segments.find((s) => s.assetId === assetA && s.startMs === 16_000)!;
const renders: string[] = [];
for (const segment of [strong, weak]) {
const plan = await repos.createPlanVersion(actor.spaceId, {
projectId: project.id,
profileId: SHORT.id,
edl: {
edlVersion: 1,
output: {
profileId: SHORT.id,
width: 1080,
height: 1920,
fps: 30,
durationTargetMs: 3000,
durationHardMinMs: 1000,
durationHardMaxMs: 60_000,
},
tracks: {
video: [
{
id: "c1",
assetId: assetA,
inMs: segment.startMs,
outMs: segment.startMs + 3000,
atMs: 0,
reframe: "none",
segmentId: segment.id,
},
],
},
metadata: {},
},
createdBy: "agent",
rationale: null,
});
const render = await repos.createRender(actor.spaceId, {
planId: plan.id,
quality: "final",
createdBy: actor.userId,
});
await repos.claimRender(actor.spaceId, render.id);
await repos.updateRender(actor.spaceId, render.id, { status: "done", progress: 1 });
renders.push(render.id);
}
const publicationIds: string[] = [];
for (let i = 0; i < 20; i++) {
const publication = await repos.createPublication(actor.spaceId, actor.userId, {
renderId: renders[i % 2]!,
destination: "bundle",
targetUrl: null,
trackingUrl: null,
utm: null,
title: null,
caption: null,
tags: [],
thumbnailVariant: null,
scheduledAt: null,
});
publicationIds.push(publication.id);
}
const mapping = await repos.saveMetricMapping(actor.spaceId, {
name: "Shorts export",
delimiter: ";",
columns: {
publication: "Post",
observedAt: "Date",
views: "Views",
clicks: "Clicks",
conversions: "Sales",
revenue: "Revenue",
},
publicationKey: "id",
revenueUnit: "major",
defaultCurrency: "EUR",
});
const csv = (ids: string[]) =>
[
"Post;Date;Views;Clicks;Sales;Revenue",
...ids.map((id, i) => {
const strongPost = i % 2 === 0;
return `${id};2026-09-0${(i % 9) + 1};1000;${strongPost ? 120 : 40};${strongPost ? 30 : 2};${strongPost ? "299,70" : "19,98"}`;
}),
"unknown-post;2026-09-01;10;1;0;0",
].join("\n");
const first19 = await marketing.importMetricsCsv(actor, {
mappingId: mapping.id as string,
csv: csv(publicationIds.slice(0, 19)),
});
expect(first19.imported).toBe(19);
expect(first19.skipped).toEqual([{ row: 21, reason: "publication not found" }]);
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
const again = await marketing.importMetricsCsv(actor, {
mappingId: mapping.id as string,
csv: csv(publicationIds.slice(0, 19)),
});
expect(again.imported).toBe(19);
const funnel = await repos.funnel(actor.spaceId, project.id);
expect(funnel.reduce((sum, row) => sum + row.views, 0)).toBe(19_000);
expect(await refitTeaserModel(deps, actor.spaceId)).toBeNull();
await recordMetric(deps, actor, {
publicationId: publicationIds[19]!,
observedAt: "2026-09-10T00:00:00.000Z",
views: 1000,
clicks: 40,
conversions: 2,
revenueCents: 1998,
currency: "EUR",
});
const model = await repos.getTeaserModel(actor.spaceId);
expect(model?.sampleCount).toBe(20);
const totals = funnel.find((row) => row.publicationId === publicationIds[0]);
expect(totals).toMatchObject({
views: 1000,
clicks: 120,
conversions: 30,
revenueCents: 29_970,
});
const bySegment = await segmentFunnel(deps, actor, project.id);
expect(bySegment.map((row) => row.segmentId)).toEqual([strong.id, weak.id]);
expect(bySegment[0]).toMatchObject({ publications: 10, views: 10_000, conversions: 300 });
expect(bySegment[1]).toMatchObject({ publications: 10, conversions: 20 });
await resetTeaserModel(deps, actor);
expect(await repos.getTeaserModel(actor.spaceId)).toBeNull();
});
});
});

View File

@ -0,0 +1,350 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Edl, MediaSegment, PlatformProfile } from "@rakazo/contracts";
import { EdlSchema, PlatformProfileSchema } from "@rakazo/contracts";
import type { MediaRepos } from "@rakazo/db";
import type { ProfileSet, ScoredSegmentInput } from "@rakazo/media";
import { escapeFilterText } from "@rakazo/media";
import { describe, expect, it } from "vitest";
import { isCubeLut } from "./media-brand.js";
import type { MediaModelClient, MediaServiceDeps } from "./media-context.js";
import { generateCopy, truncateText } from "./media-marketing-copy.js";
import { applyRanking, defaultCtaText, showsProtectedDetail } from "./media-marketing-plan.js";
import {
measureGrayFrame,
thumbnailCandidateTimes,
titleOverlayFilter,
} from "./media-marketing-thumbnails.js";
import { planFeatures } from "./media-metrics.js";
import { assertPublishable } from "./media-publish.js";
import { BundleMediaPublisher } from "./media-publisher-bundle.js";
function profile(text: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
return PlatformProfileSchema.parse({
id: "example-short-vertical",
name: "Example short vertical",
kind: "free_social",
reviewedAt: "2026-09-01",
video: {
aspectRatios: ["9:16"],
width: 1080,
height: 1920,
fps: 30,
codec: "h264",
maxBitrateKbps: 8000,
maxFileSizeMb: 500,
},
duration: { hardMinMs: 1000, hardMaxMs: 60_000 },
safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 },
captions: { recommended: true },
audio: {},
text: { captionMaxChars: 150, hashtagLimit: 3, linkPolicy: "bio_only", ...text },
content: { thresholds: {} },
disclosure: { aiAlteredLabelRequired: true },
consent: { requirePerformerConsent: false, requireAgeVerification: false },
export: {},
...overrides,
});
}
function edl(overrides: Partial<Edl> = {}): Edl {
return EdlSchema.parse({
edlVersion: 1,
output: {
profileId: "example-short-vertical",
width: 1080,
height: 1920,
fps: 30,
durationTargetMs: 9000,
durationHardMinMs: 1000,
durationHardMaxMs: 60_000,
},
tracks: {
video: [
{
id: "c1",
assetId: "a1",
inMs: 0,
outMs: 3000,
atMs: 0,
reframe: "none",
segmentId: "s1",
},
{ id: "c2", assetId: "a1", inMs: 6000, outMs: 12_000, atMs: 3000, reframe: "none" },
],
},
metadata: { title: "Lake day", tags: ["lake"] },
...overrides,
});
}
function segment(id: string, startMs: number, endMs: number, extra: Partial<MediaSegment> = {}) {
return {
id,
assetId: "a1",
kind: "shot",
startMs,
endMs,
scores: {},
teaserScore: null,
transcriptExcerpt: null,
labels: [],
paidOnly: false,
...extra,
} as MediaSegment;
}
describe("teaser plans", () => {
it("ends teasers on a call to action that follows the link policy", () => {
expect(defaultCtaText("teaser", profile({ linkPolicy: "bio_only" }))).toBe("Link in bio");
expect(defaultCtaText("teaser", profile({ linkPolicy: "allowed" }))).toBe("Link below");
expect(defaultCtaText("teaser", profile({ linkPolicy: "forbidden" }))).not.toMatch(/link/i);
expect(defaultCtaText("full_release", profile({}))).toBeNull();
});
it("re-weights by the model order and leaves paid-only segments alone", () => {
const input = (id: string, paidOnly = false): ScoredSegmentInput => ({
id,
assetId: "a1",
startMs: 0,
endMs: 1000,
capturedAt: null,
features: { motion: 1, sharpness: 0.5 },
paidOnly,
transcript: null,
});
const ranked = applyRanking([input("a"), input("b"), input("c"), input("p", true)], ["b", "a"]);
const byId = new Map(ranked.map((s) => [s.id, s.features]));
expect(byId.get("b")).toEqual({ motion: 1, sharpness: 0.5 });
expect(byId.get("a")).toEqual({ motion: 0.75, sharpness: 0.375 });
expect(byId.get("c")).toEqual({ motion: 0.5, sharpness: 0.25 });
expect(byId.get("p")).toEqual({ motion: 1, sharpness: 0.5 });
});
it("keeps protected faces and treated marks away from the model", () => {
const track = (personId: string | null) => ({
assetId: "a1",
startMs: 1000,
endMs: 2000,
personId,
});
const ctx = (tracks: unknown[], marks: unknown[] = []) =>
({ faceTracks: tracks, marks }) as Parameters<typeof showsProtectedDetail>[0];
const span = { assetId: "a1", startMs: 0, endMs: 1500 };
expect(showsProtectedDetail(ctx([track("p1")]), new Set(), span)).toBe(false);
expect(showsProtectedDetail(ctx([track("p1")]), new Set(["p1"]), span)).toBe(true);
expect(showsProtectedDetail(ctx([track(null)]), new Set(["p2"]), span)).toBe(true);
expect(
showsProtectedDetail(ctx([track("p1")]), new Set(), { ...span, startMs: 2000, endMs: 3000 }),
).toBe(false);
const tattoo = { treatment: "blur", assetId: null, personId: "p1" };
expect(showsProtectedDetail(ctx([track("p1")], [tattoo]), new Set(), span)).toBe(true);
expect(
showsProtectedDetail(ctx([track("p1")], [{ ...tattoo, treatment: "none" }]), new Set(), span),
).toBe(false);
const plate = { treatment: "cover", assetId: "a1", personId: null };
expect(showsProtectedDetail(ctx([], [plate]), new Set(), span)).toBe(true);
});
it("learns from the clip-length-weighted features of the segments a plan used", () => {
const features = planFeatures(edl(), [
segment("s1", 0, 3000, { scores: { motion: 1 } }),
segment("s2", 6000, 12_000, { scores: { motion: 0.25 }, transcriptExcerpt: "Wow!" }),
segment("s3", 15_000, 18_000, { scores: { motion: 0 } }),
]);
expect(features?.motion).toBeCloseTo((1 * 3000 + 0.25 * 6000) / 9000);
expect(features?.speechHook).toBeGreaterThan(0);
expect(planFeatures(edl(), [])).toBeNull();
});
});
describe("thumbnails", () => {
it("measures sharpness, contrast and thirds of a grey frame", () => {
const width = 30;
const height = 30;
const flat = new Uint8Array(width * height).fill(128);
const checker = Uint8Array.from({ length: width * height }, (_, i) =>
(i % width) % 2 === Math.floor(i / width) % 2 ? 255 : 0,
);
expect(measureGrayFrame(flat, width, height)).toEqual({
laplacianVariance: 0,
contrast: 0,
thirds: 0,
});
const busy = measureGrayFrame(checker, width, height);
expect(busy.laplacianVariance).toBeGreaterThan(1000);
expect(busy.contrast).toBeGreaterThan(100);
});
it("samples the main program after the intro and just after each cut", () => {
const withIntro = edl({ intro: { templateId: "intro_glitch", params: {}, durationMs: 2000 } });
const times = thumbnailCandidateTimes(withIntro, 11_000);
expect(times.length).toBeGreaterThanOrEqual(24);
expect(Math.min(...times)).toBeGreaterThan(2000);
expect(Math.max(...times)).toBeLessThan(11_000);
expect(times).toContain(2000 + 3000 + 200);
});
it("escapes the title and falls back to neutral colours", () => {
const hostile = "Hi': drawtext=text='x' ;[out] %{pts}";
const filter = titleOverlayFilter({
text: hostile,
width: 1080,
height: 1920,
profile: profile({}),
kit: null,
fontFile: "/fonts/My Font:Bold.ttf",
});
expect(filter).toContain(`text=${escapeFilterText(hostile)}`);
expect(filter).not.toContain(hostile);
expect(filter).toContain("fontcolor=white");
expect(filter).toContain(`y=${Math.round(1920 * 0.14)}`);
});
});
describe("copy", () => {
const render = {
id: "r1",
planId: "p1",
projectId: "j1",
profileId: "example-short-vertical",
};
function copyDeps(reply: unknown, text: Record<string, unknown> = {}) {
const short = profile({
titleMaxChars: 20,
bannedPatterns: [{ pattern: "guaranteed", reason: "false promise" }],
...text,
});
const profiles: ProfileSet = {
profiles: new Map([[short.id, { profile: short, origin: "builtin" as const }]]),
errors: [],
};
const calls: Array<Parameters<MediaModelClient["generate"]>[0]> = [];
const deps = {
repos: {
getRender: async () => render,
getPlan: async () => ({ edl: edl() }),
getProjectRecord: async () => ({ title: "Lake Day Launch" }),
listSegments: async () => [
segment("s1", 0, 3000, { transcriptExcerpt: "Welcome back. Today we film at the lake." }),
],
} as unknown as MediaRepos,
profiles: async () => profiles,
model: {
async generate(input) {
calls.push(input);
return reply === null ? null : input.schema.parse(reply);
},
} as MediaModelClient,
} as unknown as MediaServiceDeps;
return { deps, calls };
}
const actor = { spaceId: "s", userId: "u" };
it("falls back to the plan metadata without a model", async () => {
const { deps, calls } = copyDeps(null);
const copy = await generateCopy(deps, actor, { renderId: "r1", language: "de" });
expect(calls[0]?.instructions).toContain('language "de"');
expect(calls[0]?.prompt).toContain("Today we film at the lake");
expect(copy).toMatchObject({
title: "Lake day",
hook: "Welcome back.",
caption: "Lake day",
hashtags: ["#lake"],
issues: [],
});
});
it("cuts to the profile limits and reports banned patterns and links in every field", async () => {
const { deps } = copyDeps({
title: "The calmest lake morning you will ever see",
hook: "Guaranteed calm",
caption: `See https://example.com/video now ${"x".repeat(200)}`,
hashtags: ["lake", "calm", "morning", "travel", "nature"],
description: "More at https://example.com",
altText: "A lake at sunrise",
});
const copy = await generateCopy(deps, actor, { renderId: "r1", language: "en" });
expect([...copy.title].length).toBeLessThanOrEqual(20);
expect([...copy.caption].length).toBeLessThanOrEqual(150);
expect(copy.hashtags).toEqual(["#lake", "#calm", "#morning"]);
const codes = copy.issues.map((issue) => `${issue.code}:${issue.message}`);
expect(codes.some((code) => code.startsWith("link_bio_only:caption"))).toBe(true);
expect(codes.some((code) => code.startsWith("link_bio_only:hook or description"))).toBe(true);
expect(codes.some((code) => code.startsWith("banned_pattern:hook or description"))).toBe(true);
expect(copy.chapters).toBeUndefined();
});
it("rejects malformed language codes", async () => {
const { deps } = copyDeps(null);
await expect(
generateCopy(deps, actor, { renderId: "r1", language: "en; drop" }),
).rejects.toThrow(/language/);
});
it("cuts long text at a word boundary", () => {
expect(truncateText("one two three four", 15)).toBe("one two three");
expect(truncateText("one two three four", 12)).toBe("one two thre");
expect(truncateText("short", 12)).toBe("short");
});
});
describe("publishing", () => {
it("publishes only final renders whose gate passed", () => {
const gate = { passed: true } as never;
expect(() =>
assertPublishable({ status: "done", quality: "final", gateReport: gate }),
).not.toThrow();
expect(() => assertPublishable({ status: "done", quality: "draft", gateReport: gate })).toThrow(
/final/,
);
expect(() =>
assertPublishable({ status: "blocked", quality: "final", gateReport: gate }),
).toThrow(/finished/);
expect(() => assertPublishable({ status: "done", quality: "final", gateReport: null })).toThrow(
/gate/,
);
expect(() =>
assertPublishable({
status: "done",
quality: "final",
gateReport: { passed: false } as never,
}),
).toThrow(/gate/);
});
it("reports a bundle only when the files are really there", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "media-bundle-"));
try {
const publisher = new BundleMediaPublisher();
const request = {
bundleDir: dir,
profile: profile({}) as PlatformProfile,
publicationId: "pub1",
title: null,
caption: null,
tags: [],
};
await expect(publisher.publish(request)).rejects.toThrow(/missing video.mp4/);
await writeFile(path.join(dir, "video.mp4"), "v");
await expect(publisher.publish(request)).rejects.toThrow(/missing bundle.zip/);
await writeFile(path.join(dir, "bundle.zip"), "z");
expect(await publisher.publish(request)).toEqual({ status: "bundled", externalUrl: null });
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
describe("brand kits", () => {
it("accepts only complete 3D .cube LUTs", () => {
const rows = (n: number) => Array.from({ length: n }, () => "0.1 0.2 0.3").join("\n");
expect(isCubeLut(`TITLE "look"\nLUT_3D_SIZE 2\n${rows(8)}\n`)).toBe(true);
expect(isCubeLut(`LUT_3D_SIZE 2\n${rows(7)}\n`)).toBe(false);
expect(isCubeLut(`LUT_1D_SIZE 2\n${rows(2)}\n`)).toBe(false);
expect(isCubeLut("LUT_3D_SIZE 2\n0 0 zero\n")).toBe(false);
expect(isCubeLut("not a lut")).toBe(false);
});
});

View File

@ -0,0 +1,52 @@
import type { MediaJobHandlers } from "@rakazo/adapter-kit";
import { introVariants } from "./media-brand.js";
import type { MediaMarketingService, MediaServiceDeps } from "./media-context.js";
import type { MediaToolPaths } from "./media-folder-source-probe.js";
import { generateCopy } from "./media-marketing-copy.js";
import { autoPlan, submitPlan } from "./media-marketing-plan.js";
import { createThumbnails } from "./media-marketing-thumbnails.js";
import { importMetricsCsv } from "./media-metrics.js";
import type { MediaUploadReminder } from "./media-publish.js";
import {
buildBundle,
cancelPublication,
createMediaPublishHandler,
createPublication,
} from "./media-publish.js";
/*
* Media Studio marketing slice (spec 8–10) behind MediaMarketingService:
* edit plans with the teaser judge, intro variants, thumbnails, copy,
* publications with tracking and bundles, and results import with teaser
* learning. Brand kit saving, manual metrics and the teaser reset are plain
* functions (saveBrandKit, recordMetric, resetTeaserModel) the API calls with
* the same deps.
*/
export interface MediaMarketingOptions extends MediaToolPaths {}
export function createMediaMarketingService(
deps: MediaServiceDeps,
options: MediaMarketingOptions = {},
): MediaMarketingService {
return {
autoPlan: (actor, input) => autoPlan(deps, actor, input),
submitPlan: (actor, input) => submitPlan(deps, actor, input),
introVariants: (actor, input) => introVariants(deps, actor, input, options),
thumbnails: (actor, input) => createThumbnails(deps, actor, input, options),
copy: (actor, input) => generateCopy(deps, actor, input),
createPublication: (actor, input) => createPublication(deps, actor, input),
bundle: (actor, publicationId) => buildBundle(deps, actor, publicationId, options),
cancelPublication: (actor, publicationId) => cancelPublication(deps, actor, publicationId),
importMetricsCsv: (actor, input) => importMetricsCsv(deps, actor, input),
};
}
/** Worker handlers owned by the marketing slice. */
export function createMediaMarketingJobHandlers(
deps: MediaServiceDeps,
marketing: Pick<MediaMarketingService, "bundle">,
options: { remind?: MediaUploadReminder } = {},
): Pick<MediaJobHandlers, "media.publish"> {
return { "media.publish": createMediaPublishHandler(deps, marketing, options) };
}

View File

@ -0,0 +1,234 @@
import type { Edl, FunnelRow, MediaMetric, MediaSegment } from "@rakazo/contracts";
import { TEASER_FEATURES, TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts";
import type { NewMediaMetric, TeaserModelRecord } from "@rakazo/db";
import { InvalidMediaInputError } from "@rakazo/db";
import type { SegmentFeatures } from "@rakazo/media";
import {
clipLengthMs,
defaultTeaserWeights,
fitTeaserWeights,
parseMetricsCsv,
speechHookScore,
} from "@rakazo/media";
import type { MediaActor, MediaServiceDeps } from "./media-context.js";
/*
* Results import and teaser learning (spec 8 "Lernen", 10.6).
*
* A CSV import resolves rows through the saved mapping and records them as
* cumulative snapshots: re-importing a report replaces the same
* (publication, date, source) rows, so numbers never double. After every
* import the teaser weights are refit from all publications with views. Each
* publication contributes the clip-length-weighted mean features of the
* segments its plan used, with its latest views and conversions. Below
* TEASER_LEARNING_MIN_SAMPLES publications nothing is stored and plans keep
* the default weights; resetTeaserModel goes back to them.
*/
export const METRICS_CSV_MAX_CHARS = 5_000_000;
/** Clip-length-weighted mean of the features of the segments a plan used. */
export function planFeatures(edl: Edl, segments: MediaSegment[]): SegmentFeatures | null {
const byId = new Map(segments.map((segment) => [segment.id, segment]));
const totals: Record<string, number> = {};
let weight = 0;
for (const clip of edl.tracks.video) {
const used = clip.segmentId
? [byId.get(clip.segmentId)].filter((s): s is MediaSegment => Boolean(s))
: segments.filter(
(s) => s.assetId === clip.assetId && s.startMs < clip.outMs && s.endMs > clip.inMs,
);
if (used.length === 0) continue;
const length = clipLengthMs(clip) / used.length;
for (const segment of used) {
const features: SegmentFeatures = { ...segment.scores };
if (features.speechHook === undefined && segment.transcriptExcerpt) {
features.speechHook = speechHookScore(segment.transcriptExcerpt);
}
for (const feature of TEASER_FEATURES) {
totals[feature] = (totals[feature] ?? 0) + (features[feature] ?? 0) * length;
}
weight += length;
}
}
if (weight <= 0) return null;
return Object.fromEntries(
TEASER_FEATURES.map((feature) => [feature, (totals[feature] ?? 0) / weight]),
) as SegmentFeatures;
}
/** Funnel rows with the plan each publication's render used, and those plans' segments. */
async function withPlans(
deps: Pick<MediaServiceDeps, "repos">,
spaceId: string,
rows: FunnelRow[],
): Promise<{ samples: Array<FunnelRow & { edl: Edl }>; segments: MediaSegment[] }> {
const { repos } = deps;
const plans = new Map<string, Edl>();
const planOfRender = new Map<string, string>();
const samples: Array<FunnelRow & { edl: Edl }> = [];
for (const row of rows) {
const publication = await repos.getPublication(spaceId, row.publicationId);
let planId = planOfRender.get(publication.renderId);
if (!planId) {
planId = (await repos.getRender(spaceId, publication.renderId)).planId;
planOfRender.set(publication.renderId, planId);
}
let edl = plans.get(planId);
if (!edl) {
edl = (await repos.getPlan(spaceId, planId)).edl;
plans.set(planId, edl);
}
samples.push({ ...row, edl });
}
const assetIds = [
...new Set(samples.flatMap((s) => s.edl.tracks.video.map((clip) => clip.assetId))),
];
return { samples, segments: await repos.listSegments(spaceId, assetIds) };
}
export interface SegmentFunnelRow {
segmentId: string;
assetId: string;
startMs: number;
endMs: number;
publications: number;
views: number;
clicks: number;
conversions: number;
revenueCents: number;
currency: string;
}
/**
* Results per teaser segment (spec 10.6): every publication counts fully for
* each segment its plan used. Rows are per currency so revenue never mixes.
*/
export async function segmentFunnel(
deps: Pick<MediaServiceDeps, "repos">,
actor: MediaActor,
projectId?: string,
): Promise<SegmentFunnelRow[]> {
const rows = await deps.repos.funnel(actor.spaceId, projectId);
const { samples, segments } = await withPlans(deps, actor.spaceId, rows);
const byId = new Map(segments.map((segment) => [segment.id, segment]));
const totals = new Map<string, SegmentFunnelRow>();
for (const sample of samples) {
const used = new Set<string>();
for (const clip of sample.edl.tracks.video) {
for (const segment of segments) {
const linked = clip.segmentId
? segment.id === clip.segmentId
: segment.assetId === clip.assetId &&
segment.startMs < clip.outMs &&
segment.endMs > clip.inMs;
if (linked) used.add(segment.id);
}
}
for (const segmentId of used) {
const segment = byId.get(segmentId)!;
const key = `${segmentId}\0${sample.currency}`;
const row = totals.get(key) ?? {
segmentId,
assetId: segment.assetId,
startMs: segment.startMs,
endMs: segment.endMs,
publications: 0,
views: 0,
clicks: 0,
conversions: 0,
revenueCents: 0,
currency: sample.currency,
};
row.publications += 1;
row.views += sample.views;
row.clicks += sample.clicks;
row.conversions += sample.conversions;
row.revenueCents += sample.revenueCents;
totals.set(key, row);
}
}
return [...totals.values()].sort(
(a, b) =>
b.conversions - a.conversions || b.views - a.views || a.segmentId.localeCompare(b.segmentId),
);
}
/** Refit and store the space's teaser weights; null while there are too few results. */
export async function refitTeaserModel(
deps: Pick<MediaServiceDeps, "repos">,
spaceId: string,
): Promise<TeaserModelRecord | null> {
const { repos } = deps;
const rows = (await repos.funnel(spaceId)).filter((row) => row.views > 0);
if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null;
const { samples, segments } = await withPlans(deps, spaceId, rows);
const usable = samples.flatMap((sample) => {
const features = planFeatures(sample.edl, segments);
return features
? [{ features, views: sample.views, conversions: Math.min(sample.conversions, sample.views) }]
: [];
});
const weights = fitTeaserWeights(usable, defaultTeaserWeights("teaser"));
if (!weights) return null;
return repos.putTeaserModel(spaceId, weights, usable.length);
}
/** Manual metric entry (spec 10.6); refits the teaser weights like an import. */
export async function recordMetric(
deps: Pick<MediaServiceDeps, "repos">,
actor: MediaActor,
metric: Omit<NewMediaMetric, "source">,
): Promise<MediaMetric> {
const recorded = await deps.repos.recordMetric(actor.spaceId, { ...metric, source: "manual" });
await refitTeaserModel(deps, actor.spaceId);
return recorded;
}
/** Forget the learned weights; plans use the defaults until the next refit. */
export async function resetTeaserModel(
deps: Pick<MediaServiceDeps, "repos">,
actor: MediaActor,
): Promise<void> {
await deps.repos.resetTeaserModel(actor.spaceId);
}
export async function importMetricsCsv(
deps: Pick<MediaServiceDeps, "repos">,
actor: MediaActor,
input: { mappingId: string; csv: string },
): Promise<{ imported: number; skipped: Array<{ row: number; reason: string }> }> {
if (input.csv.length > METRICS_CSV_MAX_CHARS) {
throw new InvalidMediaInputError("CSV file is too large");
}
const { spaceId } = actor;
const mapping = await deps.repos.getMetricMapping(spaceId, input.mappingId);
const parsed = parseMetricsCsv(input.csv, mapping);
const keys = await deps.repos.resolvePublicationKeys(
spaceId,
mapping.publicationKey,
parsed.rows.map((row) => row.publicationKey),
);
const skipped = [...parsed.skipped];
const metrics: NewMediaMetric[] = [];
for (const row of parsed.rows) {
const publicationId = keys.get(row.publicationKey.trim());
if (!publicationId) {
skipped.push({ row: row.row, reason: "publication not found" });
continue;
}
metrics.push({
publicationId,
observedAt: row.observedAt.toISOString(),
views: row.views,
clicks: row.clicks,
conversions: row.conversions,
revenueCents: row.revenueCents,
currency: row.currency,
source: "csv_import",
});
}
const imported = await deps.repos.recordMetrics(spaceId, metrics);
if (imported > 0) await refitTeaserModel(deps, spaceId);
return { imported, skipped: skipped.sort((a, b) => a.row - b.row) };
}

View File

@ -0,0 +1,466 @@
import { copyFile, link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { MediaJobHandlers } from "@rakazo/adapter-kit";
import { mediaPublishJob } from "@rakazo/adapter-kit";
import type { MediaPublication, MediaRender, PlatformProfile, Utm } from "@rakazo/contracts";
import { ATTACHMENT_MAX_BYTES, UtmSchema } from "@rakazo/contracts";
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import { buildTrackingUrl, normalizeHashtags, uploadChecklist } from "@rakazo/media";
import { resolveInside } from "@rakazo/media/node";
import type { MediaActor, MediaMarketingService, MediaServiceDeps } from "./media-context.js";
import type { MediaToolPaths } from "./media-folder-source-probe.js";
import { runMediaTool } from "./media-folder-source-probe.js";
import type { StoredRenderOutput } from "./media-marketing-files.js";
import {
renderOutputPath,
slugify,
storedRenderOutputs,
storeMediaArtifact,
} from "./media-marketing-files.js";
import { thumbnailVariantName } from "./media-marketing-thumbnails.js";
import type { ZipEntry } from "./media-zip.js";
import { writeStoreZip } from "./media-zip.js";
/*
* Publications: tracking links, scheduling, and the export bundle (spec 10.3–10.5).
*
* A scheduled publication is a `media.publish` background job with
* `availableAt` (Graphile scheduling, as routines use), keyed per publication
* so rescheduling replaces it and canceling removes it.
*
* The bundle lives in `<spaceId>/bundles/<publicationId>/` and is rebuilt from
* scratch on every call. Every file name is a constant from BUNDLE_FILES (or a
* thumbnail variant name), never user input, and the ZIP writer only accepts
* flat names, so extracting the archive cannot escape its folder. The ZIP
* becomes an artifact when it fits the attachment limit (ATTACHMENT_MAX_BYTES,
* the same limit chat artifacts have); larger bundles stay as `bundle.zip` in
* the folder for the media file route and bundleArtifactId stays null.
*/
export const BUNDLE_FILES = {
video: "video.mp4",
preview: "preview.mp4",
posterPng: "poster.png",
posterJpg: "poster.jpg",
srt: "subtitles.srt",
vtt: "subtitles.vtt",
caption: "caption.txt",
metadata: "metadata.json",
checklist: "UPLOAD-CHECKLIST.md",
disclosure: "AI-DISCLOSURE.txt",
zip: "bundle.zip",
} as const;
const MAX_THUMBNAIL_VARIANTS = 10;
export function bundleRelativeDir(spaceId: string, publicationId: string): string {
return path.join(spaceId, "bundles", publicationId);
}
async function publishEvent(deps: MediaServiceDeps, spaceId: string, pub: MediaPublication) {
await deps.publish(spaceId, {
type: "media.publication",
publicationId: pub.id,
status: pub.status,
});
}
const THUMBNAIL_VARIANT = /^thumb-[a-j]$/;
/**
* Fail closed: only a final render whose stored gate report passed may leave
* the host as a publication or bundle. Drafts carry no export guarantee.
*/
export function assertPublishable(
render: Pick<MediaRender, "status" | "quality" | "gateReport">,
): void {
if (render.status !== "done") {
throw new MediaStateConflictError("Only a finished render can be published");
}
if (render.quality !== "final") {
throw new MediaStateConflictError("Only a final render can be published");
}
if (!render.gateReport?.passed) {
throw new MediaStateConflictError("The render has not passed the platform gate");
}
}
/** "9x16"-style format of an output size. */
function aspectLabel(width: number, height: number): string {
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
const divisor = gcd(width, height) || 1;
return `${width / divisor}x${height / divisor}`;
}
export async function createPublication(
deps: MediaServiceDeps,
actor: MediaActor,
input: {
renderId: string;
destination: string;
targetUrl: string | null;
utm: Utm | null;
title: string | null;
caption: string | null;
tags: string[];
thumbnailVariant: string | null;
scheduledAt: string | null;
},
): Promise<MediaPublication> {
const { spaceId } = actor;
const render = await deps.repos.getRender(spaceId, input.renderId);
assertPublishable(render);
if (input.thumbnailVariant !== null && !THUMBNAIL_VARIANT.test(input.thumbnailVariant)) {
throw new InvalidMediaInputError("Unknown thumbnail variant");
}
const [plan, project] = await Promise.all([
deps.repos.getPlan(spaceId, render.planId),
deps.repos.getProjectRecord(spaceId, render.projectId),
]);
const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null;
if (scheduledAt && Number.isNaN(scheduledAt.getTime())) {
throw new InvalidMediaInputError("Invalid schedule time");
}
const utm = UtmSchema.parse(
input.utm ?? {
source: render.profileId,
medium: `video-${aspectLabel(plan.edl.output.width, plan.edl.output.height)}`,
campaign: slugify(project.title) || project.id.slice(0, 100),
content: (input.thumbnailVariant ?? render.id).slice(0, 100),
},
);
let trackingUrl: string | null = null;
if (input.targetUrl) {
try {
trackingUrl = buildTrackingUrl(input.targetUrl, utm);
} catch (error) {
throw new InvalidMediaInputError(error instanceof Error ? error.message : "Invalid target");
}
if (deps.shortener) {
try {
trackingUrl = await deps.shortener.shorten(trackingUrl);
} catch (error) {
getLogger().warn("media link shortener failed; keeping the full tracking link", {
shortener: deps.shortener.id,
reason: error instanceof Error ? error.message : String(error),
});
}
}
}
const publication = await deps.repos.createPublication(spaceId, actor.userId, {
renderId: render.id,
destination: input.destination.trim() || "bundle",
targetUrl: input.targetUrl,
trackingUrl,
utm,
title: input.title,
caption: input.caption,
tags: input.tags,
thumbnailVariant: input.thumbnailVariant,
scheduledAt,
});
if (scheduledAt) await deps.jobs.enqueue(mediaPublishJob(publication.id, scheduledAt));
await publishEvent(deps, spaceId, publication);
return publication;
}
export async function cancelPublication(
deps: MediaServiceDeps,
actor: MediaActor,
publicationId: string,
): Promise<MediaPublication> {
const publication = await deps.repos.cancelPublication(actor.spaceId, publicationId);
await deps.jobs.cancel(mediaPublishJob(publicationId).replaceKey as string);
await publishEvent(deps, actor.spaceId, publication);
return publication;
}
async function placeFile(source: string, target: string): Promise<void> {
try {
await link(source, target);
} catch {
await copyFile(source, target);
}
}
/**
* The opening of the final video as a preview clip, for profiles that ask for
* one (spec 8, paid funnel). Stream copy keeps the gated pixels and audio
* unchanged; container metadata is dropped like in every other output.
*/
export async function cutPreviewClip(
videoPath: string,
outPath: string,
durationMs: number,
tools: MediaToolPaths = {},
): Promise<void> {
await runMediaTool(tools.ffmpegPath ?? "ffmpeg", [
"-v",
"error",
"-y",
"-i",
videoPath,
"-t",
(durationMs / 1000).toFixed(3),
"-map",
"0:v:0",
"-map",
"0:a?",
"-c",
"copy",
"-map_metadata",
"-1",
"-map_chapters",
"-1",
"-fflags",
"+bitexact",
"-movflags",
"+faststart",
outPath,
]);
}
function disclosureText(alterations: string[]): string {
return [
`This video contains AI alterations: ${alterations.join(", ")}.`,
"Set the platform's label for AI-generated or altered content when you upload it.",
"",
].join("\n");
}
export async function buildBundle(
deps: MediaServiceDeps,
actor: MediaActor,
publicationId: string,
tools: MediaToolPaths = {},
): Promise<MediaPublication> {
const { spaceId } = actor;
const pub = await deps.repos.getPublication(spaceId, publicationId);
if (pub.status === "canceled" || pub.status === "published") {
throw new MediaStateConflictError(`Publication is already ${pub.status}`);
}
assertPublishable(await deps.repos.getRender(spaceId, pub.renderId));
const row = await deps.repos.getRenderRecord(spaceId, pub.renderId);
const [plan, profileSet] = await Promise.all([
deps.repos.getPlan(spaceId, row.planId),
deps.profiles(),
]);
const profile = profileSet.profiles.get(pub.profileId)?.profile;
if (!profile) throw new MediaStateConflictError(`Profile ${pub.profileId} is not available`);
const edl = plan.edl;
const outputs = storedRenderOutputs(row.outputs);
const video = outputs.find((output) => output.kind === "video");
if (!video) throw new MediaStateConflictError("Render has no video output");
const dir = await resolveInside(deps.mediaRoot, bundleRelativeDir(spaceId, pub.id));
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { recursive: true });
const entries: ZipEntry[] = [];
const add = async (name: string, source: string) => {
const target = path.join(dir, name);
await placeFile(source, target);
entries.push({ name, path: target });
};
const write = async (name: string, content: string) => {
const target = path.join(dir, name);
await writeFile(target, content, "utf8");
entries.push({ name, path: target });
};
const sourceOf = (output: StoredRenderOutput) => renderOutputPath(deps, spaceId, row.id, output);
const videoPath = await sourceOf(video);
await add(BUNDLE_FILES.video, videoPath);
if (profile.export.previewClipMs) {
const target = path.join(dir, BUNDLE_FILES.preview);
await cutPreviewClip(videoPath, target, profile.export.previewClipMs, tools);
entries.push({ name: BUNDLE_FILES.preview, path: target });
}
if (profile.export.poster) {
const variants = new Map(
Array.from({ length: MAX_THUMBNAIL_VARIANTS }, (_, i) => thumbnailVariantName(i)).map(
(name) => [
name,
outputs.find((o) => o.kind === "thumbnail" && o.fileName === `${name}.png`),
],
),
);
const chosen =
(pub.thumbnailVariant ? variants.get(pub.thumbnailVariant) : undefined) ??
outputs.find((o) => o.kind === "poster") ??
outputs.find((o) => o.kind === "thumbnail");
if (chosen) {
const jpeg = /\.jpe?g$/i.test(chosen.fileName);
await add(jpeg ? BUNDLE_FILES.posterJpg : BUNDLE_FILES.posterPng, await sourceOf(chosen));
}
for (const [name, output] of variants) {
if (output) await add(`${name}.png`, await sourceOf(output));
}
}
let hasSubtitles = false;
if (profile.export.subtitles) {
for (const [kind, name] of [
["subtitles_srt", BUNDLE_FILES.srt],
["subtitles_vtt", BUNDLE_FILES.vtt],
] as const) {
const output = outputs.find((o) => o.kind === kind);
if (!output) continue;
await add(name, await sourceOf(output));
hasSubtitles = true;
}
}
const hashtags = normalizeHashtags(pub.tags, profile.text.hashtagLimit);
if (profile.export.captionText) {
const parts = [pub.caption?.trim() ?? "", hashtags.join(" ")];
if (pub.trackingUrl && profile.text.linkPolicy === "allowed") parts.push(pub.trackingUrl);
await write(BUNDLE_FILES.caption, `${parts.filter(Boolean).join("\n\n")}\n`);
}
const aiAlterations = [...new Set(edl.metadata.aiAlterations)].sort();
const aiAltered = edl.metadata.aiAltered || aiAlterations.length > 0;
await write(
BUNDLE_FILES.metadata,
`${JSON.stringify(
{
publicationId: pub.id,
renderId: row.id,
profile: { id: profile.id, name: profile.name, kind: profile.kind },
title: pub.title,
caption: pub.caption,
tags: hashtags,
trackingUrl: pub.trackingUrl,
utm: pub.utm,
thumbnailVariant: pub.thumbnailVariant,
aiAltered,
aiAlterations,
ageRestricted: profile.disclosure.ageRestrictedFlag,
paidPartnershipLabel: profile.disclosure.paidPartnershipLabel,
},
null,
2,
)}\n`,
);
if (profile.export.uploadChecklist) {
await write(
BUNDLE_FILES.checklist,
uploadChecklist({
profile,
aiAltered,
hasCaptions:
hasSubtitles || edl.captions?.mode === "burned" || edl.captions?.mode === "both",
trackingUrl: pub.trackingUrl,
}),
);
}
if (aiAltered) {
await write(
BUNDLE_FILES.disclosure,
disclosureText(aiAlterations.length > 0 ? aiAlterations : ["unspecified"]),
);
}
const zipPath = path.join(dir, BUNDLE_FILES.zip);
const zipBytes = await writeStoreZip(zipPath, entries);
let artifactId: string | null = null;
if (zipBytes <= ATTACHMENT_MAX_BYTES) {
artifactId = await storeMediaArtifact(deps, actor, {
name: `bundle-${slugify(profile.id, 40) || "export"}.zip`,
mimeType: "application/zip",
bytes: new Uint8Array(await readFile(zipPath)),
});
}
const bundled = await deps.repos.markPublicationBundled(spaceId, pub.id, artifactId);
await publishEvent(deps, spaceId, bundled);
return bundled;
}
/** Tells the publication's author that a scheduled bundle is ready to upload by hand. */
export type MediaUploadReminder = (input: {
spaceId: string;
userId: string;
publication: MediaPublication;
profile: Pick<PlatformProfile, "id" | "name">;
}) => Promise<void>;
/**
* Worker handler for `media.publish`: build the bundle at the scheduled time,
* hand it to the configured publisher, and record the outcome. Paid-video
* profiles only ever get the bundle (spec 10.5). When nothing was published,
* `remind` (the composition root's push channel) tells the author to upload the
* finished bundle; clients also see the realtime publication event. A canceled
* or already published publication is left alone. Failures mark the
* publication failed and rethrow so the job queue retries; a retry rebuilds
* the bundle from scratch.
*/
export function createMediaPublishHandler(
deps: MediaServiceDeps,
marketing: Pick<MediaMarketingService, "bundle">,
options: { remind?: MediaUploadReminder } = {},
): MediaJobHandlers["media.publish"] {
return async ({ publicationId }) => {
const spaceId = await deps.repos.mediaSpaceOf("publication", publicationId);
if (!spaceId) return;
const record = await deps.repos.getPublicationRecord(spaceId, publicationId);
if (record.status === "canceled" || record.status === "published") return;
const actor = { spaceId, userId: record.createdBy };
let bundled: MediaPublication;
let profile: PlatformProfile;
let published = false;
try {
bundled = await marketing.bundle(actor, publicationId);
const found = (await deps.profiles()).profiles.get(bundled.profileId)?.profile;
if (!found) {
throw new MediaStateConflictError(`Profile ${bundled.profileId} is not available`);
}
profile = found;
if (profile.kind !== "paid_video") {
const result = await deps.publisher.publish({
bundleDir: await resolveInside(deps.mediaRoot, bundleRelativeDir(spaceId, publicationId)),
profile,
publicationId,
title: bundled.title,
caption: bundled.caption,
tags: bundled.tags,
});
if (result.status === "published") {
published = true;
await publishEvent(
deps,
spaceId,
await deps.repos.markPublicationPublished(
spaceId,
publicationId,
result.externalUrl,
deps.now(),
),
);
}
}
} catch (error) {
const current = await deps.repos.getPublication(spaceId, publicationId);
if (current.status === "canceled") return;
if (current.status !== "failed" && current.status !== "published") {
const failed = await deps.repos
.markPublicationFailed(spaceId, publicationId)
.catch(() => null);
if (failed) await publishEvent(deps, spaceId, failed);
}
throw error;
}
if (published || !options.remind) return;
try {
await options.remind({
spaceId,
userId: record.createdBy,
publication: bundled,
profile: { id: profile.id, name: profile.name },
});
} catch (error) {
// The bundle is ready and visible in the app; a lost push is logged, not retried.
getLogger().warn("media upload reminder failed", {
publicationId,
reason: error instanceof Error ? error.message : String(error),
});
}
};
}

View File

@ -0,0 +1,27 @@
import { stat } from "node:fs/promises";
import path from "node:path";
import type { MediaPublisher, MediaPublishRequest, MediaPublishResult } from "@rakazo/adapter-kit";
import { BUNDLE_FILES } from "./media-publish.js";
/*
* Default MediaPublisher: the export bundle is the publication (spec 10.5).
* It uploads nothing and reports "bundled" only after checking that the
* bundle folder really holds the video and the ZIP, so a missing bundle
* surfaces as an error instead of a silent success. Uploads to
* connected accounts run through the agent's connector tools, where the
* existing approval rules apply.
*/
export class BundleMediaPublisher implements MediaPublisher {
readonly id = "bundle";
async publish(request: MediaPublishRequest): Promise<MediaPublishResult> {
request.signal?.throwIfAborted();
for (const name of [BUNDLE_FILES.video, BUNDLE_FILES.zip]) {
const info = await stat(path.join(request.bundleDir, name)).catch(() => null);
if (!info?.isFile() || info.size === 0) {
throw new Error(`export bundle for ${request.publicationId} is missing ${name}`);
}
}
return { status: "bundled", externalUrl: null };
}
}

View File

@ -0,0 +1,108 @@
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { crc32 } from "node:zlib";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isSafeZipEntryName, writeStoreZip } from "./media-zip.js";
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "media-zip-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
/** Read a store-only archive through its central directory, checking every CRC. */
function readStoreZip(bytes: Buffer): Array<{ name: string; data: Buffer; mode: number }> {
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
expect(end).toBeGreaterThan(0);
const count = bytes.readUInt16LE(end + 10);
let at = bytes.readUInt32LE(end + 16);
const entries: Array<{ name: string; data: Buffer; mode: number }> = [];
for (let index = 0; index < count; index++) {
expect(bytes.readUInt32LE(at)).toBe(0x02014b50);
const crc = bytes.readUInt32LE(at + 16);
const size = bytes.readUInt32LE(at + 20);
const nameLength = bytes.readUInt16LE(at + 28);
const mode = bytes.readUInt32LE(at + 38) >>> 16;
const offset = bytes.readUInt32LE(at + 42);
const name = bytes.subarray(at + 46, at + 46 + nameLength).toString("ascii");
expect(bytes.readUInt32LE(offset)).toBe(0x04034b50);
expect(bytes.readUInt16LE(offset + 8)).toBe(0);
const localName = bytes.readUInt16LE(offset + 26);
const start = offset + 30 + localName + bytes.readUInt16LE(offset + 28);
const data = bytes.subarray(start, start + size);
expect(crc32(data) >>> 0).toBe(crc);
entries.push({ name, data, mode });
at += 46 + nameLength;
}
return entries;
}
describe("writeStoreZip", () => {
it("stores flat entries byte for byte and deterministically", async () => {
const video = path.join(dir, "v");
const notes = path.join(dir, "n");
await writeFile(video, Buffer.alloc(200_000, 7));
await writeFile(notes, "# Upload checklist\n");
const entries = [
{ name: "video.mp4", path: video },
{ name: "UPLOAD-CHECKLIST.md", path: notes },
];
const first = path.join(dir, "a.zip");
const size = await writeStoreZip(first, entries);
expect((await stat(first)).size).toBe(size);
const read = readStoreZip(await readFile(first));
expect(read.map((entry) => entry.name)).toEqual(["video.mp4", "UPLOAD-CHECKLIST.md"]);
expect(read[0]?.data.equals(Buffer.alloc(200_000, 7))).toBe(true);
expect(read[1]?.data.toString()).toBe("# Upload checklist\n");
expect(read[0]?.mode).toBe(0o100644);
const second = path.join(dir, "b.zip");
await writeStoreZip(second, entries);
expect((await readFile(second)).equals(await readFile(first))).toBe(true);
});
it("refuses names that could escape the extraction folder", async () => {
const file = path.join(dir, "x");
await writeFile(file, "x");
for (const name of [
"../evil.sh",
"..",
"/etc/passwd",
"a/b.txt",
"a\\b.txt",
".hidden",
"C:evil",
"name\0.txt",
"",
`${"a".repeat(101)}`,
]) {
expect(isSafeZipEntryName(name), name).toBe(false);
await expect(
writeStoreZip(path.join(dir, "out.zip"), [{ name, path: file }]),
).rejects.toThrow(/unsafe zip entry/);
}
expect(isSafeZipEntryName("thumb-a.png")).toBe(true);
expect(isSafeZipEntryName("UPLOAD-CHECKLIST.md")).toBe(true);
});
it("rejects duplicate names and leaves no partial archive behind", async () => {
const file = path.join(dir, "x");
await writeFile(file, "x");
const out = path.join(dir, "dup.zip");
await expect(
writeStoreZip(out, [
{ name: "caption.txt", path: file },
{ name: "caption.txt", path: file },
]),
).rejects.toThrow(/duplicate/);
await expect(
writeStoreZip(out, [{ name: "missing.txt", path: path.join(dir, "missing") }]),
).rejects.toThrow();
await expect(stat(out)).rejects.toThrow();
});
});

View File

@ -0,0 +1,143 @@
import { createReadStream } from "node:fs";
import { open, rm, stat } from "node:fs/promises";
import { crc32 } from "node:zlib";
/*
* Minimal store-only (no compression) ZIP writer for export bundles. Videos
* are already compressed, so storing keeps it fast and dependency-free.
*
* Entry names are flat file names from the bundle's fixed allowlist: no
* directories, no "..", no absolute paths, so extracting can never write
* outside the target folder (zip slip). Timestamps are a fixed 1980-01-01 so
* the same inputs give the same archive. No ZIP64: an entry or archive past
* 4 GiB is rejected with ZipTooLargeError.
*/
const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$/;
const MAX_ENTRY_NAME = 100;
const MAX_ZIP32 = 0xffff_fffe;
const MAX_ENTRIES = 0xffff;
const DOS_TIME = 0;
/** 1980-01-01 in DOS date format (year offset 0, month 1, day 1). */
const DOS_DATE = (1 << 5) | 1;
export class ZipTooLargeError extends Error {
constructor() {
super("bundle is larger than a ZIP archive without ZIP64 can hold (4 GiB)");
this.name = "ZipTooLargeError";
}
}
export interface ZipEntry {
/** Flat file name inside the archive. */
name: string;
/** Absolute path of the file to store. */
path: string;
}
/** Whether a name is a safe flat archive entry (letters, digits, "-", "_", inner dots). */
export function isSafeZipEntryName(name: string): boolean {
return name.length <= MAX_ENTRY_NAME && ENTRY_NAME.test(name);
}
async function fileCrc(file: string, signal?: AbortSignal): Promise<number> {
let crc = 0;
for await (const chunk of createReadStream(file, { signal })) {
crc = crc32(chunk as Buffer, crc);
}
return crc >>> 0;
}
/** Write a store-only ZIP of `entries` to `outPath`, replacing it. Returns the archive size. */
export async function writeStoreZip(
outPath: string,
entries: ZipEntry[],
signal?: AbortSignal,
): Promise<number> {
if (entries.length > MAX_ENTRIES) throw new ZipTooLargeError();
const seen = new Set<string>();
for (const entry of entries) {
if (!isSafeZipEntryName(entry.name)) throw new Error(`unsafe zip entry name: ${entry.name}`);
if (seen.has(entry.name)) throw new Error(`duplicate zip entry: ${entry.name}`);
seen.add(entry.name);
}
const handle = await open(outPath, "w", 0o600);
let offset = 0;
const write = async (bytes: Uint8Array) => {
signal?.throwIfAborted();
await handle.write(bytes);
offset += bytes.byteLength;
};
const central: Buffer[] = [];
try {
for (const entry of entries) {
const size = (await stat(entry.path)).size;
const name = Buffer.from(entry.name, "ascii");
if (size > MAX_ZIP32 || offset + 30 + name.length + size > MAX_ZIP32) {
throw new ZipTooLargeError();
}
const crc = await fileCrc(entry.path, signal);
const headerOffset = offset;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(10, 4); // version needed: 1.0, stored
local.writeUInt16LE(0, 6); // flags
local.writeUInt16LE(0, 8); // method: store
local.writeUInt16LE(DOS_TIME, 10);
local.writeUInt16LE(DOS_DATE, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(size, 18);
local.writeUInt32LE(size, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
await write(local);
await write(name);
let copied = 0;
for await (const chunk of createReadStream(entry.path, { signal })) {
await write(chunk as Buffer);
copied += (chunk as Buffer).byteLength;
}
if (copied !== size) throw new Error(`${entry.name} changed while archiving`);
const record = Buffer.alloc(46);
record.writeUInt32LE(0x02014b50, 0);
record.writeUInt16LE((3 << 8) | 20, 4); // made by: Unix, 2.0
record.writeUInt16LE(10, 6);
record.writeUInt16LE(0, 8);
record.writeUInt16LE(0, 10);
record.writeUInt16LE(DOS_TIME, 12);
record.writeUInt16LE(DOS_DATE, 14);
record.writeUInt32LE(crc, 16);
record.writeUInt32LE(size, 20);
record.writeUInt32LE(size, 24);
record.writeUInt16LE(name.length, 28);
record.writeUInt16LE(0, 30); // extra
record.writeUInt16LE(0, 32); // comment
record.writeUInt16LE(0, 34); // disk
record.writeUInt16LE(0, 36); // internal attributes
record.writeUInt32LE((0o100644 << 16) >>> 0, 38); // regular file, rw-r--r--
record.writeUInt32LE(headerOffset, 42);
central.push(record, name);
}
const directory = Buffer.concat(central);
const directoryOffset = offset;
if (directoryOffset + directory.length + 22 > MAX_ZIP32) throw new ZipTooLargeError();
await write(directory);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(directory.length, 12);
end.writeUInt32LE(directoryOffset, 16);
end.writeUInt16LE(0, 20);
await write(end);
await handle.close();
return offset;
} catch (error) {
await handle.close().catch(() => undefined);
await rm(outPath, { force: true });
throw error;
}
}