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..73d135c33e 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"` | Source playback multiplier, clamped to `0.1`–`5`. Source time advances by timeline elapsed time multiplied by the canonical rate; invalid values default to `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..5e876eb07c 100644 --- a/docs/reference/html-schema.mdx +++ b/docs/reference/html-schema.mdx @@ -52,9 +52,11 @@ Common sizes: | `id` | All | Yes | Unique identifier (e.g., `"el-1"`). Used for relative timing references and CSS targeting. | | `class="clip"` | Visible elements | Yes | Enables runtime visibility management. Omit for audio-only clips. | | `data-start` | All | Yes | Start time in seconds (e.g., `"0"`, `"5.5"`), or a clip ID reference for [relative timing](#relative-timing) (e.g., `"intro"`). | -| `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-duration` | video, img, audio, nested composition hosts | See below | Timeline slot duration in seconds. **Required** for images and nested composition hosts. Optional for video/audio (defaults to source duration). | | `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 | Source playback multiplier clamped to `0.1`–`5`. Invalid values default to `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..21af57b1e0 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -1,9 +1,23 @@ import { swallow } from "./diagnostics"; import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; +import { normalizePlaybackRate } from "./playbackRate.js"; -export function readElementPlaybackRate(el: HTMLMediaElement): number { - const raw = el.defaultPlaybackRate; - return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1; +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 normalizePlaybackRate(raw); +} + +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; } /** diff --git a/packages/core/src/runtime/playbackRate.ts b/packages/core/src/runtime/playbackRate.ts new file mode 100644 index 0000000000..3451012a3c --- /dev/null +++ b/packages/core/src/runtime/playbackRate.ts @@ -0,0 +1,3 @@ +export function normalizePlaybackRate(raw: number): number { + return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1; +} 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-server/src/helpers/compositionInsertion.test.ts b/packages/studio-server/src/helpers/compositionInsertion.test.ts new file mode 100644 index 0000000000..f1fd1a9d4b --- /dev/null +++ b/packages/studio-server/src/helpers/compositionInsertion.test.ts @@ -0,0 +1,177 @@ +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("reserves existing composition identities even when host ids are missing", () => { + const dir = project(); + const existingParent = ` +
+
+
+ `; + writeFileSync(join(dir, "index.html"), existingParent); + writeFileSync(join(dir, "headline.html"), child); + + const result = insertCompositionIntoSource({ + projectDir: dir, + targetPath: "index.html", + sourcePath: "headline.html", + parentSource: existingParent, + start: 2, + desiredTrack: 0, + }); + const document = parseHTML(result.html).document; + const ids = Array.from(document.querySelectorAll("[data-composition-id]")).map((element) => + element.getAttribute("data-composition-id"), + ); + + expect(result.hostId).toBe("headline_2"); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("ignores nested composition internals when resolving a parent track collision", () => { + const dir = project(); + const inlineParent = ` +
+
+
+
+
+ `; + writeFileSync(join(dir, "index.html"), inlineParent); + writeFileSync(join(dir, "headline.html"), child); + + const result = insertCompositionIntoSource({ + projectDir: dir, + targetPath: "index.html", + sourcePath: "headline.html", + parentSource: inlineParent, + start: 2, + desiredTrack: 2, + }); + + expect(result.track).toBe(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..7aa4aca7bd --- /dev/null +++ b/packages/studio-server/src/helpers/compositionInsertion.ts @@ -0,0 +1,223 @@ +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 && element.parentElement?.closest("[data-composition-id]") === 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), + ...descendants(root, "[data-composition-id]").flatMap((element) => { + const id = element.getAttribute("data-composition-id"); + return id ? [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/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 0c08e78e3b..1d406ddd34 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -258,7 +258,13 @@ export function splitElementInHtml( target: SourceMutationTarget, splitTime: number, newId: string, - fallbackTiming?: { start: number; duration: number }, + fallbackTiming?: { + start: number; + duration: number; + playbackStart?: number; + playbackRate?: number; + stampPlaybackStart?: boolean; + }, ): SplitElementResult { const { document, wrappedFragment } = parseSourceDocument(source); const el = findTargetElement(document, target); @@ -292,6 +298,19 @@ export function splitElementInHtml( const clone = el.cloneNode(true); if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null }; clone.setAttribute("id", newId); + const compositionId = clone.getAttribute("data-composition-id"); + if (compositionId) { + const usedCompositionIds = new Set( + Array.from(document.querySelectorAll("[data-composition-id]"), (node) => + node.getAttribute("data-composition-id"), + ), + ); + const base = `${compositionId}-split`; + let nextCompositionId = base; + let suffix = 2; + while (usedCompositionIds.has(nextCompositionId)) nextCompositionId = `${base}-${suffix++}`; + clone.setAttribute("data-composition-id", nextCompositionId); + } clone.removeAttribute("data-hf-id"); // Descendants carry their own data-hf-id; leaving them duplicates the id of // every nested node (e.g. an inner ), so strip them on the clone too. @@ -306,11 +325,16 @@ export function splitElementInHtml( ? "data-playback-start" : el.hasAttribute("data-media-start") ? "data-media-start" - : null; + : fallbackTiming?.stampPlaybackStart + ? "data-playback-start" + : null; if (playbackStartAttr) { - const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0; + const currentTrim = + parseFloat(el.getAttribute(playbackStartAttr) ?? "") || fallbackTiming?.playbackStart || 0; const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? ""); - const rate = Number.isFinite(rateRaw) ? rateRaw : 1; + const rate = + Number.isFinite(rateRaw) && rateRaw > 0 ? rateRaw : (fallbackTiming?.playbackRate ?? 1); + el.setAttribute(playbackStartAttr, String(Math.round(currentTrim * 1000) / 1000)); clone.setAttribute( playbackStartAttr, String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000), diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts index 85a1632da8..a405cdb633 100644 --- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -70,6 +70,25 @@ describe("splitElementInHtml", () => { expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/); }); + it("gives a split composition host a unique composition id", () => { + const composition = source.replace( + 'id="box" class="clip"', + 'id="box" class="clip" data-composition-id="headline" data-composition-src="headline.html"', + ); + + const first = splitElementInHtml(composition, { id: "box" }, 3, "box-split"); + const second = splitElementInHtml(first.html, { id: "box-split" }, 4, "box-split-2"); + + const { document } = parseHTML(second.html); + const compositionIds = Array.from( + document.querySelectorAll("[data-composition-id]"), + (element) => element.getAttribute("data-composition-id"), + ); + expect(compositionIds).toHaveLength(4); + expect(new Set(compositionIds)).toHaveLength(4); + expect(compositionIds).toEqual(expect.arrayContaining(["root", "headline", "headline-split"])); + }); + it("returns matched false for out-of-range split time", () => { expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false); expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false); @@ -103,6 +122,20 @@ describe("splitElementInHtml", () => { const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split"); expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/); }); + + it("stamps a legacy composition offset and advances the second half by playback rate", () => { + const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", { + start: 1, + duration: 6, + playbackStart: 1.5, + playbackRate: 2, + stampPlaybackStart: true, + }); + + const { document } = parseHTML(result.html); + expect(document.getElementById("box")?.getAttribute("data-playback-start")).toBe("1.5"); + expect(document.getElementById("box-split")?.getAttribute("data-playback-start")).toBe("5.5"); + }); }); describe("wrapElementsInHtml / unwrapElementsFromHtml", () => { diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index d4f7f4d4ab..3122869e80 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -84,7 +84,115 @@ function postElementPatchBatches( }); } +function postCutBatch( + app: Hono, + files: Array<{ + path: string; + expectedVersion: string; + targets: Array<{ + target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number }; + originalId?: string; + splitTime: number; + elementStart: number; + elementDuration: number; + }>; + }>, +): Promise { + return app.request("http://localhost/projects/demo/file-mutations/split-batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ files, transactionToken: "cut-test" }), + }); +} + 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 404 when the composition insertion target does not exist", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "child.html"), + ``, + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/insert-composition/missing.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourcePath: "child.html", + start: 0, + track: 0, + expectedVersion: "missing", + }), + }, + ); + + expect(response.status).toBe(404); + }); + it("returns empty content for missing files when caller marks the read optional", async () => { const projectDir = createProjectDir(); const app = new Hono(); @@ -554,6 +662,159 @@ describe("registerFileRoutes", () => { expect(response.headers.get("etag")).toBe(payload.version); }); + it("folds multiple same-file cuts and writes one canonical file result", async () => { + const projectDir = createProjectDir(); + const before = + '
A
B
'; + writeFileSync(join(projectDir, "index.html"), before); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postCutBatch(app, [ + { + path: "index.html", + expectedVersion: fileContentVersion(before), + targets: [ + { + target: { id: "a" }, + originalId: "a", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + { + target: { id: "b" }, + originalId: "b", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + ], + }, + ]); + const payload = (await response.json()) as { + files: Array<{ after: string; version: string; splitCount: number }>; + }; + + expect(response.status).toBe(200); + expect(payload.files).toHaveLength(1); + expect(payload.files[0].splitCount).toBe(2); + expect(payload.files[0].after).toContain('id="a-split"'); + expect(payload.files[0].after).toContain('id="b-split"'); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(payload.files[0].after); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({ + path: "index.html", + version: payload.files[0].version, + writeToken: "cut-test", + }); + }); + + it("cuts multiple id-less selector targets against their original indices", async () => { + const projectDir = createProjectDir(); + const before = + '
A
Other
B
'; + writeFileSync(join(projectDir, "index.html"), before); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postCutBatch(app, [ + { + path: "index.html", + expectedVersion: fileContentVersion(before), + targets: [ + { + target: { selector: ".clip", selectorIndex: 0 }, + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + { + target: { selector: ".other", selectorIndex: 0 }, + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + { + target: { selector: ".clip", selectorIndex: 1 }, + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + ], + }, + ]); + const payload = (await response.json()) as { + files?: Array<{ after: string; splitCount: number }>; + }; + + expect(response.status).toBe(200); + expect(payload.files?.[0]?.splitCount).toBe(3); + expect(payload.files?.[0]?.after.match(/class="clip"/g) ?? []).toHaveLength(4); + expect(payload.files?.[0]?.after.match(/class="other"/g) ?? []).toHaveLength(2); + expect(payload.files?.[0]?.after).toContain(">A"); + expect(payload.files?.[0]?.after).toContain(">B"); + }); + + it("rejects a stale multi-file cut before writing either file", async () => { + const projectDir = createProjectDir(); + const beforeA = '
A
'; + const beforeB = '
B
'; + writeFileSync(join(projectDir, "index.html"), beforeA); + writeFileSync(join(projectDir, "b.html"), beforeB); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const target = (id: string) => ({ + target: { id }, + originalId: id, + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }); + + const response = await postCutBatch(app, [ + { + path: "index.html", + expectedVersion: fileContentVersion(beforeA), + targets: [target("a")], + }, + { path: "b.html", expectedVersion: '"stale"', targets: [target("b")] }, + ]); + + expect(response.status).toBe(409); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(beforeA); + expect(readFileSync(join(projectDir, "b.html"), "utf-8")).toBe(beforeB); + }); + + it("serializes rapid cuts so a stale successor cannot fragment the first result", async () => { + const projectDir = createProjectDir(); + const before = '
Clip
'; + writeFileSync(join(projectDir, "index.html"), before); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const request = () => + postCutBatch(app, [ + { + path: "index.html", + expectedVersion: fileContentVersion(before), + targets: [ + { + target: { id: "clip" }, + originalId: "clip", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }, + ], + }, + ]); + + const [first, second] = await Promise.all([request(), request()]); + + expect([first.status, second.status].sort()).toEqual([200, 409]); + const after = readFileSync(join(projectDir, "index.html"), "utf-8"); + expect(after.match(/id="clip-split"/g) ?? []).toHaveLength(1); + }); + // A realistic sub-composition: markup + GSAP wrapped in a