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
This commit is contained in:
Claude 2026-10-03 18:06:02 +00:00
parent 7ccc96ac11
commit 26d95a374e
No known key found for this signature in database
7 changed files with 3663 additions and 135 deletions

View File

@ -1,5 +1,5 @@
import type { MediaAssetKind } from "@rakazo/contracts";
import { notImplemented } from "./not-implemented.js";
import { cosineSimilarity, haversineKm, roundCoordinate } from "./geo.js";
export interface ClusterAsset {
id: string;
@ -46,15 +46,157 @@ export interface SessionCluster {
centroid: { lat: number; lon: number } | null;
}
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const dayFormatters = new Map<string, Intl.DateTimeFormat>();
/** Calendar day (YYYY-MM-DD) of an instant in an IANA time zone. */
export function calendarDay(date: Date, timeZone: string): string {
let formatter = dayFormatters.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
dayFormatters.set(timeZone, formatter);
}
return formatter.format(date);
}
type DatedAsset = ClusterAsset & { capturedAt: Date };
type PositionedAsset = ClusterAsset & { gpsLat: number; gpsLon: number };
function hasPosition(asset: ClusterAsset): asset is PositionedAsset {
return (
asset.gpsLat !== null &&
asset.gpsLon !== null &&
Number.isFinite(asset.gpsLat) &&
Number.isFinite(asset.gpsLon)
);
}
function isDated(asset: ClusterAsset): asset is DatedAsset {
return asset.capturedAt !== null && Number.isFinite(asset.capturedAt.getTime());
}
/** Mean of the given vectors, or null when none are present or dimensions disagree. */
export function meanVector(vectors: ArrayLike<number>[]): Float32Array | null {
const first = vectors[0];
if (!first) return null;
const sum = new Float64Array(first.length);
for (const vector of vectors) {
if (vector.length !== first.length) return null;
for (let i = 0; i < vector.length; i++) sum[i] = sum[i]! + (vector[i] as number);
}
const mean = new Float32Array(first.length);
for (let i = 0; i < sum.length; i++) mean[i] = sum[i]! / vectors.length;
return mean;
}
function sessionEmbedding(assets: ClusterAsset[]): Float32Array | null {
return meanVector(assets.flatMap((asset) => (asset.embedding ? [asset.embedding] : [])));
}
function finishSession(assets: ClusterAsset[], key: string): SessionCluster {
const dated = assets.filter(isDated);
const positioned = assets.filter(hasPosition);
const mean = (values: number[]) => values.reduce((sum, v) => sum + v, 0) / values.length;
return {
key,
assetIds: assets.map((asset) => asset.id),
startAt: dated[0]?.capturedAt ?? null,
endAt: dated.at(-1)?.capturedAt ?? null,
centroid:
positioned.length === 0
? null
: {
lat: roundCoordinate(mean(positioned.map((a) => a.gpsLat))),
lon: roundCoordinate(mean(positioned.map((a) => a.gpsLon))),
},
};
}
/**
* Deterministic session grouping (spec 4.4). Assets without capturedAt form
* one trailing "undated" session; exact duplicates (same id) are ignored.
*
* A new session starts on a gap longer than gapMinutes, a jump of more than
* maxDistanceKm from the session's last known position, or a calendar-day
* change (in timeZone) with a gap longer than dayBreakHours. Adjacent sessions
* are then merged when their mean embeddings reach mergeSimilarity and the gap
* between them stays below mergeMaxGapHours. Keys derive from the earliest
* asset id, so they survive re-clustering as long as that asset stays first.
*/
export function buildSessions(
_assets: ClusterAsset[],
_options?: Partial<SessionOptions>,
assets: ClusterAsset[],
options?: Partial<SessionOptions>,
): SessionCluster[] {
return notImplemented("buildSessions");
const opts = { ...DEFAULT_SESSION_OPTIONS, ...options };
const unique = new Map<string, ClusterAsset>();
for (const asset of assets) if (!unique.has(asset.id)) unique.set(asset.id, asset);
const dated = [...unique.values()]
.filter(isDated)
.sort((a, b) => a.capturedAt.getTime() - b.capturedAt.getTime() || a.id.localeCompare(b.id));
const undated = [...unique.values()]
.filter((asset) => !isDated(asset))
.sort((a, b) => a.id.localeCompare(b.id));
const split: DatedAsset[][] = [];
let lastPosition: { lat: number; lon: number } | null = null;
for (const asset of dated) {
const current = split.at(-1);
const previous = current?.at(-1);
let startNew = true;
if (current && previous) {
const gap = asset.capturedAt.getTime() - previous.capturedAt.getTime();
const jumped =
hasPosition(asset) &&
lastPosition !== null &&
haversineKm(lastPosition, { lat: asset.gpsLat, lon: asset.gpsLon }) > opts.maxDistanceKm;
const dayChanged =
gap > opts.dayBreakHours * HOUR_MS &&
calendarDay(asset.capturedAt, opts.timeZone) !==
calendarDay(previous.capturedAt, opts.timeZone);
startNew = gap > opts.gapMinutes * MINUTE_MS || jumped || dayChanged;
}
if (startNew || !current) {
split.push([asset]);
lastPosition = null;
} else {
current.push(asset);
}
if (hasPosition(asset)) lastPosition = { lat: asset.gpsLat, lon: asset.gpsLon };
}
// Merge visually similar neighbours that are close in time.
const merged: DatedAsset[][] = [];
for (const session of split) {
const last = merged.at(-1);
if (last) {
const gap = session[0]!.capturedAt.getTime() - last.at(-1)!.capturedAt.getTime();
const a = sessionEmbedding(last);
const b = sessionEmbedding(session);
if (
a &&
b &&
a.length === b.length &&
gap < opts.mergeMaxGapHours * HOUR_MS &&
cosineSimilarity(a, b) >= opts.mergeSimilarity
) {
last.push(...session);
continue;
}
}
merged.push([...session]);
}
const sessions = merged.map((group) => finishSession(group, `session:${group[0]!.id}`));
if (undated.length > 0) sessions.push(finishSession(undated, "session:undated"));
return sessions;
}
export interface VectorItem {
@ -62,26 +204,144 @@ export interface VectorItem {
vector: Float32Array;
}
/** Agglomerative average-linkage clustering; returns groups with stable keys. */
/**
* Average-linkage agglomerative clustering cut at a similarity threshold,
* using the nearest-neighbour chain algorithm (O(n²) time and memory). Items
* are sorted by id first and ties resolve to the lower index, so the result
* does not depend on input order. Callers batch very large libraries.
*/
function agglomerate(items: VectorItem[], threshold: number): VectorItem[][] {
const unique = new Map<string, VectorItem>();
for (const item of items) if (!unique.has(item.id)) unique.set(item.id, item);
const sorted = [...unique.values()].sort((a, b) => a.id.localeCompare(b.id));
const n = sorted.length;
if (n === 0) return [];
const dim = sorted[0]!.vector.length;
if (sorted.some((item) => item.vector.length !== dim)) throw new Error("vector length mismatch");
const sim = new Float64Array(n * n);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const value = cosineSimilarity(sorted[i]!.vector, sorted[j]!.vector);
sim[i * n + j] = value;
sim[j * n + i] = value;
}
}
const size = new Array<number>(n).fill(1);
const members: number[][] = sorted.map((_, i) => [i]);
const active = new Set<number>(members.map((_, i) => i));
const finished: number[][] = [];
const chain: number[] = [];
while (active.size > 0) {
if (chain.length === 0) chain.push(Math.min(...active));
const top = chain.at(-1)!;
const below = chain.length >= 2 ? chain.at(-2)! : -1;
let best = -1;
let bestSim = Number.NEGATIVE_INFINITY;
for (const j of active) {
if (j === top) continue;
const s = sim[top * n + j]!;
if (s > bestSim || (s === bestSim && j < best)) {
best = j;
bestSim = s;
}
}
// Prefer the chain predecessor on ties so the chain cannot cycle.
if (below >= 0 && sim[top * n + below]! === bestSim) best = below;
if (best < 0 || bestSim < threshold) {
// Average linkage is reducible: merges never raise similarity to this cluster again.
active.delete(top);
finished.push(members[top]!);
chain.length = 0;
continue;
}
if (best !== below) {
chain.push(best);
continue;
}
chain.length -= 2;
const a = Math.min(top, below);
const b = Math.max(top, below);
for (const k of active) {
if (k === a || k === b) continue;
const value =
(size[a]! * sim[a * n + k]! + size[b]! * sim[b * n + k]!) / (size[a]! + size[b]!);
sim[a * n + k] = value;
sim[k * n + a] = value;
}
size[a] = size[a]! + size[b]!;
members[a] = [...members[a]!, ...members[b]!];
active.delete(b);
}
return finished.map((group) => group.sort((x, y) => x - y).map((i) => sorted[i]!));
}
function sortGroups<T extends { key: string; ids: string[] }>(groups: T[]): T[] {
return groups.sort((a, b) => b.ids.length - a.ids.length || a.key.localeCompare(b.key));
}
/**
* Agglomerative average-linkage clustering; returns groups with stable keys
* (from the smallest member id), largest first. Default similarity 0.8.
*/
export function clusterThemes(
_items: VectorItem[],
_options?: { similarity?: number; minSize?: number },
items: VectorItem[],
options?: { similarity?: number; minSize?: number },
): Array<{ key: string; ids: string[] }> {
return notImplemented("clusterThemes");
const minSize = options?.minSize ?? 1;
return sortGroups(
agglomerate(items, options?.similarity ?? 0.8)
.filter((group) => group.length >= minSize)
.map((group) => ({ key: `theme:${group[0]!.id}`, ids: group.map((item) => item.id) })),
);
}
/** Group face tracks into person suggestions by identity embedding. Local only. */
/** Group face tracks into person suggestions by identity embedding. Local only. Default similarity 0.6. */
export function clusterFaces(
_tracks: VectorItem[],
_options?: { similarity?: number },
tracks: VectorItem[],
options?: { similarity?: number },
): Array<{ key: string; ids: string[]; centroid: Float32Array }> {
return notImplemented("clusterFaces");
return sortGroups(
agglomerate(tracks, options?.similarity ?? 0.6).map((group) => ({
key: `person:${group[0]!.id}`,
ids: group.map((item) => item.id),
centroid: meanVector(group.map((item) => item.vector))!,
})),
);
}
/** Rule-based title from date range, place centroid, and dominant album. No model involved. */
/**
* Rule-based title from the date range and dominant album. Places are not
* named because only rounded coordinates are stored. Returns "" when there is
* nothing to say, so the caller shows its own localized placeholder.
*/
export function ruleLabel(
_cluster: { startAt: Date | null; endAt: Date | null; albumNames: string[] },
_locale?: string,
cluster: { startAt: Date | null; endAt: Date | null; albumNames: string[] },
locale?: string,
timeZone = "UTC",
): string {
return notImplemented("ruleLabel");
const counts = new Map<string, number>();
for (const raw of cluster.albumNames) {
const name = raw.trim();
if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
}
const album = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
let dates = "";
const start = cluster.startAt ?? cluster.endAt;
const end = cluster.endAt ?? cluster.startAt;
if (start && end) {
let formatter: Intl.DateTimeFormat;
try {
formatter = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone });
} catch {
formatter = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone });
}
dates =
calendarDay(start, timeZone) === calendarDay(end, timeZone)
? formatter.format(start)
: formatter.formatRange(start, end);
}
return [album?.[0], dates].filter(Boolean).join(" · ");
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,30 @@
import type { BrandKit, Edl, EdlError, MediaAssetKind, PlatformProfile } from "@rakazo/contracts";
import { notImplemented } from "./not-implemented.js";
import type {
AspectRatio,
BrandKit,
Edl,
EdlError,
EdlTransition,
EdlVideoClip,
MediaAssetKind,
MediaProjectGoal,
NormalizedBox,
PlatformProfile,
} from "@rakazo/contracts";
import { EdlSchema } from "@rakazo/contracts";
/*
* Timeline model shared by validation, compilation, gate and auto-fix:
*
* - The output is intro + main program + outro, played back to back.
* - `tracks.video[].atMs` is a position on the main program, which starts
* after the intro. A clip lasts (outMs - inMs) / speed.
* - Clips may only overlap where a crossfade, dip or whip transition joins
* them, and then by exactly the transition duration. Cuts and match cuts
* never overlap.
* - Overlays and audio clips are positioned on the output timeline (intro
* included), so music can run under the intro.
* - Reframe keyframes are in source time of the clip's asset.
*/
export interface EdlContext {
profile: PlatformProfile;
@ -18,28 +43,384 @@ export interface EdlContext {
templateIds: Set<string>;
/** Segments the user reserved for the paid release. */
paidOnly: Array<{ assetId: string; startMs: number; endMs: number }>;
/** Project goal; a teaser never uses paid-only segments, whatever the profile. */
goal?: MediaProjectGoal;
}
export type EdlValidation = { ok: true; edl: Edl } | { ok: false; errors: EdlError[] };
/** Transitions rendered as a timed blend (xfade). Cuts and match cuts are plain concatenation. */
export const BLENDED_TRANSITIONS: ReadonlySet<EdlTransition["kind"]> = new Set([
"crossfade",
"dip",
"whip",
]);
export const ASPECT_RATIO_VALUES: Record<AspectRatio, number> = {
"9:16": 9 / 16,
"4:5": 4 / 5,
"1:1": 1,
"16:9": 16 / 9,
"4:3": 4 / 3,
"21:9": 21 / 9,
};
const ASPECT_TOLERANCE = 0.01;
/** Whether width × height matches one of the allowed aspect ratios within 1 %. */
export function matchesAspect(width: number, height: number, allowed: AspectRatio[]): boolean {
const actual = width / height;
return allowed.some(
(ratio) =>
Math.abs(actual - ASPECT_RATIO_VALUES[ratio]) / ASPECT_RATIO_VALUES[ratio] <=
ASPECT_TOLERANCE,
);
}
/** Output length of one video clip after speed change. */
export function clipLengthMs(clip: Pick<EdlVideoClip, "inMs" | "outMs" | "speed">): number {
return Math.max(0, Math.round((clip.outMs - clip.inMs) / clip.speed));
}
/** End of the main program (excluding intro and outro). */
export function mainProgramMs(edl: Edl): number {
let end = 0;
for (const clip of edl.tracks.video) end = Math.max(end, clip.atMs + clipLengthMs(clip));
return end;
}
export function introMs(edl: Edl): number {
return edl.intro?.durationMs ?? 0;
}
/** Output length in ms including intro, outro, speed, and transition overlap. */
export function edlDurationMs(edl: Edl): number {
return introMs(edl) + mainProgramMs(edl) + (edl.outro?.durationMs ?? 0);
}
/** Every clip visible at an output timestamp (two during a blended transition), earliest first. */
export function locateOutputTimeAll(
edl: Edl,
outputMs: number,
): Array<{ clipId: string; assetId: string; sourceMs: number }> {
const t = outputMs - introMs(edl);
if (t < 0) return [];
return [...edl.tracks.video]
.filter((clip) => t >= clip.atMs && t < clip.atMs + clipLengthMs(clip))
.sort((a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id))
.map((clip) => ({
clipId: clip.id,
assetId: clip.assetId,
sourceMs: Math.min(clip.outMs, Math.round(clip.inMs + (t - clip.atMs) * clip.speed)),
}));
}
/**
* Map an output timestamp back to the clip and source time it shows. During a
* blended transition the incoming clip wins; intro and outro map to null.
*/
export function locateOutputTime(
edl: Edl,
outputMs: number,
): { clipId: string; assetId: string; sourceMs: number } | null {
return locateOutputTimeAll(edl, outputMs).at(-1) ?? null;
}
/** Whether a normalized box lies inside the profile's safe zones. */
export function boxInsideSafeZone(box: NormalizedBox, profile: PlatformProfile): boolean {
const epsilon = 1e-6;
const zones = profile.safeZones;
return (
box.x >= zones.leftPct / 100 - epsilon &&
box.y >= zones.topPct / 100 - epsilon &&
box.x + box.w <= 1 - zones.rightPct / 100 + epsilon &&
box.y + box.h <= 1 - zones.bottomPct / 100 + epsilon
);
}
function formatPath(path: ReadonlyArray<PropertyKey>): string {
let out = "";
for (const part of path) {
if (typeof part === "number") out += `[${part}]`;
else out += out ? `.${String(part)}` : String(part);
}
return out || "(root)";
}
/** Video clips in timeline order, ties broken by id for determinism. */
export function sortedVideoClips(edl: Edl): EdlVideoClip[] {
return [...edl.tracks.video].sort((a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id));
}
/**
* Parse with EdlSchema, then check references, ranges, overlaps, durations,
* safe zones, paid-only use (teaser-style profiles), and music licensing.
* Every failure is reported; nothing is silently corrected.
*/
export function validateEdl(_input: unknown, _context: EdlContext): EdlValidation {
return notImplemented("validateEdl");
}
export function validateEdl(input: unknown, context: EdlContext): EdlValidation {
const parsed = EdlSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
errors: parsed.error.issues.map((issue) => ({
code: "schema",
path: formatPath(issue.path),
message: issue.message,
})),
};
}
const edl = parsed.data;
const errors: EdlError[] = [];
const fail = (code: EdlError["code"], path: string, message: string) =>
errors.push({ code, path, message });
const { profile } = context;
/** Output length in ms including intro, outro, speed, and transition overlap. */
export function edlDurationMs(_edl: Edl): number {
return notImplemented("edlDurationMs");
}
if (edl.output.profileId !== profile.id) {
fail("profile_mismatch", "output.profileId", `plan targets ${edl.output.profileId}`);
}
if (!matchesAspect(edl.output.width, edl.output.height, profile.video.aspectRatios)) {
fail(
"profile_mismatch",
"output",
`${edl.output.width}x${edl.output.height} is not one of ${profile.video.aspectRatios.join(", ")}`,
);
}
if (edl.output.fps > profile.video.fps * 1.001) {
fail("profile_mismatch", "output.fps", `fps above profile maximum ${profile.video.fps}`);
}
/** Map an output timestamp back to the clip and source time it shows. */
export function locateOutputTime(
_edl: Edl,
_outputMs: number,
): { clipId: string; assetId: string; sourceMs: number } | null {
return notImplemented("locateOutputTime");
const seenIds = new Map<string, string>();
const claimId = (id: string, path: string) => {
const previous = seenIds.get(id);
if (previous) fail("schema", path, `id ${id} already used at ${previous}`);
else seenIds.set(id, path);
};
const clipsById = new Map<string, EdlVideoClip>();
edl.tracks.video.forEach((clip, index) => {
const path = `tracks.video[${index}]`;
claimId(clip.id, `${path}.id`);
clipsById.set(clip.id, clip);
if (clip.outMs <= clip.inMs) fail("range_empty", path, "outMs must be after inMs");
const asset = context.assets.get(clip.assetId);
if (!asset) {
fail("unknown_asset", `${path}.assetId`, `asset ${clip.assetId} is not available`);
} else if (asset.durationMs !== null && asset.kind !== "photo") {
if (clip.outMs > asset.durationMs) {
fail(
"range_out_of_asset",
`${path}.outMs`,
`outMs ${clip.outMs} exceeds asset duration ${asset.durationMs}`,
);
}
if (clip.inMs >= asset.durationMs) {
fail("range_out_of_asset", `${path}.inMs`, `inMs ${clip.inMs} is past the asset end`);
}
}
if (Array.isArray(clip.reframe)) {
clip.reframe.forEach((keyframe, k) => {
if (keyframe.tMs < clip.inMs || keyframe.tMs > clip.outMs) {
fail(
"range_out_of_asset",
`${path}.reframe[${k}].tMs`,
"reframe keyframe lies outside the clip's source range",
);
}
});
}
if (
(profile.kind === "free_social" || context.goal === "teaser") &&
context.paidOnly.some(
(segment) =>
segment.assetId === clip.assetId &&
segment.startMs < clip.outMs &&
clip.inMs < segment.endMs,
)
) {
fail("paid_only_segment", path, "clip uses material reserved for the paid release");
}
});
// Transitions: known clips, adjacent in time, at most one in and one out per clip.
const blendBetween = new Map<string, number>();
const outgoing = new Set<string>();
const incoming = new Set<string>();
const order = sortedVideoClips(edl);
const nextOf = new Map<string, string>();
for (let i = 0; i + 1 < order.length; i++) nextOf.set(order[i]!.id, order[i + 1]!.id);
edl.transitions.forEach((transition, index) => {
const path = `transitions[${index}]`;
const from = clipsById.get(transition.fromClipId);
const to = clipsById.get(transition.toClipId);
if (!from) fail("unknown_clip", `${path}.fromClipId`, `no clip ${transition.fromClipId}`);
if (!to) fail("unknown_clip", `${path}.toClipId`, `no clip ${transition.toClipId}`);
if (!from || !to) return;
if (nextOf.get(from.id) !== to.id) {
fail("schema", path, "a transition must join a clip to the clip that follows it");
return;
}
if (outgoing.has(from.id) || incoming.has(to.id)) {
fail("schema", path, "a clip can have only one transition on each side");
return;
}
outgoing.add(from.id);
incoming.add(to.id);
if (BLENDED_TRANSITIONS.has(transition.kind) && transition.durationMs > 0) {
const shortest = Math.min(clipLengthMs(from), clipLengthMs(to));
if (transition.durationMs >= shortest) {
fail("overlap", `${path}.durationMs`, "transition is longer than one of its clips");
return;
}
blendBetween.set(`${from.id}>${to.id}`, transition.durationMs);
}
});
// Overlaps on the video track: only blended transitions may overlap, by exactly their length.
for (let i = 0; i + 1 < order.length; i++) {
const a = order[i]!;
const b = order[i + 1]!;
const overlap = a.atMs + clipLengthMs(a) - b.atMs;
const blend = blendBetween.get(`${a.id}>${b.id}`);
const path = `tracks.video[${edl.tracks.video.indexOf(b)}].atMs`;
if (blend !== undefined) {
if (Math.abs(overlap - blend) > 1) {
fail(
"overlap",
path,
`clips ${a.id} and ${b.id} must overlap by the ${blend} ms transition`,
);
}
} else if (overlap > 1) {
fail("overlap", path, `clip ${b.id} starts ${overlap} ms before ${a.id} ends`);
}
}
for (let i = 0; i + 2 < order.length; i++) {
const a = order[i]!;
const c = order[i + 2]!;
if (a.atMs + clipLengthMs(a) > c.atMs + 1) {
fail("overlap", `tracks.video[${edl.tracks.video.indexOf(c)}].atMs`, "three clips overlap");
}
}
// Audio clips.
const audioByKind = new Map<string, Array<{ start: number; end: number; index: number }>>();
edl.tracks.audio.forEach((clip, index) => {
const path = `tracks.audio[${index}]`;
claimId(clip.id, `${path}.id`);
if (clip.outMs <= clip.inMs) fail("range_empty", path, "outMs must be after inMs");
if (!context.artifactIds.has(clip.artifactId)) {
fail(
"unknown_artifact",
`${path}.artifactId`,
`artifact ${clip.artifactId} is not available`,
);
}
if (clip.fadeInMs + clip.fadeOutMs > clip.outMs - clip.inMs && clip.outMs > clip.inMs) {
fail("range_empty", path, "fades are longer than the clip");
}
if (clip.kind === "music") {
if (profile.audio.music === "platform_library_only") {
fail(
"music_license",
`${path}.artifactId`,
"this platform only allows music added from its own library",
);
} else if (profile.audio.music === "licensed_only") {
const license = context.music.get(clip.artifactId);
if (!license?.commercialUse) {
fail(
"music_license",
`${path}.artifactId`,
license ? "music is not licensed for commercial use" : "music license is unknown",
);
}
}
}
if (clip.kind !== "sfx") {
const list = audioByKind.get(clip.kind) ?? [];
list.push({ start: clip.atMs, end: clip.atMs + (clip.outMs - clip.inMs), index });
audioByKind.set(clip.kind, list);
}
});
for (const list of audioByKind.values()) {
list.sort((a, b) => a.start - b.start || a.index - b.index);
for (let i = 0; i + 1 < list.length; i++) {
if (list[i]!.end > list[i + 1]!.start) {
fail("overlap", `tracks.audio[${list[i + 1]!.index}].atMs`, "audio clips overlap");
}
}
}
// Templates.
const checkTemplate = (templateId: string, path: string) => {
if (!context.templateIds.has(templateId)) {
fail("unknown_template", path, `template ${templateId} is not available`);
}
};
if (edl.intro) checkTemplate(edl.intro.templateId, "intro.templateId");
if (edl.outro) checkTemplate(edl.outro.templateId, "outro.templateId");
// Overlays.
edl.overlays.forEach((overlay, index) => {
const path = `overlays[${index}]`;
claimId(overlay.id, `${path}.id`);
if (overlay.durationMs <= 0)
fail("range_empty", `${path}.durationMs`, "overlay has no duration");
if (overlay.motion) checkTemplate(overlay.motion.templateId, `${path}.motion.templateId`);
if (overlay.box && !boxInsideSafeZone(overlay.box, profile)) {
fail("overlay_outside_safe_zone", `${path}.box`, "overlay leaves the profile safe zone");
}
if (overlay.kind === "watermark" && !context.brandKit?.watermark) {
fail("unknown_artifact", path, "the brand kit has no watermark");
}
if ((overlay.kind === "text" || overlay.kind === "lower_third") && !overlay.text?.trim()) {
if (!overlay.motion) fail("range_empty", `${path}.text`, "text overlay has no text");
}
});
// Captions preset.
const presetId = edl.captions?.presetId;
if (presetId && context.brandKit) {
if (!context.brandKit.captionPresets.some((preset) => preset.id === presetId)) {
fail("unknown_template", "captions.presetId", `caption preset ${presetId} is not in the kit`);
}
}
// Redactions.
edl.redactions.faces.forEach((redaction, index) => {
const path = `redactions.faces[${index}]`;
claimId(redaction.id, `${path}.id`);
const target = redaction.target;
if ("trackId" in target && !context.faceTrackIds.has(target.trackId)) {
fail("unknown_track", `${path}.target.trackId`, `face track ${target.trackId} is unknown`);
}
if ("personId" in target && !context.personIds.has(target.personId)) {
fail("unknown_person", `${path}.target.personId`, `person ${target.personId} is unknown`);
}
});
edl.redactions.marks.forEach((mark, index) => {
if (!context.markIds.has(mark.markId)) {
fail("unknown_mark", `redactions.marks[${index}].markId`, `mark ${mark.markId} is unknown`);
}
});
edl.redactions.regions.forEach((region, index) => {
const path = `redactions.regions[${index}]`;
claimId(region.id, `${path}.id`);
if (!clipsById.has(region.clipId)) {
fail("unknown_clip", `${path}.clipId`, `no clip ${region.clipId}`);
}
});
// Duration against both the plan's and the profile's hard bounds.
const duration = edlDurationMs(edl);
const minMs = Math.max(edl.output.durationHardMinMs, profile.duration.hardMinMs);
const maxMs = Math.min(edl.output.durationHardMaxMs, profile.duration.hardMaxMs);
if (duration < minMs) {
fail("duration_below_min", "output", `duration ${duration} ms is below ${minMs} ms`);
}
if (duration > maxMs) {
fail("duration_above_max", "output", `duration ${duration} ms is above ${maxMs} ms`);
}
return errors.length > 0 ? { ok: false, errors } : { ok: true, edl };
}

View File

@ -2,16 +2,74 @@ import type {
AiAlteration,
ContentCategory,
Edl,
EdlVideoClip,
FaceRedaction,
FfprobeReport,
GateIssue,
GateReport,
NormalizedBox,
PlatformProfile,
} from "@rakazo/contracts";
import { notImplemented } from "./not-implemented.js";
import { CONTENT_CATEGORIES, MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts";
import {
BLENDED_TRANSITIONS,
boxInsideSafeZone,
clipLengthMs,
edlDurationMs,
locateOutputTimeAll,
matchesAspect,
sortedVideoClips,
} from "./edl.js";
/** Tags a clean export may carry. Everything else is a metadata leak. */
export const METADATA_ALLOWED_TAGS: readonly string[] = [];
/**
* Tags a clean export may carry. Everything else is a metadata leak.
*
* - `title`: the only descriptive tag the compiler writes, and only when set.
* - `major_brand`, `minor_version`, `compatible_brands`: the MP4 file-type
* box, always written by the muxer; it names the format, not the author.
* - `handler_name`, `vendor_id`, `language`: per-stream boilerplate the MP4
* muxer writes ("VideoHandler", "[0][0][0][0]", "und").
* - `encoder`: the MP4 sample description always names the codec. It is
* allowed only without a version number (bitexact output writes
* "Lavc libx264"); a versioned value such as "Lavf60.16.100" is a leak.
*
* `creation_time`, `location`, `com.apple.quicktime.*`, `make`, `model`,
* `software`, GPS keys and anything unknown always count as leaks.
*/
export const METADATA_ALLOWED_TAGS: readonly string[] = [
"title",
"major_brand",
"minor_version",
"compatible_brands",
"handler_name",
"vendor_id",
"language",
"encoder",
];
const ALLOWED = new Set(METADATA_ALLOWED_TAGS);
/** "Lavf60.16.100", "Lavc60.31", "x264 core 164", "HandBrake 1.7.0". */
const VERSIONED = /\d+\.\d+|lav[cf]\d|core\s*\d/i;
/** Tag name without a "format." / "stream.0." style prefix, lower-cased. */
function tagName(key: string): string {
return key
.trim()
.toLowerCase()
.replace(/^(?:format|streams?(?:[.:/]\d+)?)[.:/]/, "");
}
export function findMetadataLeaks(tags: Record<string, string>): string[] {
return Object.entries(tags)
.filter(([key, value]) => {
const name = tagName(key);
if (!ALLOWED.has(name)) return true;
if (name === "encoder") return VERSIONED.test(value);
return false;
})
.map(([key]) => key)
.sort();
}
export interface GateInput {
profile: PlatformProfile;
@ -44,28 +102,370 @@ export interface GateInput {
now: Date;
}
/** Deterministic gate (spec 6). Any blocking issue fails the report. */
export function evaluateGate(_input: GateInput): GateReport {
return notImplemented("evaluateGate");
/** Longest tracking gap the anonymizer may bridge (spec 7). */
export const MAX_TRACKING_GAP_MS = 500;
/** Residual visibility of a treated mark above which the gate blocks. */
export const MAX_MARK_RESIDUAL = 0.05;
/** Loudness tolerance around the target, in LU. */
export const LOUDNESS_TOLERANCE_LU = 2;
const TRUE_PEAK_TOLERANCE_DB = 0.5;
const ms = (value: number) => Math.max(0, Math.round(value));
/** Merge time-sorted hits into ranges when they are at most `gapMs` apart. */
function ranges<T extends { tMs: number }>(hits: T[], gapMs: number): T[][] {
const sorted = [...hits].sort((a, b) => a.tMs - b.tMs);
const out: T[][] = [];
for (const hit of sorted) {
const last = out.at(-1);
if (last && hit.tMs - last.at(-1)!.tMs <= gapMs) last.push(hit);
else out.push([hit]);
}
return out;
}
export function checkTechnical(
_profile: PlatformProfile,
_edl: Edl,
_probe: FfprobeReport,
profile: PlatformProfile,
edl: Edl,
probe: FfprobeReport,
): GateIssue[] {
return notImplemented("checkTechnical");
const issues: GateIssue[] = [];
const block = (code: string, message: string) =>
issues.push({ check: "technical", severity: "block", code, message });
if (probe.width !== edl.output.width || probe.height !== edl.output.height) {
block(
"resolution_mismatch",
`output is ${probe.width}x${probe.height}, plan expects ${edl.output.width}x${edl.output.height}`,
);
}
if (!matchesAspect(probe.width, probe.height, profile.video.aspectRatios)) {
block("aspect_not_allowed", `${probe.width}x${probe.height} is not an allowed aspect ratio`);
}
if (Math.max(probe.width, probe.height) > Math.max(profile.video.width, profile.video.height)) {
block("resolution_too_high", "output is larger than the profile allows");
}
if (
Math.abs(probe.fps - edl.output.fps) > edl.output.fps * 0.01 ||
probe.fps > profile.video.fps * 1.001
) {
block("fps_mismatch", `output runs at ${probe.fps} fps`);
}
const frameMs = 1000 / Math.max(1, edl.output.fps);
const expected = edlDurationMs(edl);
if (probe.durationMs < profile.duration.hardMinMs - frameMs) {
block("duration_too_short", `${probe.durationMs} ms is below ${profile.duration.hardMinMs} ms`);
}
if (probe.durationMs > profile.duration.hardMaxMs + frameMs) {
block("duration_too_long", `${probe.durationMs} ms is above ${profile.duration.hardMaxMs} ms`);
}
if (Math.abs(probe.durationMs - expected) > Math.max(2 * frameMs, 100)) {
issues.push({
check: "technical",
severity: "warn",
code: "duration_drift",
message: `output is ${probe.durationMs} ms, plan is ${expected} ms`,
});
}
if (probe.sizeBytes > profile.video.maxFileSizeMb * 1_000_000) {
block("file_too_large", `file is larger than ${profile.video.maxFileSizeMb} MB`);
}
const codec = probe.videoCodec.toLowerCase();
const expectedCodec = profile.video.codec === "hevc" ? ["hevc", "h265"] : ["h264", "avc1"];
if (!expectedCodec.includes(codec)) {
block("codec_mismatch", `video codec ${probe.videoCodec} is not ${profile.video.codec}`);
}
if (probe.audioCodec !== null && probe.audioCodec.toLowerCase() !== "aac") {
issues.push({
check: "technical",
severity: "warn",
code: "audio_codec",
message: `audio codec ${probe.audioCodec} is not AAC`,
});
}
const target = edl.targetLufs ?? profile.audio.targetLufs;
if (probe.audioCodec !== null) {
if (probe.integratedLufs === null) {
block("loudness_unmeasured", "loudness was not measured");
} else if (Math.abs(probe.integratedLufs - target) > LOUDNESS_TOLERANCE_LU) {
block("loudness_off_target", `${probe.integratedLufs} LUFS, target ${target} LUFS`);
}
if (
probe.truePeakDb !== null &&
probe.truePeakDb > profile.audio.truePeakDb + TRUE_PEAK_TOLERANCE_DB
) {
block("true_peak_exceeded", `true peak ${probe.truePeakDb} dBTP`);
}
}
const leaks = findMetadataLeaks(probe.tags);
if (leaks.length > 0) block("metadata_leak", `metadata not removed: ${leaks.join(", ")}`);
return issues;
}
export function findMetadataLeaks(_tags: Record<string, string>): string[] {
return notImplemented("findMetadataLeaks");
const URL_PATTERN =
/\b(?:https?:\/\/|www\.)\S+|\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:com|net|org|io|de|at|ch|eu|ly|co|me|link|shop|store|app|tv|gg|xyz)\b(?:\/\S*)?/i;
const HASHTAG_PATTERN = /#[\p{L}\p{N}_]+/gu;
/** Profile patterns compile case-insensitively; an invalid one is reported, never thrown. */
function compilePattern(pattern: string): RegExp | null {
try {
return new RegExp(pattern, "i");
} catch {
return null;
}
}
export function checkCopy(
_profile: PlatformProfile,
_copy: { title: string | null; caption: string | null; tags: string[] },
profile: PlatformProfile,
copy: { title: string | null; caption: string | null; tags: string[] },
): GateIssue[] {
return notImplemented("checkCopy");
const issues: GateIssue[] = [];
const block = (code: string, message: string) =>
issues.push({ check: "text", severity: "block", code, message });
const { text } = profile;
const title = copy.title ?? "";
const caption = copy.caption ?? "";
if (text.titleMaxChars !== undefined && [...title].length > text.titleMaxChars) {
block(
"title_too_long",
`title has ${[...title].length} characters, limit ${text.titleMaxChars}`,
);
}
if ([...caption].length > text.captionMaxChars) {
block(
"caption_too_long",
`caption has ${[...caption].length} characters, limit ${text.captionMaxChars}`,
);
}
const hashtags = new Set<string>();
for (const tag of copy.tags) {
const clean = tag.replace(/^#+/, "").trim().toLowerCase();
if (clean) hashtags.add(clean);
}
for (const match of caption.matchAll(HASHTAG_PATTERN))
hashtags.add(match[0].slice(1).toLowerCase());
if (hashtags.size > text.hashtagLimit) {
block("too_many_hashtags", `${hashtags.size} hashtags, limit ${text.hashtagLimit}`);
}
const fields: Array<[string, string]> = [
["title", title],
["caption", caption],
...copy.tags.map((tag, i): [string, string] => [`tags[${i}]`, tag]),
];
for (const banned of text.bannedPatterns) {
const regex = compilePattern(banned.pattern);
if (!regex) {
block("invalid_banned_pattern", `profile pattern cannot be checked: ${banned.reason}`);
continue;
}
for (const [field, value] of fields) {
if (value && regex.test(value)) block("banned_pattern", `${field}: ${banned.reason}`);
}
}
if (text.linkPolicy !== "allowed") {
for (const [field, value] of fields) {
if (!value || !URL_PATTERN.test(value)) continue;
if (text.linkPolicy === "forbidden") block("link_forbidden", `${field} contains a link`);
else block("link_bio_only", `${field} contains a link; links belong in the bio`);
}
}
return issues;
}
function checkContent(input: GateInput): GateIssue[] {
const { profile } = input;
const issues: GateIssue[] = [];
const thresholds = profile.content.thresholds;
const gapMs = (1000 / profile.content.sampleFps) * 1.5;
const sampled = Math.max(input.content.length, input.sampledFrames, 1);
for (const category of CONTENT_CATEGORIES) {
const threshold = thresholds[category];
if (threshold === undefined) continue;
const hits = input.content.flatMap((sample) => {
const score = sample.scores[category];
return score !== undefined && score > threshold ? [{ tMs: sample.tMs, score }] : [];
});
if (hits.length === 0) continue;
const blocking =
profile.content.blockOn === "any_frame" ||
hits.length / sampled > (profile.content.blockRatio ?? 0);
for (const range of ranges(hits, gapMs)) {
const score = Math.max(...range.map((h) => h.score));
issues.push({
check: "content",
severity: blocking ? "block" : "warn",
code: "content_threshold",
message: `${category} ${score.toFixed(2)} above ${threshold}`,
atMs: ms(range[0]!.tMs),
endMs: ms(range.at(-1)!.tMs),
category,
score,
});
}
}
return issues;
}
function checkAnonymization(input: GateInput): GateIssue[] {
const issues: GateIssue[] = [];
const protectedIds = new Set(
input.people.filter((p) => p.anonymizeByDefault !== "none").map((p) => p.id),
);
const gapMs = 1000;
const uncovered = input.faces.filter(
(face) => face.personId !== null && protectedIds.has(face.personId) && face.coverage < 1,
);
const byPerson = new Map<string, typeof uncovered>();
for (const face of uncovered) {
const list = byPerson.get(face.personId!) ?? [];
list.push(face);
byPerson.set(face.personId!, list);
}
for (const [personId, faces] of [...byPerson.entries()].sort((a, b) =>
a[0].localeCompare(b[0]),
)) {
for (const range of ranges(faces, gapMs)) {
const coverage = Math.min(...range.map((f) => f.coverage));
issues.push({
check: "anonymization",
severity: "block",
code: "face_not_covered",
message: `face covered ${(coverage * 100).toFixed(0)} %`,
atMs: ms(range[0]!.tMs),
endMs: ms(range.at(-1)!.tMs),
personId,
score: coverage,
});
}
}
if (protectedIds.size > 0) {
const unknown = input.faces.filter((face) => face.personId === null);
for (const range of ranges(unknown, gapMs)) {
issues.push({
check: "anonymization",
severity: "block",
code: "unknown_face",
message: "a face that cannot be matched appears next to a protected person",
atMs: ms(range[0]!.tMs),
endMs: ms(range.at(-1)!.tMs),
});
}
}
for (const gap of input.trackingGaps) {
if (gap.endMs - gap.startMs <= MAX_TRACKING_GAP_MS) continue;
issues.push({
check: "anonymization",
severity: "block",
code: "tracking_gap",
message: `face tracking lost for ${Math.round(gap.endMs - gap.startMs)} ms (needs review)`,
atMs: ms(gap.startMs),
endMs: ms(gap.endMs),
...(gap.personId ? { personId: gap.personId } : {}),
});
}
const visibleMarks = input.marks.filter((mark) => mark.residualVisibility > MAX_MARK_RESIDUAL);
const byMark = new Map<string, typeof visibleMarks>();
for (const mark of visibleMarks) {
const list = byMark.get(mark.markId) ?? [];
list.push(mark);
byMark.set(mark.markId, list);
}
for (const [markId, marks] of [...byMark.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
for (const range of ranges(marks, gapMs)) {
const residual = Math.max(...range.map((m) => m.residualVisibility));
issues.push({
check: "anonymization",
severity: "block",
code: "mark_visible",
message: `treated mark still ${(residual * 100).toFixed(0)} % visible`,
atMs: ms(range[0]!.tMs),
endMs: ms(range.at(-1)!.tMs),
markId,
score: residual,
});
}
}
return issues;
}
function checkConsent(input: GateInput): GateIssue[] {
const { consent } = input.profile;
if (!consent.requirePerformerConsent && !consent.requireAgeVerification) return [];
const issues: GateIssue[] = [];
for (const person of [...input.people].sort((a, b) => a.id.localeCompare(b.id))) {
if (consent.requirePerformerConsent && !person.consentRecordedAt) {
issues.push({
check: "consent",
severity: "block",
code: "consent_missing",
message: "no recorded consent",
personId: person.id,
});
}
if (consent.requireAgeVerification && !person.ageVerifiedAt) {
issues.push({
check: "consent",
severity: "block",
code: "age_unverified",
message: "age is not verified",
personId: person.id,
});
}
}
for (const range of ranges(
input.faces.filter((face) => face.personId === null),
1000,
)) {
issues.push({
check: "consent",
severity: "block",
code: "unknown_performer",
message: "a visible face is not assigned to a person with consent",
atMs: ms(range[0]!.tMs),
endMs: ms(range.at(-1)!.tMs),
});
}
return issues;
}
/** Deterministic gate (spec 6). Any blocking issue fails the report. */
export function evaluateGate(input: GateInput): GateReport {
const { profile } = input;
const issues: GateIssue[] = [
...checkTechnical(profile, input.edl, input.ffprobe),
...checkContent(input),
...checkAnonymization(input),
...checkConsent(input),
...checkCopy(profile, input.copy),
];
const alterations = new Set([...input.aiAlterations, ...input.edl.metadata.aiAlterations]);
if (
alterations.size > 0 &&
profile.disclosure.aiAlteredLabelRequired &&
!input.bundleHasDisclosure
) {
issues.push({
check: "disclosure",
severity: "block",
code: "ai_label_missing",
message: `AI alterations (${[...alterations].sort().join(", ")}) need the platform label`,
});
}
if (input.watermarkBox && !boxInsideSafeZone(input.watermarkBox, profile)) {
issues.push({
check: "brand",
severity: "warn",
code: "watermark_outside_safe_zone",
message: "watermark is covered by platform interface",
});
}
return {
profileId: profile.id,
passed: !issues.some((issue) => issue.severity === "block"),
checkedAt: input.now.toISOString(),
sampledFrames: Math.max(0, Math.round(input.sampledFrames)),
issues,
autoFixRound: Math.min(MAX_AUTO_FIX_ROUNDS, Math.max(0, Math.round(input.autoFixRound))),
};
}
export interface AutoFixResult {
@ -73,15 +473,227 @@ export interface AutoFixResult {
changes: string[];
}
const PADDING_STEP = 25;
const CUT_MARGIN_MS = 500;
const MIN_KEEP_MS = 500;
/** Lay clips out back to back again, keeping blended transitions' overlap. */
function repack(edl: Edl): Edl {
const clips = sortedVideoClips(edl);
const ids = new Set(clips.map((c) => c.id));
const transitions = edl.transitions.filter((t) => ids.has(t.fromClipId) && ids.has(t.toClipId));
const adjacent = new Set<string>();
for (let i = 0; i + 1 < clips.length; i++) adjacent.add(`${clips[i]!.id}>${clips[i + 1]!.id}`);
const kept = transitions.filter((t) => adjacent.has(`${t.fromClipId}>${t.toClipId}`));
const blend = new Map(
kept
.filter((t) => BLENDED_TRANSITIONS.has(t.kind) && t.durationMs > 0)
.map((t) => [`${t.fromClipId}>${t.toClipId}`, t.durationMs]),
);
let cursor = 0;
const packed: EdlVideoClip[] = clips.map((clip, i) => {
const previous = clips[i - 1];
let overlap = previous ? (blend.get(`${previous.id}>${clip.id}`) ?? 0) : 0;
overlap = Math.min(overlap, clipLengthMs(clip) - 1, cursor);
const atMs = Math.max(0, cursor - Math.max(0, overlap));
cursor = atMs + clipLengthMs(clip);
return { ...clip, atMs };
});
const regions = edl.redactions.regions.filter((r) => ids.has(r.clipId));
const next = {
...edl,
tracks: { ...edl.tracks, video: packed },
transitions: kept.filter((t) => {
if (!BLENDED_TRANSITIONS.has(t.kind)) return true;
const from = packed.find((c) => c.id === t.fromClipId)!;
const to = packed.find((c) => c.id === t.toClipId)!;
return t.durationMs < Math.min(clipLengthMs(from), clipLengthMs(to));
}),
redactions: { ...edl.redactions, regions },
};
// Overlays never grow or move; they are only clamped to the new end.
const total = edlDurationMs(next);
next.overlays = edl.overlays.flatMap((overlay) => {
if (overlay.atMs >= total) return [];
return [{ ...overlay, durationMs: Math.min(overlay.durationMs, total - overlay.atMs) }];
});
return next;
}
/**
* Compliance-only repairs: trim or drop offending clips, change crop, widen
* redaction padding, swap to another segment. Never adds obfuscation aimed at
* classifiers. Returns null when the round limit is reached or nothing safe applies.
*
* - Content blocks and tracking gaps: the clip showing that moment is swapped
* for an alternative segment of at least the same length that does not
* overlap the offending source range, else trimmed (when the range sits at
* one end) or dropped. The timeline is then repacked.
* - Uncovered faces: padding of the person's face redaction grows by 25
* points; a missing redaction is added as blur.
* - Unknown faces: the unassigned-face redaction grows or is added as blur.
* - Visible marks: the mark treatment falls back to cover.
*/
export function planAutoFix(
_edl: Edl,
_report: GateReport,
_alternatives: Array<{ segmentId: string; assetId: string; startMs: number; endMs: number }>,
edl: Edl,
report: GateReport,
alternatives: Array<{ segmentId: string; assetId: string; startMs: number; endMs: number }>,
): AutoFixResult | null {
return notImplemented("planAutoFix");
if (report.autoFixRound >= MAX_AUTO_FIX_ROUNDS || report.passed) return null;
const blocks = report.issues.filter((issue) => issue.severity === "block");
const changes: string[] = [];
let clips: EdlVideoClip[] = edl.tracks.video.map((clip) => ({ ...clip }));
let faces: FaceRedaction[] = edl.redactions.faces.map((f) => ({ ...f }));
let marks = edl.redactions.marks.map((m) => ({ ...m }));
let clipsChanged = false;
// Source ranges to remove, per clip.
const offending = new Map<string, Array<{ start: number; end: number; reason: string }>>();
for (const issue of blocks) {
const timeline =
issue.check === "content" ||
issue.code === "tracking_gap" ||
(issue.code === "mark_visible" &&
!edl.redactions.marks.some((m) => m.markId === issue.markId));
if (!timeline || issue.atMs === undefined) continue;
const endMs = issue.endMs ?? issue.atMs;
const step = Math.max(1, Math.floor(1000 / edl.output.fps));
const times: number[] = [];
for (let t = issue.atMs; t < endMs; t += step) times.push(t);
times.push(endMs);
for (const t of times) {
for (const hit of locateOutputTimeAll(edl, t)) {
const list = offending.get(hit.clipId) ?? [];
const last = list.at(-1);
if (last && last.reason === issue.code && hit.sourceMs - last.end <= step * 4) {
last.end = Math.max(last.end, hit.sourceMs);
} else {
list.push({ start: hit.sourceMs, end: hit.sourceMs, reason: issue.code });
}
offending.set(hit.clipId, list);
}
}
}
const usedAlternatives = new Set<string>();
for (const [clipId, found] of [...offending.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
const clip = clips.find((c) => c.id === clipId);
if (!clip) continue;
const start = Math.max(clip.inMs, Math.min(...found.map((r) => r.start)) - CUT_MARGIN_MS);
const end = Math.min(clip.outMs, Math.max(...found.map((r) => r.end)) + CUT_MARGIN_MS);
const length = clip.outMs - clip.inMs;
const alternative = alternatives
.filter(
(a) =>
!usedAlternatives.has(a.segmentId) &&
a.segmentId !== clip.segmentId &&
a.endMs - a.startMs >= length &&
!(a.assetId === clip.assetId && a.startMs < end && start < a.endMs),
)
.sort((a, b) => a.segmentId.localeCompare(b.segmentId))[0];
clipsChanged = true;
if (alternative) {
usedAlternatives.add(alternative.segmentId);
Object.assign(clip, {
assetId: alternative.assetId,
segmentId: alternative.segmentId,
inMs: alternative.startMs,
outMs: alternative.startMs + length,
reframe: clip.reframe === "none" ? "none" : "auto",
});
changes.push(
`clip ${clip.id}: swapped to segment ${alternative.segmentId} (${found[0]!.reason})`,
);
} else if (start - clip.inMs >= MIN_KEEP_MS && end >= clip.outMs - 1) {
clip.outMs = start;
changes.push(`clip ${clip.id}: trimmed the end at ${start} ms (${found[0]!.reason})`);
} else if (clip.outMs - end >= MIN_KEEP_MS && start <= clip.inMs + 1) {
clip.inMs = end;
changes.push(`clip ${clip.id}: trimmed the start to ${end} ms (${found[0]!.reason})`);
} else {
clips = clips.filter((c) => c.id !== clip.id);
changes.push(`clip ${clip.id}: removed (${found[0]!.reason})`);
}
}
for (const clip of clips) {
if (Array.isArray(clip.reframe)) {
const inside = clip.reframe.filter((k) => k.tMs >= clip.inMs && k.tMs <= clip.outMs);
clip.reframe = inside.length > 0 ? inside : "auto";
}
}
const widen = (match: (f: FaceRedaction) => boolean, add: () => FaceRedaction, label: string) => {
const existing = faces.filter(match);
if (existing.length === 0) {
faces = [...faces, add()];
changes.push(`${label}: added blur redaction`);
return;
}
for (const face of existing) {
if (face.paddingPct >= 100) continue;
face.paddingPct = Math.min(100, face.paddingPct + PADDING_STEP);
changes.push(`${label}: redaction ${face.id} padding ${face.paddingPct} %`);
}
};
const widened = new Set<string>();
for (const issue of blocks) {
if (issue.code === "face_not_covered" && issue.personId && !widened.has(issue.personId)) {
const personId = issue.personId;
widened.add(personId);
widen(
(f) => "personId" in f.target && f.target.personId === personId,
() => ({
id: `fix-face-${widened.size}`,
target: { personId },
mode: "blur",
paddingPct: 20 + PADDING_STEP,
featherPct: 10,
minSizePct: 2,
}),
`person ${personId}`,
);
}
if (issue.code === "unknown_face" && !widened.has("(unassigned)")) {
widened.add("(unassigned)");
widen(
(f) => "unassignedFaces" in f.target,
() => ({
id: "fix-face-unassigned",
target: { unassignedFaces: true },
mode: "blur",
paddingPct: 20 + PADDING_STEP,
featherPct: 10,
minSizePct: 2,
}),
"unassigned faces",
);
}
if (issue.code === "mark_visible" && issue.markId) {
const markId = issue.markId;
marks = marks.map((mark) => {
if (mark.markId !== markId || mark.treatment === "cover") return mark;
changes.push(`mark ${markId}: ${mark.treatment} replaced by cover`);
return { ...mark, treatment: "cover" as const };
});
}
}
if (changes.length === 0 || clips.length === 0) return null;
let next: Edl = {
...edl,
tracks: { ...edl.tracks, video: clips },
redactions: { ...edl.redactions, faces, marks },
};
if (clipsChanged) next = repack(next);
const stillAltered = new Set<AiAlteration>(
next.metadata.aiAlterations.filter(
(a) => a !== "inpaint" || marks.some((m) => m.treatment === "inpaint"),
),
);
const explicit = edl.metadata.aiAltered && edl.metadata.aiAlterations.length === 0;
next.metadata = {
...next.metadata,
aiAlterations: [...stillAltered],
aiAltered: stillAltered.size > 0 || explicit,
};
return { edl: next, changes };
}

View File

@ -6,15 +6,70 @@ import type {
PlatformProfile,
Utm,
} from "@rakazo/contracts";
import { notImplemented } from "./not-implemented.js";
/** Append UTM parameters to an http(s) target. Rejects other schemes. */
export function buildTrackingUrl(_targetUrl: string, _utm: Utm): string {
return notImplemented("buildTrackingUrl");
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}`;
}
export function normalizeHashtags(_tags: string[], _limit: number): string[] {
return notImplemented("normalizeHashtags");
/**
* 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 {
@ -28,39 +83,450 @@ export interface ParsedMetricRow {
currency: string;
}
/** RFC 4180 CSV parsing with the saved column mapping. */
/** 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,
csv: string,
mapping: MetricCsvMapping,
): { rows: ParsedMetricRow[]; skipped: Array<{ row: number; reason: string }> } {
return notImplemented("parseMetricsCsv");
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 };
}
/** Whether the brand intro fits before the hook window; shorts put the hook first. */
export function introFits(_profile: PlatformProfile, _introMs: number): boolean {
return notImplemented("introFits");
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
);
}
/** Deterministic intro proposals from the brand kit, tone, and available templates. */
/**
* 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,
kit: BrandKit,
templates: MotionTemplateInfo[],
count: number,
): Array<{ template: MotionTemplateRef; rationale: string }> {
return notImplemented("proposeIntroVariants");
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[] {
return notImplemented("validateBrandKit");
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: {
export function uploadChecklist(input: {
profile: PlatformProfile;
aiAltered: boolean;
hasCaptions: boolean;
trackingUrl: string | null;
}): string {
return notImplemented("uploadChecklist");
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`;
}

View File

@ -1,4 +0,0 @@
/** Phase A scaffold marker. No call site may remain once Media Studio ships. */
export function notImplemented(name: string): never {
throw new Error(`not implemented: ${name}`);
}

View File

@ -1,4 +1,6 @@
import type {
AiAlteration,
AnonymizeMode,
BrandKit,
Edl,
MediaProjectGoal,
@ -6,12 +8,88 @@ import type {
TeaserFeature,
TeaserWeights,
} from "@rakazo/contracts";
import { notImplemented } from "./not-implemented.js";
import {
AnonymizeModeSchema,
EdlSchema,
TEASER_FEATURES,
TEASER_LEARNING_MIN_SAMPLES,
} from "@rakazo/contracts";
import { introFits } from "./marketing.js";
export type SegmentFeatures = Partial<Record<TeaserFeature, number>>;
export function defaultTeaserWeights(_goal: MediaProjectGoal): TeaserWeights {
return notImplemented("defaultTeaserWeights");
const GOAL_WEIGHTS: Record<MediaProjectGoal, Record<TeaserFeature, number>> = {
teaser: {
motion: 0.14,
sharpness: 0.06,
faces: 0.08,
gaze: 0.08,
audioPeak: 0.14,
speechHook: 0.24,
novelty: 0.12,
beatProximity: 0.06,
aesthetics: 0.08,
},
trailer: {
motion: 0.16,
sharpness: 0.08,
faces: 0.08,
gaze: 0.06,
audioPeak: 0.14,
speechHook: 0.12,
novelty: 0.12,
beatProximity: 0.12,
aesthetics: 0.12,
},
highlight: {
motion: 0.2,
sharpness: 0.08,
faces: 0.12,
gaze: 0.06,
audioPeak: 0.18,
speechHook: 0.08,
novelty: 0.1,
beatProximity: 0.08,
aesthetics: 0.1,
},
story: {
motion: 0.08,
sharpness: 0.08,
faces: 0.16,
gaze: 0.12,
audioPeak: 0.06,
speechHook: 0.22,
novelty: 0.12,
beatProximity: 0.04,
aesthetics: 0.12,
},
full_release: {
motion: 0.1,
sharpness: 0.14,
faces: 0.1,
gaze: 0.08,
audioPeak: 0.08,
speechHook: 0.1,
novelty: 0.12,
beatProximity: 0.06,
aesthetics: 0.22,
},
custom: {
motion: 0.12,
sharpness: 0.1,
faces: 0.1,
gaze: 0.1,
audioPeak: 0.12,
speechHook: 0.14,
novelty: 0.12,
beatProximity: 0.08,
aesthetics: 0.12,
},
};
/** Default per-goal weights over every teaser feature; each set sums to 1. */
export function defaultTeaserWeights(goal: MediaProjectGoal): TeaserWeights {
return { ...GOAL_WEIGHTS[goal] };
}
export interface ScoredSegmentInput {
@ -25,13 +103,75 @@ export interface ScoredSegmentInput {
transcript: string | null;
}
export function scoreSegment(_features: SegmentFeatures, _weights: TeaserWeights): number {
return notImplemented("scoreSegment");
const clamp01 = (value: number) => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0);
/** Weighted mean of 0..1 features; missing features count as 0. */
export function scoreSegment(features: SegmentFeatures, weights: TeaserWeights): number {
let total = 0;
let norm = 0;
for (const feature of TEASER_FEATURES) {
const weight = weights[feature] ?? 0;
norm += Math.abs(weight);
total += weight * clamp01(features[feature] ?? 0);
}
return norm === 0 ? 0 : total / norm;
}
const HOOK_LEXICON = {
en: {
questionWords:
/^(what|why|how|who|when|where|which|did|do|does|can|could|would|should|is|are|have|ever)\b/,
announcements:
/\b(wait|watch|look|listen|check this|you won'?t believe|here'?s|secret|how to|never|finally|the moment|let me show|guess|imagine|stop|nobody|everyone|biggest|best|worst|first time|new|today)\b/,
address: /\b(you|your)\b/,
numbers: /\b(one|two|three|five|ten|hundred|thousand|million|first|second|third|top)\b/,
},
de: {
questionWords:
/^(was|warum|wieso|weshalb|wie|wer|wann|wo|welche[rsmn]?|hast|habt|kannst|könnt|willst|würdest|weißt|ist|sind)\b/,
announcements:
/\b(warte|wartet|schau|schaut|guck|hör zu|pass auf|achtung|du wirst nicht glauben|geheimnis|so geht|niemals|endlich|gleich|hier ist|zeig ich dir|stell dir vor|rate mal|niemand|alle|größte|beste|schlimmste|zum ersten mal|neu|heute)\b/,
address: /\b(du|dich|dir|dein|deine|ihr|euch|euer)\b/,
numbers: /\b(eins|zwei|drei|fünf|zehn|hundert|tausend|million|erste[rsn]?|zweite[rsn]?|top)\b/,
},
} as const;
/** 0..1 speech-hook strength from a transcript excerpt (questions, exclamations, numbers, announcements). */
export function speechHookScore(_text: string, _language?: string): number {
return notImplemented("speechHookScore");
export function speechHookScore(text: string, language?: string): number {
const normalized = text.trim().toLocaleLowerCase(language);
if (!normalized) return 0;
const code = language?.slice(0, 2).toLowerCase();
const lexicons =
code === "de"
? [HOOK_LEXICON.de]
: code === "en"
? [HOOK_LEXICON.en]
: Object.values(HOOK_LEXICON);
let score = 0;
if (normalized.includes("?")) score += 0.35;
if (normalized.includes("!")) score += 0.25;
const hasNumber =
/\d/.test(normalized) || lexicons.some((lexicon) => lexicon.numbers.test(normalized));
if (hasNumber) score += 0.2;
if (lexicons.some((lexicon) => lexicon.announcements.test(normalized))) score += 0.3;
if (lexicons.some((lexicon) => lexicon.questionWords.test(normalized))) score += 0.15;
if (lexicons.some((lexicon) => lexicon.address.test(normalized))) score += 0.1;
return Math.min(1, Math.round(score * 1000) / 1000);
}
function segmentFeatures(segment: ScoredSegmentInput): SegmentFeatures {
if (segment.features.speechHook !== undefined || !segment.transcript) return segment.features;
return { ...segment.features, speechHook: speechHookScore(segment.transcript) };
}
/** Strength of a segment as an opener: speech hooks, audio peaks, motion, eye contact. */
function hookStrength(features: SegmentFeatures): number {
return (
0.45 * clamp01(features.speechHook ?? 0) +
0.25 * clamp01(features.audioPeak ?? 0) +
0.15 * clamp01(features.motion ?? 0) +
0.15 * clamp01(features.gaze ?? 0)
);
}
export interface TeaserSelection {
@ -41,12 +181,20 @@ export interface TeaserSelection {
notes: string[];
}
const MIN_CLIP_MS = 1000;
/**
* Pick clips for a goal and profile: a hook inside hookWindowMs, rising
* tension, cut before the payoff (open loop), never paid-only segments,
* total within the profile's duration bounds.
*
* The primary asset is the one holding the best segment overall; its payoff
* is its best segment (paid-only included, since that is usually the payoff).
* Nothing from the primary asset at or after the payoff is used. The hook
* opens the teaser, the rest follows in ascending score, and the lead-in to
* the payoff closes it, leaving `endCardMs` for the end card.
*/
export function selectTeaserSegments(_input: {
export function selectTeaserSegments(input: {
segments: ScoredSegmentInput[];
profile: PlatformProfile;
goal: MediaProjectGoal;
@ -54,19 +202,227 @@ export function selectTeaserSegments(_input: {
weights: TeaserWeights;
endCardMs: number;
}): TeaserSelection {
return notImplemented("selectTeaserSegments");
const { profile } = input;
const notes: string[] = [];
const scored = input.segments
.filter((s) => s.endMs > s.startMs)
.map((segment) => {
const features = segmentFeatures(segment);
return { segment, features, score: scoreSegment(features, input.weights) };
})
.sort(
(a, b) =>
b.score - a.score ||
a.segment.assetId.localeCompare(b.segment.assetId) ||
a.segment.startMs - b.segment.startMs ||
a.segment.id.localeCompare(b.segment.id),
);
const maxMs = Math.max(
0,
Math.min(input.targetMs, profile.duration.hardMaxMs) - Math.max(0, input.endCardMs),
);
const minMs = Math.max(0, profile.duration.hardMinMs - Math.max(0, input.endCardMs));
const usable = scored.filter((s) => !s.segment.paidOnly);
if (usable.length === 0 || maxMs < MIN_CLIP_MS) {
return { clips: [], hookSegmentId: null, notes: ["no usable segments"] };
}
const primaryAsset = scored[0]!.segment.assetId;
const payoff = scored.find((s) => s.segment.assetId === primaryAsset)!;
const pool = usable.filter(
(s) =>
s.segment.assetId !== primaryAsset ||
(s.segment.id !== payoff.segment.id && s.segment.endMs <= payoff.segment.startMs),
);
const openLoop = pool.length < usable.length || payoff.segment.paidOnly;
if (pool.length === 0) {
return { clips: [], hookSegmentId: null, notes: ["only the payoff is usable"] };
}
const hook = [...pool].sort(
(a, b) =>
hookStrength(b.features) - hookStrength(a.features) ||
b.score - a.score ||
a.segment.id.localeCompare(b.segment.id),
)[0]!;
const hookWindow = profile.duration.hookWindowMs;
const clipCap = Math.max(MIN_CLIP_MS, Math.min(5000, Math.round(maxMs / 4)));
const lengthOf = (s: ScoredSegmentInput, cap: number) => Math.min(s.endMs - s.startMs, cap);
// Lead-in: the last primary-asset segment before the payoff closes the teaser.
const leadIn = openLoop
? pool
.filter((s) => s.segment.assetId === primaryAsset && s !== hook)
.sort((a, b) => b.segment.endMs - a.segment.endMs)[0]
: undefined;
const chosen: typeof pool = [hook];
let total = lengthOf(hook.segment, Math.max(clipCap, Math.min(hookWindow, maxMs)));
const reserve = leadIn ? lengthOf(leadIn.segment, clipCap) : 0;
for (const candidate of pool) {
if (candidate === hook || candidate === leadIn) continue;
const length = lengthOf(candidate.segment, clipCap);
if (length < Math.min(MIN_CLIP_MS, candidate.segment.endMs - candidate.segment.startMs))
continue;
if (total + length + reserve > maxMs) continue;
chosen.push(candidate);
total += length;
}
if (leadIn && total + reserve <= maxMs) {
chosen.push(leadIn);
total += reserve;
}
const middle = chosen
.slice(1)
.filter((s) => s !== leadIn)
.sort((a, b) => a.score - b.score || a.segment.id.localeCompare(b.segment.id));
const ordered = [hook, ...middle, ...(chosen.includes(leadIn!) ? [leadIn!] : [])];
// Lengths: start from the cap, then grow toward the minimum duration if needed.
const lengths = ordered.map((s, i) =>
lengthOf(s.segment, i === 0 ? Math.max(clipCap, Math.min(hookWindow, maxMs)) : clipCap),
);
let sum = lengths.reduce((a, b) => a + b, 0);
for (let i = 0; i < ordered.length && sum < minMs; i++) {
const full = ordered[i]!.segment.endMs - ordered[i]!.segment.startMs;
const grow = Math.min(full - lengths[i]!, minMs - sum, maxMs - sum);
if (grow > 0) {
lengths[i]! += grow;
sum += grow;
}
}
while (sum > maxMs && lengths.length > 0) {
const over = sum - maxMs;
const last = lengths.length - 1;
if (lengths[last]! - over >= MIN_CLIP_MS) {
lengths[last]! -= over;
sum -= over;
} else {
sum -= lengths.pop()!;
ordered.pop();
}
}
const clips = ordered.map((s, i) => {
const length = lengths[i]!;
// The lead-in ends where the payoff would begin; other clips open at their start.
const isLeadIn = s === leadIn;
const outMs = isLeadIn ? s.segment.endMs : s.segment.startMs + length;
return {
segmentId: s.segment.id,
assetId: s.segment.assetId,
inMs: outMs - length,
outMs,
};
});
notes.push(
`hook: segment ${hook.segment.id} opens inside the ${hookWindow} ms hook window`,
...middle.map((s) => `build: segment ${s.segment.id} (score ${s.score.toFixed(2)})`),
);
if (leadIn && ordered.includes(leadIn)) {
notes.push(`open loop: segment ${leadIn.segment.id} cuts before payoff ${payoff.segment.id}`);
} else if (openLoop) {
notes.push(`open loop: payoff ${payoff.segment.id} is left out`);
}
if (input.segments.some((s) => s.paidOnly)) notes.push("paid-only segments excluded");
if (sum < minMs) notes.push(`only ${sum} ms of usable material; profile minimum is ${minMs} ms`);
return { clips, hookSegmentId: hook.segment.id, notes };
}
/** Regularized logistic regression of conversion on features; null below TEASER_LEARNING_MIN_SAMPLES. */
const LEARNING_RATE = 0.5;
const LEARNING_ITERATIONS = 400;
const L2 = 0.05;
const SHRINKAGE = 50;
/**
* Regularized logistic regression of conversion on features; null below
* TEASER_LEARNING_MIN_SAMPLES samples with views.
*
* Each sample contributes `conversions` successes out of `views` trials,
* weighted so every publication counts equally. Full-batch gradient descent
* from zero with a fixed iteration count keeps it deterministic. Positive
* coefficients of features that vary in the data redistribute those features'
* prior mass; features without variation keep their prior. The result is
* shrunk toward the prior by n / (n + 50) so a small sample cannot overturn it.
*/
export function fitTeaserWeights(
_samples: Array<{ features: SegmentFeatures; views: number; conversions: number }>,
_prior: TeaserWeights,
samples: Array<{ features: SegmentFeatures; views: number; conversions: number }>,
prior: TeaserWeights,
): TeaserWeights | null {
return notImplemented("fitTeaserWeights");
const usable = samples.filter((s) => s.views > 0 && s.conversions >= 0);
if (usable.length < TEASER_LEARNING_MIN_SAMPLES) return null;
const k = TEASER_FEATURES.length;
const xs = usable.map((s) => TEASER_FEATURES.map((f) => clamp01(s.features[f] ?? 0)));
const ys = usable.map((s) => Math.min(1, s.conversions / s.views));
const beta = new Array<number>(k).fill(0);
let intercept = 0;
for (let iteration = 0; iteration < LEARNING_ITERATIONS; iteration++) {
const gradient = new Array<number>(k).fill(0);
let gradientIntercept = 0;
for (let i = 0; i < xs.length; i++) {
const x = xs[i]!;
let z = intercept;
for (let j = 0; j < k; j++) z += beta[j]! * x[j]!;
const error = 1 / (1 + Math.exp(-z)) - ys[i]!;
gradientIntercept += error;
for (let j = 0; j < k; j++) gradient[j]! += error * x[j]!;
}
intercept -= (LEARNING_RATE * gradientIntercept) / xs.length;
for (let j = 0; j < k; j++) {
beta[j]! -= LEARNING_RATE * (gradient[j]! / xs.length + L2 * beta[j]!);
}
}
// Only features that vary in the data are re-weighted; they share their prior mass.
const varies = TEASER_FEATURES.map((_, j) => xs.some((x) => x[j] !== xs[0]![j]));
const mass = TEASER_FEATURES.reduce(
(sum, f, j) => sum + (varies[j] ? Math.max(0, prior[f] ?? 0) : 0),
0,
);
const positive = beta.map((b, j) => (varies[j] ? Math.max(0, b) : 0));
const positiveTotal = positive.reduce((a, b) => a + b, 0);
const alpha = usable.length / (usable.length + SHRINKAGE);
return Object.fromEntries(
TEASER_FEATURES.map((feature, j) => {
const base = prior[feature] ?? 0;
const learned = varies[j] && positiveTotal > 0 ? (positive[j]! / positiveTotal) * mass : base;
const value = (1 - alpha) * base + alpha * learned;
return [feature, Math.round(value * 1e6) / 1e6];
}),
) as TeaserWeights;
}
/** Deterministic first-cut EDL for a project and profile; the agent refines it. */
export function autoPlanEdl(_input: {
const CTA_CARD_MS = 2500;
function pickTarget(profile: PlatformProfile, goal: MediaProjectGoal, availableMs: number): number {
const { hardMinMs, hardMaxMs, recommended } = profile.duration;
const options = [...recommended]
.filter((r) => r >= hardMinMs && r <= hardMaxMs)
.sort((a, b) => a - b);
let target: number;
if (goal === "full_release" || goal === "custom") {
target = Math.min(hardMaxMs, availableMs);
} else if (options.length > 0) {
target = options[Math.floor((options.length - 1) / 2)]!;
} else {
target = Math.min(hardMaxMs, 30_000);
}
return Math.max(hardMinMs, Math.min(hardMaxMs, target));
}
/**
* Deterministic first-cut EDL for a project and profile; the agent refines it.
*
* Teaser-style goals use selectTeaserSegments; full releases and custom
* projects take segments in order (paid-only ones only for non-social
* profiles). Reframing is "auto" whenever the source aspect differs from the
* output, captions follow the profile when the material has speech, the
* brand intro only plays where introFits allows it, and a teaser ends on the
* brand end card (or a CTA text card). Person and mark defaults become
* redactions, and the AI-alteration metadata follows from them.
*/
export function autoPlanEdl(input: {
goal: MediaProjectGoal;
profile: PlatformProfile;
brandKit: BrandKit | null;
@ -81,18 +437,223 @@ export function autoPlanEdl(_input: {
marks: Array<{ id: string; treatment: string; assetId: string | null; personId: string | null }>;
ctaText: string | null;
}): Edl {
return notImplemented("autoPlanEdl");
const { goal, profile, brandKit: kit } = input;
const W = profile.video.width;
const H = profile.video.height;
const segments = input.segments.filter((s) => {
const asset = input.assets.get(s.assetId);
return (
asset && s.endMs > s.startMs && (asset.durationMs === null || s.endMs <= asset.durationMs)
);
});
const available = segments.reduce((sum, s) => sum + (s.endMs - s.startMs), 0);
const targetMs = pickTarget(profile, goal, available);
const intro = kit?.intro && introFits(profile, kit.intro.durationMs) ? kit.intro : undefined;
let outro = goal === "teaser" ? undefined : kit?.outro;
const ctaText = input.ctaText?.trim() || null;
let ctaCard = false;
if (goal === "teaser") {
if (kit?.endCard) {
outro = {
...kit.endCard,
params: { ...kit.endCard.params, ...(ctaText ? { cta: ctaText.slice(0, 200) } : {}) },
};
} else if (ctaText) {
ctaCard = true;
}
}
const bookendsMs = (intro?.durationMs ?? 0) + (outro?.durationMs ?? 0);
const budget = Math.max(MIN_CLIP_MS, targetMs - bookendsMs);
let picks: Array<{ segmentId: string; assetId: string; inMs: number; outMs: number }>;
if (goal === "full_release" || goal === "custom") {
const allowPaid = profile.kind !== "free_social";
const ordered = segments
.filter((s) => allowPaid || !s.paidOnly)
.sort(
(a, b) =>
(a.capturedAt?.getTime() ?? Number.MAX_SAFE_INTEGER) -
(b.capturedAt?.getTime() ?? Number.MAX_SAFE_INTEGER) ||
a.assetId.localeCompare(b.assetId) ||
a.startMs - b.startMs,
);
picks = [];
let used = 0;
for (const s of ordered) {
const length = Math.min(s.endMs - s.startMs, budget - used);
if (length < MIN_CLIP_MS) break;
picks.push({
segmentId: s.id,
assetId: s.assetId,
inMs: s.startMs,
outMs: s.startMs + length,
});
used += length;
}
} else {
picks = selectTeaserSegments({
segments,
profile,
goal,
targetMs: budget,
weights: input.weights,
endCardMs: 0,
}).clips;
}
if (picks.length === 0) throw new Error("no usable segments for this plan");
let atMs = 0;
const lut = Boolean(kit?.lutArtifactId);
const video = picks.map((pick, index) => {
const asset = input.assets.get(pick.assetId)!;
const sameAspect =
!asset.width || !asset.height || Math.abs(asset.width / asset.height / (W / H) - 1) <= 0.01;
const clip = {
id: `c${index + 1}`,
assetId: pick.assetId,
inMs: pick.inMs,
outMs: pick.outMs,
atMs,
speed: 1,
reframe: sameAspect ? ("none" as const) : ("auto" as const),
...(lut ? { color: { useBrandLut: true } } : {}),
segmentId: pick.segmentId,
};
atMs += pick.outMs - pick.inMs;
return clip;
});
const mainMs = atMs;
const introLength = intro?.durationMs ?? 0;
const totalMs = introLength + mainMs + (outro?.durationMs ?? 0);
const usedAssets = new Set(picks.map((p) => p.assetId));
const hasSpeech = [...usedAssets].some((id) => input.assets.get(id)?.hasSpeech);
const preset = kit?.captionPresets[0];
const captions =
(profile.captions.recommended || profile.captions.required) && hasSpeech
? {
mode: profile.export.subtitles ? ("both" as const) : ("burned" as const),
...(preset ? { presetId: preset.id } : {}),
maxCharsPerLine: W < H ? 24 : 32,
wordHighlight: preset?.template === "karaoke",
position: "lower" as const,
}
: undefined;
const zones = profile.safeZones;
const overlays: Array<Record<string, unknown>> = [];
if (kit?.watermark) {
overlays.push({ id: "watermark", kind: "watermark", atMs: 0, durationMs: totalMs });
}
if (ctaCard && ctaText) {
const cardMs = Math.min(CTA_CARD_MS, mainMs);
const left = zones.leftPct / 100 + 0.02;
const right = zones.rightPct / 100 + 0.02;
overlays.push({
id: "cta",
kind: "cta_end_card",
atMs: introLength + mainMs - cardMs,
durationMs: cardMs,
text: ctaText.slice(0, 200),
box: { x: left, y: 0.42, w: Math.max(0.1, 1 - left - right), h: 0.12 },
});
}
const protectedPeople = input.people.flatMap((person) => {
const mode = AnonymizeModeSchema.safeParse(person.anonymizeByDefault);
return mode.success && mode.data !== "none" ? [{ id: person.id, mode: mode.data }] : [];
});
const faces: Array<{
id: string;
target: { personId: string } | { unassignedFaces: true };
mode: Exclude<AnonymizeMode, "none">;
}> = protectedPeople.map((person, index) => ({
id: `face-${index + 1}`,
target: { personId: person.id },
mode: person.mode,
}));
if (faces.length > 0) {
// Unknown faces next to a protected person are blurred too; the gate blocks them otherwise.
faces.push({ id: "face-unassigned", target: { unassignedFaces: true }, mode: "blur" });
}
const peopleIds = new Set(input.people.map((p) => p.id));
const marks = input.marks.flatMap((mark) => {
if (mark.treatment === "none") return [];
const relevant =
(mark.assetId !== null && usedAssets.has(mark.assetId)) ||
(mark.personId !== null && peopleIds.has(mark.personId)) ||
(mark.assetId === null && mark.personId === null);
if (!relevant) return [];
const treatment = mark.treatment as "inpaint" | "blur" | "cover" | "reframe_out";
return [{ markId: mark.id, treatment }];
});
const aiAlterations: AiAlteration[] = [];
if (faces.some((f) => f.mode === "synthetic_identity")) aiAlterations.push("synthetic_identity");
if (marks.some((m) => m.treatment === "inpaint")) aiAlterations.push("inpaint");
return EdlSchema.parse({
edlVersion: 1,
output: {
profileId: profile.id,
width: W,
height: H,
fps: profile.video.fps,
durationTargetMs: targetMs,
durationHardMinMs: profile.duration.hardMinMs,
durationHardMaxMs: profile.duration.hardMaxMs,
},
tracks: { video, audio: [] },
transitions: [],
...(captions ? { captions } : {}),
overlays,
...(intro ? { intro } : {}),
...(outro ? { outro } : {}),
redactions: { faces, marks, regions: [] },
metadata: { tags: [], aiAltered: aiAlterations.length > 0, aiAlterations },
});
}
const THUMBNAIL_WEIGHTS = { sharpness: 0.4, face: 0.25, thirds: 0.2, contrast: 0.15 };
/**
* Best thumbnail frame times, spread out: candidates are ranked by sharpness,
* face presence, rule of thirds and contrast, and each pick must be at least
* a tenth of the candidate span (min 1 s) away from earlier picks while
* enough candidates remain.
*/
export function rankThumbnailFrames(
_candidates: Array<{
candidates: Array<{
tMs: number;
sharpness: number;
face: number;
thirds: number;
contrast: number;
}>,
_count: number,
count: number,
): number[] {
return notImplemented("rankThumbnailFrames");
if (count <= 0 || candidates.length === 0) return [];
const ranked = candidates
.map((c) => ({
tMs: c.tMs,
score:
THUMBNAIL_WEIGHTS.sharpness * clamp01(c.sharpness) +
THUMBNAIL_WEIGHTS.face * clamp01(c.face) +
THUMBNAIL_WEIGHTS.thirds * clamp01(c.thirds) +
THUMBNAIL_WEIGHTS.contrast * clamp01(c.contrast),
}))
.sort((a, b) => b.score - a.score || a.tMs - b.tMs);
const times = candidates.map((c) => c.tMs);
const spacing = Math.max(1000, (Math.max(...times) - Math.min(...times)) / 10);
const picked: number[] = [];
for (const candidate of ranked) {
if (picked.length >= count) break;
if (picked.every((t) => Math.abs(t - candidate.tMs) >= spacing)) picked.push(candidate.tMs);
}
for (const candidate of ranked) {
if (picked.length >= count) break;
if (!picked.includes(candidate.tMs)) picked.push(candidate.tMs);
}
return picked;
}