mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): revamps Studio + improves code quality (#2291)
* feat(studio): glue API coexistence layer for the NLE swap What: extends 21 glue files so the OLD timeline/canvas engine and the NEW NLE components type-check side by side: playerStore (multi-select setters, zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/ timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props gain optional callbacks/params, contexts gain *Optional hooks, and TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting both engines' change shapes. patchDocumentRootDuration's test rides along. Why: this is the keystone that dissolves the old "welded glue" problem — every symbol the NLE components need is ADDED next to what the old engine still uses, so the engine components and the swaps can land as separate reviewable PRs. How: 15 authored intermediate files (main content + additive symbols; no behavior changes — new fields optional, new callbacks unused until wired) plus 6 files whose final content is already purely additive. New exports without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed by the app-shell swap. Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines compile); bunx vitest run (full suite green incl. the 6 new patchDocumentRootDuration tests); fallow audit clean. * feat(studio): timeline interaction hooks and lanes component (unwired) What: the timeline-side wiring layer, unwired: TimelineLanes (the lane renderer driving drag/resize/marquee), timelineMarquee (+tests), useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning, useTimelineEditingDrops. Why: everything between the pure drag math and <Timeline> itself; the timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these. How: new files, tsc-clean against the coexistence layer. Unwired components carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): NLE shell assembly (unwired) What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. * feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine What: flips the timeline glue to its final form (23 files): Timeline and TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag drives preview/commit through the new drag engine, range selection goes multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9 old-engine files this orphans (group drag, marquee selection, snap targets, layer gutter, selection overlays + their suites) — each is compile- or gate-forced by this swap, verified by probe. Why: second swap step; timeline-only, canvas and App untouched. How: modified files to final content + forced deletions. playerStore/timelineEditing/timelineCallbacks stay at their coexistence form until the app swap (the old App still runs on them). Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean. * feat(studio): clip thumbnail modules What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode with SVG/AVIF format fallbacks and rounded-corner clipping — plus VideoThumbnail updates. Why: the decode layer for timeline clip thumbnails, ahead of the visual refresh that renders them. How: new modules + one modified file; purely presentational. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. * feat(studio): assets/blocks panel behaviors + preview helpers What: blocks tab install flow, right-panel and global drag-overlay polish, music beat analysis and clip-content rendering hooks, and the preview-helper utilities backing asset preview. Why: completes the studio NLE stack on top of the visual refresh. How: modified files only (kept as one PR: splitting further would produce sub-150-LOC fragments of interdependent panel glue). Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc --noEmit; fallow audit clean. * fix(studio): restore timeline playback loop * fix(studio): restore missing GSAP helpers module * refactor(studio): split timeline GSAP helpers * style(studio): keep timeline helper under size limit * fix(studio): restore timeline overlays module * fix(studio): remove stale GSAP import * fix(studio): restore canonical timeline dependencies * style(studio): format restored timeline helpers * style(studio): satisfy helper line limit * fix(studio): repair rebuilt timeline integration * feat(studio): complete rebuilt NLE cutover * fix(studio): guard project and timeline race boundaries * fix(studio): preserve graded resize and crop geometry * fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local * fix(studio): treat duration-0 tweens as static holds and settle resize position before persist Instant holds (to()/fromTo() with duration 0) were classified as animated tweens by every commit route, so resizing or rotating them converted the hold into a corrupt duration-0 keyframes tween (new value at 0%, old at 100%) that GSAP drops; panel edits appended a losing set. A shared isInstantHold() now routes them through the static replace-in-place path, and percentage math guards zero-duration windows. Separately, anchored-corner resizes painted 3-5 frames at the new size but old position while the offset persist round-tripped the server. The commit path now applies the corrected GSAP position synchronously before awaiting the offset persist, mirroring the scale route's settle. * feat(studio): gesture-transaction seam with commit observability Introduce runGestureTransaction — one owner for a gesture commit's settle -> persist -> record lifecycle. It settles the live DOM synchronously before any async persist, folds every mutation into one undo entry via a per-transaction coalesceKey, restores pre-gesture state exactly once on failure, and asserts (dev console) + reports (PostHog: commit_transaction / commit_invariant_violation / commit_transaction_failed) that a persist never changes pixels. The box-size resize path is migrated onto it; the ad hoc per-route coalesceKey/reload handling is removed. Extract the resize draft-rect math into resizeDraft.ts to keep the gesture-handler file under the size cap. Also: keep url_hash telemetry to the route slug only (drop the query string, which carried the user's selected element id/selector), and gate the [hf-resize] diagnostics behind localStorage hf-resize-debug so they ship as opt-in tracing rather than console noise. * fix(studio): transaction owns the undo label The coalesced history entry took the last sub-mutation's label, so a resize surfaced as "Move layer" (the offset persist) in undo/redo. The seam now stamps tx.label on every wrapped mutation, so the folded entry reads as the gesture. * fix(studio): atomic static size/position commits (no data loss) Static resize/position holds updated an existing set via delete+add — two undo entries, and a delete that succeeded before a failed add lost the hold on disk. Use one in-place update-properties mutation when a set exists (one undo entry, no partial-failure window). The keyframed-hold heal that can't be expressed as a property update now adds before it deletes, so any single failure leaves a recoverable duplicate, never a lost hold. Transaction-owned commits are tracked via a WeakSet so the heal path never double-wraps an already-wrapped gesture. * fix(core): restore timed-clip visibility after a forced timeline rebind __hfForceTimelineRebind force-rendered the re-registered timeline but never re-ran the per-[data-start] visibility pass, so after undo or soft reload every clip rendered regardless of its time window until a full page reload. Extract the visibility loop into syncTimedElementVisibility and call it from both syncMediaForCurrentState (unchanged) and the rebind. * fix(studio): atomic z-order/keyframe/split commits, one undo entry each Three edit-commit paths hardened onto the one-transaction invariant: - Z-order reorder (useElementLifecycleOps): N per-element writes now fold into one undo entry (coalesceMs Infinity) and, on a failed persist, restore already-written files to disk so no partial reorder survives. - Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate convert phase no longer full-reloads the preview (skipReload), killing the black-flash remount; convert + edit share one coalesce key = one undo entry. - Razor split-all (useRazorSplit): snapshot before the batch and restore on any failure, so a mid-batch error never leaves un-revertable partial splits. Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams, readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit logic across these paths. Commit options thread as one partial object rather than field-by-field. Test setup extracted into colocated helpers. * fix(studio): fold multi-step edits into one undo entry; guard text revert - Gesture recording (useGestureCommit): the per-property-group commits now share one coalesce key and only the last reloads, so a recording is one undo entry and one preview reload instead of up to four. - Delete selected keyframes (deleteSelectedKeyframes, split out of timelineEditingHelpers): N removals fold into one coalesced undo entry with a single reload. - Text-field commit (useDomEditTextCommits): commitDomTextFields now uses the same version-guarded revert as handleDomTextCommit, so a stale failed commit can no longer stomp a newer successful one. * feat(studio): batch a gesture's mutations into one atomic server write A transaction that emits N mutations previously did N sequential POSTs, each rewriting the file and soft-reloading — the root of the multi-phase persist window. Add a gsap-mutations-batch endpoint that validates every mutation up front, applies them in one in-memory rewrite chain, and writes the file once (all-or-nothing: an invalid entry rejects the whole batch, no partial write). The seam buffers a transaction's commits and, when more than one targets the same file, dispatches a single batch — one write, one history entry, one reload. The batch capability rides on the existing commit-function reference; no option fields are threaded through callers. * fix(studio): soften off-canvas indicator outline to 30% opacity The dashed off-canvas selection outline at 60% was noisy with many protruding elements on screen; drop the resting opacity to 30% (hover still restores full opacity so it stays discoverable). * fix(studio): drop off-canvas indicator outline to 10% opacity Follow-up to the 30% softening — 10% resting opacity reads much calmer with many protruding elements; hover still restores full opacity. * fix(studio): gate [hf-commit] console traces to dev only The start/settled/persisted/restore lifecycle traces logged on every gesture commit in all environments — console noise for end users. Route them through a dev-only traceCommit helper (matching the pixel-violation error's existing DEV gate). The commit_* PostHog events stay always on; they are the production observability, the console lines are a dev aid. * fix(studio): count actual reloads, not softReload requests, in commit telemetry A resize's size and offset persists both request softReload; the seam counted each request, so a batched gesture reported reload_count 2 even though the batch is one write and one reload. Compute the count from what dispatchBufferedCommits actually did — one for a batch, the request count for the sequential fallback. * fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop - Hover overlay applied the element's rotation only to the selection chrome, not the hover box; it now rotates about center like the selection, via a shared orientedGroupAwareOverlayRect router (one owner for rotation-aware overlay geometry across hover/selection/off-canvas). - Off-canvas indicator was axis-aligned; it now rotates with the element and inverse-rotates the canvas-exclusion clip into the element's local frame, so the protruding-sliver clip stays correct for rotated elements. - Crop commit re-lifted the element only in the commit's .then(), so one frame painted the cropped state (the flicker). Re-lift synchronously right after onStyleCommit (which applies the clip before its first await), so the cropped state never paints; the persisted file value is unchanged. * fix(studio): address code-review findings across the commit-hardening campaign Correctness (would ship green, bite under latency): - Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert folds into one undo entry instead of splitting past the 300ms default. - The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the field); multi-keyframe delete and convert coalesce correctly when SDK-routed. - Razor split-all's rollback is guarded so a failing restore can't swallow the error toast that tells the user the split failed. Simplification (single source of truth / no dead flexibility): - Decompose resolveResizeDraftRect (drops a fallow-ignore suppression). - Delegate the third readProjectFileContent copy to the shared helper. - Inline setPatchFromUpdateProperties (its only caller passes one mutation). - One toSdkPersistOptions translates gesture overrides to SDK options. - Bundle the reorder-rollback deps into one object (was 7-9 positional args). - Dedupe the 'last group reloads' ternary; type gesture options as CommitMutationOptions; drop a Map+array wrapper around a single write. * feat(studio): atomic z-order reorder via batch patch-element endpoint Z-order reorder issued N per-element inline-style patches (one server write each), so a mid-chain failure could leave a partial reorder on disk. Add a patch-elements-batch endpoint that validates every patch, folds them over the file in one in-memory rewrite, and writes once (all-or-nothing; unsafe input rejects with no write). The reorder now sends one batch per source file and records one undo entry. Because a failed atomic write persists nothing, the interim disk-write-back rollback (restoreReorderedFile / restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure rolls back only live DOM/store state. Closes the last disk-atomicity gap. * fix(studio): razor-split undo no longer silently no-ops The split clone was written to disk without a data-hf-id, so the split endpoint recorded that unstamped HTML as the undo entry's afterHash. The next reloadPreview() ran the preview route's ensureHfIds write-back, which minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk hash no longer matched afterHash and editHistory's content-mismatch guard silently refused the undo (no write, no network, no error). Stamp the split output via ensureHfIds in splitElementInHtml before it is written/returned, so the preview write-back is a no-op and the recorded afterHash always equals the final on-disk bytes. Fixes at the source rather than relaxing the mismatch guard. Corrects the stale comment that credited forceReloadSdkSession. * feat(studio): closed-hand grab cursor on the rotate handle The rotate handle used the default arrow cursor; show a grabbing (closed-hand) cursor on hover to signal it's grabbed and dragged to rotate. * fix(studio): dropping a dragged element over another no longer selects it A moved drag's release fired the box click, which re-selected whatever now sat under the pointer via the hover cache — so dropping an element over a higher-z one selected the drop target instead of keeping the dragged element selected. The drag-move branch now suppresses the next box click, mirroring the resize branch. * fix(studio): group drag is one undo entry, not one per element Dragging a multi-selected group committed each member's position write as its own undo entry, so reverting took N Cmd+Z presses. Force a shared coalesceKey (infinite window) across every member's commit so they fold into a single undo entry, like the other multi-step commit paths. * fix(studio): undo of a split no longer leaves a ghost clip in the timeline The file and the composition iframe revert correctly on undo, but the timeline panel kept a ghost node for the split clone. The element-merge that repopulates the timeline preserves elements the fresh scan dropped — intended for enriched sub-composition children a bare DOM re-scan misses, but it also preserved a genuinely-removed TOP-LEVEL element (the split clone after undo), leaving a phantom clip. Restrict the preserve to elements with a compositionSrc (the enriched sub-comp children); a top-level element missing from the fresh scan was truly removed. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
This commit is contained in:
co-authored by
ukimsanov
parent
9940503102
commit
df29fa7a5e
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Tiny Zustand slice that carries the "asset preview overlay" state.
|
||||
*
|
||||
* When a user clicks an asset card that has NOT yet been added to the
|
||||
* timeline the overlay fires up: a dark scrim + centered media element
|
||||
* (img / video / audio) + filename label rendered inside PreviewPane.
|
||||
*
|
||||
* State lives here so AssetsTab (sidebar) and PreviewPane (preview column)
|
||||
* can communicate without prop-drilling through the multi-layer EditorShell
|
||||
* tree. The store is project-scoped: NLEProvider (NLEContext.tsx) clears it
|
||||
* whenever `projectId` changes, so a preview opened in one project can't
|
||||
* bleed into another (the overlay itself stays mounted across project
|
||||
* switches — EditorShell isn't keyed by projectId).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
|
||||
interface AssetPreviewState {
|
||||
/** Project-relative asset path currently being previewed, or null. */
|
||||
previewAsset: string | null;
|
||||
/** projectId for which the preview was opened (used to build the serve URL). */
|
||||
previewProjectId: string | null;
|
||||
/** Open a media preview for the given asset. */
|
||||
setPreviewAsset: (asset: string, projectId: string) => void;
|
||||
/** Close the preview overlay. */
|
||||
clearPreviewAsset: () => void;
|
||||
}
|
||||
|
||||
export const useAssetPreviewStore = create<AssetPreviewState>((set) => ({
|
||||
previewAsset: null,
|
||||
previewProjectId: null,
|
||||
setPreviewAsset: (asset, projectId) => set({ previewAsset: asset, previewProjectId: projectId }),
|
||||
clearPreviewAsset: () => set({ previewAsset: null, previewProjectId: null }),
|
||||
}));
|
||||
@@ -2,9 +2,10 @@ import type { RegistryItem } from "@hyperframes/core/registry";
|
||||
import type { TimelineElement } from "../player";
|
||||
import {
|
||||
insertTimelineAssetIntoSource,
|
||||
resolveTimelineAssetInitialGeometry,
|
||||
resolveTimelineAssetCompositionSize,
|
||||
} from "./timelineAssetDrop";
|
||||
import { collectHtmlIds } from "./studioHelpers";
|
||||
import { generateId } from "./generateId";
|
||||
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
||||
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
||||
import type { EditHistoryKind } from "./editHistory";
|
||||
@@ -120,7 +121,9 @@ export async function addBlockToProject(
|
||||
);
|
||||
|
||||
const isBlock = block.type === "hyperframes:block";
|
||||
const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
|
||||
const { width: hostWidth, height: hostHeight } =
|
||||
resolveTimelineAssetCompositionSize(originalContent);
|
||||
const hostDims = { left: 0, top: 0, width: hostWidth, height: hostHeight };
|
||||
|
||||
const currentTime = opts.currentTime ?? 0;
|
||||
const start = placement
|
||||
@@ -152,6 +155,11 @@ export async function addBlockToProject(
|
||||
|
||||
const subCompHtml = [
|
||||
`<div`,
|
||||
// A stable id (+ hf-id) is what authored sub-comps carry; without it the
|
||||
// timeline can't dedup the host and renders duplicate clips that multiply
|
||||
// on every interaction. Matches the authored-comp shape.
|
||||
` id="${compId}"`,
|
||||
` data-hf-id="hf-${generateId()}"`,
|
||||
` data-composition-id="${compId}"`,
|
||||
` data-composition-src="${compositionFile}"`,
|
||||
` data-start="${formatTimelineAttributeNumber(start)}"`,
|
||||
|
||||
@@ -209,6 +209,41 @@ describe("edit history", () => {
|
||||
expect(state.undo[0].files["index.html"].after).toBe("c");
|
||||
});
|
||||
|
||||
it("merges a lane-change move with its z-reorder past the default window via entry coalesceMs", () => {
|
||||
// The z entry records only after the move persist's round-trip — often >300ms.
|
||||
// Both sides pass coalesceMs: 5000 with the shared gesture key so the pair
|
||||
// still folds into ONE undo step.
|
||||
const move = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Move timeline clips",
|
||||
kind: "timeline",
|
||||
coalesceKey: "clip-lane-move:1",
|
||||
coalesceMs: 5000,
|
||||
files: { "index.html": { before: "a", after: "b" } },
|
||||
now: 100,
|
||||
id: "move-entry",
|
||||
});
|
||||
const zReorder = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
label: "Reorder layers",
|
||||
kind: "manual",
|
||||
coalesceKey: "clip-lane-move:1",
|
||||
coalesceMs: 5000,
|
||||
files: { "index.html": { before: "b", after: "c" } },
|
||||
now: 500,
|
||||
id: "z-entry",
|
||||
});
|
||||
|
||||
const state = pushEditHistoryEntry(
|
||||
pushEditHistoryEntry(createEmptyEditHistory(), move),
|
||||
zReorder,
|
||||
);
|
||||
|
||||
expect(state.undo).toHaveLength(1);
|
||||
expect(state.undo[0].files["index.html"].before).toBe("a");
|
||||
expect(state.undo[0].files["index.html"].after).toBe("c");
|
||||
});
|
||||
|
||||
it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
|
||||
const timing = buildEditHistoryEntry({
|
||||
projectId: "project-1",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { applySoftReload, ensureMotionPathPluginLoaded } from "./gsapSoftReload";
|
||||
import {
|
||||
applySoftReload,
|
||||
ensureMotionPathPluginLoaded,
|
||||
diffSoftReloadableRestore,
|
||||
applyUndoRestoreToPreview,
|
||||
} from "./gsapSoftReload";
|
||||
|
||||
const SCRIPT_TEXT = `
|
||||
window.__timelines = window.__timelines || {};
|
||||
@@ -447,3 +452,116 @@ describe("applySoftReload authored-opacity restore", () => {
|
||||
expect(restoreOpacity(el)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
||||
|
||||
const wrap = (body: string) => `<html><body>${body}</body></html>`;
|
||||
|
||||
describe("diffSoftReloadableRestore", () => {
|
||||
it("reports the changed id for an attribute/inline-style-only diff", () => {
|
||||
const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
|
||||
const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
|
||||
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
|
||||
});
|
||||
|
||||
it("treats a structural change (added element) as NOT soft-reloadable", () => {
|
||||
const prev = wrap(`<div id="a">t</div>`);
|
||||
const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
|
||||
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
||||
});
|
||||
|
||||
it("treats an element text/child change as NOT soft-reloadable", () => {
|
||||
const prev = wrap(`<div id="a">one</div>`);
|
||||
const next = wrap(`<div id="a">two</div>`);
|
||||
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
|
||||
const prev = wrap(
|
||||
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
|
||||
);
|
||||
const next = wrap(
|
||||
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
|
||||
);
|
||||
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
|
||||
});
|
||||
});
|
||||
|
||||
function buildLiveIframe(bodyHtml: string) {
|
||||
const doc = document.implementation.createHTMLDocument("");
|
||||
doc.body.innerHTML = bodyHtml;
|
||||
const contentWindow = {
|
||||
gsap: { timeline: () => {} },
|
||||
__hfForceTimelineRebind: () => {},
|
||||
__timelines: {} as Record<string, unknown>,
|
||||
__player: { getTime: () => 3, seek: vi.fn() },
|
||||
__hfStudioManualEditsApply: vi.fn(),
|
||||
};
|
||||
return {
|
||||
iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
|
||||
contentWindow,
|
||||
doc,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyUndoRestoreToPreview", () => {
|
||||
const ROOT = "index.html";
|
||||
|
||||
it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
|
||||
const { iframe, contentWindow, doc } = buildLiveIframe(
|
||||
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
||||
);
|
||||
const reloadPreview = vi.fn();
|
||||
const files = {
|
||||
[ROOT]: {
|
||||
previous: wrap(
|
||||
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
||||
),
|
||||
restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
|
||||
},
|
||||
};
|
||||
const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
|
||||
expect(outcome).toBe("soft");
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// Live element reverted to the restored inline style.
|
||||
expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
|
||||
// No GSAP script in the restore → the manual-edit reapply runs, playhead held.
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
|
||||
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("full-reloads a multi-file restore", () => {
|
||||
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
||||
const reloadPreview = vi.fn();
|
||||
const files = {
|
||||
[ROOT]: {
|
||||
previous: wrap(`<div id="a" style="x">t</div>`),
|
||||
restored: wrap(`<div id="a">t</div>`),
|
||||
},
|
||||
"scenes/intro.html": { previous: "a", restored: "b" },
|
||||
};
|
||||
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("full-reloads a structural restore (split/delete undo)", () => {
|
||||
const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
|
||||
const reloadPreview = vi.fn();
|
||||
const files = {
|
||||
[ROOT]: {
|
||||
previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
|
||||
restored: wrap(`<div id="a">t</div>`),
|
||||
},
|
||||
};
|
||||
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
|
||||
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
||||
const reloadPreview = vi.fn();
|
||||
const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
|
||||
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +183,159 @@ export interface SoftReloadOptions {
|
||||
authoredHtml?: string;
|
||||
}
|
||||
|
||||
/** One file's restore from the edit-history store: before (live) / after (target) bytes. */
|
||||
export interface UndoRestoreFile {
|
||||
previous: string;
|
||||
restored: string;
|
||||
}
|
||||
function idElementMap(doc: Document): Map<string, Element> {
|
||||
const map = new Map<string, Element>();
|
||||
for (const el of doc.querySelectorAll("[id]")) {
|
||||
const id = el.getAttribute("id");
|
||||
if (id) map.set(id, el);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Strip id'd elements to bare `id` and blank GSAP scripts, in place: docs that
|
||||
// differ only in id'd attributes/inline-style/script text normalize equal; any
|
||||
// residual difference is beyond soft-reload's reach → caller full-reloads.
|
||||
function normalizeSoftResidual(doc: Document): void {
|
||||
for (const el of doc.querySelectorAll("[id]")) {
|
||||
const id = el.getAttribute("id");
|
||||
for (const name of [...el.getAttributeNames()]) {
|
||||
if (name !== "id") el.removeAttribute(name);
|
||||
}
|
||||
if (id) el.setAttribute("id", id);
|
||||
}
|
||||
for (const script of findGsapScriptElements(doc)) script.textContent = "";
|
||||
}
|
||||
|
||||
// Soft-reloadable iff the docs differ SOLELY in id'd-element attributes/inline
|
||||
// style and/or the GSAP script; returns the changed ids to sync onto the live
|
||||
// DOM. Structural/text diffs → null → the caller full-reloads. Pure.
|
||||
export function diffSoftReloadableRestore(
|
||||
previous: string,
|
||||
restored: string,
|
||||
): { changedElementIds: string[] } | null {
|
||||
let prevDoc: Document;
|
||||
let nextDoc: Document;
|
||||
try {
|
||||
prevDoc = new DOMParser().parseFromString(previous, "text/html");
|
||||
nextDoc = new DOMParser().parseFromString(restored, "text/html");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const prevById = idElementMap(prevDoc);
|
||||
const nextById = idElementMap(nextDoc);
|
||||
// A different id set means an element was added or removed (e.g. a split, a
|
||||
// delete) — structural, so soft-reload can't express it.
|
||||
if (prevById.size !== nextById.size) return null;
|
||||
const changedElementIds: string[] = [];
|
||||
for (const [id, nextEl] of nextById) {
|
||||
const prevEl = prevById.get(id);
|
||||
if (!prevEl || prevEl.tagName !== nextEl.tagName) return null;
|
||||
// A change inside the element (text / children) is out of soft scope; only
|
||||
// its own attributes may differ. (GSAP scripts are handled via re-run.)
|
||||
if (prevEl.innerHTML !== nextEl.innerHTML) return null;
|
||||
if (prevEl.outerHTML !== nextEl.outerHTML) changedElementIds.push(id);
|
||||
}
|
||||
// Confirm nothing OUTSIDE id'd-element attributes and GSAP scripts changed.
|
||||
normalizeSoftResidual(prevDoc);
|
||||
normalizeSoftResidual(nextDoc);
|
||||
if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
|
||||
return { changedElementIds };
|
||||
}
|
||||
|
||||
/** Copy every attribute from `source` onto the live `target`, dropping extras. */
|
||||
function syncElementAttributes(target: Element, source: Element): void {
|
||||
for (const name of [...target.getAttributeNames()]) {
|
||||
if (!source.hasAttribute(name)) target.removeAttribute(name);
|
||||
}
|
||||
for (const name of source.getAttributeNames()) {
|
||||
target.setAttribute(name, source.getAttribute(name) ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
|
||||
* remount (which blanks the frame black and re-flashes the WebGL context). Only
|
||||
* the active composition — the document living in the root iframe — is eligible;
|
||||
* a sub-comp or multi-file restore falls back to `reloadPreview`.
|
||||
*
|
||||
* The restore is soft-applied when its only differences are id'd-element
|
||||
* attributes / inline-style and/or the GSAP script (see diffSoftReloadableRestore):
|
||||
* 1. Each changed element's attribute surface (inline style, data-start /
|
||||
* -duration, the studio manual-offset props + flags) is synced onto the live
|
||||
* element — so a canvas-position revert lands on the live DOM the runtime's
|
||||
* seek-reapply reads from, not just on disk.
|
||||
* 2. The restored GSAP script is re-run in place via applySoftReload, which
|
||||
* re-seeks to `currentTime` (playhead-invariant) and re-folds manual edits.
|
||||
* With no single script, the manual-edit reapply is invoked directly.
|
||||
*
|
||||
* Returns "soft" when applied in place, "full" when it escalated to reloadPreview
|
||||
* (ineligible restore, missing target, or a permanent soft-reload failure).
|
||||
*/
|
||||
export function applyUndoRestoreToPreview(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
activeCompPath: string | null,
|
||||
files: Record<string, UndoRestoreFile> | undefined,
|
||||
currentTime: number,
|
||||
reloadPreview: () => void,
|
||||
): "soft" | "full" {
|
||||
const paths = files ? Object.keys(files) : [];
|
||||
// Soft path only covers the single active-comp document in the root iframe.
|
||||
if (!iframe || !activeCompPath || !files || paths.length !== 1 || paths[0] !== activeCompPath) {
|
||||
reloadPreview();
|
||||
return "full";
|
||||
}
|
||||
const doc = iframe.contentDocument;
|
||||
const win = iframe.contentWindow as IframeWindow | null;
|
||||
if (!doc || !win) {
|
||||
reloadPreview();
|
||||
return "full";
|
||||
}
|
||||
const { previous, restored } = files[activeCompPath]!;
|
||||
const diff = diffSoftReloadableRestore(previous, restored);
|
||||
if (!diff) {
|
||||
reloadPreview();
|
||||
return "full";
|
||||
}
|
||||
|
||||
// Sync each changed element's attributes onto the live DOM from the restored
|
||||
// markup, so the runtime's seek-reapply (which reads inline offset props off
|
||||
// the live element) folds the REVERTED values, not the stale current ones.
|
||||
const restoredById = idElementMap(new DOMParser().parseFromString(restored, "text/html"));
|
||||
for (const id of diff.changedElementIds) {
|
||||
const liveEl = doc.getElementById(id);
|
||||
const restoredEl = restoredById.get(id);
|
||||
if (liveEl && restoredEl) syncElementAttributes(liveEl, restoredEl);
|
||||
}
|
||||
|
||||
const script = extractGsapScriptText(restored);
|
||||
if (script) {
|
||||
const result = applySoftReload(iframe, script, {
|
||||
onAsyncFailure: reloadPreview,
|
||||
currentTimeOverride: currentTime,
|
||||
});
|
||||
if (result === "cannot-soft-reload") {
|
||||
reloadPreview();
|
||||
return "full";
|
||||
}
|
||||
return "soft";
|
||||
}
|
||||
// No single GSAP script to re-run — the change was pure attribute/style. Re-fold
|
||||
// manual edits and hold the playhead so the synced attributes take visible effect.
|
||||
try {
|
||||
win.__player?.seek?.(currentTime);
|
||||
win.__hfStudioManualEditsApply?.();
|
||||
} catch {
|
||||
reloadPreview();
|
||||
return "full";
|
||||
}
|
||||
return "soft";
|
||||
}
|
||||
|
||||
export function applySoftReload(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
scriptText: string,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
||||
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
|
||||
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
||||
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
||||
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
|
||||
export const LUT_EXT = /\.cube$/i;
|
||||
export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
||||
export const MEDIA_EXT =
|
||||
/\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
|
||||
|
||||
export function isMediaFile(path: string): boolean {
|
||||
return MEDIA_EXT.test(path);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Resize/gesture diagnostics — grep [hf-resize]. Off by default; opt in per
|
||||
// session with `localStorage.setItem("hf-resize-debug", "1")` (then reload).
|
||||
// Granular per-move/per-gesture tracing that complements the always-on
|
||||
// [hf-commit] transaction telemetry in gestureTransaction.ts.
|
||||
let moveN = 0;
|
||||
let enabled: boolean | null = null;
|
||||
|
||||
function isEnabled(): boolean {
|
||||
if (enabled === null) {
|
||||
try {
|
||||
enabled = localStorage.getItem("hf-resize-debug") === "1";
|
||||
} catch {
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function logResize(stage: string, data: Record<string, unknown>): void {
|
||||
if (!isEnabled()) return;
|
||||
console.log(
|
||||
`[hf-resize] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...data })}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-pointermove logging, throttled: first move then every 8th. */
|
||||
export function logResizeMove(data: Record<string, unknown>): void {
|
||||
if (!isEnabled()) return;
|
||||
moveN += 1;
|
||||
if (moveN % 8 === 1) logResize("move", { n: moveN, ...data });
|
||||
}
|
||||
|
||||
export function resetResizeMoveLog(): void {
|
||||
moveN = 0;
|
||||
}
|
||||
|
||||
/** Snapshot the element's live geometry now and again after 200ms (jump detector). */
|
||||
export function logResizeSettle(el: HTMLElement, tag: string): void {
|
||||
if (!isEnabled()) return;
|
||||
const snap = (phase: string) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
||||
logResize("settle", {
|
||||
tag,
|
||||
phase,
|
||||
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
|
||||
cssW: cs?.width,
|
||||
cssH: cs?.height,
|
||||
transform: cs?.transform,
|
||||
inlineStyle: el.getAttribute("style"),
|
||||
});
|
||||
};
|
||||
snap("t0");
|
||||
setTimeout(() => snap("t200"), 200);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extendRootDurationInSource } from "./rootDuration";
|
||||
import {
|
||||
extendRootDurationInSource,
|
||||
patchRootCompositionDuration,
|
||||
readRootCompositionDuration,
|
||||
} from "./rootDuration";
|
||||
|
||||
describe("extendRootDurationInSource", () => {
|
||||
it("extends data-duration when the new end is bigger than the root duration", () => {
|
||||
@@ -31,4 +36,88 @@ describe("extendRootDurationInSource", () => {
|
||||
expect(patched).toContain(`<div data-duration="3"></div>`);
|
||||
expect(patched).toContain(`<div data-composition-id="main" data-duration="7"></div>`);
|
||||
});
|
||||
|
||||
// Reviewer round-2 finding #3: the old regex was attribute-ORDER-dependent and
|
||||
// double-quotes-only, so these hand-authored variants silently no-op'd.
|
||||
it("extends when data-duration is declared BEFORE data-composition-id", () => {
|
||||
const source = `<div data-duration="4" data-composition-id="main"></div>`;
|
||||
expect(extendRootDurationInSource(source, 9)).toBe(
|
||||
`<div data-duration="9" data-composition-id="main"></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("extends when attributes use single quotes", () => {
|
||||
const source = `<div data-composition-id='main' data-duration='4'></div>`;
|
||||
expect(extendRootDurationInSource(source, 9)).toBe(
|
||||
`<div data-composition-id='main' data-duration='9'></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("extends with swapped order AND single quotes AND extra whitespace", () => {
|
||||
const source = `<div data-duration = '4' data-composition-id = 'main' >x</div>`;
|
||||
expect(extendRootDurationInSource(source, 9)).toBe(
|
||||
`<div data-duration = '9' data-composition-id = 'main' >x</div>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRootCompositionDuration", () => {
|
||||
it("reads the root duration regardless of attribute order or quote style", () => {
|
||||
expect(
|
||||
readRootCompositionDuration(`<div data-composition-id="main" data-duration="4"></div>`),
|
||||
).toBe(4);
|
||||
expect(
|
||||
readRootCompositionDuration(`<div data-duration="4" data-composition-id="main"></div>`),
|
||||
).toBe(4);
|
||||
expect(
|
||||
readRootCompositionDuration(`<div data-composition-id='main' data-duration='4.5'></div>`),
|
||||
).toBe(4.5);
|
||||
});
|
||||
|
||||
it("reads the FIRST composition when several are present", () => {
|
||||
const source = [
|
||||
`<div data-composition-id="root" data-duration="10"></div>`,
|
||||
`<div data-composition-id="nested" data-duration="2"></div>`,
|
||||
].join("\n");
|
||||
expect(readRootCompositionDuration(source)).toBe(10);
|
||||
});
|
||||
|
||||
it("returns null when there is no composition root", () => {
|
||||
expect(readRootCompositionDuration(`<div data-duration="4"></div>`)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the root has no data-duration attribute", () => {
|
||||
expect(readRootCompositionDuration(`<div data-composition-id="main"></div>`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchRootCompositionDuration", () => {
|
||||
it("rewrites only the root's data-duration value, preserving surrounding bytes", () => {
|
||||
const source = [
|
||||
`<!doctype html>`,
|
||||
`<div data-composition-id="main" data-duration="4" data-width="640">`,
|
||||
` <img src="a.png" data-duration="3" />`,
|
||||
`</div>`,
|
||||
].join("\n");
|
||||
const patched = patchRootCompositionDuration(source, "8");
|
||||
expect(patched).toBe(
|
||||
[
|
||||
`<!doctype html>`,
|
||||
`<div data-composition-id="main" data-duration="8" data-width="640">`,
|
||||
` <img src="a.png" data-duration="3" />`,
|
||||
`</div>`,
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps single-quote style when rewriting", () => {
|
||||
expect(
|
||||
patchRootCompositionDuration(`<div data-composition-id='main' data-duration='4'></div>`, "8"),
|
||||
).toBe(`<div data-composition-id='main' data-duration='8'></div>`);
|
||||
});
|
||||
|
||||
it("is a no-op when the root has no data-duration attribute", () => {
|
||||
const source = `<div data-composition-id="main"></div>`;
|
||||
expect(patchRootCompositionDuration(source, "8")).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,80 @@
|
||||
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
||||
|
||||
/**
|
||||
* Matches the opening tag of the ROOT composition element — the first tag that
|
||||
* carries a `data-composition-id` attribute, regardless of where the attribute
|
||||
* sits in the tag or how its value is quoted. `[^>]*` keeps the match inside a
|
||||
* single tag, so the first hit is the first composition in document order (the
|
||||
* same element `doc.querySelector("[data-composition-id]")` resolves to).
|
||||
*/
|
||||
const ROOT_COMPOSITION_OPEN_TAG_RE = /<[^>]*\bdata-composition-id(?=[\s=/>])[^>]*>/i;
|
||||
|
||||
/**
|
||||
* Matches a `data-duration="..."` attribute inside a single opening tag. Quote
|
||||
* style is captured (backreferenced), so both `"` and `'` round-trip, and the
|
||||
* `\s*` around `=` tolerates author whitespace.
|
||||
*/
|
||||
const DATA_DURATION_ATTR_RE = /(\bdata-duration\s*=\s*)(["'])[^"']*\2/i;
|
||||
|
||||
/**
|
||||
* Read the ROOT composition's raw `data-duration`.
|
||||
*
|
||||
* Parses the source with DOMParser and locates the root the same way the rest of
|
||||
* the timeline code does — the first `[data-composition-id]` element in document
|
||||
* order — then reads its `data-duration`. Because it works on the parsed tree,
|
||||
* attribute order and quote style are irrelevant, unlike the previous
|
||||
* order-dependent, double-quotes-only regex.
|
||||
*
|
||||
* Returns `null` when there is no root composition or the root has no
|
||||
* `data-duration` attribute at all. When the attribute is present but not a
|
||||
* number the parsed `NaN` is returned as-is, so callers reproduce the old
|
||||
* regex's "attribute matched but value unusable" behavior.
|
||||
*
|
||||
* Deterministic and render-safe: DOMParser is the only DOM global used.
|
||||
*/
|
||||
export function readRootCompositionDuration(source: string): number | null {
|
||||
const root = new DOMParser()
|
||||
.parseFromString(source, "text/html")
|
||||
.querySelector("[data-composition-id]");
|
||||
const raw = root?.getAttribute("data-duration");
|
||||
if (raw == null) return null;
|
||||
return Number.parseFloat(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the ROOT composition's `data-duration` value, preserving the rest of
|
||||
* the document byte-for-byte.
|
||||
*
|
||||
* We deliberately do NOT re-serialize the parsed Document: a DOMParser round-trip
|
||||
* injects `<html>/<head>/<body>`, forces double quotes, self-closes void
|
||||
* elements, and drops the original indentation — it reformats unrelated markup.
|
||||
* Instead we locate the root opening tag and rewrite only its `data-duration`
|
||||
* value in place, keeping the author's quote style. The targeted splice is
|
||||
* attribute-order-, quote-, and whitespace-agnostic.
|
||||
*
|
||||
* No-op (returns `source` unchanged) when there is no root composition tag or the
|
||||
* root tag has no `data-duration` attribute to replace.
|
||||
*/
|
||||
export function patchRootCompositionDuration(source: string, newValue: string): string {
|
||||
const rootTag = ROOT_COMPOSITION_OPEN_TAG_RE.exec(source);
|
||||
if (!rootTag) return source;
|
||||
const patchedTag = rootTag[0].replace(
|
||||
DATA_DURATION_ATTR_RE,
|
||||
(_full, prefix: string, quote: string) => `${prefix}${quote}${newValue}${quote}`,
|
||||
);
|
||||
if (patchedTag === rootTag[0]) return source;
|
||||
return (
|
||||
source.slice(0, rootTag.index) + patchedTag + source.slice(rootTag.index + rootTag[0].length)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grow-only ratchet: extend the root composition's `data-duration` to `newEnd`
|
||||
* when `newEnd` is larger than the current root duration. No-op otherwise (and
|
||||
* when there is no root duration to compare against).
|
||||
*/
|
||||
export function extendRootDurationInSource(source: string, newEnd: number): string {
|
||||
const rootDurMatch = source.match(
|
||||
/(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
|
||||
);
|
||||
if (rootDurMatch) {
|
||||
const rootDur = parseFloat(rootDurMatch[2]!);
|
||||
if (newEnd > rootDur) {
|
||||
return source.replace(
|
||||
rootDurMatch[0],
|
||||
`${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return source;
|
||||
const current = readRootCompositionDuration(source);
|
||||
if (current == null || !(newEnd > current)) return source;
|
||||
return patchRootCompositionDuration(source, formatTimelineAttributeNumber(newEnd));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface CutoverDeps {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
@@ -84,6 +85,8 @@ function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
|
||||
interface CutoverOptions {
|
||||
label?: string;
|
||||
coalesceKey?: string;
|
||||
/** Coalesce window (ms); Infinity folds across a slow round-trip. */
|
||||
coalesceMs?: number;
|
||||
/** Skip the preview reload (mirrors the server path's skipRefresh). */
|
||||
skipRefresh?: boolean;
|
||||
}
|
||||
@@ -110,6 +113,7 @@ export async function persistSdkSerialize(
|
||||
label: options?.label ?? "Edit layer",
|
||||
kind: "manual",
|
||||
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
|
||||
...(options?.coalesceMs != null ? { coalesceMs: options.coalesceMs } : {}),
|
||||
files: { [targetPath]: { before: originalContent, after } },
|
||||
});
|
||||
if (deps.refresh) deps.refresh(after);
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { EditHistoryKind } from "./editHistory";
|
||||
import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
|
||||
|
||||
export interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
export interface DomEditCommitBaseParams {
|
||||
activeCompPath: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
projectIdRef: MutableRefObject<string | null>;
|
||||
reloadPreview: () => void;
|
||||
clearDomSelection: () => void;
|
||||
}
|
||||
|
||||
interface SaveProjectFilesWithHistoryInput {
|
||||
projectId: string;
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
files: Record<string, string>;
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function readProjectFileContent(pid: string, path: string): Promise<string> {
|
||||
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
|
||||
if (!response.ok) {
|
||||
throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
|
||||
}
|
||||
const data = (await response.json()) as { content?: string };
|
||||
if (typeof data.content !== "string") {
|
||||
throw new Error(`Missing file contents for ${path}`);
|
||||
}
|
||||
return data.content;
|
||||
}
|
||||
|
||||
export async function saveProjectFilesWithHistory({
|
||||
label,
|
||||
kind,
|
||||
coalesceKey,
|
||||
coalesceMs,
|
||||
files,
|
||||
readFile,
|
||||
writeFile,
|
||||
@@ -43,7 +73,7 @@ export async function saveProjectFilesWithHistory({
|
||||
writtenPaths.push(path);
|
||||
}
|
||||
|
||||
await recordEdit({ label, kind, coalesceKey, files: snapshots });
|
||||
await recordEdit({ label, kind, coalesceKey, coalesceMs, files: snapshots });
|
||||
} catch (error) {
|
||||
try {
|
||||
for (const path of writtenPaths.reverse()) {
|
||||
|
||||
@@ -308,3 +308,65 @@ export async function resolveDroppedAssetDuration(
|
||||
media.load();
|
||||
return duration;
|
||||
}
|
||||
|
||||
export async function resolveDroppedAssetDimensions(
|
||||
projectId: string,
|
||||
assetPath: string,
|
||||
kind: TimelineAssetKind,
|
||||
): Promise<{ width: number; height: number } | null> {
|
||||
if (kind === "audio") return null;
|
||||
const src = `/api/projects/${projectId}/preview/${assetPath}`;
|
||||
|
||||
if (kind === "image") {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const timeout = window.setTimeout(() => resolve(null), 3000);
|
||||
img.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(
|
||||
img.naturalWidth > 0 && img.naturalHeight > 0
|
||||
? { width: img.naturalWidth, height: img.naturalHeight }
|
||||
: null,
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
img.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
const timeout = window.setTimeout(() => resolve(null), 3000);
|
||||
const finalize = (value: { width: number; height: number } | null) => {
|
||||
window.clearTimeout(timeout);
|
||||
video.src = "";
|
||||
video.load();
|
||||
resolve(value);
|
||||
};
|
||||
video.addEventListener(
|
||||
"loadedmetadata",
|
||||
() => {
|
||||
finalize(
|
||||
video.videoWidth > 0 && video.videoHeight > 0
|
||||
? { width: video.videoWidth, height: video.videoHeight }
|
||||
: null,
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
video.addEventListener("error", () => finalize(null), { once: true });
|
||||
video.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ function stubRect(el: Element, rect: DOMRect): void {
|
||||
el.getBoundingClientRect = () => rect;
|
||||
}
|
||||
|
||||
/** Create and attach a preview iframe, returning it with its (asserted) document. */
|
||||
function createPreviewIframe(): { iframe: HTMLIFrameElement; doc: Document } {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("Expected iframe document");
|
||||
return { iframe, doc };
|
||||
}
|
||||
|
||||
describe("coversComposition (full-bleed canvas-pick exclusion)", () => {
|
||||
const viewport = { width: 1920, height: 1080 };
|
||||
|
||||
@@ -106,10 +115,7 @@ describe("pauseStudioPreviewPlayback", () => {
|
||||
|
||||
describe("getPreviewTargetFromPointer", () => {
|
||||
it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("Expected iframe document");
|
||||
const { iframe, doc } = createPreviewIframe();
|
||||
|
||||
doc.body.innerHTML = `
|
||||
<main id="scene" data-composition-id="scene">
|
||||
@@ -141,10 +147,7 @@ describe("getPreviewTargetFromPointer", () => {
|
||||
});
|
||||
|
||||
it("honors a CSS-class pointer-events:auto opt-in under a pointer-events:none ancestor", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("Expected iframe document");
|
||||
const { iframe, doc } = createPreviewIframe();
|
||||
|
||||
doc.head.innerHTML = `<style>.clickable { pointer-events: auto; }</style>`;
|
||||
doc.body.innerHTML = `
|
||||
@@ -172,4 +175,58 @@ describe("getPreviewTargetFromPointer", () => {
|
||||
|
||||
iframe.remove();
|
||||
});
|
||||
|
||||
it("selects a full-bleed <video> instead of skipping to the element behind it", () => {
|
||||
const { iframe, doc } = createPreviewIframe();
|
||||
|
||||
doc.body.innerHTML = `
|
||||
<main id="scene" data-composition-id="scene">
|
||||
<div id="backdrop"></div>
|
||||
<video id="hero"></video>
|
||||
</main>
|
||||
`;
|
||||
|
||||
const scene = doc.getElementById("scene");
|
||||
const backdrop = doc.getElementById("backdrop");
|
||||
const hero = doc.getElementById("hero");
|
||||
if (!scene || !backdrop || !hero) throw new Error("Expected preview fixture elements");
|
||||
|
||||
stubRect(iframe, domRect(0, 0, 400, 300));
|
||||
stubRect(scene, domRect(0, 0, 400, 300));
|
||||
stubRect(backdrop, domRect(0, 0, 400, 300));
|
||||
// Full-bleed hero video painted on top of a full-bleed backdrop.
|
||||
stubRect(hero, domRect(0, 0, 400, 300));
|
||||
doc.elementsFromPoint = () => [hero, backdrop, scene];
|
||||
|
||||
// Before the fix the video was full-bleed-excluded and the picker fell through
|
||||
// to the backdrop (or null). It must now return the video itself.
|
||||
expect(getPreviewTargetFromPointer(iframe, 200, 150, "index.html")).toBe(hero);
|
||||
|
||||
iframe.remove();
|
||||
});
|
||||
|
||||
it("still excludes a full-bleed non-media container so clicks reach inner content", () => {
|
||||
const { iframe, doc } = createPreviewIframe();
|
||||
|
||||
doc.body.innerHTML = `
|
||||
<main id="scene" data-composition-id="scene">
|
||||
<div id="wrapper"><h1 id="headline">Title</h1></div>
|
||||
</main>
|
||||
`;
|
||||
|
||||
const scene = doc.getElementById("scene");
|
||||
const wrapper = doc.getElementById("wrapper");
|
||||
const headline = doc.getElementById("headline");
|
||||
if (!scene || !wrapper || !headline) throw new Error("Expected preview fixture elements");
|
||||
|
||||
stubRect(iframe, domRect(0, 0, 400, 300));
|
||||
stubRect(scene, domRect(0, 0, 400, 300));
|
||||
stubRect(wrapper, domRect(0, 0, 400, 300));
|
||||
stubRect(headline, domRect(40, 40, 160, 48));
|
||||
doc.elementsFromPoint = () => [headline, wrapper, scene];
|
||||
|
||||
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
|
||||
|
||||
iframe.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,15 @@ interface PreviewLocalPointer {
|
||||
// should remain canvas-selectable.
|
||||
const FULL_BLEED_RATIO = 0.95;
|
||||
|
||||
// Media leaves (a hero/background video, a full-bleed image, an <svg>/<canvas>
|
||||
// backdrop) ARE the content a user clicks — they must stay canvas-selectable even
|
||||
// at full-bleed. Only empty containers (scene wrappers, layout backdrops) get
|
||||
// excluded. Without this, a full-bleed <video> is skipped and the click lands on
|
||||
// whatever sits behind it — the reported "can't select videos / selects the layer
|
||||
// behind" bug (and the "needs a second click" symptom, where the first click
|
||||
// resolves through the video to nothing and only the hover fallback recovers).
|
||||
const FULL_BLEED_SELECTABLE_MEDIA_TAGS = new Set(["video", "img", "canvas", "svg"]);
|
||||
|
||||
export function coversComposition(
|
||||
elRect: { width: number; height: number },
|
||||
viewport: DomEditViewport,
|
||||
@@ -33,6 +42,7 @@ export function coversComposition(
|
||||
}
|
||||
|
||||
function isFullBleedTarget(el: HTMLElement, viewport: DomEditViewport): boolean {
|
||||
if (FULL_BLEED_SELECTABLE_MEDIA_TAGS.has(el.tagName.toLowerCase())) return false;
|
||||
return coversComposition(el.getBoundingClientRect(), viewport);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@ function getSessionProperties(): EventProperties {
|
||||
viewport_width: window.innerWidth,
|
||||
viewport_height: window.innerHeight,
|
||||
user_agent: navigator.userAgent,
|
||||
url_hash: location.hash.replace(/#project\//, ""),
|
||||
// Route slug only — drop the query string, which carries the current
|
||||
// selection (selId / selSelector are the user's own element ids/CSS
|
||||
// selectors) and other view state we must not send to analytics.
|
||||
url_hash: location.hash.replace(/#project\//, "").split("?")[0],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ function renderStudioUrlStateHarness(
|
||||
previewIframeRef: { current: null },
|
||||
rightPanelTab: "renders",
|
||||
rightCollapsed: true,
|
||||
timelineVisible: true,
|
||||
activeCompPathHydrated: true,
|
||||
domEditSelection: null,
|
||||
buildDomSelectionFromTarget: () => Promise.resolve(null),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
||||
import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
|
||||
import { roundToCenti } from "./rounding";
|
||||
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
||||
|
||||
@@ -98,6 +99,8 @@ export function resolveTimelineAssetInitialGeometry(source: string): {
|
||||
|
||||
export function buildTimelineAssetInsertHtml(input: {
|
||||
id: string;
|
||||
/** Stable hf-id stamped as data-hf-id by the NLE drop path (optional in the legacy path). */
|
||||
hfId?: string;
|
||||
assetPath: string;
|
||||
kind: TimelineAssetKind;
|
||||
start: number;
|
||||
@@ -136,3 +139,62 @@ export function insertTimelineAssetIntoSource(source: string, assetHtml: string)
|
||||
.join("\n");
|
||||
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
|
||||
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null) return source;
|
||||
const next = roundToCenti(contentEnd);
|
||||
if (rootDur === next) return source;
|
||||
return patchRootCompositionDuration(source, String(next));
|
||||
}
|
||||
|
||||
export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
|
||||
return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
|
||||
* timeline length tracks content — the content-driven counterpart to
|
||||
* extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function fitTimelineAssetGeometry(
|
||||
natural: { width: number; height: number } | null,
|
||||
comp: { width: number; height: number },
|
||||
): { left: number; top: number; width: number; height: number } {
|
||||
if (!natural || natural.width <= 0 || natural.height <= 0) {
|
||||
return { left: 0, top: 0, width: comp.width, height: comp.height };
|
||||
}
|
||||
const scale = Math.min(1, comp.width / natural.width, comp.height / natural.height);
|
||||
const width = Math.round(natural.width * scale);
|
||||
const height = Math.round(natural.height * scale);
|
||||
return {
|
||||
left: Math.round((comp.width - width) / 2),
|
||||
top: Math.round((comp.height - height) / 2),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTimelineAssetCompositionSize(source: string): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
|
||||
return {
|
||||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
|
||||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
||||
getTimelineToggleTitle,
|
||||
shouldHandleTimelineToggleHotkey,
|
||||
} from "./timelineDiscovery";
|
||||
|
||||
describe("shouldHandleTimelineToggleHotkey", () => {
|
||||
it("accepts Shift+T when focus is not inside an editor", () => {
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "T",
|
||||
shiftKey: true,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: {
|
||||
tagName: "DIV",
|
||||
isContentEditable: false,
|
||||
closest: () => null,
|
||||
},
|
||||
} as KeyboardEvent),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores the shortcut inside text inputs", () => {
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "t",
|
||||
shiftKey: true,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: {
|
||||
tagName: "TEXTAREA",
|
||||
isContentEditable: false,
|
||||
closest: () => null,
|
||||
},
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the shortcut inside contenteditable editors", () => {
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "t",
|
||||
shiftKey: true,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: {
|
||||
tagName: "DIV",
|
||||
isContentEditable: true,
|
||||
closest: () => null,
|
||||
},
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires Shift without other modifiers", () => {
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "t",
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: null,
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldHandleTimelineToggleHotkey({
|
||||
key: "t",
|
||||
shiftKey: true,
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: null,
|
||||
} as KeyboardEvent),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineToggleTitle", () => {
|
||||
it("includes the shortcut in both show and hide titles", () => {
|
||||
expect(getTimelineToggleTitle(true)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
|
||||
expect(getTimelineToggleTitle(false)).toContain(TIMELINE_TOGGLE_SHORTCUT_LABEL);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,3 @@
|
||||
export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T";
|
||||
type TimelineToggleHotkeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target"
|
||||
>;
|
||||
|
||||
interface EditableTargetLike {
|
||||
tagName?: string;
|
||||
isContentEditable?: boolean;
|
||||
@@ -28,14 +22,3 @@ export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEvent): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
if (!event.shiftKey) return false;
|
||||
if (event.key.toLowerCase() !== "t") return false;
|
||||
return !isEditableTarget(event.target);
|
||||
}
|
||||
|
||||
export function getTimelineToggleTitle(timelineVisible: boolean): string {
|
||||
return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user