diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7fbf76be91..4f5f8f6b28 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -44,7 +44,7 @@ // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. "packages/studio/src/components/editor/KeyframeDiamond.tsx", "packages/studio/src/components/editor/SpringEaseEditor.tsx", - // NLE notice — rendered conditionally via NLELayout when timeline is first shown. + // NLE notice — rendered conditionally via NLEContext/EditorShell when timeline is first shown. "packages/studio/src/components/nle/TimelineEditorNotice.tsx", // Zoom hook extracted for downstream razor-blade PRs (#1330, #1331). "packages/studio/src/player/components/useTimelineZoom.ts", @@ -53,9 +53,6 @@ "packages/studio/src/hooks/gsapTargetCache.ts", // Preview helper consumed dynamically from the studio iframe bridge. "packages/studio/src/hooks/gsapRuntimePreview.ts", - // TEMP(studio-dnd): shipped unwired ahead of the NLE integration; - // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block. - "packages/studio/src/components/editor/CanvasContextMenu.tsx", ], "ignorePatterns": [ "docs/**", @@ -93,10 +90,52 @@ "packages/cli/src/cloud/_gen/**", ], "ignoreExports": [ - // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + // Part of useTimelineEditing's inferred public return type; consumers invoke + // handleTimelineGroupResize without importing the change type directly. { - "file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts", - "exports": ["readEffectiveZIndex"], + "file": "packages/studio/src/hooks/useTimelineGroupEditing.ts", + "exports": ["TimelineGroupResizeChange"], + }, + { + "file": "packages/studio/src/player/components/timelineGroupEditing.ts", + "exports": [ + "buildTimelineGroupResizeMembers", + "resolveTimelineGroupResizeChanges", + "applyTimelineGroupResizePreview", + ], + }, + { + "file": "packages/studio/src/player/components/timelineEditing.ts", + "exports": [ + "selectTimelineElementsInMarquee", + "TimelineGroupResizeEdge", + "TimelineGroupTimingMember", + ], + }, + { + "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", + "exports": ["furthestClipEndFromDocument", "furthestClipEndFromSource"], + }, + { + "file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx", + "exports": ["DeleteConfirm"], + }, + { + "file": "packages/studio/src/utils/timelineAssetDrop.ts", + "exports": [ + "setCompositionDurationToContent", + "extendCompositionDurationIfNeeded", + "fitTimelineAssetGeometry", + "resolveTimelineAssetCompositionSize", + ], + }, + { + "file": "packages/studio/src/components/sidebar/assetHelpers.ts", + "exports": ["truncateMiddle", "formatDuration"], + }, + { + "file": "packages/studio/src/utils/studioHelpers.ts", + "exports": ["resolveDroppedAssetDimensions"], }, // drawElementService is the bottom of the fast-capture Graphite stack // (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so @@ -320,6 +359,11 @@ // and attribute tag-patchers; only the PatchOperation type gained two // optional fields here, but the line shift makes fallow re-flag them. "packages/studio/src/utils/sourcePatcher.ts", + // useGsapSelectionHandlers.ts: pre-existing parallel structure with + // useDomEditWiring.ts (thin useCallback wrappers guarding on selection); + // only gained two optional pass-through parameters here, but the line + // shift makes fallow re-flag the pre-existing clone. + "packages/studio/src/hooks/useGsapSelectionHandlers.ts", // gsapParser.ts: recast/babel GSAP writer — intentional duplication between // recast and acorn parallel implementations (pre-existing, moved from core). "packages/parsers/src/gsapParser.ts", @@ -441,13 +485,6 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ - // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage); - // removed by studio-dnd/pr22 when the final config lands. - "packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts", - "packages/studio/src/components/editor/CanvasContextMenu.tsx", - "packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts", - "packages/studio/src/player/components/timelineCollision.test.ts", - "packages/studio/src/player/components/timelineStackingSync.test.ts", // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations / // patch*InTag pre-date this PR; only the PatchOperation type gained two // optional fields, but the line-shift fingerprint re-flags the inherited @@ -587,14 +624,12 @@ "packages/studio/src/components/editor/Transform3DCube.tsx", "packages/studio/src/components/LintModal.tsx", "packages/studio/src/components/MediaPreview.tsx", - "packages/studio/src/components/nle/NLELayout.tsx", "packages/studio/src/components/nle/NLEPreview.tsx", "packages/studio/src/components/sidebar/AudioRow.tsx", "packages/studio/src/components/sidebar/BlocksTab.tsx", "packages/studio/src/components/sidebar/CompositionsTab.tsx", "packages/studio/src/components/sidebar/LeftSidebar.tsx", "packages/studio/src/components/storyboard/StoryboardLoaded.tsx", - "packages/studio/src/components/StudioPreviewArea.tsx", "packages/studio/src/components/StudioRightPanel.tsx", "packages/studio/src/components/StudioToast.tsx", "packages/studio/src/components/ui/Tooltip.tsx", diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 378a6a9bb4..9c7700b5fc 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1564,6 +1564,36 @@ describe("initSandboxRuntimeModular", () => { expect(seekTimes[seekTimes.length - 1]).toBe(0); }); + it("restores timed element visibility after a forced timeline rebind", () => { + document.body.innerHTML = ` +
+
+
+
+
+ `; + const clipExpired = document.querySelector("#clip-expired"); + const clipFuture = document.querySelector("#clip-future"); + const clipControl = document.querySelector("#clip-control"); + window.__timelines = { root: createMockTimeline(30) }; + + initSandboxRuntimeModular(); + window.__player?.seek(16.2); + + expect(clipExpired?.style.visibility).toBe("hidden"); + expect(clipFuture?.style.visibility).toBe("hidden"); + expect(clipControl?.style.visibility).toBe("visible"); + + if (clipExpired) clipExpired.style.visibility = "visible"; + if (clipFuture) clipFuture.style.visibility = "visible"; + + window.__hfForceTimelineRebind?.(); + + expect(clipExpired?.style.visibility).toBe("hidden"); + expect(clipFuture?.style.visibility).toBe("hidden"); + expect(clipControl?.style.visibility).toBe("visible"); + }); + it("onSetMuted preserves authored muted attribute on video elements", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "root"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index acaf614e9e..67ace5d152 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1334,6 +1334,7 @@ export function initSandboxRuntimeModular(): void { (window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => { childrenBound = false; bindRootTimelineIfAvailable(); + syncTimedElementVisibility(state.currentTime); }; const emitRootStageLayoutDiagnostics = () => { @@ -1691,6 +1692,64 @@ export function initSandboxRuntimeModular(): void { const dataHiddenDisplayRestores = new WeakMap(); const dataHiddenDisplayNodes = new WeakSet(); + const syncTimedElementVisibility = (currentTime: number) => { + const visibilityNodes = Array.from(document.querySelectorAll("[data-start]")); + const rootComp = resolveRootCompositionElement(); + for (const rawNode of visibilityNodes) { + if (!(rawNode instanceof HTMLElement)) continue; + + if (rawNode.hasAttribute("data-hidden")) { + if (!dataHiddenDisplayNodes.has(rawNode)) { + dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display")); + dataHiddenDisplayNodes.add(rawNode); + } + rawNode.style.display = "none"; + if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { + colorGradingRuntime?.setSourceVisibility(rawNode, false); + } + continue; + } + + if (dataHiddenDisplayNodes.has(rawNode)) { + const previousDisplay = dataHiddenDisplayRestores.get(rawNode); + if (previousDisplay) { + rawNode.style.display = previousDisplay; + } else { + rawNode.style.removeProperty("display"); + } + dataHiddenDisplayRestores.delete(rawNode); + dataHiddenDisplayNodes.delete(rawNode); + } + + let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime); + // Descendants must not override a hidden ancestor clip. CSS visibility can + // otherwise leak child pixels through inactive scenes because a descendant + // with visibility:visible escapes an ancestor's visibility:hidden. + if (isVisibleNow) { + let ancestor = rawNode.parentElement; + while (ancestor) { + if (ancestor === rootComp) break; + if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) { + if (!isTimedElementVisibleAt(ancestor, currentTime)) { + isVisibleNow = false; + break; + } + } + ancestor = ancestor.parentElement; + } + } + rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; + if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { + colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); + } + if (isVisibleNow) { + if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display"); + } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) { + rawNode.style.display = "none"; + } + } + }; + const syncMediaForCurrentState = () => { const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { const compositionRoot = element.closest("[data-composition-id]"); @@ -1773,61 +1832,7 @@ export function initSandboxRuntimeModular(): void { }, }); } - const visibilityNodes = Array.from(document.querySelectorAll("[data-start]")); - const rootComp = resolveRootCompositionElement(); - for (const rawNode of visibilityNodes) { - if (!(rawNode instanceof HTMLElement)) continue; - - if (rawNode.hasAttribute("data-hidden")) { - if (!dataHiddenDisplayNodes.has(rawNode)) { - dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display")); - dataHiddenDisplayNodes.add(rawNode); - } - rawNode.style.display = "none"; - if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { - colorGradingRuntime?.setSourceVisibility(rawNode, false); - } - continue; - } - - if (dataHiddenDisplayNodes.has(rawNode)) { - const previousDisplay = dataHiddenDisplayRestores.get(rawNode); - if (previousDisplay) { - rawNode.style.display = previousDisplay; - } else { - rawNode.style.removeProperty("display"); - } - dataHiddenDisplayRestores.delete(rawNode); - dataHiddenDisplayNodes.delete(rawNode); - } - - let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime); - // Descendants must not override a hidden ancestor clip. CSS visibility can - // otherwise leak child pixels through inactive scenes because a descendant - // with visibility:visible escapes an ancestor's visibility:hidden. - if (isVisibleNow) { - let ancestor = rawNode.parentElement; - while (ancestor) { - if (ancestor === rootComp) break; - if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) { - if (!isTimedElementVisibleAt(ancestor, state.currentTime)) { - isVisibleNow = false; - break; - } - } - ancestor = ancestor.parentElement; - } - } - rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; - if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) { - colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow); - } - if (isVisibleNow) { - if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display"); - } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) { - rawNode.style.display = "none"; - } - } + syncTimedElementVisibility(state.currentTime); }; const postState = (force: boolean) => { diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index b26558b9c3..ef76ef618c 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -2,6 +2,7 @@ import { parseHTML } from "linkedom"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js"; export interface SourceMutationTarget { @@ -349,8 +350,11 @@ export function splitElementInHtml( el.parentElement!.appendChild(clone); } + const html = wrappedFragment ? document.body.innerHTML || "" : document.toString(); return { - html: wrappedFragment ? document.body.innerHTML || "" : document.toString(), + // The split owns its new nodes' stable ids. Leaving the clone unstamped makes + // the next preview request persist different bytes after history is recorded. + html: ensureHfIds(html), matched: true, newId, }; diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts index 6bd4d92dcb..47bc557fc0 100644 --- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -12,8 +12,11 @@ describe("splitElementInHtml — hfId clone isolation", () => { const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2"); expect(matched).toBe(true); + const { document } = parseHTML(html); const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length; expect(occurrences).toBe(1); + expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).toMatch(/^hf-/); + expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).not.toBe("hf-abc123"); }); }); diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 90699abd5a..6df7cf3672 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1,6 +1,14 @@ import { afterEach, describe, expect, it } from "vitest"; import { Hono } from "hono"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerFileRoutes } from "./files"; @@ -38,6 +46,14 @@ function createAdapter(projectDir: string): StudioApiAdapter { }; } +function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Promise { + return app.request(`http://localhost/projects/demo/file-mutations/patch-elements-batch/${file}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ patches }), + }); +} + describe("registerFileRoutes", () => { it("returns empty content for missing files when caller marks the read optional", async () => { const projectDir = createProjectDir(); @@ -116,13 +132,13 @@ describe("registerFileRoutes", () => { }), }, ); + expect(response.status).toBe(200); const payload = (await response.json()) as { changed?: boolean; path?: string; backupPath?: string; }; - expect(response.status).toBe(200); expect(payload.changed).toBe(true); expect(payload.path).toBe("index.html"); expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//); @@ -132,6 +148,96 @@ describe("registerFileRoutes", () => { expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After"); }); + it("applies an ordered element patch batch with one file write", async () => { + const projectDir = createProjectDir(); + const original = + '
Back
Front
'; + writeFileSync(join(projectDir, "index.html"), original); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postElementPatchBatch(app, "index.html", [ + { + target: { id: "back" }, + operations: [{ type: "inline-style", property: "z-index", value: "2" }], + }, + { + target: { id: "front" }, + operations: [{ type: "inline-style", property: "z-index", value: "1" }], + }, + ]); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + changed?: boolean; + matched?: boolean[]; + content?: string; + backupPath?: string; + }; + + expect(response.status).toBe(200); + expect(payload.changed).toBe(true); + expect(payload.matched).toEqual([true, true]); + expect(payload.content).toBe(readFileSync(join(projectDir, "index.html"), "utf-8")); + expect(payload.content).toContain('id="back" style="z-index: 2"'); + expect(payload.content).toContain('id="front" style="z-index: 1"'); + expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(original); + expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1); + }); + + it("returns changed false without writing for a no-op element patch batch", async () => { + const projectDir = createProjectDir(); + const original = '
Title
'; + writeFileSync(join(projectDir, "index.html"), original); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postElementPatchBatch(app, "index.html", [ + { + target: { id: "title" }, + operations: [{ type: "inline-style", property: "z-index", value: "4" }], + }, + ]); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + changed?: boolean; + matched?: boolean[]; + content?: string; + backupPath?: string; + }; + + expect(payload.changed).toBe(false); + expect(payload.matched).toEqual([true]); + expect(payload.content).toBe(original); + expect(payload.backupPath).toBeUndefined(); + expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); + }); + + it("rejects an unsafe value anywhere in an element patch batch without writing", async () => { + const projectDir = createProjectDir(); + const original = '
First
Second
'; + writeFileSync(join(projectDir, "index.html"), original); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postElementPatchBatch(app, "index.html", [ + { + target: { id: "first" }, + operations: [{ type: "inline-style", property: "z-index", value: "2" }], + }, + { + target: { id: "second", selectorIndex: Number.NaN }, + operations: [{ type: "inline-style", property: "z-index", value: "1" }], + }, + ]); + expect(response.status).toBe(400); + const payload = (await response.json()) as { error?: string; fields?: string[] }; + + expect(payload.error).toContain("unsafe values"); + expect(payload.fields).toContain("body.target.selectorIndex"); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(original); + expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); + }); + // A realistic sub-composition: markup + GSAP wrapped in a