fix(studio): fix array-form keyframe writes, diamond click-deselect, and nested video sync

- fs.watch's async 'error' event had no listener, crashing the preview
  server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
  required object-form keyframes: {"0%": {...}}, silently no-opping on
  array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
  to the ancestor clip's onClick, which toggles selection off when the
  clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
  covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
  duration:0 + immediateRender static hold (what remove-all-keyframes
  produces) as non-animated, so it kept showing a phantom diamond after
  Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
  data-start discarded the host composition's inherited start offset,
  so a video nested inside a sub-composition played from the root
  timeline's time instead of holding until its parent scene began

Fixes #1838
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-01 18:07:44 -07:00
parent 9b311588be
commit 1b8b2ac425
12 changed files with 467 additions and 24 deletions
+50
View File
@@ -550,6 +550,56 @@ describe("initSandboxRuntimeModular", () => {
expect(video.currentTime).toBe(9);
});
// Regression (#1838): a video authoring its OWN data-start (the normal case
// for a timed clip the studio positions on a track) took a fast literal-
// value path that skipped adding the host composition's start offset —
// unlike the no-own-data-start case above, which already went through
// resolveStartForElement and got the offset for free. The video played
// from the ROOT timeline's time instead of holding until its parent scene
// began, desyncing from the correctly-offset GSAP overlay in the same scene.
it("offsets a nested video's own data-start by its host composition's start", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "scene-2");
child.setAttribute("data-start", "20");
child.setAttribute("data-duration", "16");
root.appendChild(child);
const video = document.createElement("video");
// Authored relative to scene-2's own local timeline, not the root's.
video.setAttribute("data-start", "0");
video.setAttribute("data-duration", "16");
child.appendChild(video);
Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true });
Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true });
Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true });
Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true });
video.load = () => {};
video.play = () => Promise.resolve();
window.__timelines = {
main: createMockTimeline(40),
"scene-2": createMockTimeline(16),
};
initSandboxRuntimeModular();
const player = window.__player;
expect(player).toBeDefined();
// Root t=25 is 5s into scene-2 (which starts at root t=20) — the video
// must be 5s into its own local playback, not 25s (root time).
player?.seek(25);
expect(video.currentTime).toBe(5);
});
it("updates visibility for timed elements inside nested compositions", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+10 -1
View File
@@ -496,7 +496,16 @@ export function initSandboxRuntimeModular(): void {
const resolveMediaStartSeconds = (element: Element, fallback = 0): number => {
if (!element.hasAttribute("data-hf-auto-start") && element.hasAttribute("data-start")) {
return Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0);
// `data-start` is authored relative to the media element's OWN sub-
// composition, not the root timeline — `fallback` carries the host
// composition's resolved absolute start (see syncMediaForCurrentState's
// inheritedStart), so it must be added, not discarded. Skipping it made
// a nested video play from root t=0 instead of holding until its
// parent scene began (issue #1838) — resolveStartForElement's own
// absolute-expression branch already adds this same host offset, this
// fast literal-value path just didn't.
const own = Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0);
return own + fallback;
}
return resolveStartForElement(element, fallback);
};