feat(media): marketing service, brand kits, intro variants and bundle publisher
This commit is contained in:
parent
3c13961948
commit
872fdd2194
359
packages/adapters/src/media-brand.ts
Normal file
359
packages/adapters/src/media-brand.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
BrandKit,
|
||||||
|
BrandKitRecord,
|
||||||
|
IntroVariant,
|
||||||
|
MotionTemplateInfo,
|
||||||
|
MotionTemplateRef,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { BrandKitSchema, IntroVariantSchema } from "@rakazo/contracts";
|
||||||
|
import { InvalidMediaInputError } from "@rakazo/db";
|
||||||
|
import { getLogger } from "@rakazo/logging";
|
||||||
|
import { proposeIntroVariants, validateBrandKit } from "@rakazo/media";
|
||||||
|
import { resolveInside } from "@rakazo/media/node";
|
||||||
|
import type { MediaActor, MediaServiceDeps } from "./media-context.js";
|
||||||
|
import type { MediaToolPaths } from "./media-folder-source-probe.js";
|
||||||
|
import { runMediaTool } from "./media-folder-source-probe.js";
|
||||||
|
import {
|
||||||
|
materializeArtifact,
|
||||||
|
mediaAdapterContext,
|
||||||
|
storeMediaArtifact,
|
||||||
|
} from "./media-marketing-files.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Brand kits and the signature intro (spec 9).
|
||||||
|
*
|
||||||
|
* Saving a kit checks what the schema cannot: every referenced file is an
|
||||||
|
* artifact of this space with a usable type (PNG/SVG logos and watermark, TTF/
|
||||||
|
* OTF/TTC fonts, a parseable .cube LUT, audio music beds), and intro, outro and
|
||||||
|
* end card refer to motion templates of the right kind within their length
|
||||||
|
* limits (intro 1.5–4 s). Softer findings (contrast, missing licence notes,
|
||||||
|
* non-commercial music) come back as warnings.
|
||||||
|
*
|
||||||
|
* Intro variants are proposed deterministically from the kit's tone and
|
||||||
|
* audience, get a sound-design cue from the commercially licensed music beds,
|
||||||
|
* and are rendered as small MP4 previews over the kit background. A preview
|
||||||
|
* that cannot be rendered stays null; the variant itself is still valid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const INTRO_MIN_MS = 1500;
|
||||||
|
export const INTRO_MAX_MS = 4000;
|
||||||
|
const PREVIEW_WIDTH = 640;
|
||||||
|
const PREVIEW_HEIGHT = 360;
|
||||||
|
const PREVIEW_FPS = 24;
|
||||||
|
const LUT_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".ttc"]);
|
||||||
|
const IMAGE_EXTENSIONS = new Set([".png", ".svg"]);
|
||||||
|
const AUDIO_EXTENSIONS = new Set([".mp3", ".m4a", ".aac", ".wav", ".flac", ".ogg", ".opus"]);
|
||||||
|
|
||||||
|
export interface BrandKitSaveResult {
|
||||||
|
record: BrandKitRecord;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtifactRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
storageKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionOf(row: ArtifactRow): string {
|
||||||
|
return path.extname(row.name).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(row: ArtifactRow): boolean {
|
||||||
|
return IMAGE_EXTENSIONS.has(extensionOf(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAudio(row: ArtifactRow): boolean {
|
||||||
|
return AUDIO_EXTENSIONS.has(extensionOf(row)) || row.mimeType.startsWith("audio/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `text` is an Adobe/Resolve .cube 3D LUT with a complete table. */
|
||||||
|
export function isCubeLut(text: string): boolean {
|
||||||
|
let size = 0;
|
||||||
|
let rows = 0;
|
||||||
|
for (const raw of text.split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
const sized = /^LUT_3D_SIZE\s+(\d+)$/.exec(line);
|
||||||
|
if (sized) {
|
||||||
|
size = Number(sized[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^(TITLE|DOMAIN_MIN|DOMAIN_MAX|LUT_1D_SIZE|LUT_3D_INPUT_RANGE)\b/.test(line)) {
|
||||||
|
if (line.startsWith("LUT_1D_SIZE")) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values = line.split(/\s+/).map(Number);
|
||||||
|
if (values.length !== 3 || values.some((value) => !Number.isFinite(value))) return false;
|
||||||
|
rows += 1;
|
||||||
|
}
|
||||||
|
return size >= 2 && size <= 65 && rows === size ** 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkTemplate(
|
||||||
|
ref: MotionTemplateRef | undefined,
|
||||||
|
kind: MotionTemplateInfo["kind"],
|
||||||
|
field: string,
|
||||||
|
templates: Map<string, MotionTemplateInfo>,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
if (!ref) return;
|
||||||
|
const template = templates.get(ref.templateId);
|
||||||
|
if (!template || template.kind !== kind) {
|
||||||
|
errors.push(`${field}: "${ref.templateId}" is not a ${kind.replace("_", " ")} template`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ref.durationMs < template.minDurationMs || ref.durationMs > template.maxDurationMs) {
|
||||||
|
errors.push(
|
||||||
|
`${field}: duration must be ${template.minDurationMs}–${template.maxDurationMs} ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const param of template.params) {
|
||||||
|
if (param.required && ref.params[param.name] === undefined) {
|
||||||
|
errors.push(`${field}: parameter "${param.name}" is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate and store a brand kit of the actor's space. */
|
||||||
|
export async function saveBrandKit(
|
||||||
|
deps: Pick<MediaServiceDeps, "prisma" | "repos" | "motion" | "artifacts">,
|
||||||
|
actor: MediaActor,
|
||||||
|
input: { id?: string; kit: unknown },
|
||||||
|
): Promise<BrandKitSaveResult> {
|
||||||
|
const parsed = BrandKitSchema.safeParse(input.kit);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new InvalidMediaInputError(
|
||||||
|
`Invalid brand kit: ${parsed.error.issues
|
||||||
|
.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`)
|
||||||
|
.join("; ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const kit = parsed.data;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const referenced = [
|
||||||
|
...kit.fonts.map((font) => font.artifactId),
|
||||||
|
...kit.logoArtifactIds,
|
||||||
|
...(kit.lutArtifactId ? [kit.lutArtifactId] : []),
|
||||||
|
...(kit.watermark?.artifactId ? [kit.watermark.artifactId] : []),
|
||||||
|
...kit.musicBeds.map((bed) => bed.artifactId),
|
||||||
|
];
|
||||||
|
const rows = new Map<string, ArtifactRow>(
|
||||||
|
(
|
||||||
|
await deps.prisma.artifact.findMany({
|
||||||
|
where: { id: { in: [...new Set(referenced)] }, spaceId: actor.spaceId },
|
||||||
|
select: { id: true, name: true, mimeType: true, size: true, storageKey: true },
|
||||||
|
})
|
||||||
|
).map((row) => [row.id, row]),
|
||||||
|
);
|
||||||
|
const expectFile = (
|
||||||
|
id: string,
|
||||||
|
field: string,
|
||||||
|
accepts: (row: ArtifactRow) => boolean,
|
||||||
|
what: string,
|
||||||
|
) => {
|
||||||
|
const row = rows.get(id);
|
||||||
|
if (!row) errors.push(`${field}: file not found in this space`);
|
||||||
|
else if (!accepts(row)) errors.push(`${field}: must be ${what}`);
|
||||||
|
};
|
||||||
|
const isFont = (row: ArtifactRow) => FONT_EXTENSIONS.has(extensionOf(row));
|
||||||
|
for (const [index, font] of kit.fonts.entries()) {
|
||||||
|
expectFile(font.artifactId, `fonts.${index}`, isFont, "a TTF, OTF or TTC font");
|
||||||
|
}
|
||||||
|
for (const [index, id] of kit.logoArtifactIds.entries()) {
|
||||||
|
expectFile(id, `logoArtifactIds.${index}`, isImage, "a PNG or SVG image");
|
||||||
|
}
|
||||||
|
if (kit.watermark?.artifactId) {
|
||||||
|
expectFile(kit.watermark.artifactId, "watermark", isImage, "a PNG or SVG image");
|
||||||
|
}
|
||||||
|
for (const [index, bed] of kit.musicBeds.entries()) {
|
||||||
|
expectFile(bed.artifactId, `musicBeds.${index}`, isAudio, "an audio file");
|
||||||
|
}
|
||||||
|
if (kit.lutArtifactId) {
|
||||||
|
const row = rows.get(kit.lutArtifactId);
|
||||||
|
if (!row) errors.push("lutArtifactId: file not found in this space");
|
||||||
|
else if (extensionOf(row) !== ".cube" || row.size > LUT_MAX_BYTES) {
|
||||||
|
errors.push("lutArtifactId: must be a .cube LUT");
|
||||||
|
} else {
|
||||||
|
const bytes = await deps.artifacts
|
||||||
|
.get(row.storageKey, mediaAdapterContext(actor, `media-brand-lut:${row.id}`))
|
||||||
|
.catch(() => null);
|
||||||
|
if (!bytes || !isCubeLut(new TextDecoder().decode(bytes))) {
|
||||||
|
errors.push("lutArtifactId: not a complete 3D .cube LUT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = new Map(deps.motion.templates().map((template) => [template.id, template]));
|
||||||
|
checkTemplate(kit.intro, "intro", "intro", templates, errors);
|
||||||
|
checkTemplate(kit.outro, "outro", "outro", templates, errors);
|
||||||
|
checkTemplate(kit.endCard, "end_card", "endCard", templates, errors);
|
||||||
|
if (kit.intro && (kit.intro.durationMs < INTRO_MIN_MS || kit.intro.durationMs > INTRO_MAX_MS)) {
|
||||||
|
errors.push(`intro: duration must be ${INTRO_MIN_MS}–${INTRO_MAX_MS} ms`);
|
||||||
|
}
|
||||||
|
if (errors.length > 0)
|
||||||
|
throw new InvalidMediaInputError(`Invalid brand kit: ${errors.join("; ")}`);
|
||||||
|
|
||||||
|
const record = await deps.repos.upsertBrandKit(actor.spaceId, {
|
||||||
|
...(input.id ? { id: input.id } : {}),
|
||||||
|
kit,
|
||||||
|
});
|
||||||
|
return { record, warnings: validateBrandKit(kit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewColor(kit: BrandKit): string {
|
||||||
|
return /^#[0-9a-fA-F]{6}$/.test(kit.colors.background)
|
||||||
|
? `0x${kit.colors.background.slice(1)}`
|
||||||
|
: "black";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten an alpha overlay clip onto the kit background as a small MP4. */
|
||||||
|
async function encodePreview(
|
||||||
|
overlayPath: string,
|
||||||
|
outPath: string,
|
||||||
|
kit: BrandKit,
|
||||||
|
durationMs: number,
|
||||||
|
tools: MediaToolPaths,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
await runMediaTool(
|
||||||
|
tools.ffmpegPath ?? "ffmpeg",
|
||||||
|
[
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
`color=c=${previewColor(kit)}:s=${PREVIEW_WIDTH}x${PREVIEW_HEIGHT}:r=${PREVIEW_FPS}`,
|
||||||
|
"-i",
|
||||||
|
overlayPath,
|
||||||
|
"-filter_complex",
|
||||||
|
"[0:v][1:v]overlay=shortest=1,format=yuv420p[v]",
|
||||||
|
"-map",
|
||||||
|
"[v]",
|
||||||
|
"-t",
|
||||||
|
(durationMs / 1000).toFixed(3),
|
||||||
|
"-an",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"28",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
"-fflags",
|
||||||
|
"+bitexact",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
outPath,
|
||||||
|
],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propose `count` signature intros for a brand kit and render a preview of
|
||||||
|
* each (spec 9). Choosing one is saving it as the kit's `intro`.
|
||||||
|
*/
|
||||||
|
export async function introVariants(
|
||||||
|
deps: MediaServiceDeps,
|
||||||
|
actor: MediaActor,
|
||||||
|
input: { brandKitId: string; count: number; signal?: AbortSignal },
|
||||||
|
tools: MediaToolPaths = {},
|
||||||
|
): Promise<IntroVariant[]> {
|
||||||
|
const { kit } = await deps.repos.getBrandKit(actor.spaceId, input.brandKitId);
|
||||||
|
const count = Math.max(1, Math.min(5, Math.floor(input.count)));
|
||||||
|
const proposals = proposeIntroVariants(kit, deps.motion.templates(), count);
|
||||||
|
if (proposals.length === 0) return [];
|
||||||
|
const beds = kit.musicBeds.filter((bed) => bed.commercialUse);
|
||||||
|
|
||||||
|
const workDir = await resolveInside(
|
||||||
|
deps.mediaRoot,
|
||||||
|
path.join(actor.spaceId, "tmp", `intro-${randomUUID()}`),
|
||||||
|
);
|
||||||
|
await mkdir(workDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
let assets: { fonts: Array<{ family: string; path: string }>; logoPaths: string[] } | null;
|
||||||
|
try {
|
||||||
|
const fontDir = path.join(workDir, "assets");
|
||||||
|
assets = {
|
||||||
|
fonts: await Promise.all(
|
||||||
|
kit.fonts.map(async (font) => ({
|
||||||
|
family: font.family,
|
||||||
|
path: await materializeArtifact(deps, actor, font.artifactId, fontDir),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
logoPaths: await Promise.all(
|
||||||
|
kit.logoArtifactIds.map((id) => materializeArtifact(deps, actor, id, fontDir)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
getLogger().warn("media intro preview assets unavailable", {
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
assets = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants: IntroVariant[] = [];
|
||||||
|
for (const [index, proposal] of proposals.entries()) {
|
||||||
|
const bed = beds.length > 0 ? beds[index % beds.length] : undefined;
|
||||||
|
const rationale = bed ? `${proposal.rationale}, sound: ${bed.title}` : proposal.rationale;
|
||||||
|
let previewArtifactId: string | null = null;
|
||||||
|
if (assets) {
|
||||||
|
try {
|
||||||
|
const overlay = await deps.motion.render({
|
||||||
|
template: proposal.template,
|
||||||
|
brandKit: kit,
|
||||||
|
assets,
|
||||||
|
width: PREVIEW_WIDTH,
|
||||||
|
height: PREVIEW_HEIGHT,
|
||||||
|
fps: PREVIEW_FPS,
|
||||||
|
outPath: path.join(workDir, `intro-${index}.mov`),
|
||||||
|
...(input.signal ? { signal: input.signal } : {}),
|
||||||
|
});
|
||||||
|
const mp4 = path.join(workDir, `intro-${index}.mp4`);
|
||||||
|
await encodePreview(
|
||||||
|
overlay.path,
|
||||||
|
mp4,
|
||||||
|
kit,
|
||||||
|
proposal.template.durationMs,
|
||||||
|
tools,
|
||||||
|
input.signal,
|
||||||
|
);
|
||||||
|
previewArtifactId = await storeMediaArtifact(deps, actor, {
|
||||||
|
name: `intro-preview-${index + 1}.mp4`,
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
bytes: new Uint8Array(await readFile(mp4)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
input.signal?.throwIfAborted();
|
||||||
|
getLogger().warn("media intro preview failed", {
|
||||||
|
templateId: proposal.template.templateId,
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
variants.push(
|
||||||
|
IntroVariantSchema.parse({
|
||||||
|
id: randomUUID(),
|
||||||
|
template: proposal.template,
|
||||||
|
rationale,
|
||||||
|
previewArtifactId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return variants;
|
||||||
|
} finally {
|
||||||
|
await rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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. */
|
/** A stored render output: the public fields plus its path relative to the media root. */
|
||||||
export type StoredRenderOutput = MediaRenderOutput & { path?: string };
|
export type StoredRenderOutput = MediaRenderOutput & { path?: string };
|
||||||
|
|
||||||
export function mediaAdapterContext(actor: MediaActor, operationId: string): AdapterContext {
|
export function mediaAdapterContext(
|
||||||
return { operationId, traceId: operationId, spaceId: actor.spaceId, userId: actor.userId };
|
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(
|
export async function storeMediaArtifact(
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import type {
|
|||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
import { TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts";
|
import { TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts";
|
||||||
import type { MediaProjectRecord, MediaRepos } from "@rakazo/db";
|
import type { MediaProjectRecord, MediaRepos } from "@rakazo/db";
|
||||||
import { InvalidMediaInputError } from "@rakazo/db";
|
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
|
||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
import type { EdlContext, ScoredSegmentInput, SegmentFeatures } from "@rakazo/media";
|
import type { EdlContext, ScoredSegmentInput, SegmentFeatures } from "@rakazo/media";
|
||||||
import {
|
import {
|
||||||
@ -115,9 +115,7 @@ export async function loadPlanContext(
|
|||||||
assetId: mark.assetId,
|
assetId: mark.assetId,
|
||||||
personId: mark.personId,
|
personId: mark.personId,
|
||||||
})),
|
})),
|
||||||
profiles: new Map(
|
profiles: new Map([...profileSet.profiles.entries()].map(([id, entry]) => [id, entry.profile])),
|
||||||
[...profileSet.profiles.entries()].map(([id, entry]) => [id, entry.profile]),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,7 +151,10 @@ export function buildEdlContext(
|
|||||||
),
|
),
|
||||||
artifactIds: new Set(kitArtifactIds(ctx.kit)),
|
artifactIds: new Set(kitArtifactIds(ctx.kit)),
|
||||||
music: new Map(
|
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,
|
markIds: ctx.markIds,
|
||||||
personIds: new Set(ctx.people.map((person) => person.id)),
|
personIds: new Set(ctx.people.map((person) => person.id)),
|
||||||
@ -221,7 +222,9 @@ async function classifierFrames(
|
|||||||
let data: unknown = row.data;
|
let data: unknown = row.data;
|
||||||
if ((data === null || data === undefined) && row.dataPath) {
|
if ((data === null || data === undefined) && row.dataPath) {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -232,7 +235,9 @@ async function classifierFrames(
|
|||||||
assetId,
|
assetId,
|
||||||
frames.filter(
|
frames.filter(
|
||||||
(frame): frame is ClassifierFrames[number] =>
|
(frame): frame is ClassifierFrames[number] =>
|
||||||
typeof frame?.tMs === "number" && typeof frame.scores === "object" && frame.scores !== null,
|
typeof frame?.tMs === "number" &&
|
||||||
|
typeof frame.scores === "object" &&
|
||||||
|
frame.scores !== null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -263,18 +268,34 @@ function protectedPersonIds(ctx: PlanContext): Set<string> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showsProtectedFace(
|
/**
|
||||||
ctx: PlanContext,
|
* Whether a segment may show something the user protects: a face of a
|
||||||
|
* protected person (or an unassigned face while anyone is protected), or a
|
||||||
|
* treated identifying mark of its asset or of a person visible in it. Such
|
||||||
|
* segments never go to the model, even with consent.
|
||||||
|
*/
|
||||||
|
export function showsProtectedDetail(
|
||||||
|
ctx: Pick<PlanContext, "faceTracks" | "marks">,
|
||||||
protectedIds: Set<string>,
|
protectedIds: Set<string>,
|
||||||
segment: { assetId: string; startMs: number; endMs: number },
|
segment: { assetId: string; startMs: number; endMs: number },
|
||||||
): boolean {
|
): boolean {
|
||||||
if (protectedIds.size === 0) return false;
|
const tracks = ctx.faceTracks.filter(
|
||||||
return ctx.faceTracks.some(
|
|
||||||
(track) =>
|
(track) =>
|
||||||
track.assetId === segment.assetId &&
|
track.assetId === segment.assetId &&
|
||||||
track.startMs < segment.endMs &&
|
track.startMs < segment.endMs &&
|
||||||
track.endMs > segment.startMs &&
|
track.endMs > segment.startMs,
|
||||||
(track.personId === null || protectedIds.has(track.personId)),
|
);
|
||||||
|
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(
|
const eligible = top.filter(
|
||||||
({ segment }) =>
|
({ segment }) =>
|
||||||
classifierPasses(classifier.get(segment.assetId), segment, profile) &&
|
classifierPasses(classifier.get(segment.assetId), segment, profile) &&
|
||||||
!showsProtectedFace(ctx, protectedIds, segment),
|
!showsProtectedDetail(ctx, protectedIds, segment),
|
||||||
);
|
);
|
||||||
if (eligible.length < 2) return null;
|
if (eligible.length < 2) return null;
|
||||||
|
|
||||||
@ -325,9 +346,7 @@ async function judgeCandidates(
|
|||||||
try {
|
try {
|
||||||
await deps.renderer.extractFrame({
|
await deps.renderer.extractFrame({
|
||||||
path: await resolveInside(deps.mediaRoot, source),
|
path: await resolveInside(deps.mediaRoot, source),
|
||||||
tMs: isVideo
|
tMs: isVideo ? Math.round((candidate.segment.startMs + candidate.segment.endMs) / 2) : 0,
|
||||||
? Math.round((candidate.segment.startMs + candidate.segment.endMs) / 2)
|
|
||||||
: 0,
|
|
||||||
outPath,
|
outPath,
|
||||||
width: JUDGE_FRAME_WIDTH,
|
width: JUDGE_FRAME_WIDTH,
|
||||||
});
|
});
|
||||||
@ -404,16 +423,36 @@ function rationaleFor(input: {
|
|||||||
}
|
}
|
||||||
if (edl.intro) lines.push("Brand intro opens the video.");
|
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.");
|
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) {
|
if (input.judgement) {
|
||||||
const reasons = input.judgement.order
|
const reasons = input.judgement.order
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((id) => `${id}: ${input.judgement?.reasons.get(id) ?? ""}`.trim());
|
.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);
|
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 {
|
function requireProfile(ctx: PlanContext, profileId: string): PlatformProfile {
|
||||||
if (!ctx.project.profileIds.includes(profileId)) {
|
if (!ctx.project.profileIds.includes(profileId)) {
|
||||||
throw new InvalidMediaInputError(`Profile ${profileId} is not part of this project`);
|
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 profiles = profileIds.map((id) => requireProfile(ctx, id));
|
||||||
const model = await deps.repos.getTeaserModel(actor.spaceId);
|
const model = await deps.repos.getTeaserModel(actor.spaceId);
|
||||||
const learned = Boolean(model && model.sampleCount >= TEASER_LEARNING_MIN_SAMPLES);
|
const learned = Boolean(model && model.sampleCount >= TEASER_LEARNING_MIN_SAMPLES);
|
||||||
const weights =
|
const weights = learned && model ? model.weights : defaultTeaserWeights(ctx.project.goal);
|
||||||
learned && model ? model.weights : defaultTeaserWeights(ctx.project.goal);
|
|
||||||
const base = scoredInputs(ctx);
|
const base = scoredInputs(ctx);
|
||||||
const assets = new Map(
|
const assets = new Map(
|
||||||
[...ctx.assets.values()].map((asset) => [
|
[...ctx.assets.values()].map((asset) => [
|
||||||
@ -464,7 +502,9 @@ export async function autoPlan(
|
|||||||
? await judgeCandidates(deps, actor, ctx, profile, base, weights)
|
? await judgeCandidates(deps, actor, ctx, profile, base, weights)
|
||||||
: null;
|
: null;
|
||||||
const segments = judgement ? applyRanking(base, judgement.order) : base;
|
const segments = judgement ? applyRanking(base, judgement.order) : base;
|
||||||
const edl = autoPlanEdl({
|
let edl: Edl;
|
||||||
|
try {
|
||||||
|
edl = autoPlanEdl({
|
||||||
goal: ctx.project.goal,
|
goal: ctx.project.goal,
|
||||||
profile,
|
profile,
|
||||||
brandKit: ctx.kit,
|
brandKit: ctx.kit,
|
||||||
@ -473,8 +513,14 @@ export async function autoPlan(
|
|||||||
assets,
|
assets,
|
||||||
people,
|
people,
|
||||||
marks: ctx.marks,
|
marks: ctx.marks,
|
||||||
ctaText: typeof endCardCta === "string" ? endCardCta : null,
|
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));
|
const validation = validateEdl(edl, buildEdlContext(deps, ctx, profile));
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
throw new InvalidMediaInputError(
|
throw new InvalidMediaInputError(
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { access, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
import { access, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 { MediaStateConflictError } from "@rakazo/db";
|
||||||
import {
|
import {
|
||||||
clipLengthMs,
|
clipLengthMs,
|
||||||
@ -21,8 +27,8 @@ import {
|
|||||||
materializeArtifact,
|
materializeArtifact,
|
||||||
publicRenderOutput,
|
publicRenderOutput,
|
||||||
renderOutputPath,
|
renderOutputPath,
|
||||||
storeMediaArtifact,
|
|
||||||
storedRenderOutputs,
|
storedRenderOutputs,
|
||||||
|
storeMediaArtifact,
|
||||||
} from "./media-marketing-files.js";
|
} from "./media-marketing-files.js";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -197,6 +203,9 @@ export async function createThumbnails(
|
|||||||
tools: MediaToolPaths = {},
|
tools: MediaToolPaths = {},
|
||||||
): Promise<MediaRenderOutput[]> {
|
): Promise<MediaRenderOutput[]> {
|
||||||
const { spaceId } = actor;
|
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);
|
const row = await deps.repos.getRenderRecord(spaceId, input.renderId);
|
||||||
if (row.status !== "done") throw new MediaStateConflictError("Thumbnails need a finished render");
|
if (row.status !== "done") throw new MediaStateConflictError("Thumbnails need a finished render");
|
||||||
const outputs = storedRenderOutputs(row.outputs);
|
const outputs = storedRenderOutputs(row.outputs);
|
||||||
@ -238,8 +247,16 @@ export async function createThumbnails(
|
|||||||
const measured: Array<{ tMs: number } & FrameMeasure & { face: number }> = [];
|
const measured: Array<{ tMs: number } & FrameMeasure & { face: number }> = [];
|
||||||
for (const tMs of thumbnailCandidateTimes(edl, durationMs)) {
|
for (const tMs of thumbnailCandidateTimes(edl, durationMs)) {
|
||||||
const file = path.join(candidateDir, `${tMs}.png`);
|
const file = path.join(candidateDir, `${tMs}.png`);
|
||||||
await deps.renderer.extractFrame({ path: videoPath, tMs, outPath: file, width: ANALYSIS_WIDTH });
|
await deps.renderer.extractFrame({
|
||||||
const { data, info } = await sharp(file).greyscale().raw().toBuffer({ resolveWithObject: true });
|
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 at = locateOutputTime(edl, tMs);
|
||||||
const face =
|
const face =
|
||||||
at &&
|
at &&
|
||||||
@ -307,22 +324,20 @@ export async function createThumbnails(
|
|||||||
} else {
|
} else {
|
||||||
await rename(frame, out);
|
await rename(frame, out);
|
||||||
}
|
}
|
||||||
if (deps.inference) {
|
const content = await inference.classifyContent({
|
||||||
const content = await deps.inference.classifyContent({
|
|
||||||
path: out,
|
path: out,
|
||||||
sampleFps: 1,
|
sampleFps: 1,
|
||||||
extraTimesMs: [0],
|
extraTimesMs: [0],
|
||||||
});
|
});
|
||||||
let rejected = exceedsThresholds(content.frames, profile);
|
let rejected = content.frames.length === 0 || exceedsThresholds(content.frames, profile);
|
||||||
if (!rejected && protectsFaces) {
|
if (!rejected && protectsFaces) {
|
||||||
const faces = await deps.inference.detectFaces({ path: out, sampleFps: 1, timesMs: [0] });
|
const faces = await inference.detectFaces({ path: out, sampleFps: 1, timesMs: [0] });
|
||||||
rejected = faces.frames.some((f) => f.faces.length > 0);
|
rejected = faces.frames.length === 0 || faces.frames.some((f) => f.faces.length > 0);
|
||||||
}
|
}
|
||||||
if (rejected) {
|
if (rejected) {
|
||||||
await rm(out, { force: true });
|
await rm(out, { force: true });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const bytes = await readFile(out);
|
const bytes = await readFile(out);
|
||||||
const artifactId = await storeMediaArtifact(deps, actor, {
|
const artifactId = await storeMediaArtifact(deps, actor, {
|
||||||
name: `${name}.png`,
|
name: `${name}.png`,
|
||||||
|
|||||||
52
packages/adapters/src/media-marketing.ts
Normal file
52
packages/adapters/src/media-marketing.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { MediaJobHandlers } from "@rakazo/adapter-kit";
|
||||||
|
import { introVariants } from "./media-brand.js";
|
||||||
|
import type { MediaMarketingService, MediaServiceDeps } from "./media-context.js";
|
||||||
|
import type { MediaToolPaths } from "./media-folder-source-probe.js";
|
||||||
|
import { generateCopy } from "./media-marketing-copy.js";
|
||||||
|
import { autoPlan, submitPlan } from "./media-marketing-plan.js";
|
||||||
|
import { createThumbnails } from "./media-marketing-thumbnails.js";
|
||||||
|
import { importMetricsCsv } from "./media-metrics.js";
|
||||||
|
import type { MediaUploadReminder } from "./media-publish.js";
|
||||||
|
import {
|
||||||
|
buildBundle,
|
||||||
|
cancelPublication,
|
||||||
|
createMediaPublishHandler,
|
||||||
|
createPublication,
|
||||||
|
} from "./media-publish.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Media Studio marketing slice (spec 8–10) behind MediaMarketingService:
|
||||||
|
* edit plans with the teaser judge, intro variants, thumbnails, copy,
|
||||||
|
* publications with tracking and bundles, and results import with teaser
|
||||||
|
* learning. Brand kit saving, manual metrics and the teaser reset are plain
|
||||||
|
* functions (saveBrandKit, recordMetric, resetTeaserModel) the API calls with
|
||||||
|
* the same deps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MediaMarketingOptions extends MediaToolPaths {}
|
||||||
|
|
||||||
|
export function createMediaMarketingService(
|
||||||
|
deps: MediaServiceDeps,
|
||||||
|
options: MediaMarketingOptions = {},
|
||||||
|
): MediaMarketingService {
|
||||||
|
return {
|
||||||
|
autoPlan: (actor, input) => autoPlan(deps, actor, input),
|
||||||
|
submitPlan: (actor, input) => submitPlan(deps, actor, input),
|
||||||
|
introVariants: (actor, input) => introVariants(deps, actor, input, options),
|
||||||
|
thumbnails: (actor, input) => createThumbnails(deps, actor, input, options),
|
||||||
|
copy: (actor, input) => generateCopy(deps, actor, input),
|
||||||
|
createPublication: (actor, input) => createPublication(deps, actor, input),
|
||||||
|
bundle: (actor, publicationId) => buildBundle(deps, actor, publicationId, options),
|
||||||
|
cancelPublication: (actor, publicationId) => cancelPublication(deps, actor, publicationId),
|
||||||
|
importMetricsCsv: (actor, input) => importMetricsCsv(deps, actor, input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worker handlers owned by the marketing slice. */
|
||||||
|
export function createMediaMarketingJobHandlers(
|
||||||
|
deps: MediaServiceDeps,
|
||||||
|
marketing: Pick<MediaMarketingService, "bundle">,
|
||||||
|
options: { remind?: MediaUploadReminder } = {},
|
||||||
|
): Pick<MediaJobHandlers, "media.publish"> {
|
||||||
|
return { "media.publish": createMediaPublishHandler(deps, marketing, options) };
|
||||||
|
}
|
||||||
@ -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 { TEASER_FEATURES, TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts";
|
||||||
import type { NewMediaMetric, TeaserModelRecord } from "@rakazo/db";
|
import type { NewMediaMetric, TeaserModelRecord } from "@rakazo/db";
|
||||||
import { InvalidMediaInputError } 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;
|
if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null;
|
||||||
const plans = new Map<string, Edl>();
|
const plans = new Map<string, Edl>();
|
||||||
const samples: Array<{ edl: Edl; views: number; conversions: number }> = [];
|
const samples: Array<{ edl: Edl; views: number; conversions: number }> = [];
|
||||||
|
const planOfRender = new Map<string, string>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const publication = await repos.getPublication(spaceId, row.publicationId);
|
const publication = await repos.getPublication(spaceId, row.publicationId);
|
||||||
const render = await repos.getRender(spaceId, publication.renderId);
|
let planId = planOfRender.get(publication.renderId);
|
||||||
let edl = plans.get(render.planId);
|
if (!planId) {
|
||||||
|
planId = (await repos.getRender(spaceId, publication.renderId)).planId;
|
||||||
|
planOfRender.set(publication.renderId, planId);
|
||||||
|
}
|
||||||
|
let edl = plans.get(planId);
|
||||||
if (!edl) {
|
if (!edl) {
|
||||||
edl = (await repos.getPlan(spaceId, render.planId)).edl;
|
edl = (await repos.getPlan(spaceId, planId)).edl;
|
||||||
plans.set(render.planId, edl);
|
plans.set(planId, edl);
|
||||||
}
|
}
|
||||||
samples.push({ edl, views: row.views, conversions: row.conversions });
|
samples.push({ edl, views: row.views, conversions: row.conversions });
|
||||||
}
|
}
|
||||||
@ -92,6 +97,25 @@ export async function refitTeaserModel(
|
|||||||
return repos.putTeaserModel(spaceId, weights, usable.length);
|
return repos.putTeaserModel(spaceId, weights, usable.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Manual metric entry (spec 10.6); refits the teaser weights like an import. */
|
||||||
|
export async function recordMetric(
|
||||||
|
deps: Pick<MediaServiceDeps, "repos">,
|
||||||
|
actor: MediaActor,
|
||||||
|
metric: Omit<NewMediaMetric, "source">,
|
||||||
|
): Promise<MediaMetric> {
|
||||||
|
const recorded = await deps.repos.recordMetric(actor.spaceId, { ...metric, source: "manual" });
|
||||||
|
await refitTeaserModel(deps, actor.spaceId);
|
||||||
|
return recorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the learned weights; plans use the defaults until the next refit. */
|
||||||
|
export async function resetTeaserModel(
|
||||||
|
deps: Pick<MediaServiceDeps, "repos">,
|
||||||
|
actor: MediaActor,
|
||||||
|
): Promise<void> {
|
||||||
|
await deps.repos.resetTeaserModel(actor.spaceId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function importMetricsCsv(
|
export async function importMetricsCsv(
|
||||||
deps: Pick<MediaServiceDeps, "repos">,
|
deps: Pick<MediaServiceDeps, "repos">,
|
||||||
actor: MediaActor,
|
actor: MediaActor,
|
||||||
|
|||||||
@ -2,13 +2,15 @@ import { copyFile, link, mkdir, readFile, rm, writeFile } from "node:fs/promises
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { MediaJobHandlers } from "@rakazo/adapter-kit";
|
import type { MediaJobHandlers } from "@rakazo/adapter-kit";
|
||||||
import { mediaPublishJob } 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 { ATTACHMENT_MAX_BYTES, UtmSchema } from "@rakazo/contracts";
|
||||||
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
|
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
|
||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
import { buildTrackingUrl, normalizeHashtags, uploadChecklist } from "@rakazo/media";
|
import { buildTrackingUrl, normalizeHashtags, uploadChecklist } from "@rakazo/media";
|
||||||
import { resolveInside } from "@rakazo/media/node";
|
import { resolveInside } from "@rakazo/media/node";
|
||||||
import type { MediaActor, MediaMarketingService, MediaServiceDeps } from "./media-context.js";
|
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 type { StoredRenderOutput } from "./media-marketing-files.js";
|
||||||
import {
|
import {
|
||||||
renderOutputPath,
|
renderOutputPath,
|
||||||
@ -38,6 +40,7 @@ import { writeStoreZip } from "./media-zip.js";
|
|||||||
|
|
||||||
export const BUNDLE_FILES = {
|
export const BUNDLE_FILES = {
|
||||||
video: "video.mp4",
|
video: "video.mp4",
|
||||||
|
preview: "preview.mp4",
|
||||||
posterPng: "poster.png",
|
posterPng: "poster.png",
|
||||||
posterJpg: "poster.jpg",
|
posterJpg: "poster.jpg",
|
||||||
srt: "subtitles.srt",
|
srt: "subtitles.srt",
|
||||||
@ -63,6 +66,26 @@ async function publishEvent(deps: MediaServiceDeps, spaceId: string, pub: MediaP
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THUMBNAIL_VARIANT = /^thumb-[a-j]$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail closed: only a final render whose stored gate report passed may leave
|
||||||
|
* the host as a publication or bundle. Drafts carry no export guarantee.
|
||||||
|
*/
|
||||||
|
export function assertPublishable(
|
||||||
|
render: Pick<MediaRender, "status" | "quality" | "gateReport">,
|
||||||
|
): void {
|
||||||
|
if (render.status !== "done") {
|
||||||
|
throw new MediaStateConflictError("Only a finished render can be published");
|
||||||
|
}
|
||||||
|
if (render.quality !== "final") {
|
||||||
|
throw new MediaStateConflictError("Only a final render can be published");
|
||||||
|
}
|
||||||
|
if (!render.gateReport?.passed) {
|
||||||
|
throw new MediaStateConflictError("The render has not passed the platform gate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** "9x16"-style format of an output size. */
|
/** "9x16"-style format of an output size. */
|
||||||
function aspectLabel(width: number, height: number): string {
|
function aspectLabel(width: number, height: number): string {
|
||||||
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
|
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
|
||||||
@ -87,8 +110,9 @@ export async function createPublication(
|
|||||||
): Promise<MediaPublication> {
|
): Promise<MediaPublication> {
|
||||||
const { spaceId } = actor;
|
const { spaceId } = actor;
|
||||||
const render = await deps.repos.getRender(spaceId, input.renderId);
|
const render = await deps.repos.getRender(spaceId, input.renderId);
|
||||||
if (render.status !== "done") {
|
assertPublishable(render);
|
||||||
throw new MediaStateConflictError("Only a finished render can be published");
|
if (input.thumbnailVariant !== null && !THUMBNAIL_VARIANT.test(input.thumbnailVariant)) {
|
||||||
|
throw new InvalidMediaInputError("Unknown thumbnail variant");
|
||||||
}
|
}
|
||||||
const [plan, project] = await Promise.all([
|
const [plan, project] = await Promise.all([
|
||||||
deps.repos.getPlan(spaceId, render.planId),
|
deps.repos.getPlan(spaceId, render.planId),
|
||||||
@ -160,6 +184,43 @@ async function placeFile(source: string, target: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opening of the final video as a preview clip, for profiles that ask for
|
||||||
|
* one (spec 8, paid funnel). Stream copy keeps the gated pixels and audio
|
||||||
|
* unchanged; container metadata is dropped like in every other output.
|
||||||
|
*/
|
||||||
|
export async function cutPreviewClip(
|
||||||
|
videoPath: string,
|
||||||
|
outPath: string,
|
||||||
|
durationMs: number,
|
||||||
|
tools: MediaToolPaths = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await runMediaTool(tools.ffmpegPath ?? "ffmpeg", [
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
videoPath,
|
||||||
|
"-t",
|
||||||
|
(durationMs / 1000).toFixed(3),
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-map",
|
||||||
|
"0:a?",
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
"-map_chapters",
|
||||||
|
"-1",
|
||||||
|
"-fflags",
|
||||||
|
"+bitexact",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
outPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function disclosureText(alterations: string[]): string {
|
function disclosureText(alterations: string[]): string {
|
||||||
return [
|
return [
|
||||||
`This video contains AI alterations: ${alterations.join(", ")}.`,
|
`This video contains AI alterations: ${alterations.join(", ")}.`,
|
||||||
@ -172,12 +233,14 @@ export async function buildBundle(
|
|||||||
deps: MediaServiceDeps,
|
deps: MediaServiceDeps,
|
||||||
actor: MediaActor,
|
actor: MediaActor,
|
||||||
publicationId: string,
|
publicationId: string,
|
||||||
|
tools: MediaToolPaths = {},
|
||||||
): Promise<MediaPublication> {
|
): Promise<MediaPublication> {
|
||||||
const { spaceId } = actor;
|
const { spaceId } = actor;
|
||||||
const pub = await deps.repos.getPublication(spaceId, publicationId);
|
const pub = await deps.repos.getPublication(spaceId, publicationId);
|
||||||
if (pub.status === "canceled" || pub.status === "published") {
|
if (pub.status === "canceled" || pub.status === "published") {
|
||||||
throw new MediaStateConflictError(`Publication is already ${pub.status}`);
|
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 row = await deps.repos.getRenderRecord(spaceId, pub.renderId);
|
||||||
const [plan, profileSet] = await Promise.all([
|
const [plan, profileSet] = await Promise.all([
|
||||||
deps.repos.getPlan(spaceId, row.planId),
|
deps.repos.getPlan(spaceId, row.planId),
|
||||||
@ -206,12 +269,21 @@ export async function buildBundle(
|
|||||||
};
|
};
|
||||||
const sourceOf = (output: StoredRenderOutput) => renderOutputPath(deps, spaceId, row.id, output);
|
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) {
|
if (profile.export.poster) {
|
||||||
const variants = new Map(
|
const variants = new Map(
|
||||||
Array.from({ length: MAX_THUMBNAIL_VARIANTS }, (_, i) => thumbnailVariantName(i)).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 =
|
const chosen =
|
||||||
@ -302,18 +374,28 @@ export async function buildBundle(
|
|||||||
return bundled;
|
return bundled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tells the publication's author that a scheduled bundle is ready to upload by hand. */
|
||||||
|
export type MediaUploadReminder = (input: {
|
||||||
|
spaceId: string;
|
||||||
|
userId: string;
|
||||||
|
publication: MediaPublication;
|
||||||
|
profile: Pick<PlatformProfile, "id" | "name">;
|
||||||
|
}) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worker handler for `media.publish`: build the bundle at the scheduled time,
|
* 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
|
* hand it to the configured publisher, and record the outcome. Paid-video
|
||||||
* already published publication is left alone. Failures mark the publication
|
* profiles only ever get the bundle (spec 10.5). When nothing was published,
|
||||||
* failed and rethrow so the job queue retries; a retry rebuilds from scratch.
|
* `remind` (the composition root's push channel) tells the author to upload the
|
||||||
*
|
* finished bundle; clients also see the realtime publication event. A canceled
|
||||||
* No notification is sent here: MediaServiceDeps has no notification provider,
|
* or already published publication is left alone. Failures mark the
|
||||||
* so clients learn about the bundle through the realtime publication event.
|
* publication failed and rethrow so the job queue retries; a retry rebuilds
|
||||||
|
* the bundle from scratch.
|
||||||
*/
|
*/
|
||||||
export function createMediaPublishHandler(
|
export function createMediaPublishHandler(
|
||||||
deps: MediaServiceDeps,
|
deps: MediaServiceDeps,
|
||||||
marketing: Pick<MediaMarketingService, "bundle">,
|
marketing: Pick<MediaMarketingService, "bundle">,
|
||||||
|
options: { remind?: MediaUploadReminder } = {},
|
||||||
): MediaJobHandlers["media.publish"] {
|
): MediaJobHandlers["media.publish"] {
|
||||||
return async ({ publicationId }) => {
|
return async ({ publicationId }) => {
|
||||||
const spaceId = await deps.repos.mediaSpaceOf("publication", publicationId);
|
const spaceId = await deps.repos.mediaSpaceOf("publication", publicationId);
|
||||||
@ -321,10 +403,17 @@ export function createMediaPublishHandler(
|
|||||||
const record = await deps.repos.getPublicationRecord(spaceId, publicationId);
|
const record = await deps.repos.getPublicationRecord(spaceId, publicationId);
|
||||||
if (record.status === "canceled" || record.status === "published") return;
|
if (record.status === "canceled" || record.status === "published") return;
|
||||||
const actor = { spaceId, userId: record.createdBy };
|
const actor = { spaceId, userId: record.createdBy };
|
||||||
|
let bundled: MediaPublication;
|
||||||
|
let profile: PlatformProfile;
|
||||||
|
let published = false;
|
||||||
try {
|
try {
|
||||||
const bundled = await marketing.bundle(actor, publicationId);
|
bundled = await marketing.bundle(actor, publicationId);
|
||||||
const profile = (await deps.profiles()).profiles.get(bundled.profileId)?.profile;
|
const found = (await deps.profiles()).profiles.get(bundled.profileId)?.profile;
|
||||||
if (!profile) throw new MediaStateConflictError(`Profile ${bundled.profileId} is not available`);
|
if (!found) {
|
||||||
|
throw new MediaStateConflictError(`Profile ${bundled.profileId} is not available`);
|
||||||
|
}
|
||||||
|
profile = found;
|
||||||
|
if (profile.kind !== "paid_video") {
|
||||||
const result = await deps.publisher.publish({
|
const result = await deps.publisher.publish({
|
||||||
bundleDir: await resolveInside(deps.mediaRoot, bundleRelativeDir(spaceId, publicationId)),
|
bundleDir: await resolveInside(deps.mediaRoot, bundleRelativeDir(spaceId, publicationId)),
|
||||||
profile,
|
profile,
|
||||||
@ -334,13 +423,18 @@ export function createMediaPublishHandler(
|
|||||||
tags: bundled.tags,
|
tags: bundled.tags,
|
||||||
});
|
});
|
||||||
if (result.status === "published") {
|
if (result.status === "published") {
|
||||||
const published = await deps.repos.markPublicationPublished(
|
published = true;
|
||||||
|
await publishEvent(
|
||||||
|
deps,
|
||||||
|
spaceId,
|
||||||
|
await deps.repos.markPublicationPublished(
|
||||||
spaceId,
|
spaceId,
|
||||||
publicationId,
|
publicationId,
|
||||||
result.externalUrl,
|
result.externalUrl,
|
||||||
deps.now(),
|
deps.now(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await publishEvent(deps, spaceId, published);
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const current = await deps.repos.getPublication(spaceId, publicationId);
|
const current = await deps.repos.getPublication(spaceId, publicationId);
|
||||||
@ -353,5 +447,20 @@ export function createMediaPublishHandler(
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (published || !options.remind) return;
|
||||||
|
try {
|
||||||
|
await options.remind({
|
||||||
|
spaceId,
|
||||||
|
userId: record.createdBy,
|
||||||
|
publication: bundled,
|
||||||
|
profile: { id: profile.id, name: profile.name },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// The bundle is ready and visible in the app; a lost push is logged, not retried.
|
||||||
|
getLogger().warn("media upload reminder failed", {
|
||||||
|
publicationId,
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
27
packages/adapters/src/media-publisher-bundle.ts
Normal file
27
packages/adapters/src/media-publisher-bundle.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { MediaPublisher, MediaPublishRequest, MediaPublishResult } from "@rakazo/adapter-kit";
|
||||||
|
import { BUNDLE_FILES } from "./media-publish.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Default MediaPublisher: the export bundle is the publication (spec 10.5).
|
||||||
|
* It uploads nothing and reports "bundled" only after checking that the
|
||||||
|
* bundle folder really holds the video and the ZIP, so a missing bundle
|
||||||
|
* surfaces as an error instead of a silent success. Uploads to
|
||||||
|
* connected accounts run through the agent's connector tools, where the
|
||||||
|
* existing approval rules apply.
|
||||||
|
*/
|
||||||
|
export class BundleMediaPublisher implements MediaPublisher {
|
||||||
|
readonly id = "bundle";
|
||||||
|
|
||||||
|
async publish(request: MediaPublishRequest): Promise<MediaPublishResult> {
|
||||||
|
request.signal?.throwIfAborted();
|
||||||
|
for (const name of [BUNDLE_FILES.video, BUNDLE_FILES.zip]) {
|
||||||
|
const info = await stat(path.join(request.bundleDir, name)).catch(() => null);
|
||||||
|
if (!info?.isFile() || info.size === 0) {
|
||||||
|
throw new Error(`export bundle for ${request.publicationId} is missing ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: "bundled", externalUrl: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user