From 3f9a601dba3d84af1965fe785217ff01caed48a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:24:53 +0000 Subject: [PATCH 1/5] wip(media): w2-marketing partial work (handover, unverified) Stopped mid-implementation for the handover to the project thread. Not typechecked or tested; do not merge as is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj --- packages/adapters/src/media-marketing-copy.ts | 164 ++++++ .../adapters/src/media-marketing-files.ts | 124 ++++ packages/adapters/src/media-marketing-plan.ts | 532 ++++++++++++++++++ .../src/media-marketing-thumbnails.ts | 350 ++++++++++++ packages/adapters/src/media-metrics.ts | 133 +++++ packages/adapters/src/media-publish.ts | 357 ++++++++++++ packages/adapters/src/media-zip.ts | 143 +++++ 7 files changed, 1803 insertions(+) create mode 100644 packages/adapters/src/media-marketing-copy.ts create mode 100644 packages/adapters/src/media-marketing-files.ts create mode 100644 packages/adapters/src/media-marketing-plan.ts create mode 100644 packages/adapters/src/media-marketing-thumbnails.ts create mode 100644 packages/adapters/src/media-metrics.ts create mode 100644 packages/adapters/src/media-publish.ts create mode 100644 packages/adapters/src/media-zip.ts diff --git a/packages/adapters/src/media-marketing-copy.ts b/packages/adapters/src/media-marketing-copy.ts new file mode 100644 index 0000000..f8ed067 --- /dev/null +++ b/packages/adapters/src/media-marketing-copy.ts @@ -0,0 +1,164 @@ +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; + +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 { + 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) + : []; + return MarketingCopySchema.parse({ + profileId: profile.id, + language, + title, + hook: truncateText(draft.hook, 200), + caption, + hashtags, + ...(draft.description ? { description: truncateText(draft.description, 5000) } : {}), + ...(chapterList.length > 0 ? { chapters: chapterList } : {}), + ...(draft.altText?.trim() ? { altText: truncateText(draft.altText, 500) } : {}), + issues: checkCopy(profile, { title, caption, tags: hashtags }), + }); +} diff --git a/packages/adapters/src/media-marketing-files.ts b/packages/adapters/src/media-marketing-files.ts new file mode 100644 index 0000000..534c9c2 --- /dev/null +++ b/packages/adapters/src/media-marketing-files.ts @@ -0,0 +1,124 @@ +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): AdapterContext { + return { operationId, traceId: operationId, spaceId: actor.spaceId, userId: actor.userId }; +} + +export async function storeMediaArtifact( + deps: Pick, + actor: MediaActor, + input: { name: string; mimeType: string; bytes: Uint8Array }, +): Promise { + 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, + actor: MediaActor, + artifactId: string, + dir: string, +): Promise { + 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 + * `/renders//` when the renderer stored none. + */ +export function renderOutputPath( + deps: Pick, + spaceId: string, + renderId: string, + output: StoredRenderOutput, +): Promise { + 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, ""); +} diff --git a/packages/adapters/src/media-marketing-plan.ts b/packages/adapters/src/media-marketing-plan.ts new file mode 100644 index 0000000..ff9377e --- /dev/null +++ b/packages/adapters/src/media-marketing-plan.ts @@ -0,0 +1,532 @@ +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 } 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 = 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>[number]; +type FaceTrackRow = Awaited>[number]; +type PersonRow = Awaited>[number]; +type ClassifierFrames = Array<{ tMs: number; scores: Partial> }>; + +export interface PlanContext { + project: MediaProjectRecord; + kit: BrandKit | null; + assets: Map; + segments: MediaSegment[]; + faceTracks: FaceTrackRow[]; + /** Every person of the space; plans may reference any of them. */ + people: PersonRow[]; + markIds: Set; + marks: Array<{ id: string; treatment: string; assetId: string | null; personId: string | null }>; + profiles: Map; +} + +export async function loadPlanContext( + deps: MediaServiceDeps, + actor: MediaActor, + projectId: string, +): Promise { + 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, + 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, +): Promise> { + const analyses = await deps.repos.latestAnalysesByKind(spaceId, "classifier"); + const out = new Map(); + 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 { + return new Set( + ctx.people.filter((person) => person.anonymizeByDefault !== "none").map((p) => p.id), + ); +} + +function showsProtectedFace( + ctx: PlanContext, + protectedIds: Set, + segment: { assetId: string; startMs: number; endMs: number }, +): boolean { + if (protectedIds.size === 0) return false; + return ctx.faceTracks.some( + (track) => + track.assetId === segment.assetId && + track.startMs < segment.endMs && + track.endMs > segment.startMs && + (track.personId === null || protectedIds.has(track.personId)), + ); +} + +interface Judgement { + order: string[]; + reasons: Map; +} + +async function judgeCandidates( + deps: MediaServiceDeps, + actor: MediaActor, + ctx: PlanContext, + profile: PlatformProfile, + segments: ScoredSegmentInput[], + weights: TeaserWeights, +): Promise { + 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) && + !showsProtectedFace(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(); + 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."); + if (ctx.segments.some((segment) => segment.paidOnly)) 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); +} + +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 { + 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; + const edl = autoPlanEdl({ + goal: ctx.project.goal, + profile, + brandKit: ctx.kit, + segments, + weights, + assets, + people, + marks: ctx.marks, + ctaText: typeof endCardCta === "string" ? endCardCta : null, + }); + 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 }; +} diff --git a/packages/adapters/src/media-marketing-thumbnails.ts b/packages/adapters/src/media-marketing-thumbnails.ts new file mode 100644 index 0000000..213e9dd --- /dev/null +++ b/packages/adapters/src/media-marketing-thumbnails.ts @@ -0,0 +1,350 @@ +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, + storeMediaArtifact, + storedRenderOutputs, +} 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(); + 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 { + 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> }>, + 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 { + const { spaceId } = actor; + 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); + } + 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 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 }); + } +} diff --git a/packages/adapters/src/media-metrics.ts b/packages/adapters/src/media-metrics.ts new file mode 100644 index 0000000..fec5389 --- /dev/null +++ b/packages/adapters/src/media-metrics.ts @@ -0,0 +1,133 @@ +import type { Edl, 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 = {}; + 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; +} + +/** Refit and store the space's teaser weights; null while there are too few results. */ +export async function refitTeaserModel( + deps: Pick, + spaceId: string, +): Promise { + const { repos } = deps; + const rows = (await repos.funnel(spaceId)).filter((row) => row.views > 0); + if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null; + const plans = new Map(); + const samples: Array<{ edl: Edl; views: number; conversions: number }> = []; + 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); + if (!edl) { + edl = (await repos.getPlan(spaceId, render.planId)).edl; + plans.set(render.planId, edl); + } + samples.push({ edl, views: row.views, conversions: row.conversions }); + } + const assetIds = [ + ...new Set(samples.flatMap((s) => s.edl.tracks.video.map((clip) => clip.assetId))), + ]; + const segments = await repos.listSegments(spaceId, assetIds); + 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); +} + +export async function importMetricsCsv( + deps: Pick, + 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) }; +} diff --git a/packages/adapters/src/media-publish.ts b/packages/adapters/src/media-publish.ts new file mode 100644 index 0000000..76dafac --- /dev/null +++ b/packages/adapters/src/media-publish.ts @@ -0,0 +1,357 @@ +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 { 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 { 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 `/bundles//` 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", + 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, + }); +} + +/** "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 { + 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"); + } + 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 { + 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 { + try { + await link(source, target); + } catch { + await copyFile(source, target); + } +} + +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, +): 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}`); + } + 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); + + await add(BUNDLE_FILES.video, await sourceOf(video)); + + 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; +} + +/** + * 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. + */ +export function createMediaPublishHandler( + deps: MediaServiceDeps, + marketing: Pick, +): 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 }; + 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, + publicationId, + result.externalUrl, + deps.now(), + ); + await publishEvent(deps, spaceId, published); + } + } 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; + } + }; +} diff --git a/packages/adapters/src/media-zip.ts b/packages/adapters/src/media-zip.ts new file mode 100644 index 0000000..6ac9b82 --- /dev/null +++ b/packages/adapters/src/media-zip.ts @@ -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 { + 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 { + if (entries.length > MAX_ENTRIES) throw new ZipTooLargeError(); + const seen = new Set(); + 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; + } +} From 872fdd21943340fb95ca272a52f22bab9215cb1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:36:53 +0000 Subject: [PATCH 2/5] 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 }; + } +} From 03e4961c9f2b5f6823a9bba1f794cac8d932d1d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:43:37 +0000 Subject: [PATCH 3/5] test(media): cover marketing plans, brand kits, bundles, publishing and learning --- packages/adapters/src/media-marketing-copy.ts | 22 +- .../src/media-marketing.postgres.test.ts | 941 ++++++++++++++++++ packages/adapters/src/media-marketing.test.ts | 350 +++++++ packages/adapters/src/media-zip.test.ts | 108 ++ 4 files changed, 1417 insertions(+), 4 deletions(-) create mode 100644 packages/adapters/src/media-marketing.postgres.test.ts create mode 100644 packages/adapters/src/media-marketing.test.ts create mode 100644 packages/adapters/src/media-zip.test.ts diff --git a/packages/adapters/src/media-marketing-copy.ts b/packages/adapters/src/media-marketing-copy.ts index f8ed067..4c43890 100644 --- a/packages/adapters/src/media-marketing-copy.ts +++ b/packages/adapters/src/media-marketing-copy.ts @@ -149,16 +149,30 @@ export async function generateCopy( .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: truncateText(draft.hook, 200), + hook, caption, hashtags, - ...(draft.description ? { description: truncateText(draft.description, 5000) } : {}), + ...(description ? { description } : {}), ...(chapterList.length > 0 ? { chapters: chapterList } : {}), - ...(draft.altText?.trim() ? { altText: truncateText(draft.altText, 500) } : {}), - issues: checkCopy(profile, { title, caption, tags: hashtags }), + ...(altText ? { altText } : {}), + issues: [...checkCopy(profile, { title, caption, tags: hashtags }), ...extraIssues], }); } diff --git a/packages/adapters/src/media-marketing.postgres.test.ts b/packages/adapters/src/media-marketing.postgres.test.ts new file mode 100644 index 0000000..892847c --- /dev/null +++ b/packages/adapters/src/media-marketing.postgres.test.ts @@ -0,0 +1,941 @@ +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 } 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 { + await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args]); +} + +function profile(overrides: Record): 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 }; + 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[0]) => unknown) | null = null; + const modelCalls: Array[0]> = []; + let deps: MediaServiceDeps; + + async function newActor(): Promise { + 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, + }); + await resetTeaserModel(deps, actor); + expect(await repos.getTeaserModel(actor.spaceId)).toBeNull(); + }); + }); +}); diff --git a/packages/adapters/src/media-marketing.test.ts b/packages/adapters/src/media-marketing.test.ts new file mode 100644 index 0000000..9820519 --- /dev/null +++ b/packages/adapters/src/media-marketing.test.ts @@ -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, overrides: Record = {}) { + 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 { + 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 = {}) { + 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[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 = {}) { + 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[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); + }); +}); diff --git a/packages/adapters/src/media-zip.test.ts b/packages/adapters/src/media-zip.test.ts new file mode 100644 index 0000000..c3ab600 --- /dev/null +++ b/packages/adapters/src/media-zip.test.ts @@ -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(); + }); +}); From 7c744b98a0c36097e648e0063915e7a8dd5d53e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:46:36 +0000 Subject: [PATCH 4/5] feat(media): per-segment results and copy checks on every field --- .../src/media-marketing.postgres.test.ts | 11 ++- packages/adapters/src/media-metrics.ts | 95 +++++++++++++++++-- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/adapters/src/media-marketing.postgres.test.ts b/packages/adapters/src/media-marketing.postgres.test.ts index 892847c..54f1081 100644 --- a/packages/adapters/src/media-marketing.postgres.test.ts +++ b/packages/adapters/src/media-marketing.postgres.test.ts @@ -23,7 +23,12 @@ import type { import { EmulatorMediaInferenceProvider } from "./media-emulator.js"; import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js"; import { storeMediaArtifact } from "./media-marketing-files.js"; -import { recordMetric, refitTeaserModel, resetTeaserModel } from "./media-metrics.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"; @@ -934,6 +939,10 @@ describePostgres("media marketing on Postgres", () => { 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(); }); diff --git a/packages/adapters/src/media-metrics.ts b/packages/adapters/src/media-metrics.ts index 1e6ed08..8f212fe 100644 --- a/packages/adapters/src/media-metrics.ts +++ b/packages/adapters/src/media-metrics.ts @@ -1,4 +1,4 @@ -import type { Edl, MediaMetric, MediaSegment } from "@rakazo/contracts"; +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"; @@ -57,17 +57,16 @@ export function planFeatures(edl: Edl, segments: MediaSegment[]): SegmentFeature ) as SegmentFeatures; } -/** Refit and store the space's teaser weights; null while there are too few results. */ -export async function refitTeaserModel( +/** Funnel rows with the plan each publication's render used, and those plans' segments. */ +async function withPlans( deps: Pick, spaceId: string, -): Promise { + rows: FunnelRow[], +): Promise<{ samples: Array; segments: MediaSegment[] }> { const { repos } = deps; - const rows = (await repos.funnel(spaceId)).filter((row) => row.views > 0); - 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(); + const samples: Array = []; for (const row of rows) { const publication = await repos.getPublication(spaceId, row.publicationId); let planId = planOfRender.get(publication.renderId); @@ -80,12 +79,90 @@ export async function refitTeaserModel( edl = (await repos.getPlan(spaceId, planId)).edl; plans.set(planId, edl); } - samples.push({ edl, views: row.views, conversions: row.conversions }); + samples.push({ ...row, edl }); } const assetIds = [ ...new Set(samples.flatMap((s) => s.edl.tracks.video.map((clip) => clip.assetId))), ]; - const segments = await repos.listSegments(spaceId, assetIds); + 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, + actor: MediaActor, + projectId?: string, +): Promise { + 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(); + for (const sample of samples) { + const used = new Set(); + 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, + spaceId: string, +): Promise { + 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 From 4876c58fca3cacd550368460158c10fb344fc02f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:46:41 +0000 Subject: [PATCH 5/5] style(media): format zip test --- packages/adapters/src/media-zip.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/adapters/src/media-zip.test.ts b/packages/adapters/src/media-zip.test.ts index c3ab600..cf1e3e3 100644 --- a/packages/adapters/src/media-zip.test.ts +++ b/packages/adapters/src/media-zip.test.ts @@ -82,9 +82,9 @@ describe("writeStoreZip", () => { `${"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/, - ); + 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);