From 471568bd656607bd14bcb51a14c1c9ab43d09df2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 17 Jul 2026 01:42:38 -0400 Subject: [PATCH 1/9] fix(studio): preserve composition playback continuity --- docs/concepts/compositions.mdx | 4 ++ docs/concepts/data-attributes.mdx | 2 + docs/reference/html-schema.mdx | 2 + packages/core/src/runtime/init.test.ts | 27 +++++++++++++ packages/core/src/runtime/init.ts | 32 +++++++-------- packages/core/src/runtime/media.ts | 17 +++++++- packages/core/src/runtime/timeline.test.ts | 22 ++++++++++ packages/core/src/runtime/timeline.ts | 8 +++- packages/core/src/runtime/types.ts | 2 + .../src/hooks/timelineEditingHelpers.ts | 14 +++++-- .../studio/src/hooks/useTimelineEditing.ts | 6 +-- .../src/hooks/useTimelineGroupEditing.ts | 6 +-- .../components/timelineClipDragCommit.ts | 2 +- .../timelineClipDragPreview.test.ts | 34 +++++++++++++++- .../components/timelineClipDragPreview.ts | 3 +- .../components/timelineEditCapabilities.ts | 5 ++- .../components/timelineGroupEditing.test.ts | 10 +++++ .../player/components/timelineGroupEditing.ts | 7 ++-- .../studio/src/player/lib/playbackTypes.ts | 2 + .../studio/src/player/lib/timelineDOM.test.ts | 40 +++++++++++++++++++ packages/studio/src/player/lib/timelineDOM.ts | 19 ++++++++- .../src/player/lib/timelineElementHelpers.ts | 26 +++++++----- .../studio/src/player/store/playerStore.ts | 1 + .../src/utils/timelineElementSplit.test.ts | 16 +++++++- .../studio/src/utils/timelineElementSplit.ts | 6 ++- 25 files changed, 260 insertions(+), 53 deletions(-) diff --git a/docs/concepts/compositions.mdx b/docs/concepts/compositions.mdx index fecb3d2ecb..7d229575ed 100644 --- a/docs/concepts/compositions.mdx +++ b/docs/concepts/compositions.mdx @@ -43,6 +43,8 @@ You can embed one composition inside another in two ways: loading from an extern data-composition-id="intro-anim" data-composition-src="compositions/intro-anim.html" data-start="0" + data-duration="4" + data-playback-start="0" data-track-index="3" > ``` @@ -68,6 +70,8 @@ You can embed one composition inside another in two ways: loading from an extern ``` + + `data-playback-start` selects the child timeline time shown when the host begins. It defaults to `0`. A left trim or split advances this source-time offset by the elapsed host time multiplied by `data-playback-rate`, so the nested animation remains continuous instead of restarting. Define a nested composition directly inside the parent. This is simpler for one-off compositions that do not need to be reused. diff --git a/docs/concepts/data-attributes.mdx b/docs/concepts/data-attributes.mdx index f50a692b18..a386d6ca49 100644 --- a/docs/concepts/data-attributes.mdx +++ b/docs/concepts/data-attributes.mdx @@ -18,6 +18,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co | Attribute | Example | Description | |-----------|---------|-------------| | `data-media-start` | `"2"` | Media playback offset / trim point in seconds. Default: `0` | +| `data-playback-start` | `"2"` | Source-time offset in seconds for media wrappers and nested composition hosts. Missing values default to `0`; Studio writes this attribute when a composition is trimmed or split. | +| `data-playback-rate` | `"1.5"` | Positive source playback multiplier. Source time advances by timeline elapsed time multiplied by this value. Default: `1`. | | `data-volume` | `"0.8"` | Audio/video volume, 0 to 1 | | `data-has-audio` | `"true"` | Indicates video has an audio track | diff --git a/docs/reference/html-schema.mdx b/docs/reference/html-schema.mdx index 9ecfc8866e..5b2ed21a84 100644 --- a/docs/reference/html-schema.mdx +++ b/docs/reference/html-schema.mdx @@ -55,6 +55,8 @@ Common sizes: | `data-duration` | video, img, audio | See below | Duration in seconds. **Required** for images. Optional for video/audio (defaults to source duration). Not used on compositions. | | `data-track-index` | All | Yes | Timeline track number. Controls z-ordering (higher = in front). Clips on the same track cannot overlap. | | `data-media-start` | video, audio | No | Playback offset / trim point in source file (seconds). Default: `0`. See [Data Attributes](/concepts/data-attributes). | +| `data-playback-start` | video, audio, composition | No | Source-time offset in seconds. On a nested composition, this is the child timeline time shown at the host's `data-start`. Default: `0`. | +| `data-playback-rate` | video, audio, composition | No | Positive source playback multiplier. Default: `1`. | | `data-volume` | audio, video | No | Volume level from `0` to `1`. Default: `1`. | | `data-composition-id` | div | On compositions | Unique composition ID. Must match the key used in `window.__timelines`. | | `data-composition-src` | div | No | Path to external composition HTML file (for [nested compositions](#composition-clips)). | diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index c83fa4d30b..404c2567ad 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1097,6 +1097,33 @@ describe("initSandboxRuntimeModular", () => { expect(hookHost.style.visibility).toBe("visible"); }); + it("seeks child compositions in source time using host offset and playback rate", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "20"); + document.body.appendChild(root); + + const child = document.createElement("div"); + child.setAttribute("data-composition-id", "child"); + child.setAttribute("data-start", "3"); + child.setAttribute("data-duration", "8"); + child.setAttribute("data-playback-start", "1.5"); + child.setAttribute("data-playback-rate", "2"); + root.appendChild(child); + + const childTimeline = createMockTimeline(6); + window.__timelines = { main: createMockTimeline(20), child: childTimeline }; + initSandboxRuntimeModular(); + + window.__player?.renderSeek(5); + expect(childTimeline.time()).toBeCloseTo(5.5); + + window.__player?.renderSeek(10); + expect(childTimeline.time()).toBe(6); + }); + it("keeps the root GSAP render nudge for normal frames but not silent probes", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bac909fd68..dc88d2df4f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -20,6 +20,8 @@ import { import { forceDispatchSeekEvent } from "./adapters/seek-dispatch"; import { createWaapiAdapter } from "./adapters/waapi"; import { + readElementPlaybackRate, + readElementPlaybackStart, refreshRuntimeMediaCache, resolveRuntimeMediaClipDuration, syncRuntimeMedia, @@ -2632,17 +2634,15 @@ export function initSandboxRuntimeModular(): void { if (!node) continue; const start = resolveStartForElement(node, 0); if (!Number.isFinite(start)) continue; - const authoredDuration = resolveDurationForElement(node, { - includeAuthoredTimingAttrs: true, - }); const timelineDuration = getTimelineDurationSeconds(timeline); - const duration = - authoredDuration != null && authoredDuration > 0 ? authoredDuration : timelineDuration; + const sourceTime = + readElementPlaybackStart(node) + + Math.max(0, timeSeconds - start) * readElementPlaybackRate(node); const localTime = Math.max( 0, - duration != null && duration > 0 - ? Math.min(duration, timeSeconds - start) - : timeSeconds - start, + timelineDuration != null && timelineDuration > 0 + ? Math.min(timelineDuration, sourceTime) + : sourceTime, ); seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline", options); } @@ -2785,15 +2785,13 @@ export function initSandboxRuntimeModular(): void { } catch (err) { swallow("runtime.init.transport.seek", err); } - // Sibling timelines (registered in __timelines but not nested under - // the root) are paused alongside the master. We do NOT seek them to - // absolute position `t` here — child timelines nested under the root - // are already propagated via tl.totalTime(), and seeking them again - // at absolute `t` would clobber their offset-relative position. - // Play/pause propagation for siblings happens in the player.play() - // and player.pause() overrides via the adapter layer. - } else { - seekStandaloneRegisteredTimelines(t, opts); + // Root propagation cannot represent an authored child source offset or + // playback rate. Re-seek registered children below with their host's + // explicit source-time contract. + } + seekStandaloneRegisteredTimelines(t, opts); + if (tl && opts?.activateChildren) { + activateSiblingTimelines(tl); } for (const adapter of state.deterministicAdapters) { if (adapter.name === "gsap" && tl) continue; diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index d69ce7dd7c..d82a66a77f 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -1,11 +1,24 @@ import { swallow } from "./diagnostics"; import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; -export function readElementPlaybackRate(el: HTMLMediaElement): number { - const raw = el.defaultPlaybackRate; +export function readElementPlaybackRate(el: Element): number { + const authored = Number.parseFloat(el.getAttribute("data-playback-rate") ?? ""); + const raw = + Number.isFinite(authored) && authored > 0 + ? authored + : el instanceof HTMLMediaElement + ? el.defaultPlaybackRate + : 1; return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1; } +export function readElementPlaybackStart(el: Element): number { + const raw = Number.parseFloat( + el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start") ?? "", + ); + return Number.isFinite(raw) && raw >= 0 ? raw : 0; +} + /** * Resolve a media element's timeline window without conflating a video's * authored display slot with the amount of source left to decode. diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index e0da371d24..b37aab05d2 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -312,10 +312,32 @@ describe("collectRuntimeTimelinePayload", () => { comp.setAttribute("data-composition-id", "scene-1"); comp.setAttribute("data-start", "0"); comp.setAttribute("data-duration", "10"); + comp.setAttribute("data-playback-start", "1.5"); + comp.setAttribute("data-playback-rate", "2"); root.appendChild(comp); const result = collectRuntimeTimelinePayload(defaultParams); expect(result.clips[0].kind).toBe("composition"); + expect(result.clips[0].playbackStart).toBe(1.5); + expect(result.clips[0].playbackRate).toBe(2); + }); + + it("defaults a legacy composition host playback window to zero at unit rate", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "20"); + document.body.appendChild(root); + + const comp = document.createElement("div"); + comp.id = "scene-legacy"; + comp.setAttribute("data-composition-id", "scene-legacy"); + comp.setAttribute("data-start", "0"); + comp.setAttribute("data-duration", "10"); + root.appendChild(comp); + + const clip = collectRuntimeTimelinePayload(defaultParams).clips[0]; + expect(clip.playbackStart).toBe(0); + expect(clip.playbackRate).toBe(1); }); it("collects scenes from composition nodes", () => { diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 3a56f2815c..f9b92df924 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -6,7 +6,7 @@ import type { } from "./types"; import { stableClipId } from "./clipTree"; import { swallow } from "./diagnostics"; -import { readElementPlaybackRate } from "./media"; +import { readElementPlaybackRate, readElementPlaybackStart } from "./media"; import { resolveCssStackingContextId } from "./stackingContext"; import { createRuntimeStartTimeResolver } from "./startResolver"; import { isSceneLikeCompositionId } from "../slideshow/index.js"; @@ -412,6 +412,8 @@ export function collectRuntimeTimelinePayload(params: { parentCompositionId: compositionContext.parentCompositionId, nodePath: null, compositionSrc: toAbsoluteAssetUrl(node.getAttribute("data-composition-src")), + playbackStart: readElementPlaybackStart(node), + playbackRate: readElementPlaybackRate(node), assetUrl: resolveNodeAssetUrl(node), timelineRole: node.getAttribute("data-timeline-role"), timelineLabel: node.getAttribute("data-timeline-label"), @@ -521,6 +523,8 @@ export function collectRuntimeTimelinePayload(params: { parentCompositionId: rootCompositionIdForGsap, nodePath: null, compositionSrc: null, + playbackStart: readElementPlaybackStart(el), + playbackRate: readElementPlaybackRate(el), assetUrl: null, timelineRole: el.getAttribute("data-timeline-role"), timelineLabel: el.getAttribute("data-timeline-label"), @@ -576,6 +580,8 @@ export function collectRuntimeTimelinePayload(params: { parentCompositionId: rootCompositionIdForGsap, nodePath: null, compositionSrc: null, + playbackStart: readElementPlaybackStart(el), + playbackRate: readElementPlaybackRate(el), assetUrl: null, timelineRole, timelineLabel: el.getAttribute("data-timeline-label"), diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index de52422891..79502d88c8 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -64,6 +64,8 @@ export type RuntimeTimelineClip = { parentCompositionId: string | null; nodePath: string | null; compositionSrc: string | null; + playbackStart: number; + playbackRate: number; assetUrl: string | null; timelineRole: string | null; timelineLabel: string | null; diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index d360ecfc63..7d0ecc7cf8 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -166,6 +166,14 @@ export function patchIframeDomTiming( // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort. } } + +export function playbackStartAttributeForElement( + element: Pick, +): "data-media-start" | "data-playback-start" { + return element.playbackStartAttr === "playback-start" || element.kind === "composition" + ? "data-playback-start" + : "data-media-start"; +} // fallow-ignore-next-line complexity function resolveResizePlaybackStart( original: string, @@ -174,8 +182,7 @@ function resolveResizePlaybackStart( updates: Pick, ): { attrName: string; value: number } | null { if (updates.playbackStart != null) { - const attrName = - element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start"; + const attrName = playbackStartAttributeForElement(element).slice("data-".length); return { attrName, value: updates.playbackStart }; } const trimDelta = updates.start - element.start; @@ -185,8 +192,7 @@ function resolveResizePlaybackStart( readAttributeByTarget(original, target, "media-start"); const current = raw != null ? parseFloat(raw) : undefined; if (current == null || !Number.isFinite(current)) return null; - const attrName = - element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start"; + const attrName = playbackStartAttributeForElement(element).slice("data-".length); return { attrName, value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)), diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index a763f798eb..04839118f7 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -12,6 +12,7 @@ import { applyTimelineStackingReorder, buildPatchTarget, patchIframeDomTiming, + playbackStartAttributeForElement, persistTimelineEdit, formatTimelineAttributeNumber, extendRootDurationIfNeeded, @@ -263,10 +264,7 @@ export function useTimelineEditing({ ["data-duration", formatTimelineAttributeNumber(updates.duration)], ]; if (updates.playbackStart != null) { - const liveAttr = - element.playbackStartAttr === "playback-start" - ? "data-playback-start" - : "data-media-start"; + const liveAttr = playbackStartAttributeForElement(element); liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]); } patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath); diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts index 85e93cc693..aa424ddecc 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -12,6 +12,7 @@ import { extendRootDurationIfNeeded, formatTimelineAttributeNumber, patchIframeDomTiming, + playbackStartAttributeForElement, persistTimelineBatchEdit, type PersistTimelineBatchChange, type RecordEditInput, @@ -348,10 +349,7 @@ export function useTimelineGroupEditing({ ["data-duration", formatTimelineAttributeNumber(change.duration)], ]; if (change.playbackStart != null) { - const liveAttr = - change.element.playbackStartAttr === "playback-start" - ? "data-playback-start" - : "data-media-start"; + const liveAttr = playbackStartAttributeForElement(change.element); liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]); } patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath); diff --git a/packages/studio/src/player/components/timelineClipDragCommit.ts b/packages/studio/src/player/components/timelineClipDragCommit.ts index cce89f586b..122530a2f0 100644 --- a/packages/studio/src/player/components/timelineClipDragCommit.ts +++ b/packages/studio/src/player/components/timelineClipDragCommit.ts @@ -72,7 +72,6 @@ export interface DragCommitDeps { const keyOf = (e: TimelineElement) => e.key ?? e.id; const round3 = (v: number) => Math.round(v * 1000) / 1000; - // One deterministic coalesce key shared by both records in a lane-change gesture. let laneChangeGestureSeq = 0; @@ -80,6 +79,7 @@ let laneChangeGestureSeq = 0; function canMoveElement(element: TimelineElement): boolean { return getTimelineEditCapabilities({ tag: element.tag, + kind: element.kind, duration: element.duration, domId: element.domId, selector: element.selector, diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts index fc1a4ca2bf..5f9bae4ef8 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.test.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { TimelineElement } from "../store/playerStore"; -import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview"; +import { + computeDragPreview, + computeResizePreview, + type DragPreviewContext, +} from "./timelineClipDragPreview"; import type { DraggedClipState } from "./timelineClipDragTypes"; import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout"; @@ -142,3 +146,31 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse expect(next.insertRow).toBe(0); // a new TOP track will be created on drop }); }); + +describe("computeResizePreview — composition source continuity", () => { + it("seeds a legacy composition offset and advances it at playback rate", () => { + const element = { + ...clip("comp", 0, 2, 4, 0, "div"), + kind: "composition" as const, + playbackRate: 2, + }; + const result = computeResizePreview( + { + element, + edge: "start", + originClientX: 0, + previewStart: 2, + previewDuration: 4, + started: true, + }, + 100, + { scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [] }, + ); + + expect(result).toMatchObject({ + previewStart: 3, + previewDuration: 3, + previewPlaybackStart: 2, + }); + }); +}); diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index 30b06d3ea6..0e2d92b551 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -221,7 +221,8 @@ export function computeResizePreview( ) : Number.POSITIVE_INFINITY; const normalizedTag = resize.element.tag.toLowerCase(); - const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video"; + const canSeedPlaybackStart = + resize.element.kind === "composition" || normalizedTag === "audio" || normalizedTag === "video"; const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1); // Trim limit = available source media only — NOT the composition length. // Duration is content-driven (the comp grows/shrinks to fit on commit), so diff --git a/packages/studio/src/player/components/timelineEditCapabilities.ts b/packages/studio/src/player/components/timelineEditCapabilities.ts index bdcc834ae0..9d211f4a2f 100644 --- a/packages/studio/src/player/components/timelineEditCapabilities.ts +++ b/packages/studio/src/player/components/timelineEditCapabilities.ts @@ -6,11 +6,13 @@ export interface TimelineEditCapabilities { function isDeterministicTimelineWindow(input: { tag: string; + kind?: "video" | "audio" | "image" | "element" | "composition"; compositionSrc?: string; playbackStartAttr?: "media-start" | "playback-start"; sourceDuration?: number; }): boolean { - if (input.compositionSrc || input.playbackStartAttr != null) return true; + if (input.kind === "composition" || input.compositionSrc || input.playbackStartAttr != null) + return true; if ( input.sourceDuration != null && Number.isFinite(input.sourceDuration) && @@ -27,6 +29,7 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s export function getTimelineEditCapabilities(input: { tag: string; + kind?: "video" | "audio" | "image" | "element" | "composition"; duration: number; domId?: string; selector?: string; diff --git a/packages/studio/src/player/components/timelineGroupEditing.test.ts b/packages/studio/src/player/components/timelineGroupEditing.test.ts index 31c7ec2e51..ef4d356bf0 100644 --- a/packages/studio/src/player/components/timelineGroupEditing.test.ts +++ b/packages/studio/src/player/components/timelineGroupEditing.test.ts @@ -53,6 +53,16 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => { ]); }); + it("seeds legacy composition offsets and advances them at playback rate", () => { + const a = el("a", { kind: "composition", tag: "div", start: 2, playbackRate: 2 }); + const b = el("b", { kind: "composition", tag: "div", start: 5, playbackRate: 0.5 }); + const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!; + + expect(members.map((member) => member.playbackStart)).toEqual([0, 0]); + const changes = resolveTimelineGroupResizeChanges(members, "start", 1); + expect(changes.map((change) => change.playbackStart)).toEqual([2, 0.5]); + }); + it("does not seed playbackStart on the END edge", () => { const grabbed = el("a", { tag: "audio", start: 0, duration: 2 }); const b = el("b", { tag: "audio", start: 3, duration: 2 }); diff --git a/packages/studio/src/player/components/timelineGroupEditing.ts b/packages/studio/src/player/components/timelineGroupEditing.ts index 5e16f578af..567a98cd3e 100644 --- a/packages/studio/src/player/components/timelineGroupEditing.ts +++ b/packages/studio/src/player/components/timelineGroupEditing.ts @@ -181,14 +181,15 @@ function elementKey(element: TimelineElement): string { return element.key ?? element.id; } -function isMediaTimelineElement(element: TimelineElement): boolean { +function hasSourcePlaybackOffset(element: TimelineElement): boolean { const tag = element.tag.toLowerCase(); - return tag === "audio" || tag === "video"; + return element.kind === "composition" || tag === "audio" || tag === "video"; } function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean { const caps = getTimelineEditCapabilities({ tag: element.tag, + kind: element.kind, duration: element.duration, domId: element.domId, selector: element.selector, @@ -228,7 +229,7 @@ export function buildTimelineGroupResizeMembers( start: element.start, duration: element.duration, playbackStart: - edge === "start" && isMediaTimelineElement(element) + edge === "start" && hasSourcePlaybackOffset(element) ? (element.playbackStart ?? 0) : element.playbackStart, playbackRate: element.playbackRate, diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index 0136d6cd3f..76bfd1c344 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -46,6 +46,8 @@ export interface ClipManifestClip { compositionAncestors?: string[]; parentCompositionId: string | null; compositionSrc: string | null; + playbackStart?: number; + playbackRate?: number; assetUrl: string | null; } diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts index 784ce6a178..19bb127ecd 100644 --- a/packages/studio/src/player/lib/timelineDOM.test.ts +++ b/packages/studio/src/player/lib/timelineDOM.test.ts @@ -111,6 +111,46 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => { }); describe("createTimelineElementFromManifestClip — source-scoped selector identity", () => { + it("preserves composition kind and source timing on first translation", () => { + const doc = makeDoc(` +
+
+
+ `); + const host = doc.getElementById("host"); + + const element = createTimelineElementFromManifestClip({ + clip: { + id: "host", + label: "Scene", + kind: "composition", + tagName: "div", + start: 2, + duration: 4, + track: 0, + compositionId: "scene", + parentCompositionId: "root", + compositionSrc: "scene.html", + playbackStart: 1.5, + playbackRate: 2, + assetUrl: null, + }, + fallbackIndex: 0, + doc, + hostEl: host, + }); + + expect(element).toMatchObject({ + kind: "composition", + compositionSrc: "scene.html", + playbackStart: 1.5, + playbackStartAttr: "playback-start", + playbackRate: 2, + domId: "host", + }); + }); + it("ignores an index.html duplicate when indexing a scene.html selector", () => { const doc = makeDoc(`
diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index 7be5496ce8..0819582b5a 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -111,6 +111,7 @@ export function createTimelineElementFromManifestClip(params: { id: identity.id, label, key: identity.key, + kind: clip.kind, tag: resolveClipTag(clip), start: clip.start, duration: clip.duration, @@ -129,6 +130,8 @@ export function createTimelineElementFromManifestClip(params: { selector, selectorIndex, sourceFile, + playbackStart: clip.playbackStart, + playbackRate: clip.playbackRate, }; if (hostEl) { @@ -140,6 +143,8 @@ export function createTimelineElementFromManifestClip(params: { } if (clip.assetUrl) entry.src = clip.assetUrl; if (clip.kind === "composition" && clip.compositionId) { + entry.playbackStart ??= 0; + entry.playbackRate ??= 1; let resolvedSrc = clip.compositionSrc; if (!resolvedSrc) { hostEl = @@ -293,6 +298,14 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel id: identity.id, label, key: identity.key, + kind: + compId && compId !== rootComp?.getAttribute("data-composition-id") + ? "composition" + : tagLower === "video" || tagLower === "audio" + ? tagLower + : tagLower === "img" + ? "image" + : "element", tag: tagLower, start, duration: dur, @@ -308,13 +321,13 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel }; const mediaEl = resolveMediaElement(el); + applyMediaMetadataFromElement(entry, el); if (mediaEl) { if (mediaEl.tagName === "IMG") { entry.tag = "img"; } const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume"); if (vol) entry.volume = parseFloat(vol); - applyMediaMetadataFromElement(entry, el); // Override AFTER the helper (which sets the raw relative attribute) so the // resolved absolute URL wins — the Studio can then fetch the asset // regardless of whether the attribute value was relative or absolute. @@ -345,6 +358,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel entry.tag = "video"; } } + if (entry.kind === "composition") { + entry.playbackStart ??= 0; + entry.playbackRate ??= 1; + } els.push(entry); }); diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts index 08e125539c..0874d443dd 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.ts @@ -149,19 +149,25 @@ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageEl : null; } -export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void { - const mediaStartAttr = el.getAttribute("data-playback-start") - ? "playback-start" - : el.getAttribute("data-media-start") - ? "media-start" - : undefined; - const mediaStartValue = - el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start"); +function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element): void { + const playbackStartValue = el.getAttribute("data-playback-start"); + const legacyMediaStartValue = el.getAttribute("data-media-start"); + const mediaStartValue = playbackStartValue ?? legacyMediaStartValue; if (mediaStartValue != null) { const playbackStart = parseFloat(mediaStartValue); if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart; } - if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr; + if (playbackStartValue != null) entry.playbackStartAttr = "playback-start"; + else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start"; + + const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? ""); + if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) { + entry.playbackRate = authoredPlaybackRate; + } +} + +export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void { + applyPlaybackMetadataFromElement(entry, el); const mediaEl = resolveMediaElement(el); if (!mediaEl) return; @@ -182,7 +188,7 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen } const playbackRate = mediaEl.defaultPlaybackRate; - if (Number.isFinite(playbackRate) && playbackRate > 0) { + if (entry.playbackRate == null && Number.isFinite(playbackRate) && playbackRate > 0) { entry.playbackRate = playbackRate; } } diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 665a63beee..f1a512f4ed 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -25,6 +25,7 @@ export interface TimelineElement { id: string; label?: string; key?: string; + kind?: ClipManifestClip["kind"]; tag: string; start: number; duration: number; diff --git a/packages/studio/src/utils/timelineElementSplit.test.ts b/packages/studio/src/utils/timelineElementSplit.test.ts index d5262dd8e8..210c6fc3a9 100644 --- a/packages/studio/src/utils/timelineElementSplit.test.ts +++ b/packages/studio/src/utils/timelineElementSplit.test.ts @@ -14,6 +14,7 @@ function element(overrides: Partial = {}): TimelineElement { start: 1, duration: 4, track: 0, + domId: "el-1", ...overrides, }; } @@ -77,10 +78,21 @@ describe("canSplitElementAt", () => { ).toBe(false); }); - it("rejects locked, implicit and sub-composition elements", () => { + it("rejects locked and implicit elements while allowing identified compositions", () => { expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false); expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false); - expect(canSplitElementAt(element({ compositionSrc: "child.html" }), 3)).toBe(false); + expect( + canSplitElementAt( + element({ kind: "composition", compositionSrc: "child.html", playbackRate: 2 }), + 3, + ), + ).toBe(true); + }); + + it("rejects missing identity and invalid playback rates", () => { + expect(canSplitElementAt(element({ domId: undefined }), 3)).toBe(false); + expect(canSplitElementAt(element({ playbackRate: 0 }), 3)).toBe(false); + expect(canSplitElementAt(element({ playbackRate: Number.NaN }), 3)).toBe(false); }); }); diff --git a/packages/studio/src/utils/timelineElementSplit.ts b/packages/studio/src/utils/timelineElementSplit.ts index cb5521e25b..c1d483d9d2 100644 --- a/packages/studio/src/utils/timelineElementSplit.ts +++ b/packages/studio/src/utils/timelineElementSplit.ts @@ -22,10 +22,14 @@ export function isSplitTimeWithinBounds( } export function canSplitElement(el: TimelineElement): boolean { + const hasStableIdentity = Boolean(el.hfId || el.domId || el.selector); + const hasValidRate = + el.playbackRate == null || (Number.isFinite(el.playbackRate) && el.playbackRate > 0); return ( !el.timelineLocked && el.timingSource !== "implicit" && - !el.compositionSrc && + hasStableIdentity && + hasValidRate && !!el.duration && Number.isFinite(el.duration) ); From 1f0cab6b6a2376c37fd502273945d7aa063ed091 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 17 Jul 2026 01:58:37 -0400 Subject: [PATCH 2/9] feat(studio): drag compositions into the timeline --- .../src/helpers/compositionInsertion.test.ts | 126 ++++++++++ .../src/helpers/compositionInsertion.ts | 216 ++++++++++++++++++ .../studio-server/src/routes/files.test.ts | 61 +++++ packages/studio-server/src/routes/files.ts | 72 ++++++ packages/studio/src/App.tsx | 17 +- .../studio/src/components/EditorShell.tsx | 12 + .../src/components/StudioLeftSidebar.tsx | 3 + .../src/components/nle/TimelinePane.tsx | 6 + .../sidebar/CompositionsTab.drag.test.tsx | 82 +++++++ .../components/sidebar/CompositionsTab.tsx | 46 +++- .../src/components/sidebar/LeftSidebar.tsx | 3 + .../src/hooks/useTimelineAddAtPlayhead.ts | 15 ++ .../src/hooks/useTimelineAssetDropOps.ts | 54 ++++- .../studio/src/hooks/useTimelineEditing.ts | 29 +-- .../studio/src/player/components/Timeline.tsx | 6 +- .../player/components/timelineCallbacks.ts | 4 + .../src/player/components/timelineDragDrop.ts | 35 ++- .../src/player/components/timelineLayout.ts | 4 +- .../components/useTimelineEditPinning.ts | 12 + .../src/utils/timelineCompositionDrop.test.ts | 68 ++++++ .../src/utils/timelineCompositionDrop.ts | 49 ++++ .../utils/timelineCompositionInsert.test.ts | 94 ++++++++ .../src/utils/timelineCompositionInsert.ts | 78 +++++++ 23 files changed, 1061 insertions(+), 31 deletions(-) create mode 100644 packages/studio-server/src/helpers/compositionInsertion.test.ts create mode 100644 packages/studio-server/src/helpers/compositionInsertion.ts create mode 100644 packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx create mode 100644 packages/studio/src/hooks/useTimelineAddAtPlayhead.ts create mode 100644 packages/studio/src/utils/timelineCompositionDrop.test.ts create mode 100644 packages/studio/src/utils/timelineCompositionDrop.ts create mode 100644 packages/studio/src/utils/timelineCompositionInsert.test.ts create mode 100644 packages/studio/src/utils/timelineCompositionInsert.ts diff --git a/packages/studio-server/src/helpers/compositionInsertion.test.ts b/packages/studio-server/src/helpers/compositionInsertion.test.ts new file mode 100644 index 0000000000..e6169ec4c8 --- /dev/null +++ b/packages/studio-server/src/helpers/compositionInsertion.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseHTML } from "linkedom"; +import { CompositionInsertionError, insertCompositionIntoSource } from "./compositionInsertion"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function project(): string { + const dir = mkdtempSync(join(tmpdir(), "hf-comp-insert-")); + dirs.push(dir); + return dir; +} + +const parent = `
`; +const child = ``; + +function writeFixture(dir: string): void { + writeFileSync(join(dir, "index.html"), parent); + writeFileSync(join(dir, "headline.html"), child); +} + +describe("insertCompositionIntoSource", () => { + it("inserts template-root compositions with stable timing and spills a collision", () => { + const dir = project(); + writeFixture(dir); + + const result = insertCompositionIntoSource({ + projectDir: dir, + targetPath: "index.html", + sourcePath: "headline.html", + parentSource: parent, + start: 2, + desiredTrack: 2, + }); + const host = parseHTML(result.html).document.getElementById(result.hostId); + + expect(result.track).toBe(3); + expect(host?.getAttribute("data-composition-src")).toBe("headline.html"); + expect(host?.getAttribute("data-playback-start")).toBe("0"); + expect(host?.getAttribute("data-duration")).toBe("4.9"); + expect(host?.getAttribute("data-hf-id")).toMatch(/^hf-/); + expect(result.html).toContain('data-duration="6.9"'); + }); + + it("gives repeated sources distinct host identities", () => { + const dir = project(); + writeFixture(dir); + const first = insertCompositionIntoSource({ + projectDir: dir, + targetPath: "index.html", + sourcePath: "headline.html", + parentSource: parent, + start: 0, + desiredTrack: 0, + }); + writeFileSync(join(dir, "index.html"), first.html); + const second = insertCompositionIntoSource({ + projectDir: dir, + targetPath: "index.html", + sourcePath: "headline.html", + parentSource: first.html, + start: 5, + desiredTrack: 0, + }); + + expect(second.hostId).not.toBe(first.hostId); + expect(second.html.match(/data-composition-src="headline.html"/g)).toHaveLength(2); + }); + + it("inserts into a template-root parent composition", () => { + const dir = project(); + const templateParent = ``; + writeFileSync(join(dir, "parent.html"), templateParent); + writeFileSync(join(dir, "headline.html"), child); + + const result = insertCompositionIntoSource({ + projectDir: dir, + targetPath: "parent.html", + sourcePath: "headline.html", + parentSource: templateParent, + start: 1, + desiredTrack: 0, + }); + + expect(result.html).toContain('data-composition-src="headline.html"'); + expect(result.html).toContain('data-duration="5.9"'); + }); + + it("rejects self-nesting, transitive cycles, missing files, and invalid durations", () => { + const dir = project(); + writeFixture(dir); + writeFileSync( + join(dir, "middle.html"), + `
`, + ); + writeFileSync( + join(dir, "cycle-source.html"), + `
`, + ); + writeFileSync( + join(dir, "invalid.html"), + `
`, + ); + const insert = (sourcePath: string) => + insertCompositionIntoSource({ + projectDir: dir, + targetPath: "index.html", + sourcePath, + parentSource: parent, + start: 0, + desiredTrack: 0, + }); + + expect(() => insert("index.html")).toThrow(/cycle/); + expect(() => insert("cycle-source.html")).toThrow(/cycle/); + expect(() => insert("missing.html")).toThrow(CompositionInsertionError); + expect(() => insert("invalid.html")).toThrow(/valid data-composition-duration/); + expect(() => insert("../outside.html")).toThrow(CompositionInsertionError); + }); +}); diff --git a/packages/studio-server/src/helpers/compositionInsertion.ts b/packages/studio-server/src/helpers/compositionInsertion.ts new file mode 100644 index 0000000000..56d99a3eed --- /dev/null +++ b/packages/studio-server/src/helpers/compositionInsertion.ts @@ -0,0 +1,216 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { dirname, relative, resolve, sep } from "node:path"; +import { parseHTML } from "linkedom"; +import { isSafePath, resolveWithinProject } from "./safePath.js"; + +export class CompositionInsertionError extends Error { + constructor( + message: string, + readonly status: 400 | 404, + ) { + super(message); + } +} + +function descendants(root: Document | Element, selector: string): Element[] { + const found = Array.from(root.querySelectorAll(selector)); + for (const template of root.querySelectorAll("template")) { + found.push(...descendants(template, selector)); + } + return [...new Set(found)]; +} + +function compositionRoot(source: string): { document: Document; root: Element } { + const document = parseHTML(source).document; + const root = descendants(document, "[data-composition-id]")[0]; + if (!root) throw new CompositionInsertionError("Composition source has no root", 400); + return { document, root }; +} + +function positiveAttribute(root: Element, ...names: string[]): number { + for (const name of names) { + const value = Number.parseFloat(root.getAttribute(name) ?? ""); + if (Number.isFinite(value) && value > 0) return value; + } + throw new CompositionInsertionError(`Composition source has no valid ${names[0]}`, 400); +} + +function canonicalProjectPath(projectDir: string, candidate: string | null): string { + if (!candidate) { + throw new CompositionInsertionError("Composition source escapes the project", 400); + } + if (!existsSync(candidate)) { + throw new CompositionInsertionError("Composition source was not found", 404); + } + const canonical = realpathSync(candidate); + if (!isSafePath(realpathSync(projectDir), canonical)) { + throw new CompositionInsertionError("Composition source escapes the project", 400); + } + return canonical; +} + +function validateSourcePath(sourcePath: string): void { + if (!sourcePath.trim() || sourcePath.includes("\0") || /^[a-z]+:/i.test(sourcePath)) { + throw new CompositionInsertionError("Invalid composition source path", 400); + } +} + +function canonicalProjectFile(projectDir: string, sourcePath: string): string { + validateSourcePath(sourcePath); + return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath)); +} + +function canonicalDependency(projectDir: string, ownerAbs: string, sourcePath: string): string { + validateSourcePath(sourcePath); + return canonicalProjectPath( + projectDir, + resolveWithinProject(projectDir, relative(projectDir, resolve(dirname(ownerAbs), sourcePath))), + ); +} + +function validateDependencyGraph(projectDir: string, targetAbs: string, sourceAbs: string): void { + const visited = new Set(); + const visiting = new Set(); + const visit = (file: string) => { + if (file === targetAbs) { + throw new CompositionInsertionError("Composition insertion would create a cycle", 400); + } + if (visiting.has(file)) { + throw new CompositionInsertionError("Composition dependency cycle detected", 400); + } + if (visited.has(file)) return; + visiting.add(file); + const source = readFileSync(file, "utf-8"); + const { document } = compositionRoot(source); + for (const host of descendants(document, "[data-composition-src]")) { + const dependency = host.getAttribute("data-composition-src"); + if (dependency) { + visit(canonicalDependency(projectDir, file, dependency)); + } + } + visiting.delete(file); + visited.add(file); + }; + visit(sourceAbs); +} + +function numberAttribute(element: Element, name: string, fallback = 0): number { + const value = Number.parseFloat(element.getAttribute(name) ?? ""); + return Number.isFinite(value) ? value : fallback; +} + +function rangesOverlap(start: number, duration: number, other: Element): boolean { + const otherStart = numberAttribute(other, "data-start"); + const otherDuration = numberAttribute(other, "data-duration"); + return start < otherStart + otherDuration && otherStart < start + duration; +} + +function resolveTrack( + root: Element, + desiredTrack: number, + start: number, + duration: number, +): number { + const clips = descendants(root, "[data-start][data-duration]").filter( + (element) => element !== root, + ); + const tracks = [...new Set(clips.map((clip) => numberAttribute(clip, "data-track-index")))].sort( + (a, b) => a - b, + ); + const isFree = (track: number) => + !clips.some( + (clip) => + numberAttribute(clip, "data-track-index") === track && rangesOverlap(start, duration, clip), + ); + if (isFree(desiredTrack)) return desiredTrack; + const row = tracks.indexOf(desiredTrack); + for (let index = row - 1; index >= 0; index--) { + const track = tracks[index]; + if (track !== undefined && isFree(track)) return track; + } + for (let index = Math.max(0, row + 1); index < tracks.length; index++) { + const track = tracks[index]; + if (track !== undefined && isFree(track)) return track; + } + return Math.max(desiredTrack, ...tracks, -1) + 1; +} + +function uniqueHostId(root: Element, base: string): string { + const ids = new Set(descendants(root, "[id]").map((element) => element.id)); + if (!ids.has(base)) return base; + let suffix = 2; + while (ids.has(`${base}_${suffix}`)) suffix += 1; + return `${base}_${suffix}`; +} + +function relativeSourcePath(targetAbs: string, sourceAbs: string): string { + return relative(dirname(targetAbs), sourceAbs).split(sep).join("/"); +} + +export function insertCompositionIntoSource(input: { + projectDir: string; + targetPath: string; + sourcePath: string; + parentSource: string; + start: number; + desiredTrack: number; +}): { html: string; hostId: string; track: number; duration: number } { + const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath); + const sourceAbs = canonicalProjectFile(input.projectDir, input.sourcePath); + validateDependencyGraph(input.projectDir, targetAbs, sourceAbs); + + const source = readFileSync(sourceAbs, "utf-8"); + const sourceComposition = compositionRoot(source).root; + const duration = positiveAttribute( + sourceComposition, + "data-composition-duration", + "data-duration", + ); + const width = positiveAttribute(sourceComposition, "data-width"); + const height = positiveAttribute(sourceComposition, "data-height"); + const { document, root } = compositionRoot(input.parentSource); + const parentDuration = positiveAttribute(root, "data-duration", "data-composition-duration"); + const base = + (sourceComposition.getAttribute("data-composition-id") ?? "composition") + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") || "composition"; + const hostId = uniqueHostId(root, base); + const track = resolveTrack( + root, + Math.max(0, Math.round(input.desiredTrack)), + input.start, + duration, + ); + const zIndex = + Math.max( + 0, + ...descendants(root, "[style]").map((element) => { + const match = /(?:^|;)\s*z-index\s*:\s*(-?\d+)/i.exec(element.getAttribute("style") ?? ""); + return match?.[1] ? Number.parseInt(match[1], 10) : 0; + }), + ) + 1; + + const host = document.createElement("div"); + host.id = hostId; + host.className = "clip"; + host.setAttribute("data-hf-id", `hf-${randomUUID()}`); + host.setAttribute("data-composition-id", hostId); + host.setAttribute("data-composition-src", relativeSourcePath(targetAbs, sourceAbs)); + host.setAttribute("data-start", String(Math.round(input.start * 100) / 100)); + host.setAttribute("data-duration", String(duration)); + host.setAttribute("data-playback-start", "0"); + host.setAttribute("data-track-index", String(track)); + host.setAttribute("data-width", String(width)); + host.setAttribute("data-height", String(height)); + host.setAttribute( + "style", + `position: absolute; left: 0px; top: 0px; width: ${width}px; height: ${height}px; z-index: ${zIndex}`, + ); + root.appendChild(host); + if (input.start + duration > parentDuration) { + const name = root.hasAttribute("data-duration") ? "data-duration" : "data-composition-duration"; + root.setAttribute(name, String(Math.round((input.start + duration) * 100) / 100)); + } + return { html: document.toString(), hostId, track, duration }; +} diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index d4f7f4d4ab..d819d1d4e3 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -85,6 +85,67 @@ function postElementPatchBatches( } describe("registerFileRoutes", () => { + it("CAS-inserts one composition host and leaves stale requests side-effect free", async () => { + const projectDir = createProjectDir(); + const before = `
`; + writeFileSync(join(projectDir, "index.html"), before); + writeFileSync( + join(projectDir, "child.html"), + ``, + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const insert = (expectedVersion: string) => + app.request("http://localhost/projects/demo/file-mutations/insert-composition/index.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourcePath: "child.html", start: 4, track: 0, expectedVersion }), + }); + + const response = await insert(fileContentVersion(before)); + const result = (await response.json()) as { after: string; hostId: string; version: string }; + + expect(response.status).toBe(200); + expect(result.after).toBe(readFileSync(join(projectDir, "index.html"), "utf-8")); + expect(result.after).toContain('data-duration="7"'); + expect(result.after).toContain(`id="${result.hostId}"`); + expect(result.version).toBe(fileContentVersion(result.after)); + + const committed = result.after; + const stale = await insert(fileContentVersion(before)); + expect(stale.status).toBe(409); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(committed); + }); + + it.each([ + ["index.html", 400], + ["missing.html", 404], + ["../outside.html", 400], + ])("rejects invalid composition source %s without writing", async (sourcePath, status) => { + const projectDir = createProjectDir(); + const before = `
`; + writeFileSync(join(projectDir, "index.html"), before); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/insert-composition/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourcePath, + start: 0, + track: 0, + expectedVersion: fileContentVersion(before), + }), + }, + ); + + expect(response.status).toBe(status); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(before); + }); + it("returns empty content for missing files when caller marks the read optional", async () => { const projectDir = createProjectDir(); const app = new Hono(); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 9df05d4ad8..482cfdec9a 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -75,6 +75,10 @@ import { type ElementRebase, } from "../helpers/sourceMutation.js"; import { parseHTML } from "linkedom"; +import { + CompositionInsertionError, + insertCompositionIntoSource, +} from "../helpers/compositionInsertion.js"; // ── Server cutover flag ───────────────────────────────────────────────────── @@ -2109,6 +2113,74 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { }); }); + api.post("/projects/:id/file-mutations/insert-composition/*", async (c) => { + const ctx = await resolveFileMutationContext(c, adapter, "insert-composition"); + if ("error" in ctx) return ctx.error; + if (!existsSync(ctx.absPath)) return c.json({ error: "not found" }, 404); + + const body = (await c.req.json().catch(() => null)) as { + sourcePath?: unknown; + start?: unknown; + track?: unknown; + expectedVersion?: unknown; + } | null; + if ( + !body || + typeof body.sourcePath !== "string" || + typeof body.start !== "number" || + !Number.isFinite(body.start) || + body.start < 0 || + typeof body.track !== "number" || + !Number.isFinite(body.track) || + typeof body.expectedVersion !== "string" + ) { + return c.json({ error: "sourcePath, finite placement, and expectedVersion required" }, 400); + } + + const before = readFileSync(ctx.absPath, "utf-8"); + const currentVersion = fileContentVersion(before); + if (body.expectedVersion !== currentVersion) { + return c.json({ error: "file conflict", currentVersion, currentContent: before }, 409); + } + + let insertion: ReturnType; + try { + insertion = insertCompositionIntoSource({ + projectDir: ctx.project.dir, + targetPath: ctx.filePath, + sourcePath: body.sourcePath, + parentSource: before, + start: body.start, + desiredTrack: body.track, + }); + } catch (error) { + if (error instanceof CompositionInsertionError) { + return c.json({ error: error.message }, error.status); + } + throw error; + } + + const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); + if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500); + writeFileSync(ctx.absPath, insertion.html, "utf-8"); + const version = fileContentVersion(insertion.html); + const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token")); + recordFileWriteReceipt(ctx.absPath, { path: ctx.filePath, version, writeToken }); + c.header("ETag", version); + return c.json({ + ok: true, + path: ctx.filePath, + hostId: insertion.hostId, + track: insertion.track, + duration: insertion.duration, + before, + after: insertion.html, + version, + writeToken, + backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + }); + }); + api.post("/projects/:id/file-mutations/remove-element/*", async (c) => { const ctx = await resolveFileMutationContext(c, adapter, "remove-element"); if ("error" in ctx) return ctx.error; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 6b2df53800..63fe5cb561 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -58,6 +58,7 @@ import { FileManagerProvider } from "./contexts/FileManagerContext"; import { DomEditProvider } from "./contexts/DomEditContext"; import { StudioSplash } from "./components/StudioSplash"; import { useServerConnection } from "./hooks/useServerConnection"; +import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead"; import { normalizeStudioCompositionPath, readStudioUrlStateFromWindow, @@ -65,7 +66,6 @@ import { } from "./utils/studioUrlState"; import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; -type CanvasRect = { left: number; top: number; width: number; height: number }; // fallow-ignore-next-line complexity export function StudioApp() { const { projectId, resolving, waitingForServer } = useServerConnection(); @@ -196,7 +196,13 @@ export function StudioApp() { }, [timelineEditing.handleTimelineGroupMove], ); - const handleAddAssetAtPlayhead = useAddAssetAtPlayhead(timelineEditing.handleTimelineAssetDrop); + const { + addAssetAtPlayhead: handleAddAssetAtPlayhead, + addCompositionAtPlayhead: handleAddCompositionAtPlayhead, + } = useTimelineAddAtPlayhead( + timelineEditing.handleTimelineAssetDrop, + timelineEditing.handleTimelineCompositionDrop, + ); const { activeBlockParams, setActiveBlockParams, @@ -369,14 +375,13 @@ export function StudioApp() { }); handleToggleRecordingRef.current = handleToggleRecording; const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined; - const canvasRectRef = useRef(null); + const canvasRectRef = useRef(null); useLayoutEffect(() => { if (gestureState !== "recording" || !previewIframe) { canvasRectRef.current = null; return; } - const r = previewIframe.getBoundingClientRect(); - canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height }; + canvasRectRef.current = previewIframe.getBoundingClientRect(); }, [gestureState, previewIframe]); const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { @@ -511,6 +516,7 @@ export function StudioApp() { lintFindingCount={lintModal?.length ?? findingsByFile.size} lintFindingsByFile={findingsByFile} onAddAssetToTimeline={handleAddAssetAtPlayhead} + onAddCompositionToTimeline={handleAddCompositionAtPlayhead} /> } right={ @@ -540,6 +546,7 @@ export function StudioApp() { handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete} handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop} handleTimelineBlockDrop={handleTimelineBlockDrop} + handleTimelineCompositionDrop={timelineEditing.handleTimelineCompositionDrop} handlePreviewBlockDrop={handlePreviewBlockDrop} handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop} handleTimelineElementMove={timelineEditing.handleTimelineElementMove} diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index a5d42e732c..a5724c12e0 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -42,6 +42,10 @@ export interface EditorShellProps extends TimelineEditCallbackDeps { blockName: string, placement: TimelineDropPlacement, ) => Promise | void; + handleTimelineCompositionDrop?: ( + sourcePath: string, + placement: TimelineDropPlacement, + ) => Promise | void; handlePreviewBlockDrop?: ( blockName: string, position: { left: number; top: number }, @@ -72,6 +76,7 @@ export function EditorShell({ handleTimelineElementDelete, handleTimelineAssetDrop, handleTimelineBlockDrop, + handleTimelineCompositionDrop, handlePreviewBlockDrop, handleTimelineFileDrop, handleTimelineElementMove, @@ -140,6 +145,7 @@ export function EditorShell({ onFileDrop={handleTimelineFileDrop} onAssetDrop={handleTimelineAssetDrop} onBlockDrop={handleTimelineBlockDrop} + onCompositionDrop={handleTimelineCompositionDrop} onDeleteElement={handleTimelineElementDelete} previewOverlay={ Promise | void; onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise | void; onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise | void; + onCompositionDrop?: ( + sourcePath: string, + placement: TimelineDropPlacement, + ) => Promise | void; onDeleteElement: (element: TimelineElement) => Promise | void; } @@ -189,6 +199,7 @@ function EditorShellBody({ onFileDrop, onAssetDrop, onBlockDrop, + onCompositionDrop, onDeleteElement, }: EditorShellBodyProps) { const { compositionStack, updateCompositionStack, containerRef } = useNLEContext(); @@ -233,6 +244,7 @@ function EditorShellBody({ onFileDrop={onFileDrop} onAssetDrop={onAssetDrop} onBlockDrop={onBlockDrop} + onCompositionDrop={onCompositionDrop} onDeleteElement={onDeleteElement} onSelectTimelineElement={onSelectTimelineElement} timelineFooter={ diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index d1ebda1387..9db71a2d23 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -19,6 +19,7 @@ export interface StudioLeftSidebarProps { lintFindingCount?: number; lintFindingsByFile?: Map; onAddAssetToTimeline?: (path: string) => void; + onAddCompositionToTimeline?: (path: string) => void; } // fallow-ignore-next-line complexity @@ -32,6 +33,7 @@ export function StudioLeftSidebar({ lintFindingCount, lintFindingsByFile, onAddAssetToTimeline, + onAddCompositionToTimeline, }: StudioLeftSidebarProps) { const { leftCollapsed, @@ -150,6 +152,7 @@ export function StudioLeftSidebar({ onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} onAddAssetToTimeline={onAddAssetToTimeline} + onAddCompositionToTimeline={onAddCompositionToTimeline} /> {/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via the absolutely-positioned inner hit area. The outer element is w-[3px] so diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 3c9587f756..52f10ae87d 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -96,6 +96,10 @@ export interface TimelinePaneProps { blockName: string, placement: Pick, ) => Promise | void; + onCompositionDrop?: ( + sourcePath: string, + placement: Pick, + ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; } @@ -109,6 +113,7 @@ export function TimelinePane({ onDeleteElement, onAssetDrop, onBlockDrop, + onCompositionDrop, onBlockedEditAttempt, onSelectTimelineElement, }: TimelinePaneProps) { @@ -273,6 +278,7 @@ export function TimelinePane({ onDeleteElement={handleDeleteElement} onAssetDrop={onAssetDrop} onBlockDrop={onBlockDrop} + onCompositionDrop={onCompositionDrop} onMoveElement={handleMoveElement} onMoveElements={handleMoveElements} onResizeElement={handleResizeElement} diff --git a/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx b/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx new file mode 100644 index 0000000000..b125dce5bf --- /dev/null +++ b/packages/studio/src/components/sidebar/CompositionsTab.drag.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop"; +import { CompositionsTab } from "./CompositionsTab"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +( + window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } } +).happyDOM.settings.disableIframePageLoading = true; + +let root: Root | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; +}); + +function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) { + const host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + act(() => { + root?.render( + , + ); + }); + const card = host.querySelector('[draggable="true"]'); + if (!card) throw new Error("composition card did not render"); + return { host, card, onSelect, onAddToTimeline }; +} + +describe("composition card drag", () => { + it("keeps ordinary click navigation", () => { + const { card, onSelect } = mount(); + act(() => card.click()); + expect(onSelect).toHaveBeenCalledWith("compositions/headline.html"); + }); + + it("emits only source identity and suppresses the click following a drag", () => { + const { card, onSelect } = mount(); + const data = new Map(); + const event = new Event("dragstart", { bubbles: true }); + Object.defineProperty(event, "dataTransfer", { + value: { + effectAllowed: "none", + setData: (type: string, value: string) => data.set(type, value), + }, + }); + act(() => { + card.dispatchEvent(event); + card.click(); + }); + + expect(JSON.parse(data.get(TIMELINE_COMPOSITION_MIME) ?? "null")).toEqual({ + sourcePath: "compositions/headline.html", + }); + expect(card.className).toContain("select-none"); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("offers a keyboard-focusable add-at-playhead action without opening the card", () => { + const { host, onSelect, onAddToTimeline } = mount(); + const add = host.querySelector( + '[aria-label="Add headline to timeline at playhead"]', + ); + if (!add) throw new Error("add action did not render"); + act(() => add.click()); + + expect(onAddToTimeline).toHaveBeenCalledWith("compositions/headline.html"); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index 5ba36a43d9..740877ea12 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers"; +import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop"; interface CompositionsTabProps { projectId: string; @@ -7,6 +8,7 @@ interface CompositionsTabProps { activeComposition: string | null; onSelect: (comp: string) => void; onRenderComposition?: (comp: string) => void; + onAddToTimeline?: (comp: string) => void; isRendering?: boolean; lintFindingsByFile?: Map; } @@ -115,6 +117,7 @@ function CompCard({ onRender, isRendering, lintInfo, + onAddToTimeline, }: { projectId: string; comp: string; @@ -123,12 +126,14 @@ function CompCard({ onRender?: () => void; isRendering?: boolean; lintInfo?: { count: number; messages: string[] }; + onAddToTimeline?: () => void; }) { const [hovered, setHovered] = useState(false); const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE); const iframeRef = useRef(null); const hoverTimer = useRef | null>(null); const syncTimer = useRef | null>(null); + const draggedRef = useRef(false); const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => { if (syncTimer.current) { @@ -179,10 +184,31 @@ function CompCard({ return (
{ + draggedRef.current = true; + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData(TIMELINE_COMPOSITION_MIME, JSON.stringify({ sourcePath: comp })); + }} + onDragEnd={() => { + window.setTimeout(() => { + draggedRef.current = false; + }, 0); + }} + onClick={() => { + if (!draggedRef.current) onSelect(); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + }} onPointerEnter={handleEnter} onPointerLeave={handleLeave} - className={`group/card w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ + className={`group/card w-full select-none text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-grab active:cursor-grabbing ${ isActive ? "bg-studio-accent/10 border-l-2 border-studio-accent" : "border-l-2 border-transparent hover:bg-neutral-800/50" @@ -232,6 +258,20 @@ function CompCard({
{comp}
+ {onAddToTimeline && ( + + )} {onRender && (