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. */
|
||||
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(
|
||||
|
||||
@ -14,7 +14,7 @@ import type {
|
||||
} from "@rakazo/contracts";
|
||||
import { TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts";
|
||||
import type { MediaProjectRecord, MediaRepos } from "@rakazo/db";
|
||||
import { InvalidMediaInputError } from "@rakazo/db";
|
||||
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
import type { EdlContext, ScoredSegmentInput, SegmentFeatures } from "@rakazo/media";
|
||||
import {
|
||||
@ -115,9 +115,7 @@ export async function loadPlanContext(
|
||||
assetId: mark.assetId,
|
||||
personId: mark.personId,
|
||||
})),
|
||||
profiles: new Map(
|
||||
[...profileSet.profiles.entries()].map(([id, entry]) => [id, entry.profile]),
|
||||
),
|
||||
profiles: new Map([...profileSet.profiles.entries()].map(([id, entry]) => [id, entry.profile])),
|
||||
};
|
||||
}
|
||||
|
||||
@ -153,7 +151,10 @@ export function buildEdlContext(
|
||||
),
|
||||
artifactIds: new Set(kitArtifactIds(ctx.kit)),
|
||||
music: new Map(
|
||||
(ctx.kit?.musicBeds ?? []).map((bed) => [bed.artifactId, { commercialUse: bed.commercialUse }]),
|
||||
(ctx.kit?.musicBeds ?? []).map((bed) => [
|
||||
bed.artifactId,
|
||||
{ commercialUse: bed.commercialUse },
|
||||
]),
|
||||
),
|
||||
markIds: ctx.markIds,
|
||||
personIds: new Set(ctx.people.map((person) => person.id)),
|
||||
@ -221,7 +222,9 @@ async function classifierFrames(
|
||||
let data: unknown = row.data;
|
||||
if ((data === null || data === undefined) && row.dataPath) {
|
||||
try {
|
||||
data = JSON.parse(await readFile(await resolveInside(deps.mediaRoot, row.dataPath), "utf8"));
|
||||
data = JSON.parse(
|
||||
await readFile(await resolveInside(deps.mediaRoot, row.dataPath), "utf8"),
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@ -232,7 +235,9 @@ async function classifierFrames(
|
||||
assetId,
|
||||
frames.filter(
|
||||
(frame): frame is ClassifierFrames[number] =>
|
||||
typeof frame?.tMs === "number" && typeof frame.scores === "object" && frame.scores !== null,
|
||||
typeof frame?.tMs === "number" &&
|
||||
typeof frame.scores === "object" &&
|
||||
frame.scores !== null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -263,18 +268,34 @@ function protectedPersonIds(ctx: PlanContext): Set<string> {
|
||||
);
|
||||
}
|
||||
|
||||
function showsProtectedFace(
|
||||
ctx: PlanContext,
|
||||
/**
|
||||
* Whether a segment may show something the user protects: a face of a
|
||||
* protected person (or an unassigned face while anyone is protected), or a
|
||||
* treated identifying mark of its asset or of a person visible in it. Such
|
||||
* segments never go to the model, even with consent.
|
||||
*/
|
||||
export function showsProtectedDetail(
|
||||
ctx: Pick<PlanContext, "faceTracks" | "marks">,
|
||||
protectedIds: Set<string>,
|
||||
segment: { assetId: string; startMs: number; endMs: number },
|
||||
): boolean {
|
||||
if (protectedIds.size === 0) return false;
|
||||
return ctx.faceTracks.some(
|
||||
const tracks = ctx.faceTracks.filter(
|
||||
(track) =>
|
||||
track.assetId === segment.assetId &&
|
||||
track.startMs < segment.endMs &&
|
||||
track.endMs > segment.startMs &&
|
||||
(track.personId === null || protectedIds.has(track.personId)),
|
||||
track.endMs > segment.startMs,
|
||||
);
|
||||
if (
|
||||
protectedIds.size > 0 &&
|
||||
tracks.some((track) => track.personId === null || protectedIds.has(track.personId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const visible = new Set(tracks.flatMap((track) => (track.personId ? [track.personId] : [])));
|
||||
return ctx.marks.some(
|
||||
(mark) =>
|
||||
mark.treatment !== "none" &&
|
||||
(mark.assetId === segment.assetId || (mark.personId !== null && visible.has(mark.personId))),
|
||||
);
|
||||
}
|
||||
|
||||
@ -306,7 +327,7 @@ async function judgeCandidates(
|
||||
const eligible = top.filter(
|
||||
({ segment }) =>
|
||||
classifierPasses(classifier.get(segment.assetId), segment, profile) &&
|
||||
!showsProtectedFace(ctx, protectedIds, segment),
|
||||
!showsProtectedDetail(ctx, protectedIds, segment),
|
||||
);
|
||||
if (eligible.length < 2) return null;
|
||||
|
||||
@ -325,9 +346,7 @@ async function judgeCandidates(
|
||||
try {
|
||||
await deps.renderer.extractFrame({
|
||||
path: await resolveInside(deps.mediaRoot, source),
|
||||
tMs: isVideo
|
||||
? Math.round((candidate.segment.startMs + candidate.segment.endMs) / 2)
|
||||
: 0,
|
||||
tMs: isVideo ? Math.round((candidate.segment.startMs + candidate.segment.endMs) / 2) : 0,
|
||||
outPath,
|
||||
width: JUDGE_FRAME_WIDTH,
|
||||
});
|
||||
@ -404,16 +423,36 @@ function rationaleFor(input: {
|
||||
}
|
||||
if (edl.intro) lines.push("Brand intro opens the video.");
|
||||
else if (ctx.kit?.intro) lines.push("Brand intro left out so the hook comes first.");
|
||||
if (ctx.segments.some((segment) => segment.paidOnly)) lines.push("Paid-only segments excluded.");
|
||||
const paidOnly = new Set(ctx.segments.filter((s) => s.paidOnly).map((s) => s.id));
|
||||
if (
|
||||
paidOnly.size > 0 &&
|
||||
!edl.tracks.video.some((c) => c.segmentId && paidOnly.has(c.segmentId))
|
||||
) {
|
||||
lines.push("Paid-only segments excluded.");
|
||||
}
|
||||
if (input.judgement) {
|
||||
const reasons = input.judgement.order
|
||||
.slice(0, 3)
|
||||
.map((id) => `${id}: ${input.judgement?.reasons.get(id) ?? ""}`.trim());
|
||||
lines.push(`Model ranking applied (${input.judgement.order.length} ranked): ${reasons.join("; ")}`);
|
||||
lines.push(
|
||||
`Model ranking applied (${input.judgement.order.length} ranked): ${reasons.join("; ")}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n").slice(0, RATIONALE_MAX_CHARS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teasers end on a call to action that follows the profile's link policy
|
||||
* (spec 8). The tracking link itself goes into the caption or bio, never into
|
||||
* the picture: it only exists once the render is published.
|
||||
*/
|
||||
export function defaultCtaText(goal: MediaProjectGoal, profile: PlatformProfile): string | null {
|
||||
if (goal !== "teaser") return null;
|
||||
if (profile.text.linkPolicy === "allowed") return "Link below";
|
||||
if (profile.text.linkPolicy === "bio_only") return "Link in bio";
|
||||
return "Full video on my profile";
|
||||
}
|
||||
|
||||
function requireProfile(ctx: PlanContext, profileId: string): PlatformProfile {
|
||||
if (!ctx.project.profileIds.includes(profileId)) {
|
||||
throw new InvalidMediaInputError(`Profile ${profileId} is not part of this project`);
|
||||
@ -434,8 +473,7 @@ export async function autoPlan(
|
||||
const profiles = profileIds.map((id) => requireProfile(ctx, id));
|
||||
const model = await deps.repos.getTeaserModel(actor.spaceId);
|
||||
const learned = Boolean(model && model.sampleCount >= TEASER_LEARNING_MIN_SAMPLES);
|
||||
const weights =
|
||||
learned && model ? model.weights : defaultTeaserWeights(ctx.project.goal);
|
||||
const weights = learned && model ? model.weights : defaultTeaserWeights(ctx.project.goal);
|
||||
const base = scoredInputs(ctx);
|
||||
const assets = new Map(
|
||||
[...ctx.assets.values()].map((asset) => [
|
||||
@ -464,17 +502,25 @@ export async function autoPlan(
|
||||
? await judgeCandidates(deps, actor, ctx, profile, base, weights)
|
||||
: null;
|
||||
const segments = judgement ? applyRanking(base, judgement.order) : base;
|
||||
const edl = autoPlanEdl({
|
||||
goal: ctx.project.goal,
|
||||
profile,
|
||||
brandKit: ctx.kit,
|
||||
segments,
|
||||
weights,
|
||||
assets,
|
||||
people,
|
||||
marks: ctx.marks,
|
||||
ctaText: typeof endCardCta === "string" ? endCardCta : null,
|
||||
});
|
||||
let edl: Edl;
|
||||
try {
|
||||
edl = autoPlanEdl({
|
||||
goal: ctx.project.goal,
|
||||
profile,
|
||||
brandKit: ctx.kit,
|
||||
segments,
|
||||
weights,
|
||||
assets,
|
||||
people,
|
||||
marks: ctx.marks,
|
||||
ctaText:
|
||||
typeof endCardCta === "string" ? endCardCta : defaultCtaText(ctx.project.goal, profile),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new MediaStateConflictError(
|
||||
`No automatic plan for ${profile.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const validation = validateEdl(edl, buildEdlContext(deps, ctx, profile));
|
||||
if (!validation.ok) {
|
||||
throw new InvalidMediaInputError(
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { access, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { BrandKit, ContentCategory, Edl, MediaRenderOutput, PlatformProfile } from "@rakazo/contracts";
|
||||
import type {
|
||||
BrandKit,
|
||||
ContentCategory,
|
||||
Edl,
|
||||
MediaRenderOutput,
|
||||
PlatformProfile,
|
||||
} from "@rakazo/contracts";
|
||||
import { MediaStateConflictError } from "@rakazo/db";
|
||||
import {
|
||||
clipLengthMs,
|
||||
@ -21,8 +27,8 @@ import {
|
||||
materializeArtifact,
|
||||
publicRenderOutput,
|
||||
renderOutputPath,
|
||||
storeMediaArtifact,
|
||||
storedRenderOutputs,
|
||||
storeMediaArtifact,
|
||||
} from "./media-marketing-files.js";
|
||||
|
||||
/*
|
||||
@ -197,6 +203,9 @@ export async function createThumbnails(
|
||||
tools: MediaToolPaths = {},
|
||||
): Promise<MediaRenderOutput[]> {
|
||||
const { spaceId } = actor;
|
||||
// Fail closed: every variant is re-checked against the profile, which needs inference.
|
||||
const inference = deps.inference;
|
||||
if (!inference) throw new MediaStateConflictError("Thumbnails need the media worker");
|
||||
const row = await deps.repos.getRenderRecord(spaceId, input.renderId);
|
||||
if (row.status !== "done") throw new MediaStateConflictError("Thumbnails need a finished render");
|
||||
const outputs = storedRenderOutputs(row.outputs);
|
||||
@ -238,8 +247,16 @@ export async function createThumbnails(
|
||||
const measured: Array<{ tMs: number } & FrameMeasure & { face: number }> = [];
|
||||
for (const tMs of thumbnailCandidateTimes(edl, durationMs)) {
|
||||
const file = path.join(candidateDir, `${tMs}.png`);
|
||||
await deps.renderer.extractFrame({ path: videoPath, tMs, outPath: file, width: ANALYSIS_WIDTH });
|
||||
const { data, info } = await sharp(file).greyscale().raw().toBuffer({ resolveWithObject: true });
|
||||
await deps.renderer.extractFrame({
|
||||
path: videoPath,
|
||||
tMs,
|
||||
outPath: file,
|
||||
width: ANALYSIS_WIDTH,
|
||||
});
|
||||
const { data, info } = await sharp(file)
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const at = locateOutputTime(edl, tMs);
|
||||
const face =
|
||||
at &&
|
||||
@ -307,21 +324,19 @@ export async function createThumbnails(
|
||||
} else {
|
||||
await rename(frame, out);
|
||||
}
|
||||
if (deps.inference) {
|
||||
const content = await deps.inference.classifyContent({
|
||||
path: out,
|
||||
sampleFps: 1,
|
||||
extraTimesMs: [0],
|
||||
});
|
||||
let rejected = exceedsThresholds(content.frames, profile);
|
||||
if (!rejected && protectsFaces) {
|
||||
const faces = await deps.inference.detectFaces({ path: out, sampleFps: 1, timesMs: [0] });
|
||||
rejected = faces.frames.some((f) => f.faces.length > 0);
|
||||
}
|
||||
if (rejected) {
|
||||
await rm(out, { force: true });
|
||||
continue;
|
||||
}
|
||||
const content = await inference.classifyContent({
|
||||
path: out,
|
||||
sampleFps: 1,
|
||||
extraTimesMs: [0],
|
||||
});
|
||||
let rejected = content.frames.length === 0 || exceedsThresholds(content.frames, profile);
|
||||
if (!rejected && protectsFaces) {
|
||||
const faces = await inference.detectFaces({ path: out, sampleFps: 1, timesMs: [0] });
|
||||
rejected = faces.frames.length === 0 || faces.frames.some((f) => f.faces.length > 0);
|
||||
}
|
||||
if (rejected) {
|
||||
await rm(out, { force: true });
|
||||
continue;
|
||||
}
|
||||
const bytes = await readFile(out);
|
||||
const artifactId = await storeMediaArtifact(deps, actor, {
|
||||
|
||||
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 type { NewMediaMetric, TeaserModelRecord } from "@rakazo/db";
|
||||
import { InvalidMediaInputError } from "@rakazo/db";
|
||||
@ -67,13 +67,18 @@ export async function refitTeaserModel(
|
||||
if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null;
|
||||
const plans = new Map<string, Edl>();
|
||||
const samples: Array<{ edl: Edl; views: number; conversions: number }> = [];
|
||||
const planOfRender = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const publication = await repos.getPublication(spaceId, row.publicationId);
|
||||
const render = await repos.getRender(spaceId, publication.renderId);
|
||||
let edl = plans.get(render.planId);
|
||||
let planId = planOfRender.get(publication.renderId);
|
||||
if (!planId) {
|
||||
planId = (await repos.getRender(spaceId, publication.renderId)).planId;
|
||||
planOfRender.set(publication.renderId, planId);
|
||||
}
|
||||
let edl = plans.get(planId);
|
||||
if (!edl) {
|
||||
edl = (await repos.getPlan(spaceId, render.planId)).edl;
|
||||
plans.set(render.planId, edl);
|
||||
edl = (await repos.getPlan(spaceId, planId)).edl;
|
||||
plans.set(planId, edl);
|
||||
}
|
||||
samples.push({ edl, views: row.views, conversions: row.conversions });
|
||||
}
|
||||
@ -92,6 +97,25 @@ export async function refitTeaserModel(
|
||||
return repos.putTeaserModel(spaceId, weights, usable.length);
|
||||
}
|
||||
|
||||
/** Manual metric entry (spec 10.6); refits the teaser weights like an import. */
|
||||
export async function recordMetric(
|
||||
deps: Pick<MediaServiceDeps, "repos">,
|
||||
actor: MediaActor,
|
||||
metric: Omit<NewMediaMetric, "source">,
|
||||
): Promise<MediaMetric> {
|
||||
const recorded = await deps.repos.recordMetric(actor.spaceId, { ...metric, source: "manual" });
|
||||
await refitTeaserModel(deps, actor.spaceId);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/** Forget the learned weights; plans use the defaults until the next refit. */
|
||||
export async function resetTeaserModel(
|
||||
deps: Pick<MediaServiceDeps, "repos">,
|
||||
actor: MediaActor,
|
||||
): Promise<void> {
|
||||
await deps.repos.resetTeaserModel(actor.spaceId);
|
||||
}
|
||||
|
||||
export async function importMetricsCsv(
|
||||
deps: Pick<MediaServiceDeps, "repos">,
|
||||
actor: MediaActor,
|
||||
|
||||
@ -2,13 +2,15 @@ import { copyFile, link, mkdir, readFile, rm, writeFile } from "node:fs/promises
|
||||
import path from "node:path";
|
||||
import type { MediaJobHandlers } from "@rakazo/adapter-kit";
|
||||
import { mediaPublishJob } from "@rakazo/adapter-kit";
|
||||
import type { MediaPublication, Utm } from "@rakazo/contracts";
|
||||
import type { MediaPublication, MediaRender, PlatformProfile, Utm } from "@rakazo/contracts";
|
||||
import { ATTACHMENT_MAX_BYTES, UtmSchema } from "@rakazo/contracts";
|
||||
import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
import { buildTrackingUrl, normalizeHashtags, uploadChecklist } from "@rakazo/media";
|
||||
import { resolveInside } from "@rakazo/media/node";
|
||||
import type { MediaActor, MediaMarketingService, MediaServiceDeps } from "./media-context.js";
|
||||
import type { MediaToolPaths } from "./media-folder-source-probe.js";
|
||||
import { runMediaTool } from "./media-folder-source-probe.js";
|
||||
import type { StoredRenderOutput } from "./media-marketing-files.js";
|
||||
import {
|
||||
renderOutputPath,
|
||||
@ -38,6 +40,7 @@ import { writeStoreZip } from "./media-zip.js";
|
||||
|
||||
export const BUNDLE_FILES = {
|
||||
video: "video.mp4",
|
||||
preview: "preview.mp4",
|
||||
posterPng: "poster.png",
|
||||
posterJpg: "poster.jpg",
|
||||
srt: "subtitles.srt",
|
||||
@ -63,6 +66,26 @@ async function publishEvent(deps: MediaServiceDeps, spaceId: string, pub: MediaP
|
||||
});
|
||||
}
|
||||
|
||||
const THUMBNAIL_VARIANT = /^thumb-[a-j]$/;
|
||||
|
||||
/**
|
||||
* Fail closed: only a final render whose stored gate report passed may leave
|
||||
* the host as a publication or bundle. Drafts carry no export guarantee.
|
||||
*/
|
||||
export function assertPublishable(
|
||||
render: Pick<MediaRender, "status" | "quality" | "gateReport">,
|
||||
): void {
|
||||
if (render.status !== "done") {
|
||||
throw new MediaStateConflictError("Only a finished render can be published");
|
||||
}
|
||||
if (render.quality !== "final") {
|
||||
throw new MediaStateConflictError("Only a final render can be published");
|
||||
}
|
||||
if (!render.gateReport?.passed) {
|
||||
throw new MediaStateConflictError("The render has not passed the platform gate");
|
||||
}
|
||||
}
|
||||
|
||||
/** "9x16"-style format of an output size. */
|
||||
function aspectLabel(width: number, height: number): string {
|
||||
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
|
||||
@ -87,8 +110,9 @@ export async function createPublication(
|
||||
): Promise<MediaPublication> {
|
||||
const { spaceId } = actor;
|
||||
const render = await deps.repos.getRender(spaceId, input.renderId);
|
||||
if (render.status !== "done") {
|
||||
throw new MediaStateConflictError("Only a finished render can be published");
|
||||
assertPublishable(render);
|
||||
if (input.thumbnailVariant !== null && !THUMBNAIL_VARIANT.test(input.thumbnailVariant)) {
|
||||
throw new InvalidMediaInputError("Unknown thumbnail variant");
|
||||
}
|
||||
const [plan, project] = await Promise.all([
|
||||
deps.repos.getPlan(spaceId, render.planId),
|
||||
@ -160,6 +184,43 @@ async function placeFile(source: string, target: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening of the final video as a preview clip, for profiles that ask for
|
||||
* one (spec 8, paid funnel). Stream copy keeps the gated pixels and audio
|
||||
* unchanged; container metadata is dropped like in every other output.
|
||||
*/
|
||||
export async function cutPreviewClip(
|
||||
videoPath: string,
|
||||
outPath: string,
|
||||
durationMs: number,
|
||||
tools: MediaToolPaths = {},
|
||||
): Promise<void> {
|
||||
await runMediaTool(tools.ffmpegPath ?? "ffmpeg", [
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
videoPath,
|
||||
"-t",
|
||||
(durationMs / 1000).toFixed(3),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c",
|
||||
"copy",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-map_chapters",
|
||||
"-1",
|
||||
"-fflags",
|
||||
"+bitexact",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
outPath,
|
||||
]);
|
||||
}
|
||||
|
||||
function disclosureText(alterations: string[]): string {
|
||||
return [
|
||||
`This video contains AI alterations: ${alterations.join(", ")}.`,
|
||||
@ -172,12 +233,14 @@ export async function buildBundle(
|
||||
deps: MediaServiceDeps,
|
||||
actor: MediaActor,
|
||||
publicationId: string,
|
||||
tools: MediaToolPaths = {},
|
||||
): Promise<MediaPublication> {
|
||||
const { spaceId } = actor;
|
||||
const pub = await deps.repos.getPublication(spaceId, publicationId);
|
||||
if (pub.status === "canceled" || pub.status === "published") {
|
||||
throw new MediaStateConflictError(`Publication is already ${pub.status}`);
|
||||
}
|
||||
assertPublishable(await deps.repos.getRender(spaceId, pub.renderId));
|
||||
const row = await deps.repos.getRenderRecord(spaceId, pub.renderId);
|
||||
const [plan, profileSet] = await Promise.all([
|
||||
deps.repos.getPlan(spaceId, row.planId),
|
||||
@ -206,12 +269,21 @@ export async function buildBundle(
|
||||
};
|
||||
const sourceOf = (output: StoredRenderOutput) => renderOutputPath(deps, spaceId, row.id, output);
|
||||
|
||||
await add(BUNDLE_FILES.video, await sourceOf(video));
|
||||
const videoPath = await sourceOf(video);
|
||||
await add(BUNDLE_FILES.video, videoPath);
|
||||
if (profile.export.previewClipMs) {
|
||||
const target = path.join(dir, BUNDLE_FILES.preview);
|
||||
await cutPreviewClip(videoPath, target, profile.export.previewClipMs, tools);
|
||||
entries.push({ name: BUNDLE_FILES.preview, path: target });
|
||||
}
|
||||
|
||||
if (profile.export.poster) {
|
||||
const variants = new Map(
|
||||
Array.from({ length: MAX_THUMBNAIL_VARIANTS }, (_, i) => thumbnailVariantName(i)).map(
|
||||
(name) => [name, outputs.find((o) => o.kind === "thumbnail" && o.fileName === `${name}.png`)],
|
||||
(name) => [
|
||||
name,
|
||||
outputs.find((o) => o.kind === "thumbnail" && o.fileName === `${name}.png`),
|
||||
],
|
||||
),
|
||||
);
|
||||
const chosen =
|
||||
@ -302,18 +374,28 @@ export async function buildBundle(
|
||||
return bundled;
|
||||
}
|
||||
|
||||
/** Tells the publication's author that a scheduled bundle is ready to upload by hand. */
|
||||
export type MediaUploadReminder = (input: {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
publication: MediaPublication;
|
||||
profile: Pick<PlatformProfile, "id" | "name">;
|
||||
}) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Worker handler for `media.publish`: build the bundle at the scheduled time,
|
||||
* hand it to the configured publisher, and record the outcome. A canceled or
|
||||
* already published publication is left alone. Failures mark the publication
|
||||
* failed and rethrow so the job queue retries; a retry rebuilds from scratch.
|
||||
*
|
||||
* No notification is sent here: MediaServiceDeps has no notification provider,
|
||||
* so clients learn about the bundle through the realtime publication event.
|
||||
* hand it to the configured publisher, and record the outcome. Paid-video
|
||||
* profiles only ever get the bundle (spec 10.5). When nothing was published,
|
||||
* `remind` (the composition root's push channel) tells the author to upload the
|
||||
* finished bundle; clients also see the realtime publication event. A canceled
|
||||
* or already published publication is left alone. Failures mark the
|
||||
* publication failed and rethrow so the job queue retries; a retry rebuilds
|
||||
* the bundle from scratch.
|
||||
*/
|
||||
export function createMediaPublishHandler(
|
||||
deps: MediaServiceDeps,
|
||||
marketing: Pick<MediaMarketingService, "bundle">,
|
||||
options: { remind?: MediaUploadReminder } = {},
|
||||
): MediaJobHandlers["media.publish"] {
|
||||
return async ({ publicationId }) => {
|
||||
const spaceId = await deps.repos.mediaSpaceOf("publication", publicationId);
|
||||
@ -321,26 +403,38 @@ export function createMediaPublishHandler(
|
||||
const record = await deps.repos.getPublicationRecord(spaceId, publicationId);
|
||||
if (record.status === "canceled" || record.status === "published") return;
|
||||
const actor = { spaceId, userId: record.createdBy };
|
||||
let bundled: MediaPublication;
|
||||
let profile: PlatformProfile;
|
||||
let published = false;
|
||||
try {
|
||||
const bundled = await marketing.bundle(actor, publicationId);
|
||||
const profile = (await deps.profiles()).profiles.get(bundled.profileId)?.profile;
|
||||
if (!profile) throw new MediaStateConflictError(`Profile ${bundled.profileId} is not available`);
|
||||
const result = await deps.publisher.publish({
|
||||
bundleDir: await resolveInside(deps.mediaRoot, bundleRelativeDir(spaceId, publicationId)),
|
||||
profile,
|
||||
publicationId,
|
||||
title: bundled.title,
|
||||
caption: bundled.caption,
|
||||
tags: bundled.tags,
|
||||
});
|
||||
if (result.status === "published") {
|
||||
const published = await deps.repos.markPublicationPublished(
|
||||
spaceId,
|
||||
bundled = await marketing.bundle(actor, publicationId);
|
||||
const found = (await deps.profiles()).profiles.get(bundled.profileId)?.profile;
|
||||
if (!found) {
|
||||
throw new MediaStateConflictError(`Profile ${bundled.profileId} is not available`);
|
||||
}
|
||||
profile = found;
|
||||
if (profile.kind !== "paid_video") {
|
||||
const result = await deps.publisher.publish({
|
||||
bundleDir: await resolveInside(deps.mediaRoot, bundleRelativeDir(spaceId, publicationId)),
|
||||
profile,
|
||||
publicationId,
|
||||
result.externalUrl,
|
||||
deps.now(),
|
||||
);
|
||||
await publishEvent(deps, spaceId, published);
|
||||
title: bundled.title,
|
||||
caption: bundled.caption,
|
||||
tags: bundled.tags,
|
||||
});
|
||||
if (result.status === "published") {
|
||||
published = true;
|
||||
await publishEvent(
|
||||
deps,
|
||||
spaceId,
|
||||
await deps.repos.markPublicationPublished(
|
||||
spaceId,
|
||||
publicationId,
|
||||
result.externalUrl,
|
||||
deps.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const current = await deps.repos.getPublication(spaceId, publicationId);
|
||||
@ -353,5 +447,20 @@ export function createMediaPublishHandler(
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (published || !options.remind) return;
|
||||
try {
|
||||
await options.remind({
|
||||
spaceId,
|
||||
userId: record.createdBy,
|
||||
publication: bundled,
|
||||
profile: { id: profile.id, name: profile.name },
|
||||
});
|
||||
} catch (error) {
|
||||
// The bundle is ready and visible in the app; a lost push is logged, not retried.
|
||||
getLogger().warn("media upload reminder failed", {
|
||||
publicationId,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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