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 `/bundles//` and is rebuilt from * scratch on every call. Every file name is a constant from BUNDLE_FILES (or a * thumbnail variant name), never user input, and the ZIP writer only accepts * flat names, so extracting the archive cannot escape its folder. The ZIP * becomes an artifact when it fits the attachment limit (ATTACHMENT_MAX_BYTES, * the same limit chat artifacts have); larger bundles stay as `bundle.zip` in * the folder for the media file route and bundleArtifactId stays null. */ export const BUNDLE_FILES = { video: "video.mp4", 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, ): 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 { 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 { const publication = await deps.repos.cancelPublication(actor.spaceId, publicationId); await deps.jobs.cancel(mediaPublishJob(publicationId).replaceKey as string); await publishEvent(deps, actor.spaceId, publication); return publication; } async function placeFile(source: string, target: string): Promise { try { await link(source, target); } catch { await copyFile(source, target); } } /** * The opening of the final video as a preview clip, for profiles that ask for * one (spec 8, paid funnel). Stream copy keeps the gated pixels and audio * unchanged; container metadata is dropped like in every other output. */ export async function cutPreviewClip( videoPath: string, outPath: string, durationMs: number, tools: MediaToolPaths = {}, ): Promise { await runMediaTool(tools.ffmpegPath ?? "ffmpeg", [ "-v", "error", "-y", "-i", videoPath, "-t", (durationMs / 1000).toFixed(3), "-map", "0:v:0", "-map", "0:a?", "-c", "copy", "-map_metadata", "-1", "-map_chapters", "-1", "-fflags", "+bitexact", "-movflags", "+faststart", outPath, ]); } function disclosureText(alterations: string[]): string { return [ `This video contains AI alterations: ${alterations.join(", ")}.`, "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 { 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; }) => Promise; /** * 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, 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), }); } }; }