evOo-local/packages/media/src/marketing.ts
Claude 26d95a374e
feat(media): implement pure domain logic for Media Studio
Session and theme clustering, EDL validation and timeline mapping, the
EDL-to-ffmpeg compiler with caption files and filtergraph escaping,
teaser selection and learning, the compliance gate with compliance-only
auto-fix, and the marketing helpers (UTM, hashtags, CSV metrics, intro
proposals, brand-kit checks, upload checklist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:02 +00:00

533 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 type {
BrandKit,
MetricCsvMapping,
MotionTemplateInfo,
MotionTemplateRef,
PlatformProfile,
Utm,
} from "@rakazo/contracts";
const UTM_KEYS: Array<[keyof Utm, string]> = [
["source", "utm_source"],
["medium", "utm_medium"],
["campaign", "utm_campaign"],
["content", "utm_content"],
["term", "utm_term"],
];
/**
* Append UTM parameters to an http(s) target. Rejects other schemes. Existing
* query parameters keep their exact encoding; existing utm_* values for the
* keys being set are replaced, and the fragment stays last.
*/
export function buildTrackingUrl(targetUrl: string, utm: Utm): string {
let url: URL;
try {
url = new URL(targetUrl.trim());
} catch {
throw new Error("tracking target is not a valid URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("tracking target must use http or https");
}
const setting = UTM_KEYS.filter(([key]) => utm[key]?.trim());
const replaced = new Set(setting.map(([, name]) => name));
const kept = url.search
.replace(/^\?/, "")
.split("&")
.filter((part) => {
if (!part) return false;
const rawKey = part.split("=")[0] ?? "";
let key = rawKey;
try {
key = decodeURIComponent(rawKey.replace(/\+/g, " "));
} catch {
// keep undecodable keys as they are
}
return !replaced.has(key);
});
const added = setting.map(
([key, name]) => `${name}=${encodeURIComponent((utm[key] as string).trim())}`,
);
const query = [...kept, ...added].join("&");
return `${url.origin}${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
}
/**
* Normalize to "#tag": strip leading #, drop spaces and punctuation (letters,
* digits and _ stay, in any script), dedupe case-insensitively, and cap.
*/
export function normalizeHashtags(tags: string[], limit: number): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of tags) {
const tag = raw.normalize("NFC").replace(/[^\p{L}\p{N}_]/gu, "");
if (!tag) continue;
const key = tag.toLocaleLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(`#${tag}`);
if (out.length >= Math.max(0, limit)) break;
}
return out.slice(0, Math.max(0, limit));
}
export interface ParsedMetricRow {
row: number;
publicationKey: string;
observedAt: Date;
views: number;
clicks: number;
conversions: number;
revenueCents: number;
currency: string;
}
/** RFC 4180 records; quoted fields may hold delimiters, quotes ("") and newlines. */
export function parseCsvRecords(csv: string, delimiter: string): string[][] {
const text = csv.replace(/^/, "");
const records: string[][] = [];
let record: string[] = [];
let field = "";
let quoted = false;
let i = 0;
while (i < text.length) {
const char = text[i]!;
if (quoted) {
if (char === '"') {
if (text[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
quoted = false;
i += 1;
continue;
}
field += char;
i += 1;
continue;
}
if (char === '"' && field.length === 0) {
quoted = true;
i += 1;
} else if (char === delimiter) {
record.push(field);
field = "";
i += 1;
} else if (char === "\r" || char === "\n") {
record.push(field);
records.push(record);
record = [];
field = "";
i += char === "\r" && text[i + 1] === "\n" ? 2 : 1;
} else {
field += char;
i += 1;
}
}
if (field.length > 0 || record.length > 0) {
record.push(field);
records.push(record);
}
return records;
}
const CURRENCY_SYMBOLS: Record<string, string> = { "€": "EUR", $: "USD", "£": "GBP", "¥": "JPY" };
/** Whole, non-negative count with optional thousands separators. */
function parseCount(raw: string): number | null {
const value = raw.trim().replace(/[\s  ']/g, "");
if (value === "") return 0;
if (/^\d+$/.test(value)) return Number(value);
if (/^\d{1,3}([.,]\d{3})+$/.test(value) && new Set(value.match(/[.,]/g)).size === 1) {
return Number(value.replace(/[.,]/g, ""));
}
return null;
}
/**
* Money in major units to cents. With both "," and "." the later one is the
* decimal separator; a single separator followed by one or two digits is
* decimal, by exactly three digits after 1–999 it groups thousands ("1,234"),
* otherwise it is decimal ("0.995"). Extra decimals round half up.
*/
export function parseMoneyToCents(raw: string): { cents: number; currency: string | null } | null {
let value = raw.trim();
let currency: string | null = null;
for (const [symbol, code] of Object.entries(CURRENCY_SYMBOLS)) {
if (value.includes(symbol)) {
currency = code;
value = value.replace(symbol, "");
}
}
const code = value.match(/[A-Za-z]{3}/)?.[0];
if (code) {
currency = code.toUpperCase();
value = value.replace(code, "");
}
value = value.replace(/[\s  ']/g, "");
if (value === "") return { cents: 0, currency };
if (!/^\d[\d.,]*$/.test(value)) return null;
const lastComma = value.lastIndexOf(",");
const lastDot = value.lastIndexOf(".");
let integer = value;
let fraction = "";
if (lastComma >= 0 && lastDot >= 0) {
const decimal = Math.max(lastComma, lastDot);
integer = value.slice(0, decimal);
fraction = value.slice(decimal + 1);
} else if (lastComma >= 0 || lastDot >= 0) {
const separator = lastComma >= 0 ? "," : ".";
const parts = value.split(separator);
const tail = parts.at(-1)!;
const decimal =
parts.length === 2 &&
tail.length >= 1 &&
(tail.length <= 2 || !/^[1-9]\d{0,2}$/.test(parts[0]!));
if (decimal) {
integer = parts[0]!;
fraction = tail;
} else if (parts.slice(1).every((part) => part.length === 3)) {
integer = parts.join("");
} else {
return null;
}
}
integer = integer.replace(/[.,]/g, "");
if (!/^\d+$/.test(integer) || !/^\d*$/.test(fraction)) return null;
const twoDigits = `${fraction}00`.slice(0, 2);
const roundUp = fraction.length > 2 && Number(fraction[2]) >= 5 ? 1 : 0;
return { cents: Number(integer) * 100 + Number(twoDigits) + roundUp, currency };
}
/** ISO dates and times, YYYY/MM/DD, and DD.MM.YYYY. Ambiguous slash dates are rejected. */
function parseDate(raw: string): Date | null {
const value = raw.trim();
let match = value.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/);
if (match) return utcDate(Number(match[1]), Number(match[2]), Number(match[3]));
match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (match) return utcDate(Number(match[3]), Number(match[2]), Number(match[1]));
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value)) {
const date = new Date(value.replace(" ", "T"));
return Number.isNaN(date.getTime()) ? null : date;
}
return null;
}
function utcDate(year: number, month: number, day: number): Date | null {
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
? date
: null;
}
/** RFC 4180 CSV parsing with the saved column mapping. Rows count from the header as row 1. */
export function parseMetricsCsv(
csv: string,
mapping: MetricCsvMapping,
): { rows: ParsedMetricRow[]; skipped: Array<{ row: number; reason: string }> } {
const records = parseCsvRecords(csv, mapping.delimiter ?? ",");
const header = records[0];
const rows: ParsedMetricRow[] = [];
const skipped: Array<{ row: number; reason: string }> = [];
if (!header) return { rows, skipped: [{ row: 1, reason: "file is empty" }] };
const names = header.map((name) => name.trim());
const column = (name: string | undefined): number => {
if (!name) return -1;
const exact = names.indexOf(name.trim());
if (exact >= 0) return exact;
const lower = name.trim().toLowerCase();
return names.findIndex((n) => n.toLowerCase() === lower);
};
const columns = mapping.columns;
const index = {
publication: column(columns.publication),
observedAt: column(columns.observedAt),
views: column(columns.views),
clicks: column(columns.clicks),
conversions: column(columns.conversions),
revenue: column(columns.revenue),
currency: column(columns.currency),
};
const missing = (Object.keys(index) as Array<keyof typeof index>).filter(
(key) => columns[key] !== undefined && index[key] < 0,
);
if (missing.length > 0) {
return {
rows,
skipped: [
{ row: 1, reason: `missing columns: ${missing.map((k) => columns[k]).join(", ")}` },
],
};
}
const defaultCurrency = (mapping.defaultCurrency ?? "EUR").toUpperCase();
for (const [offset, record] of records.slice(1).entries()) {
const row = offset + 2;
if (record.every((field) => field.trim() === "")) continue;
const cell = (i: number) => (i >= 0 ? (record[i] ?? "") : "");
const publicationKey = cell(index.publication).trim();
if (!publicationKey) {
skipped.push({ row, reason: "publication is empty" });
continue;
}
const observedAt = parseDate(cell(index.observedAt));
if (!observedAt) {
skipped.push({ row, reason: "date is not recognized" });
continue;
}
const counts = {
views: parseCount(cell(index.views)),
clicks: parseCount(cell(index.clicks)),
conversions: parseCount(cell(index.conversions)),
};
const invalid = (Object.keys(counts) as Array<keyof typeof counts>).find(
(key) => counts[key] === null,
);
if (invalid) {
skipped.push({ row, reason: `${invalid} is not a whole number` });
continue;
}
let revenueCents = 0;
let currency: string | null = null;
const revenueRaw = cell(index.revenue);
if (revenueRaw.trim()) {
if (/^\s*-/.test(revenueRaw)) {
skipped.push({ row, reason: "revenue is negative" });
continue;
}
if (mapping.revenueUnit === "cents") {
const value = parseCount(revenueRaw);
if (value === null) {
skipped.push({ row, reason: "revenue is not a whole number" });
continue;
}
revenueCents = value;
} else {
const money = parseMoneyToCents(revenueRaw);
if (!money) {
skipped.push({ row, reason: "revenue is not a number" });
continue;
}
revenueCents = money.cents;
currency = money.currency;
}
}
const currencyCell = cell(index.currency).trim().toUpperCase();
if (currencyCell && !/^[A-Z]{3}$/.test(currencyCell)) {
skipped.push({ row, reason: "currency is not a 3-letter code" });
continue;
}
rows.push({
row,
publicationKey,
observedAt,
views: counts.views ?? 0,
clicks: counts.clicks ?? 0,
conversions: counts.conversions ?? 0,
revenueCents,
currency: currencyCell || currency || defaultCurrency,
});
}
return { rows, skipped };
}
const SHORT_FORM_MAX_MS = 180_000;
const MAX_INTRO_MS = 4000;
/** Vertical profiles capped at three minutes: the hook must come before any branding. */
export function isShortForm(profile: PlatformProfile): boolean {
return (
profile.video.aspectRatios.includes("9:16") && profile.duration.hardMaxMs <= SHORT_FORM_MAX_MS
);
}
/**
* Whether the brand intro may open the video. Short-form vertical profiles
* always put the hook first (the brand appears as watermark or end card).
* Elsewhere the intro may take at most 4 s and a tenth of the maximum length.
*/
export function introFits(profile: PlatformProfile, introMs: number): boolean {
if (introMs <= 0) return true;
if (isShortForm(profile)) return false;
return introMs <= Math.min(MAX_INTRO_MS, profile.duration.hardMaxMs / 10);
}
const TONE_STYLES: Array<{ pattern: RegExp; keywords: string[] }> = [
{
pattern: /energ|bold|dynamic|loud|wild|sport|fast|laut|schnell|kraft/i,
keywords: ["glitch", "energy", "kinetic"],
},
{
pattern: /elegan|calm|luxur|soft|premium|ruhig|edel|sanft|warm/i,
keywords: ["elegant", "fade", "minimal"],
},
{
pattern: /minimal|clean|simple|schlicht|klar|modern/i,
keywords: ["minimal", "logo", "fade"],
},
{ pattern: /playful|fun|witzig|verspielt|bunt/i, keywords: ["kinetic", "typo", "glitch"] },
];
const PARAM_SOURCES: Record<string, (kit: BrandKit) => string | undefined> = {
primarycolor: (kit) => kit.colors.primary,
secondarycolor: (kit) => kit.colors.secondary,
accentcolor: (kit) => kit.colors.accent,
textcolor: (kit) => kit.colors.text,
backgroundcolor: (kit) => kit.colors.background,
brandname: (kit) => kit.name,
title: (kit) => kit.name,
name: (kit) => kit.name,
logoartifactid: (kit) => kit.logoArtifactIds[0],
logo: (kit) => kit.logoArtifactIds[0],
fontfamily: (kit) => (kit.fonts.find((f) => f.role === "heading") ?? kit.fonts[0])?.family,
soundartifactid: (kit) => kit.musicBeds.find((bed) => bed.commercialUse)?.artifactId,
sound: (kit) => kit.musicBeds.find((bed) => bed.commercialUse)?.artifactId,
};
const VARIANT_DURATIONS = [2500, 2000, 3000, 3500, 1500, 4000];
/**
* Deterministic intro proposals from the brand kit, tone, and available
* templates: intro templates are ranked by how well their id and name match
* the tone's style keywords (then by id), each proposal uses a distinct
* template while there are enough, and durations stay within 1.5–4 s and the
* template's own limits. Parameters are filled from the kit by name.
*/
export function proposeIntroVariants(
kit: BrandKit,
templates: MotionTemplateInfo[],
count: number,
): Array<{ template: MotionTemplateRef; rationale: string }> {
const intros = templates
.filter((t) => t.kind === "intro")
.sort((a, b) => a.id.localeCompare(b.id));
if (intros.length === 0 || count <= 0) return [];
const tone = `${kit.tone ?? ""} ${kit.audience ?? ""}`;
const keywords = TONE_STYLES.filter((style) => style.pattern.test(tone)).flatMap(
(style) => style.keywords,
);
const affinity = (template: MotionTemplateInfo) => {
const haystack = `${template.id} ${template.name}`.toLowerCase();
return keywords.reduce(
(score, keyword, index) => score + (haystack.includes(keyword) ? keywords.length - index : 0),
0,
);
};
const ranked = [...intros].sort((a, b) => affinity(b) - affinity(a) || a.id.localeCompare(b.id));
return Array.from({ length: count }, (_, index) => {
const template = ranked[index % ranked.length]!;
const round = Math.floor(index / ranked.length);
const wanted = VARIANT_DURATIONS[(index + round) % VARIANT_DURATIONS.length]!;
const durationMs = Math.min(
Math.max(wanted, 1500, template.minDurationMs),
Math.min(4000, template.maxDurationMs),
);
const params: Record<string, string | number | boolean> = {};
for (const param of template.params) {
const source = PARAM_SOURCES[param.name.toLowerCase().replace(/[^a-z]/g, "")];
const value = source?.(kit);
if (value !== undefined) params[param.name] = value.slice(0, 200);
}
const matched = affinity(template) > 0 && kit.tone ? ` matches the tone "${kit.tone}"` : "";
return {
template: { templateId: template.id, params, durationMs: Math.max(0, durationMs) },
rationale: `${template.name}${matched}, ${(durationMs / 1000).toFixed(1)} s`,
};
});
}
function luminance(hex: string): number {
const channel = (offset: number) => {
const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
}
/** WCAG contrast ratio of two #RRGGBB colours. */
export function contrastRatio(a: string, b: string): number {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
return (light + 0.05) / (dark + 0.05);
}
const MIN_CONTRAST = 4.5;
/** Structural brand-kit checks beyond the schema (contrast, duplicate presets). */
export function validateBrandKit(kit: BrandKit): string[] {
const warnings: string[] = [];
const ratio = contrastRatio(kit.colors.text, kit.colors.background);
if (ratio < MIN_CONTRAST) {
warnings.push(`text on background has contrast ${ratio.toFixed(1)}:1, below ${MIN_CONTRAST}:1`);
}
const seen = new Set<string>();
for (const preset of kit.captionPresets) {
if (seen.has(preset.id)) warnings.push(`caption preset id "${preset.id}" is used twice`);
seen.add(preset.id);
if (preset.backgroundColor) {
const presetRatio = contrastRatio(preset.textColor, preset.backgroundColor);
if (presetRatio < MIN_CONTRAST) {
warnings.push(
`caption preset "${preset.id}" has contrast ${presetRatio.toFixed(1)}:1, below ${MIN_CONTRAST}:1`,
);
}
}
}
for (const bed of kit.musicBeds) {
if (!bed.commercialUse)
warnings.push(`music bed "${bed.title}" is not licensed for commercial use`);
}
for (const font of kit.fonts) {
if (!font.license?.trim()) warnings.push(`font "${font.family}" has no license note`);
}
if (kit.watermark && !kit.watermark.artifactId) warnings.push("watermark has no image");
return warnings;
}
/** Upload checklist markdown for an export bundle. */
export function uploadChecklist(input: {
profile: PlatformProfile;
aiAltered: boolean;
hasCaptions: boolean;
trackingUrl: string | null;
}): string {
const { profile } = input;
const items: string[] = ["Upload the video file and choose the poster as cover image."];
if (input.aiAltered && profile.disclosure.aiAlteredLabelRequired) {
items.push(
"Set the platform's AI-generated or altered content label: this video contains AI alterations.",
);
}
if (profile.disclosure.ageRestrictedFlag) items.push("Mark the upload as age-restricted.");
if (profile.disclosure.paidPartnershipLabel) {
items.push("Turn on the paid partnership label if this post is sponsored.");
}
if (input.trackingUrl) {
const link = `<${input.trackingUrl}>`;
if (profile.text.linkPolicy === "allowed") items.push(`Add the tracking link: ${link}`);
else if (profile.text.linkPolicy === "bio_only") {
items.push(`Put the tracking link in the profile bio, not in the caption: ${link}`);
} else items.push("Do not add links: this platform does not allow them.");
} else if (profile.text.linkPolicy === "forbidden") {
items.push("Do not add links: this platform does not allow them.");
}
if (input.hasCaptions) {
items.push("Upload the SRT or VTT subtitles, or confirm the burned-in captions are readable.");
} else if (profile.captions.required) {
items.push("Captions are required on this platform: add them before publishing.");
}
const limits = [
profile.text.titleMaxChars ? `title up to ${profile.text.titleMaxChars} characters` : null,
`caption up to ${profile.text.captionMaxChars} characters`,
`at most ${profile.text.hashtagLimit} hashtags`,
].filter(Boolean);
items.push(`Paste the caption from caption.txt (${limits.join(", ")}).`);
return `# Upload checklist: ${profile.name.replace(/[\r\n]+/g, " ")}\n\n${items
.map((item) => `- [ ] ${item}`)
.join("\n")}\n`;
}