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(); 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 = { "€": "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).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).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 | 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 = {}; 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(); 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`; }