merge: W2 worktree-agent-a78be97f0347925a3
This commit is contained in:
commit
f29e6379cb
359
packages/adapters/src/media-brand.ts
Normal file
359
packages/adapters/src/media-brand.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
178
packages/adapters/src/media-marketing-copy.ts
Normal file
178
packages/adapters/src/media-marketing-copy.ts
Normal 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],
|
||||
});
|
||||
}
|
||||
134
packages/adapters/src/media-marketing-files.ts
Normal file
134
packages/adapters/src/media-marketing-files.ts
Normal 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, "");
|
||||
}
|
||||
578
packages/adapters/src/media-marketing-plan.ts
Normal file
578
packages/adapters/src/media-marketing-plan.ts
Normal 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 };
|
||||
}
|
||||
365
packages/adapters/src/media-marketing-thumbnails.ts
Normal file
365
packages/adapters/src/media-marketing-thumbnails.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
950
packages/adapters/src/media-marketing.postgres.test.ts
Normal file
950
packages/adapters/src/media-marketing.postgres.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
350
packages/adapters/src/media-marketing.test.ts
Normal file
350
packages/adapters/src/media-marketing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
packages/adapters/src/media-marketing.ts
Normal file
52
packages/adapters/src/media-marketing.ts
Normal 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) };
|
||||
}
|
||||
234
packages/adapters/src/media-metrics.ts
Normal file
234
packages/adapters/src/media-metrics.ts
Normal 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) };
|
||||
}
|
||||
466
packages/adapters/src/media-publish.ts
Normal file
466
packages/adapters/src/media-publish.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
27
packages/adapters/src/media-publisher-bundle.ts
Normal file
27
packages/adapters/src/media-publisher-bundle.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
108
packages/adapters/src/media-zip.test.ts
Normal file
108
packages/adapters/src/media-zip.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
143
packages/adapters/src/media-zip.ts
Normal file
143
packages/adapters/src/media-zip.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user