467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
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),
|
||
});
|
||
}
|
||
};
|
||
}
|