feat(media): marketing service, brand kits, intro variants and bundle publisher

This commit is contained in:
Claude 2026-10-03 18:36:53 +00:00
parent 3c13961948
commit 872fdd2194
No known key found for this signature in database
8 changed files with 729 additions and 87 deletions

View File

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

View File

@ -17,8 +17,18 @@ import type { MediaActor, MediaServiceDeps } from "./media-context.js";
/** 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): AdapterContext {
return { operationId, traceId: operationId, spaceId: actor.spaceId, userId: actor.userId };
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(

View File

@ -14,7 +14,7 @@ import type {
} from "@rakazo/contracts";
import { TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts";
import type { MediaProjectRecord, MediaRepos } from "@rakazo/db";
import { InvalidMediaInputError } from "@rakazo/db";
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { EdlContext, ScoredSegmentInput, SegmentFeatures } from "@rakazo/media";
import {
@ -115,9 +115,7 @@ export async function loadPlanContext(
assetId: mark.assetId,
personId: mark.personId,
})),
profiles: new Map(
[...profileSet.profiles.entries()].map(([id, entry]) => [id, entry.profile]),
),
profiles: new Map([...profileSet.profiles.entries()].map(([id, entry]) => [id, entry.profile])),
};
}
@ -153,7 +151,10 @@ export function buildEdlContext(
),
artifactIds: new Set(kitArtifactIds(ctx.kit)),
music: new Map(
(ctx.kit?.musicBeds ?? []).map((bed) => [bed.artifactId, { commercialUse: bed.commercialUse }]),
(ctx.kit?.musicBeds ?? []).map((bed) => [
bed.artifactId,
{ commercialUse: bed.commercialUse },
]),
),
markIds: ctx.markIds,
personIds: new Set(ctx.people.map((person) => person.id)),
@ -221,7 +222,9 @@ async function classifierFrames(
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"));
data = JSON.parse(
await readFile(await resolveInside(deps.mediaRoot, row.dataPath), "utf8"),
);
} catch {
continue;
}
@ -232,7 +235,9 @@ async function classifierFrames(
assetId,
frames.filter(
(frame): frame is ClassifierFrames[number] =>
typeof frame?.tMs === "number" && typeof frame.scores === "object" && frame.scores !== null,
typeof frame?.tMs === "number" &&
typeof frame.scores === "object" &&
frame.scores !== null,
),
);
}
@ -263,18 +268,34 @@ function protectedPersonIds(ctx: PlanContext): Set<string> {
);
}
function showsProtectedFace(
ctx: PlanContext,
/**
* 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 {
if (protectedIds.size === 0) return false;
return ctx.faceTracks.some(
const tracks = ctx.faceTracks.filter(
(track) =>
track.assetId === segment.assetId &&
track.startMs < segment.endMs &&
track.endMs > segment.startMs &&
(track.personId === null || protectedIds.has(track.personId)),
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))),
);
}
@ -306,7 +327,7 @@ async function judgeCandidates(
const eligible = top.filter(
({ segment }) =>
classifierPasses(classifier.get(segment.assetId), segment, profile) &&
!showsProtectedFace(ctx, protectedIds, segment),
!showsProtectedDetail(ctx, protectedIds, segment),
);
if (eligible.length < 2) return null;
@ -325,9 +346,7 @@ async function judgeCandidates(
try {
await deps.renderer.extractFrame({
path: await resolveInside(deps.mediaRoot, source),
tMs: isVideo
? Math.round((candidate.segment.startMs + candidate.segment.endMs) / 2)
: 0,
tMs: isVideo ? Math.round((candidate.segment.startMs + candidate.segment.endMs) / 2) : 0,
outPath,
width: JUDGE_FRAME_WIDTH,
});
@ -404,16 +423,36 @@ function rationaleFor(input: {
}
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.");
if (ctx.segments.some((segment) => segment.paidOnly)) lines.push("Paid-only segments excluded.");
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("; ")}`);
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`);
@ -434,8 +473,7 @@ export async function autoPlan(
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 weights = learned && model ? model.weights : defaultTeaserWeights(ctx.project.goal);
const base = scoredInputs(ctx);
const assets = new Map(
[...ctx.assets.values()].map((asset) => [
@ -464,17 +502,25 @@ export async function autoPlan(
? await judgeCandidates(deps, actor, ctx, profile, base, weights)
: null;
const segments = judgement ? applyRanking(base, judgement.order) : base;
const edl = autoPlanEdl({
goal: ctx.project.goal,
profile,
brandKit: ctx.kit,
segments,
weights,
assets,
people,
marks: ctx.marks,
ctaText: typeof endCardCta === "string" ? endCardCta : null,
});
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(

View File

@ -1,6 +1,12 @@
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 type {
BrandKit,
ContentCategory,
Edl,
MediaRenderOutput,
PlatformProfile,
} from "@rakazo/contracts";
import { MediaStateConflictError } from "@rakazo/db";
import {
clipLengthMs,
@ -21,8 +27,8 @@ import {
materializeArtifact,
publicRenderOutput,
renderOutputPath,
storeMediaArtifact,
storedRenderOutputs,
storeMediaArtifact,
} from "./media-marketing-files.js";
/*
@ -197,6 +203,9 @@ export async function createThumbnails(
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);
@ -238,8 +247,16 @@ export async function createThumbnails(
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 });
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 &&
@ -307,21 +324,19 @@ export async function createThumbnails(
} else {
await rename(frame, out);
}
if (deps.inference) {
const content = await deps.inference.classifyContent({
path: out,
sampleFps: 1,
extraTimesMs: [0],
});
let rejected = exceedsThresholds(content.frames, profile);
if (!rejected && protectsFaces) {
const faces = await deps.inference.detectFaces({ path: out, sampleFps: 1, timesMs: [0] });
rejected = faces.frames.some((f) => f.faces.length > 0);
}
if (rejected) {
await rm(out, { force: true });
continue;
}
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, {

View File

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

View File

@ -1,4 +1,4 @@
import type { Edl, MediaSegment } from "@rakazo/contracts";
import type { Edl, 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";
@ -67,13 +67,18 @@ export async function refitTeaserModel(
if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null;
const plans = new Map<string, Edl>();
const samples: Array<{ edl: Edl; views: number; conversions: number }> = [];
const planOfRender = new Map<string, string>();
for (const row of rows) {
const publication = await repos.getPublication(spaceId, row.publicationId);
const render = await repos.getRender(spaceId, publication.renderId);
let edl = plans.get(render.planId);
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, render.planId)).edl;
plans.set(render.planId, edl);
edl = (await repos.getPlan(spaceId, planId)).edl;
plans.set(planId, edl);
}
samples.push({ edl, views: row.views, conversions: row.conversions });
}
@ -92,6 +97,25 @@ export async function refitTeaserModel(
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,

View File

@ -2,13 +2,15 @@ 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, Utm } from "@rakazo/contracts";
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,
@ -38,6 +40,7 @@ import { writeStoreZip } from "./media-zip.js";
export const BUNDLE_FILES = {
video: "video.mp4",
preview: "preview.mp4",
posterPng: "poster.png",
posterJpg: "poster.jpg",
srt: "subtitles.srt",
@ -63,6 +66,26 @@ async function publishEvent(deps: MediaServiceDeps, spaceId: string, pub: MediaP
});
}
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));
@ -87,8 +110,9 @@ export async function createPublication(
): Promise<MediaPublication> {
const { spaceId } = actor;
const render = await deps.repos.getRender(spaceId, input.renderId);
if (render.status !== "done") {
throw new MediaStateConflictError("Only a finished render can be published");
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),
@ -160,6 +184,43 @@ async function placeFile(source: string, target: string): Promise<void> {
}
}
/**
* 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(", ")}.`,
@ -172,12 +233,14 @@ 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),
@ -206,12 +269,21 @@ export async function buildBundle(
};
const sourceOf = (output: StoredRenderOutput) => renderOutputPath(deps, spaceId, row.id, output);
await add(BUNDLE_FILES.video, await sourceOf(video));
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`)],
(name) => [
name,
outputs.find((o) => o.kind === "thumbnail" && o.fileName === `${name}.png`),
],
),
);
const chosen =
@ -302,18 +374,28 @@ export async function buildBundle(
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. A canceled or
* already published publication is left alone. Failures mark the publication
* failed and rethrow so the job queue retries; a retry rebuilds from scratch.
*
* No notification is sent here: MediaServiceDeps has no notification provider,
* so clients learn about the bundle through the realtime publication event.
* 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);
@ -321,26 +403,38 @@ export function createMediaPublishHandler(
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 {
const bundled = await marketing.bundle(actor, publicationId);
const profile = (await deps.profiles()).profiles.get(bundled.profileId)?.profile;
if (!profile) throw new MediaStateConflictError(`Profile ${bundled.profileId} is not available`);
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") {
const published = await deps.repos.markPublicationPublished(
spaceId,
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,
result.externalUrl,
deps.now(),
);
await publishEvent(deps, spaceId, published);
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);
@ -353,5 +447,20 @@ export function createMediaPublishHandler(
}
throw error;
}
if (published || !options.remind) return;
try {
await options.remind({
spaceId,
userId: record.createdBy,
publication: bundled,
profile: { id: profile.id, name: profile.name },
});
} catch (error) {
// The bundle is ready and visible in the app; a lost push is logged, not retried.
getLogger().warn("media upload reminder failed", {
publicationId,
reason: error instanceof Error ? error.message : String(error),
});
}
};
}

View File

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