Documents/technical/Software/videos/src/shared/compileEpisodeScript.ts
ts • 39,698 bytes
import type { AddConfidenceClaimCommand } from "@debate-core/Commands.ts";
import type { Claim, ClaimId } from "@debate-core/Claim.ts";
import type {
ConfidenceConnector,
ConfidenceConnectorId,
RelevanceConnector,
RelevanceConnectorId,
TargetRelation,
} from "@debate-core/Connector.ts";
import type { DebateCore } from "@debate-core/Debate.ts";
import { applyConfidenceClaimAddCommand } from "@planner/applyDebateCommand.ts";
import type { AnimationStepId, DebateAnimationPlan } from "@planner/DebateAnimationPlan.ts";
import { planDebateAnimationBatch, planStaticDebate } from "@planner/planner.ts";
import {
episodeScriptSpecSchema,
type ClaimSide,
type EpisodeAction,
type EpisodeScriptSpec,
type GraphClaimState,
type GraphLayout,
type MediaLayout,
type MediaLayoutUpdate,
type ScoreboardLayout,
type Anchor,
type CssStyle,
type ObjectLayout,
} from "./episodeScriptSpec";
import { resolveGraphSceneTargets } from "./graphSceneTargets";
const DEFAULT_DURATION_SECONDS: Readonly<Record<EpisodeAction["type"], number>> = {
"camera.cut": 0,
"camera.move": 1.2,
"media.add": 0,
"media.update": 0,
"balance.add": 0,
"balance.update": 0,
"captions.show": 0,
"graph.addClaim": 4,
"graph.create": 0,
"graph.patch": 0,
"graph.set": 0,
"wait": 0,
};
const GRAPH_PHASES = [
["voila", 1 / 4],
["sprout", 2 / 7],
["firstFill", 13 / 56],
["wave", 13 / 56],
] as const satisfies readonly (readonly [AnimationStepId, number])[];
const SUPPORTED_ACTION_TYPES = [
"balance.add",
"balance.update",
"camera.cut",
"camera.move",
"captions.show",
"graph.addClaim",
"graph.create",
"graph.patch",
"graph.set",
"media.add",
"media.update",
"wait",
] as const;
const DEFAULT_SCREEN_OBJECT_STYLE: CssStyle = {
opacity: 1,
};
type ClaimTarget = string | { relevanceOf: string };
type GraphAction = Extract<EpisodeAction, { type: `graph.${string}` }>;
type SceneObjectAddAction = Extract<EpisodeAction, { type: "media.add" | "balance.add" }>;
type SceneObjectUpdateAction = Extract<EpisodeAction, { type: "media.update" | "balance.update" }>;
type SceneObjectAction = SceneObjectAddAction | SceneObjectUpdateAction;
type SceneObject =
| { aspectRatio: number; source: string; type: "media" }
| { scorePercent: number; type: "balance" };
export type EpisodeMediaAsset = {
aspectRatio: number
src: string
};
type ClaimDefinition = {
key: string
side: ClaimSide
showScore?: boolean
target?: ClaimTarget
text: string
textReveal?: boolean
};
type GraphCompilerState = {
anchor: Anchor
claimDefinitions: Map<string, ClaimDefinition>
debateCore?: DebateCore
hideScores: boolean
key: string
lastAnimationAction?: ScheduledEpisodeAction
lastAnimationEndFrame: number
layout: GraphLayout
scoreboard?: ScoreboardLayout
};
export type ScheduledEpisodeAction = {
action: EpisodeAction
durationInFrames: number
endFrame: number
from: number
index: number
label: string
};
export type CompiledGraphAnimation = {
addedClaimIds: readonly ClaimId[]
anchor: Anchor
claimScoreVisibility: Readonly<Record<ClaimId, boolean>>
debateCore: DebateCore
durationInFrames: number
from: number
graph: string
hideScores: boolean
label: string
plan: DebateAnimationPlan
scoreboard?: ScoreboardLayout
sourceActionIndexes: readonly number[]
};
export type ClaimTextReveal = {
durationInFrames: number
from: number
};
export type ResolvedSceneObjectState = {
anchor: Anchor
layout: ObjectLayout
object: SceneObject
style: CssStyle
};
export type SceneTarget = {
id: string
parentId?: string
anchor: Anchor
bounds: { minX: number; minY: number; width: number; height: number }
};
export type CompiledSceneObjectTransition = {
durationInFrames: number
from: number
initialState: ResolvedSceneObjectState
targetState: ResolvedSceneObjectState
};
export type CompiledSceneObject = {
from: number
initialState: ResolvedSceneObjectState
key: string
transitions: readonly CompiledSceneObjectTransition[]
};
export type GraphPlayback = {
animation: CompiledGraphAnimation
stepId?: AnimationStepId
stepProgress: number
};
export type CompiledEpisodeScript = {
actions: readonly ScheduledEpisodeAction[]
composition: EpisodeScriptSpec["settings"]["composition"]
durationInFrames: number
graphAnimations: readonly CompiledGraphAnimation[]
claimTextReveals: Readonly<Record<ClaimId, ClaimTextReveal>>
sceneObjects: readonly CompiledSceneObject[]
spec: EpisodeScriptSpec
};
export function compileEpisodeScript(
input: unknown,
mediaAssets: Readonly<Record<string, EpisodeMediaAsset>> = {},
): CompiledEpisodeScript {
const result = episodeScriptSpecSchema.safeParse(input);
if (!result.success) {
throw new Error(formatEpisodeScriptValidationError(input, result.error.issues));
}
const spec = result.data;
const actions = scheduleActions(spec);
const graphAnimations = compileGraphActions(spec, actions);
const claimTextReveals = compileClaimTextReveals(actions);
const sceneObjects = compileSceneObjects(actions, mediaAssets);
assertUniqueSceneTargetRoots(graphAnimations, sceneObjects);
return {
actions,
claimTextReveals,
composition: spec.settings.composition,
durationInFrames: Math.max(1, ...actions.map((action) => action.endFrame)),
graphAnimations,
sceneObjects,
spec,
};
}
function assertUniqueSceneTargetRoots(
graphAnimations: readonly CompiledGraphAnimation[],
sceneObjects: readonly CompiledSceneObject[],
): void {
const graphKeys = new Set(graphAnimations.map((animation) => animation.graph));
const duplicate = sceneObjects.find((sceneObject) => graphKeys.has(sceneObject.key));
if (duplicate) {
throw new Error(`Graph and retained object keys share the scene target namespace: ${duplicate.key}`);
}
}
function formatEpisodeScriptValidationError(
input: unknown,
issues: readonly EpisodeScriptValidationIssue[],
): string {
return ["Invalid episode script:", ...issues.map((issue) => formatEpisodeScriptIssue(input, issue))]
.join("\n");
}
function formatEpisodeScriptIssue(
input: unknown,
issue: EpisodeScriptValidationIssue,
): string {
const [root, actionIndex, field, ...nestedPath] = issue.path;
if (root === "script" && typeof actionIndex === "number") {
const actionType = getEpisodeActionType(input, actionIndex);
if (field === "type") {
return `- script[${actionIndex}] (${String(actionType)}): type must be one of ${SUPPORTED_ACTION_TYPES.join(", ")}.`;
}
const property = [field, ...nestedPath].filter((part) => part !== undefined).join(".");
return `- script[${actionIndex}] (${String(actionType)})${property ? `.${property}` : ""}: ${describeEpisodeScriptIssue(issue)}`;
}
const path = issue.path.length > 0 ? issue.path.join(".") : "root";
return `- ${path}: ${describeEpisodeScriptIssue(issue)}`;
}
type EpisodeScriptValidationIssue = {
code?: string
expected?: string
input?: unknown
maximum?: number | bigint
minimum?: number | bigint
keys?: readonly string[]
message: string
origin?: string
path: readonly PropertyKey[]
};
function describeEpisodeScriptIssue(issue: EpisodeScriptValidationIssue): string {
if (issue.code === "invalid_type") {
return issue.input === undefined
? "is required."
: `must be ${issue.expected ?? "the required type"}; received ${JSON.stringify(issue.input)}.`;
}
if (issue.code === "unrecognized_keys") {
return `does not allow ${issue.keys?.map((key) => JSON.stringify(key)).join(", ") ?? "these fields"}.`;
}
if (issue.code === "invalid_union") {
return "must use exactly one supported object shape.";
}
if (issue.code === "too_small") {
const unit = issue.origin === "array" ? " item(s)" : " character(s)";
return `must contain at least ${String(issue.minimum)}${unit}.`;
}
if (issue.code === "too_big") {
const unit = issue.origin === "array" ? " item(s)" : " character(s)";
return `must contain no more than ${String(issue.maximum)}${unit}.`;
}
return issue.message;
}
function getEpisodeActionType(input: unknown, actionIndex: number): unknown {
if (!input || typeof input !== "object") {
return undefined;
}
const script = (input as { script?: unknown }).script;
if (!Array.isArray(script)) {
return undefined;
}
const action = script[actionIndex];
return action && typeof action === "object" ? (action as { type?: unknown }).type : undefined;
}
export function resolveSceneObjectStates(
episode: CompiledEpisodeScript,
frame: number,
): readonly (ResolvedSceneObjectState & Pick<CompiledSceneObject, "key">)[] {
return episode.sceneObjects
.filter((sceneObject) => sceneObject.from <= frame)
.map((sceneObject) => ({
...resolveSceneObjectState(sceneObject, frame),
key: sceneObject.key,
}));
}
export function resolveSceneTargets(
episode: CompiledEpisodeScript,
frame: number,
): readonly SceneTarget[] {
const sceneObjectTargets = resolveSceneObjectStates(episode, frame).map((sceneObject): SceneTarget => {
return {
anchor: sceneObject.anchor,
bounds: resolveObjectLayoutBounds(sceneObject.layout),
id: sceneObject.key,
};
});
const playback = resolveGraphPlayback(episode, frame);
return playback
? [...sceneObjectTargets, ...resolveGraphSceneTargets(playback)]
: sceneObjectTargets;
}
function resolveObjectLayoutBounds(layout: ObjectLayout): SceneTarget["bounds"] {
const originX = layout.originX ?? layout.width / 2;
const originY = layout.originY ?? layout.height / 2;
const radians = layout.rotation * (Math.PI / 180);
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const corners = [
[0, 0],
[layout.width, 0],
[layout.width, layout.height],
[0, layout.height],
].map(([x, y]) => {
const scaledX = (x! - originX) * layout.scale;
const scaledY = (y! - originY) * layout.scale;
return {
x: layout.x + originX + (scaledX * cosine) - (scaledY * sine),
y: layout.y + originY + (scaledX * sine) + (scaledY * cosine),
};
});
const minX = Math.min(...corners.map((corner) => corner.x));
const minY = Math.min(...corners.map((corner) => corner.y));
const maxX = Math.max(...corners.map((corner) => corner.x));
const maxY = Math.max(...corners.map((corner) => corner.y));
return { height: maxY - minY, minX, minY, width: maxX - minX };
}
function compileSceneObjects(
actions: readonly ScheduledEpisodeAction[],
mediaAssets: Readonly<Record<string, EpisodeMediaAsset>>,
): readonly CompiledSceneObject[] {
type SceneObjectCompilerState = {
compiled: {
from: number
initialState: ResolvedSceneObjectState
key: string
transitions: CompiledSceneObjectTransition[]
}
lastTransitionEndFrame: number
state: ResolvedSceneObjectState
};
const states = new Map<string, SceneObjectCompilerState>();
const objectActions = actions
.filter(isScheduledSceneObjectAction)
.sort((left, right) => left.from - right.from || left.index - right.index);
for (const scheduled of objectActions) {
const action = scheduled.action;
if (isSceneObjectAddAction(action)) {
if (states.has(action.key)) {
throw new Error(`Action ${scheduled.index} creates duplicate object key: ${action.key}`);
}
const initialState = createSceneObjectState(action, mediaAssets, scheduled.index);
states.set(action.key, {
compiled: {
from: scheduled.from,
initialState,
key: action.key,
transitions: [],
},
lastTransitionEndFrame: scheduled.from,
state: initialState,
});
continue;
}
const state = states.get(action.key);
if (!state) {
throw new Error(`Action ${scheduled.index} patches unknown object key: ${action.key}`);
}
if (scheduled.from < state.lastTransitionEndFrame) {
throw new Error(
`Object ${action.key}: action ${scheduled.index} starts before its prior transition ends. Object patches cannot overlap.`,
);
}
const targetState = mergeSceneObjectState(state.state, action, scheduled.index, mediaAssets);
state.compiled.transitions.push({
durationInFrames: scheduled.durationInFrames,
from: scheduled.from,
initialState: state.state,
targetState,
});
state.lastTransitionEndFrame = scheduled.endFrame;
state.state = targetState;
}
return [...states.values()].map((state) => state.compiled);
}
function createSceneObjectState(
action: SceneObjectAddAction,
mediaAssets: Readonly<Record<string, EpisodeMediaAsset>>,
actionIndex: number,
): ResolvedSceneObjectState {
const style = { ...DEFAULT_SCREEN_OBJECT_STYLE, ...action.style };
switch (action.type) {
case "media.add": {
const mediaAsset = requireMediaAsset(mediaAssets, action.source, actionIndex);
return {
anchor: action.anchor,
layout: resolveMediaLayout(action.layout, mediaAsset.aspectRatio),
object: { aspectRatio: mediaAsset.aspectRatio, source: action.source, type: "media" },
style,
};
}
case "balance.add":
return { anchor: action.anchor, layout: action.layout, object: { scorePercent: action.scorePercent, type: "balance" }, style };
}
}
function mergeSceneObjectState(
state: ResolvedSceneObjectState,
action: SceneObjectUpdateAction,
actionIndex: number,
mediaAssets: Readonly<Record<string, EpisodeMediaAsset>>,
): ResolvedSceneObjectState {
const object = updateSceneObject(state.object, action, actionIndex, mediaAssets);
return {
anchor: state.anchor,
layout: object.type === "media" && action.type === "media.update"
? resolveMediaLayout(action.layout, object.aspectRatio, state.layout)
: { ...state.layout, ...action.layout },
object,
style: { ...state.style, ...action.style },
};
}
function updateSceneObject(
object: SceneObject,
action: SceneObjectUpdateAction,
actionIndex: number,
mediaAssets: Readonly<Record<string, EpisodeMediaAsset>>,
): SceneObject {
switch (action.type) {
case "media.update":
if (object.type !== "media") {
throwSceneObjectKindMismatch(actionIndex, object, action);
}
if (!action.source) {
return object;
}
const mediaAsset = requireMediaAsset(mediaAssets, action.source, actionIndex);
return { aspectRatio: mediaAsset.aspectRatio, source: action.source, type: "media" };
case "balance.update":
if (object.type !== "balance") {
throwSceneObjectKindMismatch(actionIndex, object, action);
}
return { ...object, scorePercent: action.scorePercent ?? object.scorePercent };
}
}
function requireMediaAsset(
mediaAssets: Readonly<Record<string, EpisodeMediaAsset>>,
source: string,
actionIndex: number,
): EpisodeMediaAsset {
const mediaAsset = mediaAssets[source];
if (!mediaAsset || !Number.isFinite(mediaAsset.aspectRatio) || mediaAsset.aspectRatio <= 0) {
throw new Error(`Action ${actionIndex} references media source without a valid intrinsic aspect ratio: ${source}`);
}
return mediaAsset;
}
function resolveMediaLayout(
layout: MediaLayout | MediaLayoutUpdate | undefined,
aspectRatio: number,
previousLayout?: ObjectLayout,
): ObjectLayout {
const width = layout?.width ?? (layout?.height === undefined
? previousLayout?.width
: layout.height * aspectRatio);
if (width === undefined) {
throw new Error("Media layout requires a width or height.");
}
const height = layout?.height ?? width / aspectRatio;
return {
height,
originX: layout?.originX ?? previousLayout?.originX ?? width / 2,
originY: layout?.originY ?? previousLayout?.originY ?? height / 2,
rotation: layout?.rotation ?? previousLayout?.rotation ?? 0,
scale: layout?.scale ?? previousLayout?.scale ?? 1,
width,
x: layout?.x ?? previousLayout?.x ?? 0,
y: layout?.y ?? previousLayout?.y ?? 0,
};
}
function throwSceneObjectKindMismatch(
actionIndex: number,
object: SceneObject,
action: SceneObjectUpdateAction,
): never {
throw new Error(
`Action ${actionIndex} updates a ${object.type} object with ${action.type} changes. Object kinds cannot change.`,
);
}
function resolveSceneObjectState(
sceneObject: CompiledSceneObject,
frame: number,
): ResolvedSceneObjectState {
let state = sceneObject.initialState;
for (const transition of sceneObject.transitions) {
if (frame < transition.from) {
break;
}
const progress = transition.durationInFrames <= 1
? 1
: Math.min(1, (frame - transition.from) / (transition.durationInFrames - 1));
state = interpolateSceneObjectState(transition.initialState, transition.targetState, progress);
}
return state;
}
function interpolateSceneObjectState(
initialState: ResolvedSceneObjectState,
targetState: ResolvedSceneObjectState,
progress: number,
): ResolvedSceneObjectState {
return {
anchor: initialState.anchor,
layout: interpolateObjectLayout(initialState.layout, targetState.layout, progress),
object: interpolateSceneObject(initialState.object, targetState.object, progress),
style: interpolateCssStyle(initialState.style, targetState.style, progress),
};
}
function interpolateObjectLayout(
initialLayout: ObjectLayout,
targetLayout: ObjectLayout,
progress: number,
): ObjectLayout {
return {
height: interpolateNumber(initialLayout.height, targetLayout.height, progress),
originX: interpolateNumber(
initialLayout.originX ?? initialLayout.width / 2,
targetLayout.originX ?? targetLayout.width / 2,
progress,
),
originY: interpolateNumber(
initialLayout.originY ?? initialLayout.height / 2,
targetLayout.originY ?? targetLayout.height / 2,
progress,
),
rotation: interpolateNumber(initialLayout.rotation, targetLayout.rotation, progress),
scale: interpolateNumber(initialLayout.scale, targetLayout.scale, progress),
width: interpolateNumber(initialLayout.width, targetLayout.width, progress),
x: interpolateNumber(initialLayout.x, targetLayout.x, progress),
y: interpolateNumber(initialLayout.y, targetLayout.y, progress),
};
}
function interpolateSceneObject(
initialObject: SceneObject,
targetObject: SceneObject,
progress: number,
): SceneObject {
if (initialObject.type !== targetObject.type) {
throw new Error("Screen object kinds cannot change during a transition.");
}
if (initialObject.type !== "balance" || targetObject.type !== "balance") {
return targetObject;
}
return {
...targetObject,
scorePercent: interpolateNumber(initialObject.scorePercent, targetObject.scorePercent, progress),
};
}
function interpolateNumber(from: number, to: number, progress: number): number {
return from + (to - from) * progress;
}
function interpolateCssStyle(
initialStyle: CssStyle,
targetStyle: CssStyle,
progress: number,
): CssStyle {
const style: CssStyle = {};
for (const property of new Set([...Object.keys(initialStyle), ...Object.keys(targetStyle)])) {
style[property] = interpolateCssStyleValue(
initialStyle[property],
targetStyle[property],
progress,
);
}
return style;
}
function interpolateCssStyleValue(
initialValue: CssStyle[string] | undefined,
targetValue: CssStyle[string] | undefined,
progress: number,
): CssStyle[string] {
if (initialValue === undefined || targetValue === undefined) {
return targetValue ?? initialValue ?? "";
}
if (typeof initialValue === "number" && typeof targetValue === "number") {
return interpolateNumber(initialValue, targetValue, progress);
}
const initialNumber = parseCssNumber(initialValue);
const targetNumber = parseCssNumber(targetValue);
if (!initialNumber || !targetNumber || initialNumber.unit !== targetNumber.unit) {
return targetValue;
}
return `${interpolateNumber(initialNumber.value, targetNumber.value, progress)}${targetNumber.unit}`;
}
function parseCssNumber(value: CssStyle[string]): { unit: string; value: number } | undefined {
if (typeof value === "number") {
return { unit: "", value };
}
const match = /^(-?(?:\d+\.?\d*|\.\d+))(.*)$/.exec(value);
if (!match) {
return undefined;
}
const numericValue = Number(match[1]);
return Number.isFinite(numericValue)
? { unit: match[2] ?? "", value: numericValue }
: undefined;
}
function compileClaimTextReveals(
actions: readonly ScheduledEpisodeAction[],
): Readonly<Record<ClaimId, ClaimTextReveal>> {
const reveals: Record<ClaimId, ClaimTextReveal> = {};
for (const scheduled of actions) {
const action = scheduled.action;
if (action.type === "graph.create") {
if (action.mainClaim.textReveal) {
reveals[claimId(action.key, action.mainClaim.key)] = requireClaimTextReveal(
scheduled,
);
}
for (const claim of action.claims ?? []) {
if (claim.textReveal) {
reveals[claimId(action.key, claim.key)] = requireClaimTextReveal(scheduled);
}
}
continue;
}
if (action.type === "graph.addClaim" && action.textReveal) {
reveals[claimId(action.graph, action.key)] = requireClaimTextReveal(scheduled);
}
}
return reveals;
}
function requireClaimTextReveal(action: ScheduledEpisodeAction): ClaimTextReveal {
if (action.durationInFrames < 1) {
throw new Error(`Action ${action.index} enables textReveal but has no duration.`);
}
return { durationInFrames: action.durationInFrames, from: action.from };
}
export function resolveGraphPlayback(
episode: CompiledEpisodeScript,
frame: number,
): GraphPlayback | undefined {
let animation: CompiledGraphAnimation | undefined;
for (const candidate of episode.graphAnimations) {
if (candidate.from <= frame) {
animation = candidate;
}
}
if (!animation) {
return undefined;
}
if (frame >= animation.from + animation.durationInFrames) {
return { animation, stepId: "wave", stepProgress: 1 };
}
const progress = animation.durationInFrames <= 1
? 1
: (frame - animation.from) / (animation.durationInFrames - 1);
let phaseStart = 0;
for (const [step, weight] of GRAPH_PHASES) {
const phaseEnd = phaseStart + weight;
if (progress < phaseEnd || step === "wave") {
return {
animation,
stepId: step,
stepProgress: Math.min(1, Math.max(0, (progress - phaseStart) / weight)),
};
}
phaseStart = phaseEnd;
}
return { animation, stepId: "wave", stepProgress: 1 };
}
function scheduleActions(spec: EpisodeScriptSpec): readonly ScheduledEpisodeAction[] {
const { fps } = spec.settings.composition;
let cursorFrame = 0;
return spec.script.map((action, index) => {
const durationSeconds = resolveDurationSeconds(spec, action);
const durationInFrames = Math.max(0, Math.round(durationSeconds * fps));
const from = cursorFrame + signedSecondsToFrames(action.offsetSeconds ?? 0, fps);
if (from < 0) {
throw new Error(`Action ${index} (${action.type}) starts before frame zero.`);
}
const endFrame = from + durationInFrames;
if (action.blocking ?? true) {
cursorFrame = Math.max(cursorFrame, endFrame);
}
return {
action,
durationInFrames,
endFrame,
from,
index,
label: action.label ?? describeAction(action),
};
});
}
function signedSecondsToFrames(seconds: number, fps: number): number {
return Math.sign(seconds) * Math.round(Math.abs(seconds) * fps);
}
function resolveDurationSeconds(spec: EpisodeScriptSpec, action: EpisodeAction): number {
if (action.durationSeconds !== undefined) {
return action.durationSeconds;
}
const defaults = spec.settings.defaults;
switch (action.type) {
case "graph.create":
return defaults?.["graph.create"]?.durationSeconds
?? DEFAULT_DURATION_SECONDS[action.type];
case "graph.addClaim":
return defaults?.["graph.addClaim"]?.durationSeconds
?? DEFAULT_DURATION_SECONDS[action.type];
case "camera.move":
return defaults?.["camera.move"]?.durationSeconds
?? DEFAULT_DURATION_SECONDS[action.type];
default:
return DEFAULT_DURATION_SECONDS[action.type];
}
}
function describeAction(action: EpisodeAction): string {
switch (action.type) {
case "graph.create":
return `Create ${action.key}`;
case "graph.addClaim":
return `Add ${action.key}`;
case "graph.set":
return `Set ${action.graph}`;
case "graph.patch":
return `Patch ${action.graph}`;
case "camera.cut":
return "Camera cut";
case "camera.move":
return "Camera move";
case "media.add":
case "balance.add":
return `Add ${action.key}`;
case "media.update":
case "balance.update":
return `Patch ${action.key}`;
case "captions.show":
return "Show closed captions";
case "wait":
return "Wait";
}
}
function compileGraphActions(
spec: EpisodeScriptSpec,
actions: readonly ScheduledEpisodeAction[],
): readonly CompiledGraphAnimation[] {
const graphStates = new Map<string, GraphCompilerState>();
const animations: CompiledGraphAnimation[] = [];
const graphActions = actions
.filter(isScheduledGraphAction);
graphActions.sort((left, right) => left.from - right.from || left.index - right.index);
for (let actionIndex = 0; actionIndex < graphActions.length;) {
const scheduled = graphActions[actionIndex]!;
const action = scheduled.action;
if (action.type === "graph.create") {
if (graphStates.has(action.key)) {
throw new Error(`Action ${scheduled.index} creates duplicate graph: ${action.key}`);
}
const state: GraphCompilerState = {
anchor: action.anchor,
claimDefinitions: new Map(),
hideScores: action.hideScores ?? false,
key: action.key,
lastAnimationEndFrame: 0,
layout: action.layout,
scoreboard: action.scoreboard,
};
state.claimDefinitions.set(action.mainClaim.key, {
key: action.mainClaim.key,
side: "pro-main",
showScore: action.mainClaim.showScore ?? false,
text: action.mainClaim.text,
textReveal: action.mainClaim.textReveal,
});
for (const claim of action.claims ?? []) {
setClaimDefinition(state, claim, scheduled.index);
}
state.debateCore = createDebateCore(
spec,
state,
[action.mainClaim.key, ...(action.claims ?? []).map((claim) => claim.key)],
action.mainClaim.key,
);
graphStates.set(action.key, state);
animations.push({
addedClaimIds: [],
anchor: state.anchor,
claimScoreVisibility: resolveClaimScoreVisibility(state),
debateCore: state.debateCore,
durationInFrames: Math.max(1, scheduled.durationInFrames),
from: scheduled.from,
graph: action.key,
hideScores: state.hideScores,
label: scheduled.label,
plan: planStaticDebate({
debateCore: state.debateCore,
origin: state.layout,
}),
scoreboard: state.scoreboard,
sourceActionIndexes: [scheduled.index],
});
state.lastAnimationEndFrame = scheduled.endFrame;
state.lastAnimationAction = scheduled;
actionIndex += 1;
continue;
}
const graphKey = action.graph;
const state = requireGraphState(graphStates, graphKey, scheduled.index);
if (action.type === "graph.set") {
applyGraphSet(spec, state, action.claims, scheduled.index);
animations.push(createStaticGraphAnimation(state, scheduled));
actionIndex += 1;
continue;
}
if (action.type === "graph.patch") {
applyGraphPatch(state, action.claims, action.removeConnectionsFrom, scheduled.index);
animations.push(createStaticGraphAnimation(state, scheduled));
actionIndex += 1;
continue;
}
if (action.type !== "graph.addClaim") {
throw new Error("Unsupported graph action.");
}
const batch: ScheduledEpisodeAction[] = [scheduled];
let nextIndex = actionIndex + 1;
while (nextIndex < graphActions.length) {
const next = graphActions[nextIndex]!;
if (
next.from !== scheduled.from
|| next.action.type !== "graph.addClaim"
|| next.action.graph !== graphKey
) {
break;
}
batch.push(next);
nextIndex += 1;
}
compileGraphAddBatch(state, batch, animations, spec.settings.composition.fps);
actionIndex = nextIndex;
}
return animations;
}
function isScheduledGraphAction(
action: ScheduledEpisodeAction,
): action is ScheduledEpisodeAction & { action: GraphAction } {
return action.action.type.startsWith("graph.");
}
function isScheduledSceneObjectAction(
action: ScheduledEpisodeAction,
): action is ScheduledEpisodeAction & { action: SceneObjectAction } {
return isSceneObjectAddAction(action.action) || isSceneObjectUpdateAction(action.action);
}
function isSceneObjectAddAction(action: EpisodeAction): action is SceneObjectAddAction {
return action.type === "media.add" || action.type === "balance.add";
}
function isSceneObjectUpdateAction(action: EpisodeAction): action is SceneObjectUpdateAction {
return action.type === "media.update" || action.type === "balance.update";
}
function compileGraphAddBatch(
state: GraphCompilerState,
batch: readonly ScheduledEpisodeAction[],
animations: CompiledGraphAnimation[],
fps: number,
): void {
const first = batch[0]!;
const previous = state.lastAnimationAction;
if (previous && first.from < state.lastAnimationEndFrame) {
throw new Error(
`Graph ${state.key}: action ${describeScheduledGraphAction(first)} starts at ${formatFrameTime(first.from, fps)}, but action ${describeScheduledGraphAction(previous)} is still running until ${formatFrameTime(state.lastAnimationEndFrame, fps)}. Graph mutations cannot overlap; let the earlier graph action block or start this action at or after that time.`,
);
}
if (batch.some((item) => item.durationInFrames !== first.durationInFrames)) {
throw new Error(`Same-start graph additions on ${state.key} must use the same duration.`);
}
if (!state.debateCore) {
throw new Error(`Graph ${state.key} must be created before claims are added.`);
}
const commands = batch.map((scheduled): AddConfidenceClaimCommand => {
if (scheduled.action.type !== "graph.addClaim") {
throw new Error("Internal graph batch contains a non-add action.");
}
const action = scheduled.action;
if (state.debateCore?.claims[claimId(state.key, action.key)]) {
throw new Error(`Action ${scheduled.index} adds duplicate claim key: ${action.key}`);
}
const target = requireClaimDefinition(state, action.target, scheduled.index);
const definition: ClaimDefinition = {
key: action.key,
side: action.side,
showScore: action.showScore,
target: action.target,
text: action.text,
textReveal: action.textReveal,
};
state.claimDefinitions.set(action.key, definition);
return createAddCommand(state, definition, target);
});
const plan = planDebateAnimationBatch({
commands,
debateCore: state.debateCore,
origin: state.layout,
});
const addedClaimIds: ClaimId[] = [];
for (const command of commands) {
const applied = applyConfidenceClaimAddCommand({
command,
debateCore: state.debateCore,
});
state.debateCore = applied.debateCore;
addedClaimIds.push(applied.claimId);
}
state.lastAnimationEndFrame = first.endFrame;
state.lastAnimationAction = first;
animations.push({
addedClaimIds,
anchor: state.anchor,
claimScoreVisibility: resolveClaimScoreVisibility(state),
debateCore: state.debateCore,
durationInFrames: Math.max(1, first.durationInFrames),
from: first.from,
graph: state.key,
hideScores: state.hideScores,
label: batch.map((item) => item.label).join(" + "),
plan,
scoreboard: state.scoreboard,
sourceActionIndexes: batch.map((item) => item.index),
});
}
function describeScheduledGraphAction(scheduled: ScheduledEpisodeAction): string {
const action = scheduled.action;
if (action.type === "graph.create") {
return `${scheduled.index} (graph.create ${action.key})`;
}
if (action.type === "graph.addClaim") {
return `${scheduled.index} (graph.addClaim ${action.key})`;
}
if (action.type === "graph.set" || action.type === "graph.patch") {
return `${scheduled.index} (${action.type} ${action.graph})`;
}
throw new Error(`Expected graph action, received ${action.type}.`);
}
function formatFrameTime(frame: number, fps: number): string {
return `${(frame / fps).toFixed(2)}s`;
}
function createStaticGraphAnimation(
state: GraphCompilerState,
scheduled: ScheduledEpisodeAction,
): CompiledGraphAnimation {
if (!state.debateCore) {
throw new Error(`Graph ${state.key} must be created before it is displayed.`);
}
return {
addedClaimIds: [],
anchor: state.anchor,
claimScoreVisibility: resolveClaimScoreVisibility(state),
debateCore: state.debateCore,
durationInFrames: 1,
from: scheduled.from,
graph: state.key,
hideScores: state.hideScores,
label: scheduled.label,
plan: planStaticDebate({
debateCore: state.debateCore,
origin: state.layout,
}),
scoreboard: state.scoreboard,
sourceActionIndexes: [scheduled.index],
};
}
function resolveClaimScoreVisibility(
state: GraphCompilerState,
): Readonly<Record<ClaimId, boolean>> {
return Object.fromEntries(
[...state.claimDefinitions.values()].map((definition) => [
claimId(state.key, definition.key),
!state.hideScores && (definition.showScore ?? true),
]),
) as Record<ClaimId, boolean>;
}
function applyGraphSet(
spec: EpisodeScriptSpec,
state: GraphCompilerState,
claims: readonly GraphClaimState[],
actionIndex: number,
): void {
for (const claim of claims) {
mergeClaimDefinition(state, claim, actionIndex);
}
const mainClaimKey = requireMainClaimKey(state, actionIndex);
state.debateCore = createDebateCore(
spec,
state,
claims.map((claim) => claim.key),
mainClaimKey,
);
}
function applyGraphPatch(
state: GraphCompilerState,
claims: readonly GraphClaimState[],
removeConnectionsFrom: readonly string[],
actionIndex: number,
): void {
if (!state.debateCore) {
throw new Error(`Graph ${state.key} must be created before it is patched.`);
}
for (const claim of claims) {
mergeClaimDefinition(state, claim, actionIndex);
}
const removedClaimIds = new Set(
removeConnectionsFrom.map((key) => claimId(state.key, key)),
);
const connectors = Object.fromEntries(
Object.entries(state.debateCore.connectors).filter(([, connector]) =>
!removedClaimIds.has(connector.source)
),
) as DebateCore["connectors"];
const debateCore: DebateCore = {
...state.debateCore,
claims: { ...state.debateCore.claims },
connectors,
};
for (const claim of claims) {
const definition = requireClaimDefinition(state, claim.key, actionIndex);
debateCore.claims[claimId(state.key, claim.key)] = createClaim(state.key, definition);
if (definition.target) {
const connector = createConnector(state, definition, actionIndex);
debateCore.connectors[connector.id] = connector;
}
}
state.debateCore = debateCore;
}
function createDebateCore(
spec: EpisodeScriptSpec,
state: GraphCompilerState,
claimKeys: readonly string[],
mainClaimKey: string,
): DebateCore {
const includedKeys = new Set(claimKeys);
if (!includedKeys.has(mainClaimKey)) {
throw new Error(`Graph ${state.key} state must include main claim ${mainClaimKey}.`);
}
const claims = Object.fromEntries(claimKeys.map((key) => {
const definition = requireClaimDefinition(state, key, -1);
const claim = createClaim(state.key, definition);
return [claim.id, claim];
})) as DebateCore["claims"];
const connectors = Object.fromEntries(claimKeys.flatMap((key) => {
const definition = requireClaimDefinition(state, key, -1);
if (!definition.target) {
return [];
}
const targetKey = typeof definition.target === "string"
? definition.target
: definition.target.relevanceOf;
if (!includedKeys.has(targetKey)) {
throw new Error(`Graph ${state.key} claim ${key} targets omitted claim ${targetKey}.`);
}
const connector = createConnector(state, definition, -1);
return [[connector.id, connector] as const];
})) as DebateCore["connectors"];
return {
claims,
connectors,
description: `${spec.settings.composition.id} ${state.key}`,
id: `${spec.settings.composition.id}:${state.key}` as DebateCore["id"],
mainClaimId: claimId(state.key, mainClaimKey),
name: state.key,
};
}
function createAddCommand(
state: GraphCompilerState,
definition: ClaimDefinition,
target: ClaimDefinition,
): AddConfidenceClaimCommand {
return {
claim: createClaim(state.key, definition),
connector: {
id: confidenceConnectorId(state.key, definition.key),
targetClaimId: claimId(state.key, target.key),
targetRelationship: toTargetRelation(definition.side, target.side),
type: "confidence",
},
type: "confidence/claim/add",
};
}
function createClaim(graphKey: string, definition: ClaimDefinition): Claim {
return {
content: definition.text,
id: claimId(graphKey, definition.key),
};
}
function createConnector(
state: GraphCompilerState,
definition: ClaimDefinition,
actionIndex: number,
): ConfidenceConnector | RelevanceConnector {
const target = definition.target;
if (!target) {
throw new Error(`Claim ${definition.key} has no target at action ${actionIndex}.`);
}
const targetKey = typeof target === "string" ? target : target.relevanceOf;
const targetDefinition = requireClaimDefinition(state, targetKey, actionIndex);
const targetRelationship = toTargetRelation(definition.side, targetDefinition.side);
if (typeof target === "string") {
return {
id: confidenceConnectorId(state.key, definition.key),
source: claimId(state.key, definition.key),
targetClaimId: claimId(state.key, target),
targetRelationship,
type: "confidence",
};
}
return {
id: relevanceConnectorId(state.key, definition.key),
source: claimId(state.key, definition.key),
targetConfidenceConnectorId: confidenceConnectorId(state.key, target.relevanceOf),
targetRelationship,
type: "relevance",
};
}
function setClaimDefinition(
state: GraphCompilerState,
claim: ClaimDefinition,
actionIndex: number,
): void {
if (state.claimDefinitions.has(claim.key)) {
throw new Error(`Action ${actionIndex} defines duplicate claim key: ${claim.key}`);
}
state.claimDefinitions.set(claim.key, claim);
}
function mergeClaimDefinition(
state: GraphCompilerState,
claim: GraphClaimState,
actionIndex: number,
): void {
const existing = state.claimDefinitions.get(claim.key);
if (!existing && (!claim.side || !claim.target || !claim.text)) {
throw new Error(
`Action ${actionIndex} must fully define new claim ${claim.key}.`,
);
}
state.claimDefinitions.set(claim.key, {
key: claim.key,
side: claim.side ?? existing!.side,
showScore: claim.showScore ?? existing?.showScore,
target: claim.target ?? existing?.target,
text: claim.text ?? existing!.text,
textReveal: claim.textReveal ?? existing?.textReveal,
});
}
function requireGraphState(
states: ReadonlyMap<string, GraphCompilerState>,
key: string,
actionIndex: number,
): GraphCompilerState {
const state = states.get(key);
if (!state) {
throw new Error(`Action ${actionIndex} references missing graph: ${key}`);
}
return state;
}
function requireClaimDefinition(
state: GraphCompilerState,
key: string,
actionIndex: number,
): ClaimDefinition {
const definition = state.claimDefinitions.get(key);
if (!definition) {
throw new Error(`Action ${actionIndex} references missing claim: ${state.key}.${key}`);
}
return definition;
}
function requireMainClaimKey(state: GraphCompilerState, actionIndex: number): string {
if (!state.debateCore) {
throw new Error(`Graph ${state.key} must be created before action ${actionIndex}.`);
}
const mainClaimId = state.debateCore.mainClaimId;
const mainClaim = [...state.claimDefinitions.values()].find(
(definition) => claimId(state.key, definition.key) === mainClaimId,
);
if (!mainClaim) {
throw new Error(`Graph ${state.key} has no main claim definition.`);
}
return mainClaim.key;
}
function claimId(graphKey: string, claimKey: string): ClaimId {
return `${graphKey}:claim:${claimKey}` as ClaimId;
}
function confidenceConnectorId(graphKey: string, claimKey: string): ConfidenceConnectorId {
return `${graphKey}:confidence:${claimKey}` as ConfidenceConnectorId;
}
function relevanceConnectorId(graphKey: string, claimKey: string): RelevanceConnectorId {
return `${graphKey}:relevance:${claimKey}` as RelevanceConnectorId;
}
function toTargetRelation(sourceSide: ClaimSide, targetSide: ClaimSide): TargetRelation {
return sourceSide === targetSide ? "proTarget" : "conTarget";
}