diff --git a/docs/guides/frame-sources.md b/docs/guides/frame-sources.md index 1c600a5fb0..afb17d93df 100644 --- a/docs/guides/frame-sources.md +++ b/docs/guides/frame-sources.md @@ -16,7 +16,7 @@ const unregister = window.__hyperframes.registerFrameSource({ The host uses the usual composition attributes: `data-composition-id`, `data-start`, `data-duration`, and `data-track-index`. Declare the root's dimensions, FPS, and duration. A frame source does not need a dummy GSAP timeline. Add `data-no-timeline` to the host, and to a root that registers no timeline; otherwise `hyperframes lint` reports `missing_timeline_registry` and each render waits 45 seconds for timeline registration. -The callback receives seconds in source time: playback inpoint (`data-playback-start`) plus the rate-adjusted time since the host's resolved start. Existing playback rate and speed-ramp semantics apply. Timing is read again on every seek, so move, trim and rate edits do not require rewriting the animation code. Each overlapping scene needs its own source instance. +The callback receives seconds in source time: playback inpoint (`data-playback-start`) plus the rate-adjusted time since the host's resolved start. Optional `sourceRange: { start, duration, fps }` adds an original source offset and clamps this time to the last source frame within that interval. Existing playback rate and speed-ramp semantics apply. Timing is read again on every seek, so move, trim and rate edits do not require rewriting the animation code. Each overlapping scene needs its own source instance. `ready` holds initialization and export capture. Return a promise from `render` when drawing is asynchronous: capture waits for it before taking pixels. Repeated and out-of-order times must produce the same frame. HyperFrames owns playback; do not start a second clock. diff --git a/docs/guides/imported-film-html.md b/docs/guides/imported-film-html.md index 575256612d..d468978607 100644 --- a/docs/guides/imported-film-html.md +++ b/docs/guides/imported-film-html.md @@ -25,8 +25,30 @@ const unregister = window.__hyperframes.registerFrameSource({ The bridge installs its listener before setting `srcdoc`. It accepts messages only from that iframe's window with opaque origin `"null"`. It sends `load` after `hello`, waits for `ready`, and sends explicit `frame` requests with increasing sequence IDs. Capture waits for the matching frame acknowledgement; stale replies do not release it. The wrapper never calls the original player's autoplay path. -Startup has a 20-second deadline and a frame has a 15-second deadline. Startup, runner and timeout failures reject capture. An individual `frame-error` can recover on the next seek. Unregistering rejects outstanding bridge work and removes its listener. Use a separate bridge and runner iframe for each independently timed or overlapping scene. +Startup has a 20-second deadline and a frame has a 15-second deadline. Startup, runner and timeout failures reject capture. An individual `frame-error` can recover on the next seek. Unregistering rejects outstanding bridge work, removes its listener and clears the runner document. Use a separate bridge and runner iframe for each independently timed or overlapping scene. Use screenshot capture for iframe pixels. This bridge does not read the sandbox DOM, expose internal scene nodes as Studio layers, or provide audio mixing and video extraction. Font/asset readiness depends on the preserved runner's `ready` and `frame` guarantees and must be checked for each supported export version. The compatibility target is the current protocol, not every arbitrary animated web page. Visual comparison against the partner preview remains an integration acceptance step after MCP packaging. + +## Editable scene timing + +To expose individual scenes on the HyperFrames timeline, mount one timed host and independent runner iframe per scene. Each runner can load the preserved full film. Register the original scene's immutable source interval separately from its editable host timing: + +```javascript +window.__hyperframes.registerFrameSource({ + element: sceneHost, + ready: bridge.ready, + render: bridge.render, + dispose: bridge.dispose, + sourceRange: { start: 3, duration: 4, fps: 60 }, +}); +``` + +For a scene originally covering `[3, 7)`, source time is `3 + data-playback-start + rateAdjustedLocalTime`. The source interval clamps that result between 3 and the last source frame before 7. Moving the host changes `data-start`; trimming changes its duration and source inpoint; stretching uses the existing playback-rate attributes. Extending beyond available source footage holds the last frame. Changing duration alone does not automatically stretch the animation. + +Adjacent clips use half-open timeline windows. At the final composition instant, the final scene holds its last frame. Different source instances allow reordered or overlapping scenes to request different original film times without sharing mutable runner state. + +Persist the preserved runner/load payload and `sourceRange` in the wrapper's initialization data. Host timing stays in ordinary HyperFrames attributes, so serialization, save/reopen, undo and redo preserve it without rewriting scene modules. Do not use the original player's `recut` field as a replacement for host timing: that protocol does not represent arbitrary trim, reorder and overlap edits. + +This provides scene-level timing and placement. Text, colors and individual animation changes still require editing the preserved scene modules. The runtime does not expose iframe internals as manually editable Studio objects or keyframes. diff --git a/packages/core/src/runtime/filmBridge.test.ts b/packages/core/src/runtime/filmBridge.test.ts index f0ad23c4f8..5e8616e9f4 100644 --- a/packages/core/src/runtime/filmBridge.test.ts +++ b/packages/core/src/runtime/filmBridge.test.ts @@ -189,6 +189,7 @@ describe("film runner bridge", () => { first.bridge.dispose(); first.initialize(); await expect(first.bridge.ready).rejects.toThrow("disposed"); + expect(first.iframe.srcdoc).toBe(""); expect(first.send).not.toHaveBeenCalled(); const second = setup(); second.initialize(); diff --git a/packages/core/src/runtime/filmBridge.ts b/packages/core/src/runtime/filmBridge.ts index 5a24f49d92..b882dbdfaa 100644 --- a/packages/core/src/runtime/filmBridge.ts +++ b/packages/core/src/runtime/filmBridge.ts @@ -118,6 +118,7 @@ export function createFilmBridge(options: { disposed = true; window.removeEventListener("message", receive); fail(new Error("Film bridge was disposed")); + iframe.srcdoc = ""; }, }; } diff --git a/packages/core/src/runtime/frameSources.test.ts b/packages/core/src/runtime/frameSources.test.ts index 36a27145b3..e6bd4c16fc 100644 --- a/packages/core/src/runtime/frameSources.test.ts +++ b/packages/core/src/runtime/frameSources.test.ts @@ -14,10 +14,13 @@ function deferred() { } const adapters: ReturnType[] = []; -const adapter = () => { +const adapter = (compositionDuration = 20, exportRenderSeek = false) => { const runtime = createFrameSourceAdapter({ start: (element) => createRuntimeStartTimeResolver({}).resolveStartForElement(element, 0), duration: (element) => createRuntimeStartTimeResolver({}).resolveDurationForElement(element), + compositionDuration: () => compositionDuration, + canonicalFps: () => 30, + exportRenderSeek: () => exportRenderSeek, }); adapters.push(runtime); return runtime; @@ -215,6 +218,110 @@ describe("frame sources", () => { b.resolve(); await waitForSeekCompletion(); }); + it("keeps trimmed, sped-up and extended clips inside their original scene", async () => { + const host = mount("5", "8"); + host.setAttribute("data-playback-start", "0.5"); + host.setAttribute("data-playback-rate", "2"); + const render = vi.fn(); + disposers.push( + registerFrameSource({ + element: host, + render, + sourceRange: { start: 3, duration: 4, fps: 60 }, + }), + ); + const runtime = adapter(); + runtime.seek({ time: 5 }); + await waitForSeekCompletion(); + expect(render).toHaveBeenLastCalledWith(3.5, expect.any(AbortSignal)); + runtime.seek({ time: 6 }); + await waitForSeekCompletion(); + expect(render).toHaveBeenLastCalledWith(5.5, expect.any(AbortSignal)); + runtime.seek({ time: 12 }); + await waitForSeekCompletion(); + expect(render).toHaveBeenLastCalledWith(7 - 1 / 60, expect.any(AbortSignal)); + host.setAttribute("data-start", "0"); + host.setAttribute("data-playback-start", "0"); + runtime.seek({ time: 0 }); + await waitForSeekCompletion(); + expect(render).toHaveBeenLastCalledWith(3, expect.any(AbortSignal)); + }); + + it("uses half-open cut boundaries and holds the terminal scene's last source frame", async () => { + const first = vi.fn(); + const last = vi.fn(); + disposers.push(registerFrameSource({ element: mount("0", "3"), render: first })); + disposers.push( + registerFrameSource({ + element: mount("3", "4"), + render: last, + sourceRange: { start: 10, duration: 4, fps: 60 }, + }), + ); + const runtime = adapter(7); + runtime.seek({ time: 3 }); + await waitForSeekCompletion(); + expect(first).not.toHaveBeenCalled(); + expect(last).toHaveBeenLastCalledWith(10, expect.any(AbortSignal)); + runtime.seek({ time: 7 }); + await waitForSeekCompletion(); + expect(last.mock.lastCall?.[0]).toBeCloseTo(14 - 1 / 60, 10); + }); + + it("does not redraw an ended scene across a gap or seek backward before its start", async () => { + const render = vi.fn(); + disposers.push(registerFrameSource({ element: mount("2", "2"), render })); + const runtime = adapter(8); + for (const time of [0, 1, 4, 6, 8]) runtime.seek({ time }); + await waitForSeekCompletion(); + expect(render).not.toHaveBeenCalled(); + runtime.seek({ time: 2 }); + await waitForSeekCompletion(); + expect(render).toHaveBeenCalledTimes(1); + }); + + it("matches snapped export visibility at near-frame starts and ends", async () => { + const render = vi.fn(); + disposers.push(registerFrameSource({ element: mount("1.00001", "1"), render })); + const runtime = adapter(3, true); + runtime.seek({ time: 1 }); + await waitForSeekCompletion(); + expect(render).toHaveBeenLastCalledWith(0, expect.any(AbortSignal)); + runtime.seek({ time: 59 / 30 }); + await waitForSeekCompletion(); + expect(render.mock.lastCall?.[0]).toBeCloseTo(59 / 30 - 1.00001, 10); + runtime.seek({ time: 2 }); + await waitForSeekCompletion(); + expect(render).toHaveBeenCalledTimes(2); + }); + + it("retains unsnapped authored timing during interactive preview", async () => { + const render = vi.fn(); + disposers.push(registerFrameSource({ element: mount("1.00001", "1"), render })); + const runtime = adapter(3); + runtime.seek({ time: 1 }); + await waitForSeekCompletion(); + expect(render).not.toHaveBeenCalled(); + runtime.seek({ time: 2 }); + await waitForSeekCompletion(); + expect(render).toHaveBeenCalledTimes(1); + }); + + it("rejects invalid original ranges before registering a source", () => { + const element = mount(); + for (const sourceRange of [ + { start: -1, duration: 4, fps: 60 }, + { start: 0, duration: 0, fps: 60 }, + { start: 0, duration: 4, fps: NaN }, + { start: Infinity, duration: 4, fps: 60 }, + ]) { + expect(() => registerFrameSource({ element, render: vi.fn(), sourceRange })).toThrow( + "ranges", + ); + } + disposers.push(registerFrameSource({ element, render: vi.fn() })); + }); + it("drains a queued seek after an earlier draw fails and retains the failure for capture", async () => { const first = deferred(); const render = vi diff --git a/packages/core/src/runtime/frameSources.ts b/packages/core/src/runtime/frameSources.ts index 67f9414c69..6b944e9d65 100644 --- a/packages/core/src/runtime/frameSources.ts +++ b/packages/core/src/runtime/frameSources.ts @@ -1,11 +1,14 @@ +import { exportClipWindow } from "../inline-scripts/parityContract"; import { sourceTimeAt } from "../speedRamp"; import { readElementRateSpec, readMediaStart } from "./playbackRate"; import { registerSeekCompletion } from "./adapters/seek-dispatch"; +import { isClipVisibleAt } from "./clipWindow"; import type { RuntimeDeterministicAdapter } from "./types"; export interface FrameSource { element: Element; ready?: PromiseLike; + sourceRange?: { start: number; duration: number; fps: number }; render: (sourceTime: number, signal: AbortSignal) => void | PromiseLike; dispose?: () => void; } @@ -24,6 +27,18 @@ export const hasFrameSources = (): boolean => sources.size > 0; /** Bind a frame source to a timed host. Unregister before replacing its source. */ export function registerFrameSource(source: FrameSource): () => void { if (sources.has(source.element)) throw new Error("This element already has a frame source"); + const range = source.sourceRange; + if ( + range && + (!Number.isFinite(range.start) || + range.start < 0 || + !Number.isFinite(range.duration) || + range.duration <= 0 || + !Number.isFinite(range.fps) || + range.fps <= 0) + ) { + throw new Error("Frame source ranges require a nonnegative start, positive duration and FPS"); + } const controller = new AbortController(); const cancelled = new Promise((resolve) => { controller.signal.addEventListener("abort", () => resolve(), { once: true }); @@ -55,7 +70,9 @@ export function registerFrameSource(source: FrameSource): () => void { element: source.element, ready, seek(time) { - pending = time; + pending = range + ? range.start + Math.min(Math.max(0, time), Math.max(0, range.duration - 1 / range.fps)) + : time; work ??= drain(); return work; }, @@ -74,6 +91,9 @@ export function registerFrameSource(source: FrameSource): () => void { export function createFrameSourceAdapter(timing: { start: (element: Element) => number; duration: (element: Element) => number | null; + compositionDuration: () => number; + canonicalFps: () => number; + exportRenderSeek: () => boolean; }): RuntimeDeterministicAdapter { const owned = new Set(); let readySources: RegisteredSource[] = []; @@ -111,7 +131,12 @@ export function createFrameSourceAdapter(timing: { for (const [element, source] of current()) { const start = timing.start(element); const duration = timing.duration(element); - if (time < start || (duration !== null && time > start + duration)) continue; + const end = start + (duration ?? Infinity); + const clipWindow = timing.exportRenderSeek() + ? exportClipWindow(start, end, timing.canonicalFps()) + : { start, end }; + if (!isClipVisibleAt(time, clipWindow.start, clipWindow.end, timing.compositionDuration())) + continue; const localTime = Math.max(0, time - start); const sourceTime = readMediaStart(element) + sourceTimeAt(readElementRateSpec(element), localTime); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 8e06e9a3d0..bbc8f1ac38 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4025,6 +4025,9 @@ export function initSandboxRuntimeModular(): void { createFrameSourceAdapter({ start: (element) => resolveStartForElement(element, 0), duration: (element) => resolveDurationForElement(element), + compositionDuration: () => getSafeTimelineDurationSeconds(state.capturedTimeline, 0), + canonicalFps: () => state.canonicalFps, + exportRenderSeek: () => Boolean(window.__HF_EXPORT_RENDER_SEEK_CONFIG), }), createWaapiAdapter(), createCssAdapter({ diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 7eb7cc15a4..c052c3ac77 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1,10 +1,11 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from "vitest"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { bundleToSingleHtml } from "@hyperframes/core/compiler"; import { resolve } from "node:path"; import puppeteer, { type Browser, type Page } from "puppeteer"; import type {} from "../../../core/src/runtime/window"; +import { openComposition } from "../../../sdk/src/session"; import { computeStaticFrameSet, waitForPendingSeekCompletion, @@ -1116,3 +1117,200 @@ describe("core runtime browser contract", () => { expect(result).toEqual({ hadTeardown: true, teardownCleared: true, isPlaying: false }); }); }); + +// This is a first-party protocol fixture, not the partner runner or its animation code. +const FILM_RUNNER_FIXTURE = ` +`; + +function filmRuntimeFixture(runtime: string): string { + const runnerLiteral = JSON.stringify(FILM_RUNNER_FIXTURE).replaceAll("<", "\\u003c"); + return ` + + +
+
+ +
+`; +} + +describe("film bridge browser capture contract", () => { + let browser: Browser; + let html: string; + beforeAll(async () => { + html = filmRuntimeFixture(readFileSync(RUNTIME_PATH, "utf8")); + browser = await puppeteer.launch({ + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + }, 30_000); + afterAll(async () => { + await browser?.close(); + }); + + async function openFilm(source = html): Promise { + const page = await browser.newPage(); + await page.setViewport({ width: 320, height: 180, deviceScaleFactor: 1 }); + await page.setContent(source); + await page.waitForFunction( + () => window.__playerReady === true && window.__renderReady === true, + ); + return page; + } + + async function capture(page: Page): Promise { + // Linux headless Chrome can stall capture when the reference tab is foreground. + await page.bringToFront(); + return Buffer.from(await page.screenshot()); + } + + it("renders the executable wrapper after SDK edits, save and reopen", async () => { + const composition = await openComposition(html); + composition.setTiming("hf-scene", { start: 0.2, duration: 0.6 }); + composition.setAttribute("hf-scene", "data-playback-start", "0.1"); + composition.setAttribute("hf-scene", "data-playback-rate", "2"); + const reopened = await openComposition(composition.serialize()); + const page = await openFilm(reopened.serialize()); + const reference = await browser.newPage(); + try { + await page.evaluate(() => window.__player?.renderSeek?.(0.4)); + await waitForPendingSeekCompletion(page); + expect( + Number(await page.$eval("#scene", (el) => el.getAttribute("data-rendered-source-time"))), + ).toBeCloseTo(3.5, 10); + await reference.setViewport({ width: 320, height: 180, deviceScaleFactor: 1 }); + await reference.setContent(""); + expect(await capture(page)).toEqual(await capture(reference)); + } finally { + await page.close(); + await reference.close(); + } + }); + + it("draws the first visible export frame at a near-frame start and stops at its snapped end", async () => { + const composition = await openComposition(html); + composition.setTiming("hf-root", { duration: 3 }); + composition.setTiming("hf-scene", { start: 1.00001, duration: 1 }); + const source = composition + .serialize() + .replace( + "", + '', + ); + const page = await openFilm(source); + const reference = await browser.newPage(); + await reference.setViewport({ width: 320, height: 180, deviceScaleFactor: 1 }); + try { + for (const [time, sourceTime] of [ + [1, 3], + [59 / 30, 3 + 59 / 30 - 1.00001], + ]) { + await page.evaluate((t) => window.__player?.renderSeek?.(t), time); + await waitForPendingSeekCompletion(page); + expect( + Number(await page.$eval("#scene", (el) => el.getAttribute("data-rendered-source-time"))), + ).toBeCloseTo(sourceTime!, 10); + await reference.setContent( + ``, + ); + expect(await capture(page)).toEqual(await capture(reference)); + } + const last = await page.$eval("#scene", (el) => el.getAttribute("data-rendered-source-time")); + await page.evaluate(() => window.__player?.renderSeek?.(2)); + await waitForPendingSeekCompletion(page); + expect(await page.$eval("#scene", (el) => el.getAttribute("data-rendered-source-time"))).toBe( + last, + ); + expect( + await page.evaluate(() => ({ + duration: window.__player?.getDuration?.(), + visibility: getComputedStyle(document.getElementById("scene")!).visibility, + background: getComputedStyle(document.body).backgroundColor, + })), + ).toEqual({ duration: 3, visibility: "hidden", background: "rgba(0, 0, 0, 0)" }); + await reference.setContent(""); + expect(await capture(page)).toEqual(await capture(reference)); + } finally { + await page.close(); + await reference.close(); + } + }); + + it("captures the acknowledged frame for reverse, repeated and fresh-page seeks", async () => { + let phase = "opening film"; + onTestFailed(() => console.error(`Film capture failed during: ${phase}`)); + const page = await openFilm(); + phase = "opening reference"; + const reference = await browser.newPage(); + await reference.setViewport({ width: 320, height: 180, deviceScaleFactor: 1 }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const captures = new Map(); + try { + for (const time of [0, 0.5, 0.2, 0.5, 0.9, 1, 0]) { + phase = `seeking ${time}`; + await page.evaluate((t) => window.__player?.renderSeek?.(t), time); + phase = `waiting for frame ${time}`; + await waitForPendingSeekCompletion(page); + phase = `capturing frame ${time}`; + const actual = await capture(page); + const sourceTime = 3 + Math.min(time, 1 - 1 / 30); + phase = `setting reference ${time}`; + await reference.setContent( + ``, + ); + phase = `capturing reference ${time}`; + expect(Buffer.from(actual)).toEqual(await capture(reference)); + const previous = captures.get(time); + if (previous) expect(Buffer.from(actual)).toEqual(Buffer.from(previous)); + captures.set(time, actual); + } + phase = "opening fresh film"; + const fresh = await openFilm(); + try { + phase = "seeking fresh film"; + await fresh.evaluate(() => window.__player?.renderSeek?.(0.5)); + phase = "waiting for fresh frame"; + await waitForPendingSeekCompletion(fresh); + phase = "capturing fresh frame"; + expect(await capture(fresh)).toEqual(Buffer.from(captures.get(0.5)!)); + } finally { + phase = "closing fresh film"; + await fresh.close(); + } + phase = "seeking edited film"; + await page.evaluate(() => { + const scene = document.getElementById("scene")!; + scene.setAttribute("data-playback-start", "0.25"); + scene.setAttribute("data-playback-rate", "2"); + window.__player?.renderSeek?.(0.2); + }); + phase = "waiting for edited frame"; + await waitForPendingSeekCompletion(page); + phase = "setting edited reference"; + await reference.setContent(""); + phase = "capturing edited frame"; + expect(await capture(page)).toEqual(await capture(reference)); + expect(errors).toEqual([]); + } finally { + phase = "closing film"; + await page.close(); + phase = "closing reference"; + await reference.close(); + } + }, 30_000); +}); diff --git a/packages/sdk/src/session.film-scenes.test.ts b/packages/sdk/src/session.film-scenes.test.ts new file mode 100644 index 0000000000..696a7feee5 --- /dev/null +++ b/packages/sdk/src/session.film-scenes.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { openComposition } from "./session.js"; + +const SOURCE = "const originalScene = (t) => ({ x: 200 * t });"; +const LOAD = JSON.stringify({ + script: SOURCE, + modules: [], + assets: [], + sourceRange: { start: 3, duration: 4, fps: 60 }, +}); +const HTML = ` +
+
+ +
+ +
`; + +function timing(html: string) { + const tag = /]*data-hf-id="hf-boat"[^>]*>/.exec(html)?.[0] ?? ""; + const attr = (name: string) => new RegExp(`${name}="([^"]*)"`).exec(tag)?.[1]; + return { + start: attr("data-start"), + duration: attr("data-duration"), + inpoint: attr("data-playback-start"), + rate: attr("data-playback-rate"), + track: attr("data-track-index"), + }; +} + +function expectPreserved(html: string) { + expect(html).toContain(LOAD); + expect(html).toContain('sandbox="allow-scripts"'); + expect(html).not.toContain("allow-same-origin"); + expect(html).not.toContain("gsap.timeline"); +} + +describe("preserved film scene editing", () => { + it("moves and resizes a no-GSAP scene without rewriting its source", async () => { + const comp = await openComposition(HTML); + comp.setTiming("hf-boat", { start: 7, duration: 6, trackIndex: 2 }); + const exported = comp.serialize(); + expect(timing(exported)).toMatchObject({ start: "7", duration: "6", track: "2" }); + expectPreserved(exported); + const reopened = await openComposition(exported); + expect(timing(reopened.serialize())).toEqual(timing(exported)); + expectPreserved(reopened.serialize()); + }); + + it("persists an independent source inpoint and rate across save/reopen", async () => { + const comp = await openComposition(HTML); + comp.setAttribute("hf-boat", "data-playback-start", "0.5"); + comp.setAttribute("hf-boat", "data-playback-rate", "2"); + comp.setTiming("hf-boat", { start: 0, duration: 1.5 }); + const reopened = await openComposition(comp.serialize()); + expect(timing(reopened.serialize())).toMatchObject({ + start: "0", + duration: "1.5", + inpoint: "0.5", + rate: "2", + }); + expectPreserved(reopened.serialize()); + }); + + it("undoes and redoes timing edits while retaining the original scene payload", async () => { + const comp = await openComposition(HTML); + comp.setTiming("hf-boat", { start: 0, duration: 8 }); + comp.undo(); + expect(timing(comp.serialize())).toMatchObject({ start: "3", duration: "4" }); + expectPreserved(comp.serialize()); + comp.redo(); + expect(timing(comp.serialize())).toMatchObject({ start: "0", duration: "8" }); + expectPreserved(comp.serialize()); + }); +});