fix(core): retiming a parked scene in the preview takes effect without a reload (#6522)

* fix(core): retiming a parked scene in the preview takes effect without a reload

* fix(core): an edit that leaves a parked scene without a start or length in seconds reloads instead
This commit is contained in:
Miguel Ángel
2026-10-27 06:15:38 +00:00
committed by GitHub
parent 33e0ba550b
commit 859cd2fb9c
3 changed files with 66 additions and 9 deletions
@@ -81,6 +81,7 @@ interface Scene {
hash: string;
extraAttrs?: string;
script?: string;
duration?: number;
}
function preview(scenes: Scene[], shared = "s1", sharedMarkup = "") {
@@ -92,11 +93,11 @@ function preview(scenes: Scene[], shared = "s1", sharedMarkup = "") {
`<meta name="hf-scene-parts" content="${manifest}"><style>.shared{}</style>` +
scenes.map((s) => `<style data-hf-scene="${s.id}">${s.css}</style>`).join("");
const body =
`<div data-composition-id="main" data-root="true" data-start="0" data-duration="${Math.max(6, ...scenes.map((s) => s.start + 2))}">${sharedMarkup}` +
`<div data-composition-id="main" data-root="true" data-start="0" data-duration="${Math.max(6, ...scenes.map((s) => s.start + (s.duration ?? 2)))}">${sharedMarkup}` +
scenes
.map(
(s) =>
`<div data-composition-id="${s.id}" data-hf-scene="${s.id}" data-start="${s.start}" data-duration="2"${s.extraAttrs ?? ""}>${s.body}</div>`,
`<div data-composition-id="${s.id}" data-hf-scene="${s.id}" data-start="${s.start}" data-duration="${s.duration ?? 2}"${s.extraAttrs ?? ""}>${s.body}</div>`,
)
.join("") +
`</div>` +
@@ -1949,6 +1950,22 @@ describe("the preview's scene window", () => {
expect(sceneHost("s2").textContent).toBe("two, edited");
});
it("retimes a parked scene an edit moves, for when it mounts and as it mounts", async () => {
bootWindowed([S0, S1, S2], trackingRoot().root);
await tick();
// From 4 s to 1 s, and 3 s long: paused at 1.5 it plays, where before it was not even next.
const moved: Scene = { ...S2, start: 1, duration: 3, hash: "h2b" };
await window.__hfSwapScenes!(preview([S0, S1, moved]).html);
expect(parked()).toEqual(["s2"]);
await seekAndSettle(1.5);
const host = sceneHost("s2");
expect([
host.textContent,
host.getAttribute("data-start"),
host.getAttribute("data-hf-authored-duration"),
]).toEqual(["two", "1", "3"]);
});
it("still mounts parked scenes after an edit to a mounted one", async () => {
bootWindowed([S0, S1, S2], trackingRoot().root);
await tick();
@@ -1972,7 +1989,10 @@ describe("the preview's scene window", () => {
body: '<audio src="/two.mp3" data-start="0"></audio>',
hash: "h2c",
};
for (const edit of [fetching, untrimmed])
// The window times a parked scene by its host's own seconds: a reference start or no length has none.
const referenceStart: Scene = { ...S2, start: "s1" as unknown as number, hash: "h2d" };
const noLength: Scene = { ...S2, duration: 0, hash: "h2e" };
for (const edit of [fetching, untrimmed, referenceStart, noLength])
await expect(window.__hfSwapScenes!(preview([S0, S1, edit]).html)).rejects.toThrow("s2");
await seekAndSettle(5);
expect(sceneHost("s2").textContent).toBe("two");
+4
View File
@@ -3,6 +3,7 @@ import { setPreviewRasterScale } from "./previewRasterHints";
import { createFarSceneSkip } from "./farScenes";
import {
clipLengthsWritten,
ownWindow,
hostOf as sceneHostOf,
isParked,
parkedClips,
@@ -4288,6 +4289,8 @@ export function initSandboxRuntimeModular(): void {
if (refusal !== null) throw new Error(`scene ${name} cannot be swapped: ${refusal}`);
if (!clipLengthsWritten(host))
throw new Error(`scene ${name} cannot be swapped: a clip's length is not written`);
if (!ownWindow(host))
throw new Error(`scene ${name} cannot be swapped: its start or length is not in seconds`);
const oldStyles = styles(partsOf(document, name));
if (oldStyles.length !== styles(parts).length)
throw new Error(`scene ${name} cannot be swapped: its styles moved`);
@@ -4308,6 +4311,7 @@ export function initSandboxRuntimeModular(): void {
document
.querySelector(`meta[name="${SCENE_PARTS_META}"]`)
?.setAttribute("content", JSON.stringify(nextParts));
windowScenes = windowedScenes();
postTimeline();
return;
}
+39 -6
View File
@@ -1,5 +1,6 @@
import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR, SCENE_PARTS_META } from "../sceneParts";
import { STUDIO_PREVIEW_PARKED_SCENE_ATTR as PARKED } from "../studioPreviewMark";
import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR } from "./authoredTiming";
import { parseElementDurationAttr, type ParkedTimelineScene } from "./timeline";
import type { RuntimeTimelineClip } from "./types";
@@ -32,15 +33,19 @@ const parkable = (host: Element): boolean => {
);
};
/** A host's window when its own attributes give it in seconds; a reference start or no length gives none. */
export function ownWindow(host: Element): { start: number; end: number } | null {
const start = Number(host.getAttribute("data-start")?.trim() || Number.NaN);
const duration = ownDuration(host);
return Number.isFinite(start) && duration > 0 ? { start, end: start + duration } : null;
}
function windowedHosts(): Array<WindowedScene & { host: Element }> {
if (!document.querySelector(`meta[name="${SCENE_PARTS_META}"]`)) return [];
return Array.from(document.querySelectorAll(`[${SCENE_PART_ATTR}]:not(style, script)`))
.flatMap((host) => {
const start = Number(host.getAttribute("data-start")?.trim() || Number.NaN);
const duration = ownDuration(host);
const name = host.getAttribute(SCENE_PART_ATTR)!;
const windowed = Number.isFinite(start) && duration > 0 && parkable(host);
return windowed ? [{ name, host, start, end: start + duration }] : [];
const own = parkable(host) ? ownWindow(host) : null;
return own ? [{ name: host.getAttribute(SCENE_PART_ATTR)!, host, ...own }] : [];
})
.sort((a, b) => a.start - b.start);
}
@@ -115,10 +120,38 @@ export function parkFarScenesAtBoot(time = 0): void {
}
}
// The runtime keeps a host's authored length under its own name, which an edit to the length replaces.
const AUTHORED_COPY: Record<string, string> = {
"data-duration": AUTHORED_DURATION_ATTR,
"data-end": AUTHORED_END_ATTR,
};
/** A parked scene is timed by its stand-in: it takes the host attributes an edit changed, keeping the runtime's. */
function retimeParked(placeholder: Element, before: Element, after: Element): void {
const changed = new Set(
[...before.getAttributeNames(), ...after.getAttributeNames()].filter(
(attr) => before.getAttribute(attr) !== after.getAttribute(attr),
),
);
for (const attr of changed) {
const value = after.getAttribute(attr);
if (value === null) placeholder.removeAttribute(attr);
else placeholder.setAttribute(attr, value);
if (attr in AUTHORED_COPY) placeholder.removeAttribute(AUTHORED_COPY[attr]!);
}
}
/** Forgets what a swap replaced: the swapped scene's new markup is what a later mount puts back. */
export function rememberScene(name: string, host: Element, scripts: Element[]): void {
if (!pristine.has(name)) return;
const old = pristine.get(name);
if (!old) return;
parkedClips.delete(name);
const placeholder = hostOf(name);
if (placeholder && isParked(placeholder)) {
const template = document.createElement("template");
template.innerHTML = old.html;
retimeParked(placeholder, template.content.firstElementChild!, host);
}
pristine.set(name, {
html: host.outerHTML,
scripts: scripts.map((el) => el.cloneNode(true) as Element),