import type { Edl, MarketingCopy, MediaSegment, PlatformProfile } from "@rakazo/contracts"; import { MarketingCopySchema } from "@rakazo/contracts"; import { InvalidMediaInputError, MediaStateConflictError } from "@rakazo/db"; import { checkCopy, edlDurationMs, isShortForm, normalizeHashtags } from "@rakazo/media"; import { z } from "zod"; import type { MediaActor, MediaServiceDeps } from "./media-context.js"; /* * Platform copy for a render (spec 10.2). The model writes from the plan's * metadata and the transcript of the clips actually used, under the * profile's limits; it is told to state nothing the material does not show. * Without a model (or consent) the copy comes from the plan metadata and the * project title only. Either way hashtags are normalized and capped, long * fields are cut at a word boundary to the profile limits, and the result is * checked like the gate's text check; findings are returned as `issues`. */ const TRANSCRIPT_CHARS = 1500; const CHAPTER_MIN_PROGRAM_MS = 60_000; const CopyReplySchema = z.object({ title: z.string().max(200), hook: z.string().max(200), caption: z.string().max(5000), hashtags: z.array(z.string().max(60)).max(50), description: z.string().max(5000).optional(), chapters: z .array(z.object({ atMs: z.number().int().min(0), title: z.string().max(100) })) .max(100) .optional(), altText: z.string().max(500).optional(), }); type CopyReply = z.infer; const LANGUAGE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?$/; /** Cut to `max` characters, at the last word boundary when there is one. */ export function truncateText(value: string, max: number): string { const chars = [...value.trim()]; if (chars.length <= max) return value.trim(); const cut = chars.slice(0, Math.max(0, max)).join(""); const space = cut.lastIndexOf(" "); return (space > max * 0.6 ? cut.slice(0, space) : cut).trim(); } function transcriptOf(edl: Edl, segments: MediaSegment[]): string { const byId = new Map(segments.map((segment) => [segment.id, segment])); const parts: string[] = []; for (const clip of [...edl.tracks.video].sort((a, b) => a.atMs - b.atMs)) { const linked = clip.segmentId ? byId.get(clip.segmentId) : undefined; const overlapping = linked ? [linked] : segments.filter( (s) => s.assetId === clip.assetId && s.startMs < clip.outMs && s.endMs > clip.inMs, ); for (const segment of overlapping) { const text = segment.transcriptExcerpt?.replace(/\s+/g, " ").trim(); if (text && !parts.includes(text)) parts.push(text); } } return parts.join(" ").slice(0, TRANSCRIPT_CHARS); } function wantsChapters(profile: PlatformProfile, edl: Edl): boolean { return !isShortForm(profile) && edlDurationMs(edl) >= CHAPTER_MIN_PROGRAM_MS; } function fallbackCopy(edl: Edl, projectTitle: string, transcript: string): CopyReply { const title = edl.metadata.title?.trim() || projectTitle; const sentence = transcript.split(/(?<=[.!?])\s+/)[0]?.trim() ?? ""; return { title, hook: sentence && sentence.length <= 120 ? sentence : title, caption: edl.metadata.description?.trim() || title, hashtags: edl.metadata.tags, ...(edl.metadata.description ? { description: edl.metadata.description } : {}), }; } function instructions(profile: PlatformProfile, language: string, chapters: boolean): string { const { text } = profile; const link = text.linkPolicy === "allowed" ? "Links are allowed." : text.linkPolicy === "bio_only" ? 'Never put a link in the text; say "link in bio" if a call to action needs one.' : "Never mention or include links."; return [ `You write social video copy in the language "${language}" for ${profile.name}.`, "Use only facts from the given title, description, tags and transcript. Never invent claims, prices, results, quotes, guarantees or urgency.", "No clickbait that misrepresents the video, no engagement bait, no promises the video does not keep.", `Limits: title ${text.titleMaxChars ?? 100} characters, hook 120 characters, caption ${text.captionMaxChars} characters, at most ${text.hashtagLimit} hashtags without the # sign.`, link, chapters ? "Add chapters (atMs from 0, at least 10 s apart) only where the transcript shows topic changes." : "Do not add chapters.", "altText describes what is visible for screen readers, factually.", 'Reply with one JSON object: {"title","hook","caption","hashtags":[],"description","chapters":[{"atMs","title"}],"altText"}.', ].join("\n"); } export async function generateCopy( deps: MediaServiceDeps, actor: MediaActor, input: { renderId: string; language: string }, ): Promise { const language = input.language.trim(); if (!LANGUAGE.test(language)) throw new InvalidMediaInputError("Invalid language code"); const { spaceId } = actor; const render = await deps.repos.getRender(spaceId, input.renderId); const [plan, profileSet, project] = await Promise.all([ deps.repos.getPlan(spaceId, render.planId), deps.profiles(), deps.repos.getProjectRecord(spaceId, render.projectId), ]); const profile = profileSet.profiles.get(render.profileId)?.profile; if (!profile) throw new MediaStateConflictError(`Profile ${render.profileId} is not available`); const edl = plan.edl; const segments = await deps.repos.listSegments(spaceId, [ ...new Set(edl.tracks.video.map((clip) => clip.assetId)), ]); const transcript = transcriptOf(edl, segments); const chapters = wantsChapters(profile, edl); const durationMs = edlDurationMs(edl); const reply = await deps.model.generate({ spaceId, userId: actor.userId, instructions: instructions(profile, language, chapters), prompt: JSON.stringify({ projectTitle: project.title, title: edl.metadata.title ?? null, description: edl.metadata.description ?? null, tags: edl.metadata.tags, durationMs, transcript: transcript || null, }), schema: CopyReplySchema, }); const draft = reply ?? fallbackCopy(edl, project.title, transcript); const title = truncateText(draft.title, Math.min(200, profile.text.titleMaxChars ?? 200)); const caption = truncateText(draft.caption, Math.min(5000, profile.text.captionMaxChars)); const hashtags = normalizeHashtags(draft.hashtags, profile.text.hashtagLimit).map((tag) => tag.slice(0, 60), ); const chapterList = chapters ? (draft.chapters ?? []) .filter((chapter) => chapter.atMs < durationMs && chapter.title.trim()) .sort((a, b) => a.atMs - b.atMs) : []; const hook = truncateText(draft.hook, 200); const description = draft.description ? truncateText(draft.description, 5000) : undefined; const altText = draft.altText?.trim() ? truncateText(draft.altText, 500) : undefined; // The other fields get the same banned-pattern and link checks as the caption. const extraIssues = checkCopy(profile, { title: null, caption: [hook, description, altText].filter(Boolean).join("\n"), tags: [], }) .filter((issue) => issue.code === "banned_pattern" || issue.code.startsWith("link_")) .map((issue) => ({ ...issue, message: issue.message.replace(/^caption/, "hook or description"), })); return MarketingCopySchema.parse({ profileId: profile.id, language, title, hook, caption, hashtags, ...(description ? { description } : {}), ...(chapterList.length > 0 ? { chapters: chapterList } : {}), ...(altText ? { altText } : {}), issues: [...checkCopy(profile, { title, caption, tags: hashtags }), ...extraIssues], }); }