// Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image. export type AtlProps = Record; export interface AtlBlock { steps: AtlStep[]; repeat: null | true | number; } export type AtlStep = | { op: "set"; props: AtlProps } | { op: "move"; warp: "linear" | "ease" | "easein" | "easeout"; t: number; props: AtlProps } | { op: "pause"; t: number } | { op: "parallel"; blocks: AtlBlock[] } | { op: "choice"; blocks: { w: number; block: AtlBlock }[] } | { op: "block"; block: AtlBlock } | { op: "on"; events: string[]; block: AtlBlock } | { op: "use"; name: string }; export interface AtlHost { /** Timeline of a named animated transform. */ block(name: string): AtlBlock | null; /** Properties of a named static transform. */ props(name: string): AtlProps | null; /** Called after every change of the working properties. */ apply(props: AtlProps): void; } const SHOW_EVENTS = new Set(["show", "appear", "default", "start", "update", "replace"]); const HIDE_EVENTS = new Set(["hide", "replaced"]); const NEUTRAL: AtlProps = { alpha: 1, zoom: 1, xzoom: 1, yzoom: 1, rotate: 0, xoffset: 0, yoffset: 0, xpos: 0, ypos: 0, xanchor: 0, yanchor: 0, xalign: 0, yalign: 0, xcenter: 0, ycenter: 0, }; const MAX_DEPTH = 16; class Stopped extends Error {} const curves = { linear: (t: number) => t, ease: (t: number) => 0.5 - Math.cos(Math.PI * t) / 2, easein: (t: number) => Math.cos((1 - t) * Math.PI / 2), easeout: (t: number) => 1 - Math.cos(t * Math.PI / 2), }; function nextFrame() { return new Promise((resolve) => requestAnimationFrame(resolve)); } export class AtlRunner { private token = 0; constructor(private host: AtlHost, private props: AtlProps) {} /** Start the timeline; resolves when it ends (never, for endless loops) or is stopped. */ async start(block: AtlBlock): Promise { const token = ++this.token; try { await this.run(block, token, 0, "show"); } catch (err) { if (!(err instanceof Stopped)) throw err; } } stop() { this.token++; } /** Run the `on hide` handler of the block, if any; resolves once it has finished. */ async hide(block: AtlBlock): Promise { const handler = this.handlers(block, HIDE_EVENTS); this.stop(); if (!handler.length) return; const token = ++this.token; try { for (const b of handler) await this.run(b, token, 0, "hide"); } catch (err) { if (!(err instanceof Stopped)) throw err; } } hasHideHandler(block: AtlBlock): boolean { return this.handlers(block, HIDE_EVENTS).length > 0; } private handlers(block: AtlBlock, events: Set, depth = 0): AtlBlock[] { if (depth > MAX_DEPTH) return []; const out: AtlBlock[] = []; for (const step of block.steps) { if (step.op === "on" && step.events.some((e) => events.has(e))) out.push(step.block); else if (step.op === "parallel") for (const b of step.blocks) out.push(...this.handlers(b, events, depth + 1)); else if (step.op === "block") out.push(...this.handlers(step.block, events, depth + 1)); else if (step.op === "use") { const named = this.host.block(step.name); if (named) out.push(...this.handlers(named, events, depth + 1)); } } return out; } private alive(token: number) { if (token !== this.token) throw new Stopped(); } private apply() { this.host.apply(this.props); } private async run(block: AtlBlock, token: number, depth: number, event: string): Promise { if (depth > MAX_DEPTH) throw new Error("ATL: слишком глубокая вложенность"); let loops = 0; for (;;) { const startedAt = performance.now(); let waited = false; for (let i = 0; i < block.steps.length; i++) { this.alive(token); const step = block.steps[i]; if (step.op === "on") { // `on` statements form an event loop: run the show-time handler, then wait for hide const matching = block.steps.filter((s) => s.op === "on" && s.events.some((e) => (event === "hide" ? HIDE_EVENTS : SHOW_EVENTS).has(e))); for (const handler of matching) await this.run((handler as { block: AtlBlock }).block, token, depth + 1, event); if (event === "hide") return; await new Promise(() => {}); // idle until stopped } if (await this.step(step, token, depth, event)) waited = true; } loops++; if (block.repeat === null) return; if (block.repeat !== true && loops >= block.repeat) return; // a loop without any waiting step would freeze the page: yield one frame per turn if (!waited && performance.now() - startedAt < 1) await nextFrame(); } } /** Returns true when the step took time. */ private async step(step: AtlStep, token: number, depth: number, event: string): Promise { switch (step.op) { case "set": Object.assign(this.props, step.props); this.apply(); return false; case "pause": await this.sleep(step.t, token); return step.t > 0; case "move": await this.move(step, token); return step.t > 0; case "parallel": await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event))); return true; case "choice": { const total = step.blocks.reduce((s, b) => s + b.w, 0); let pick = Math.random() * total; const chosen = step.blocks.find((b) => (pick -= b.w) < 0) ?? step.blocks[step.blocks.length - 1]; if (chosen) await this.run(chosen.block, token, depth + 1, event); return true; } case "block": await this.run(step.block, token, depth + 1, event); return true; case "use": { const named = this.host.block(step.name); if (named) await this.run(named, token, depth + 1, event); else { const props = this.host.props(step.name); if (props) { Object.assign(this.props, props); this.apply(); } } return true; } case "on": return false; } } private async sleep(seconds: number, token: number) { const end = performance.now() + seconds * 1000; while (performance.now() < end) { await nextFrame(); this.alive(token); } } /** Value a property has right now, including values implied by its aliases (xalign <- xpos). */ private current(key: string): any { const p = this.props; const pair = (a: string, b: string, fa = 0, fb = 0) => [p[a] ?? fa, p[b] ?? fb]; switch (key) { case "xalign": return p.xalign ?? p.xpos ?? 0; case "yalign": return p.yalign ?? p.ypos ?? 0; case "xcenter": return p.xcenter ?? p.xpos ?? 0; case "ycenter": return p.ycenter ?? p.ypos ?? 0; case "pos": return p.pos ?? pair("xpos", "ypos"); case "align": return p.align ?? [p.xalign ?? p.xpos ?? 0, p.yalign ?? p.ypos ?? 0]; case "anchor": return p.anchor ?? pair("xanchor", "yanchor"); default: return p[key] ?? NEUTRAL[key] ?? 0; } } private async move(step: Extract, token: number) { const from: AtlProps = {}; for (const key of Object.keys(step.props)) from[key] = this.current(key); const curve = curves[step.warp] ?? curves.linear; const start = performance.now(); const duration = step.t * 1000; while (duration > 0) { await nextFrame(); this.alive(token); const raw = Math.min(1, (performance.now() - start) / duration); const k = curve(raw); for (const [key, to] of Object.entries(step.props)) this.props[key] = mix(from[key], to, k); this.apply(); if (raw >= 1) return; } Object.assign(this.props, step.props); this.apply(); } } function mix(a: any, b: any, k: number): any { if (typeof a === "number" && typeof b === "number") return a + (b - a) * k; if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return a.map((x, i) => mix(x, b[i], k)); return k >= 1 ? b : a; }