179 lines
7.5 KiB
TypeScript
179 lines
7.5 KiB
TypeScript
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<typeof CopyReplySchema>;
|
|
|
|
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<MarketingCopy> {
|
|
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],
|
|
});
|
|
}
|