evOo-local/packages/adapters/src/media-publish.ts

467 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, 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,
slugify,
storedRenderOutputs,
storeMediaArtifact,
} from "./media-marketing-files.js";
import { thumbnailVariantName } from "./media-marketing-thumbnails.js";
import type { ZipEntry } from "./media-zip.js";
import { writeStoreZip } from "./media-zip.js";
/*
* Publications: tracking links, scheduling, and the export bundle (spec 10.3–10.5).
*
* A scheduled publication is a `media.publish` background job with
* `availableAt` (Graphile scheduling, as routines use), keyed per publication
* so rescheduling replaces it and canceling removes it.
*
* The bundle lives in `<spaceId>/bundles/<publicationId>/` and is rebuilt from
* scratch on every call. Every file name is a constant from BUNDLE_FILES (or a
* thumbnail variant name), never user input, and the ZIP writer only accepts
* flat names, so extracting the archive cannot escape its folder. The ZIP
* becomes an artifact when it fits the attachment limit (ATTACHMENT_MAX_BYTES,
* the same limit chat artifacts have); larger bundles stay as `bundle.zip` in
* the folder for the media file route and bundleArtifactId stays null.
*/
export const BUNDLE_FILES = {
video: "video.mp4",
preview: "preview.mp4",
posterPng: "poster.png",
posterJpg: "poster.jpg",
srt: "subtitles.srt",
vtt: "subtitles.vtt",
caption: "caption.txt",
metadata: "metadata.json",
checklist: "UPLOAD-CHECKLIST.md",
disclosure: "AI-DISCLOSURE.txt",
zip: "bundle.zip",
} as const;
const MAX_THUMBNAIL_VARIANTS = 10;
export function bundleRelativeDir(spaceId: string, publicationId: string): string {
return path.join(spaceId, "bundles", publicationId);
}
async function publishEvent(deps: MediaServiceDeps, spaceId: string, pub: MediaPublication) {
await deps.publish(spaceId, {
type: "media.publication",
publicationId: pub.id,
status: pub.status,
});
}
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));
const divisor = gcd(width, height) || 1;
return `${width / divisor}x${height / divisor}`;
}
export async function createPublication(
deps: MediaServiceDeps,
actor: MediaActor,
input: {
renderId: string;
destination: string;
targetUrl: string | null;
utm: Utm | null;
title: string | null;
caption: string | null;
tags: string[];
thumbnailVariant: string | null;
scheduledAt: string | null;
},
): Promise<MediaPublication> {
const { spaceId } = actor;
const render = await deps.repos.getRender(spaceId, input.renderId);
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),
deps.repos.getProjectRecord(spaceId, render.projectId),
]);
const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null;
if (scheduledAt && Number.isNaN(scheduledAt.getTime())) {
throw new InvalidMediaInputError("Invalid schedule time");
}
const utm = UtmSchema.parse(
input.utm ?? {
source: render.profileId,
medium: `video-${aspectLabel(plan.edl.output.width, plan.edl.output.height)}`,
campaign: slugify(project.title) || project.id.slice(0, 100),
content: (input.thumbnailVariant ?? render.id).slice(0, 100),
},
);
let trackingUrl: string | null = null;
if (input.targetUrl) {
try {
trackingUrl = buildTrackingUrl(input.targetUrl, utm);
} catch (error) {
throw new InvalidMediaInputError(error instanceof Error ? error.message : "Invalid target");
}
if (deps.shortener) {
try {
trackingUrl = await deps.shortener.shorten(trackingUrl);
} catch (error) {
getLogger().warn("media link shortener failed; keeping the full tracking link", {
shortener: deps.shortener.id,
reason: error instanceof Error ? error.message : String(error),
});
}
}
}
const publication = await deps.repos.createPublication(spaceId, actor.userId, {
renderId: render.id,
destination: input.destination.trim() || "bundle",
targetUrl: input.targetUrl,
trackingUrl,
utm,
title: input.title,
caption: input.caption,
tags: input.tags,
thumbnailVariant: input.thumbnailVariant,
scheduledAt,
});
if (scheduledAt) await deps.jobs.enqueue(mediaPublishJob(publication.id, scheduledAt));
await publishEvent(deps, spaceId, publication);
return publication;
}
export async function cancelPublication(
deps: MediaServiceDeps,
actor: MediaActor,
publicationId: string,
): Promise<MediaPublication> {
const publication = await deps.repos.cancelPublication(actor.spaceId, publicationId);
await deps.jobs.cancel(mediaPublishJob(publicationId).replaceKey as string);
await publishEvent(deps, actor.spaceId, publication);
return publication;
}
async function placeFile(source: string, target: string): Promise<void> {
try {
await link(source, target);
} catch {
await copyFile(source, target);
}
}
/**
* 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(", ")}.`,
"Set the platform's label for AI-generated or altered content when you upload it.",
"",
].join("\n");
}
export async function buildBundle(
deps: MediaServiceDeps,
actor: MediaActor,
publicationId: string,
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),
deps.profiles(),
]);
const profile = profileSet.profiles.get(pub.profileId)?.profile;
if (!profile) throw new MediaStateConflictError(`Profile ${pub.profileId} is not available`);
const edl = plan.edl;
const outputs = storedRenderOutputs(row.outputs);
const video = outputs.find((output) => output.kind === "video");
if (!video) throw new MediaStateConflictError("Render has no video output");
const dir = await resolveInside(deps.mediaRoot, bundleRelativeDir(spaceId, pub.id));
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { recursive: true });
const entries: ZipEntry[] = [];
const add = async (name: string, source: string) => {
const target = path.join(dir, name);
await placeFile(source, target);
entries.push({ name, path: target });
};
const write = async (name: string, content: string) => {
const target = path.join(dir, name);
await writeFile(target, content, "utf8");
entries.push({ name, path: target });
};
const sourceOf = (output: StoredRenderOutput) => renderOutputPath(deps, spaceId, row.id, output);
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`),
],
),
);
const chosen =
(pub.thumbnailVariant ? variants.get(pub.thumbnailVariant) : undefined) ??
outputs.find((o) => o.kind === "poster") ??
outputs.find((o) => o.kind === "thumbnail");
if (chosen) {
const jpeg = /\.jpe?g$/i.test(chosen.fileName);
await add(jpeg ? BUNDLE_FILES.posterJpg : BUNDLE_FILES.posterPng, await sourceOf(chosen));
}
for (const [name, output] of variants) {
if (output) await add(`${name}.png`, await sourceOf(output));
}
}
let hasSubtitles = false;
if (profile.export.subtitles) {
for (const [kind, name] of [
["subtitles_srt", BUNDLE_FILES.srt],
["subtitles_vtt", BUNDLE_FILES.vtt],
] as const) {
const output = outputs.find((o) => o.kind === kind);
if (!output) continue;
await add(name, await sourceOf(output));
hasSubtitles = true;
}
}
const hashtags = normalizeHashtags(pub.tags, profile.text.hashtagLimit);
if (profile.export.captionText) {
const parts = [pub.caption?.trim() ?? "", hashtags.join(" ")];
if (pub.trackingUrl && profile.text.linkPolicy === "allowed") parts.push(pub.trackingUrl);
await write(BUNDLE_FILES.caption, `${parts.filter(Boolean).join("\n\n")}\n`);
}
const aiAlterations = [...new Set(edl.metadata.aiAlterations)].sort();
const aiAltered = edl.metadata.aiAltered || aiAlterations.length > 0;
await write(
BUNDLE_FILES.metadata,
`${JSON.stringify(
{
publicationId: pub.id,
renderId: row.id,
profile: { id: profile.id, name: profile.name, kind: profile.kind },
title: pub.title,
caption: pub.caption,
tags: hashtags,
trackingUrl: pub.trackingUrl,
utm: pub.utm,
thumbnailVariant: pub.thumbnailVariant,
aiAltered,
aiAlterations,
ageRestricted: profile.disclosure.ageRestrictedFlag,
paidPartnershipLabel: profile.disclosure.paidPartnershipLabel,
},
null,
2,
)}\n`,
);
if (profile.export.uploadChecklist) {
await write(
BUNDLE_FILES.checklist,
uploadChecklist({
profile,
aiAltered,
hasCaptions:
hasSubtitles || edl.captions?.mode === "burned" || edl.captions?.mode === "both",
trackingUrl: pub.trackingUrl,
}),
);
}
if (aiAltered) {
await write(
BUNDLE_FILES.disclosure,
disclosureText(aiAlterations.length > 0 ? aiAlterations : ["unspecified"]),
);
}
const zipPath = path.join(dir, BUNDLE_FILES.zip);
const zipBytes = await writeStoreZip(zipPath, entries);
let artifactId: string | null = null;
if (zipBytes <= ATTACHMENT_MAX_BYTES) {
artifactId = await storeMediaArtifact(deps, actor, {
name: `bundle-${slugify(profile.id, 40) || "export"}.zip`,
mimeType: "application/zip",
bytes: new Uint8Array(await readFile(zipPath)),
});
}
const bundled = await deps.repos.markPublicationBundled(spaceId, pub.id, artifactId);
await publishEvent(deps, spaceId, bundled);
return bundled;
}
/** 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. 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);
if (!spaceId) return;
const record = await deps.repos.getPublicationRecord(spaceId, publicationId);
if (record.status === "canceled" || record.status === "published") return;
const actor = { spaceId, userId: record.createdBy };
let bundled: MediaPublication;
let profile: PlatformProfile;
let published = false;
try {
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,
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);
if (current.status === "canceled") return;
if (current.status !== "failed" && current.status !== "published") {
const failed = await deps.repos
.markPublicationFailed(spaceId, publicationId)
.catch(() => null);
if (failed) await publishEvent(deps, spaceId, failed);
}
throw error;
}
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),
});
}
};
}