import { episode, episodeSampleDescription, youtubeDraftMetadata } from "./episode";
export interface AnimationRouteSampleForVisual {
readonly time: number;
readonly frame: number;
readonly shotId?: string;
readonly captionId?: string;
readonly captionText?: string;
readonly dialogueLineId?: string;
readonly visemeId?: string;
readonly nodeUpdates?: readonly {
readonly characterId?: string;
readonly action?: string;
readonly emotion?: string;
}[];
}
export interface SampleEpisodeVisualOptions {
readonly sampleAt: (time: number) => AnimationRouteSampleForVisual;
readonly duration: number;
readonly fixedTime?: number;
readonly usesTypedAssets?: boolean;
}
export function installSampleEpisodeVisual(options: SampleEpisodeVisualOptions) {
const root = document.createElement("section");
root.id = "sample-episode-visual";
root.className = "sample-episode";
root.setAttribute("aria-label", "Aura3D animation sample episode preview");
root.innerHTML = `
${robotMarkup("miko", "Miko")}
${robotMarkup("luma", "Luma")}
${escapeHtml(episode.episodePlan.title)}
Shot 1 / 3
Aura3D animation contract · captions · visemes · render queue
`;
document.body.appendChild(root);
const frame = root.querySelector(".sample-episode__frame");
const hostSlot = root.querySelector("[data-aura-host-slot]");
const appHost = document.querySelector("#app");
if (frame && hostSlot && appHost) {
hostSlot.replaceWith(appHost);
}
const caption = root.querySelector("[data-sample-caption]");
const shotLabel = root.querySelector("[data-sample-shot-label]");
const progress = root.querySelector("[data-sample-progress]");
const queryTime = new URLSearchParams(window.location.search).get("sampleTime");
const fixedTime = options.fixedTime ?? (queryTime ? Number(queryTime) : undefined);
const start = performance.now();
function render(time: number) {
const sample = options.sampleAt(clampTime(time, options.duration));
const shotIndex = Math.max(0, episode.shotTimeline.shots.findIndex((shot) => shot.shotId === sample.shotId));
const speaker = sample.nodeUpdates?.find((update) => update.action === "speak")?.characterId ?? "";
const progressPercent = Math.max(0, Math.min(100, (sample.time / options.duration) * 100));
root.dataset.shotId = sample.shotId ?? "";
root.dataset.captionId = sample.captionId ?? "";
root.dataset.speaker = speaker;
root.dataset.visemeId = sample.visemeId ?? "";
root.dataset.usesTypedAssets = options.usesTypedAssets ? "true" : "false";
root.dataset.animationSampleEpisode = "moon-garden-cleanup";
root.dataset.youtubeDraftTitle = youtubeDraftMetadata.title;
if (caption) {
caption.textContent = sample.captionText ?? "Two tiny robots clean a glowing moon garden.";
}
if (shotLabel) {
shotLabel.textContent = `Shot ${shotIndex + 1} / ${episode.shotTimeline.shots.length}`;
}
if (progress) {
progress.style.setProperty("--progress", `${progressPercent}%`);
}
window.__AURA3D_ANIMATION_SAMPLE_EPISODE__ = {
contractId: episodeSampleDescription.contractId,
episodeId: episode.episodePlan.episodeId,
title: episode.episodePlan.title,
route: "/",
sample,
visualLayer: {
id: root.id,
deterministicSampleTime: fixedTime ?? null,
screenshotTarget: "#sample-episode-visual",
renderedBy: "aura3d-scene",
usesTypedAssets: Boolean(options.usesTypedAssets),
characters: episode.episodePlan.characters.map((character) => character.id),
styleGuide: episode.storyBible.styleGuide.visualStyle,
youtubeDraftTitle: youtubeDraftMetadata.title
}
};
}
if (Number.isFinite(fixedTime)) {
render(Number(fixedTime));
return root;
}
const tick = () => {
render(((performance.now() - start) / 1000) % options.duration);
requestAnimationFrame(tick);
};
tick();
return root;
}
declare global {
interface Window {
__AURA3D_ANIMATION_SAMPLE_EPISODE__?: {
readonly contractId: string;
readonly episodeId: string;
readonly title: string;
readonly route: string;
readonly sample: AnimationRouteSampleForVisual;
readonly visualLayer: {
readonly id: string;
readonly deterministicSampleTime: number | null;
readonly screenshotTarget: string;
readonly renderedBy: "aura3d-scene";
readonly usesTypedAssets: boolean;
readonly characters: readonly string[];
readonly styleGuide: string;
readonly youtubeDraftTitle: string;
};
};
}
}
function robotMarkup(id: "miko" | "luma", label: string) {
return `
`;
}
function clampTime(time: number, duration: number) {
if (!Number.isFinite(time)) return 0;
return Math.max(0, Math.min(duration - 1 / 30, time));
}
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (character) => {
switch (character) {
case "&":
return "&";
case "<":
return "<";
case ">":
return ">";
case '"':
return """;
case "'":
return "'";
default:
return character;
}
});
}