From 872fdd21943340fb95ca272a52f22bab9215cb1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:36:53 +0000 Subject: [PATCH] feat(media): marketing service, brand kits, intro variants and bundle publisher --- packages/adapters/src/media-brand.ts | 359 ++++++++++++++++++ .../adapters/src/media-marketing-files.ts | 14 +- packages/adapters/src/media-marketing-plan.ts | 110 ++++-- .../src/media-marketing-thumbnails.ts | 53 ++- packages/adapters/src/media-marketing.ts | 52 +++ packages/adapters/src/media-metrics.ts | 34 +- packages/adapters/src/media-publish.ts | 167 ++++++-- .../adapters/src/media-publisher-bundle.ts | 27 ++ 8 files changed, 729 insertions(+), 87 deletions(-) create mode 100644 packages/adapters/src/media-brand.ts create mode 100644 packages/adapters/src/media-marketing.ts create mode 100644 packages/adapters/src/media-publisher-bundle.ts diff --git a/packages/adapters/src/media-brand.ts b/packages/adapters/src/media-brand.ts new file mode 100644 index 0000000..300a962 --- /dev/null +++ b/packages/adapters/src/media-brand.ts @@ -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, + 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, + actor: MediaActor, + input: { id?: string; kit: unknown }, +): Promise { + 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( + ( + 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 { + 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 { + 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 }); + } +} diff --git a/packages/adapters/src/media-marketing-files.ts b/packages/adapters/src/media-marketing-files.ts index 534c9c2..78792eb 100644 --- a/packages/adapters/src/media-marketing-files.ts +++ b/packages/adapters/src/media-marketing-files.ts @@ -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( diff --git a/packages/adapters/src/media-marketing-plan.ts b/packages/adapters/src/media-marketing-plan.ts index ff9377e..496bc89 100644 --- a/packages/adapters/src/media-marketing-plan.ts +++ b/packages/adapters/src/media-marketing-plan.ts @@ -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 { ); } -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, protectedIds: Set, 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( diff --git a/packages/adapters/src/media-marketing-thumbnails.ts b/packages/adapters/src/media-marketing-thumbnails.ts index 213e9dd..ee166f0 100644 --- a/packages/adapters/src/media-marketing-thumbnails.ts +++ b/packages/adapters/src/media-marketing-thumbnails.ts @@ -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 { 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, { diff --git a/packages/adapters/src/media-marketing.ts b/packages/adapters/src/media-marketing.ts new file mode 100644 index 0000000..cccebea --- /dev/null +++ b/packages/adapters/src/media-marketing.ts @@ -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, + options: { remind?: MediaUploadReminder } = {}, +): Pick { + return { "media.publish": createMediaPublishHandler(deps, marketing, options) }; +} diff --git a/packages/adapters/src/media-metrics.ts b/packages/adapters/src/media-metrics.ts index fec5389..1e6ed08 100644 --- a/packages/adapters/src/media-metrics.ts +++ b/packages/adapters/src/media-metrics.ts @@ -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(); const samples: Array<{ edl: Edl; views: number; conversions: number }> = []; + const planOfRender = new Map(); 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, + actor: MediaActor, + metric: Omit, +): Promise { + 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, + actor: MediaActor, +): Promise { + await deps.repos.resetTeaserModel(actor.spaceId); +} + export async function importMetricsCsv( deps: Pick, actor: MediaActor, diff --git a/packages/adapters/src/media-publish.ts b/packages/adapters/src/media-publish.ts index 76dafac..008a75d 100644 --- a/packages/adapters/src/media-publish.ts +++ b/packages/adapters/src/media-publish.ts @@ -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, +): 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 { 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 { } } +/** + * 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 { + 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 { 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; +}) => Promise; + /** * 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, + 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), + }); + } }; } diff --git a/packages/adapters/src/media-publisher-bundle.ts b/packages/adapters/src/media-publisher-bundle.ts new file mode 100644 index 0000000..dabf55a --- /dev/null +++ b/packages/adapters/src/media-publisher-bundle.ts @@ -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 { + 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 }; + } +}