import { episode, episodeSampleDescription } from "../episode";
import type { AnimationRouteSampleForVisual } from "../sample-episode-visual";
import "./image-puppet-episode.css";
export interface ImagePuppetEpisodeOptions {
readonly sampleAt: (time: number) => AnimationRouteSampleForVisual;
readonly duration: number;
readonly fixedTime?: number;
}
export interface ImagePuppetEpisodeProof {
readonly contractId: string;
readonly episodeId: string;
readonly title: string;
readonly route: string;
readonly mode: "image-derived-puppet-cutouts";
readonly sourceImage: string;
readonly sourcePixelsAnimated: true;
readonly notTrue3D: true;
readonly screenshotTarget: "#image-puppet-episode";
readonly sample: AnimationRouteSampleForVisual;
readonly movingCutouts: readonly string[];
readonly limitations: readonly string[];
}
const sourceImage = "/aura-assets/moon-garden-feature-frame.png";
const movingCutouts = [
"blue robot source-pixel head/body cutout",
"blue robot source-pixel broom area",
"yellow robot source-pixel head/body cutout",
"yellow robot source-pixel arm/tool area",
"wheelbarrow source-pixel cutout",
"foreground flower source-pixel layer",
"moon source-pixel pulse layer"
] as const;
export function installImagePuppetEpisode(options: ImagePuppetEpisodeOptions) {
document.body.dataset.animationView = "image-puppet";
const root = document.createElement("section");
root.id = "image-puppet-episode";
root.className = "image-puppet";
root.setAttribute("aria-label", "Image-derived puppet animation using cutouts from the moon garden source PNG");
root.innerHTML = `
Image-derived cutouts
Shot 1 / 3
`;
document.body.appendChild(root);
const caption = root.querySelector("[data-image-puppet-caption]");
const shotLabel = root.querySelector("[data-image-puppet-shot-label]");
const progress = root.querySelector("[data-image-puppet-progress]");
const query = new URLSearchParams(window.location.search);
const queryTime = query.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 progressPercent = Math.max(0, Math.min(100, (sample.time / options.duration) * 100));
root.dataset.shotId = sample.shotId ?? "";
root.dataset.captionId = sample.captionId ?? "";
root.dataset.sourceImage = sourceImage;
if (caption) {
caption.textContent = sample.captionText ?? "The source image is duplicated into moving cutout layers.";
}
if (shotLabel) {
shotLabel.textContent = `Shot ${shotIndex + 1} / ${episode.shotTimeline.shots.length}`;
}
if (progress) {
progress.style.setProperty("--progress", `${progressPercent}%`);
}
window.__AURA3D_ANIMATION_IMAGE_PUPPET_PROOF__ = {
contractId: episodeSampleDescription.contractId,
episodeId: episode.episodePlan.episodeId,
title: episode.episodePlan.title,
route: "/?view=image-puppet",
mode: "image-derived-puppet-cutouts",
sourceImage,
sourcePixelsAnimated: true,
notTrue3D: true,
screenshotTarget: "#image-puppet-episode",
sample,
movingCutouts,
limitations: [
"The PNG is flattened, so these are approximate masked source-pixel cutouts, not clean production layers.",
"The original still remains visible under the moving cutouts unless a real background plate or inpainted clean plate exists.",
"This is image-derived 2D animation, not 3D reconstruction or rigged GLB character animation."
]
};
}
render(Number.isFinite(fixedTime) ? Number(fixedTime) : 0);
const tick = () => {
if (!Number.isFinite(fixedTime)) {
render(((performance.now() - start) / 1000) % options.duration);
}
requestAnimationFrame(tick);
};
tick();
return root;
}
declare global {
interface Window {
__AURA3D_ANIMATION_IMAGE_PUPPET_PROOF__?: ImagePuppetEpisodeProof;
}
}
function clampTime(time: number, duration: number) {
if (!Number.isFinite(time)) return 0;
return Math.max(0, Math.min(duration - 1 / 30, time));
}