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:
Miguel Ángel
2026-07-13 02:55:36 -04:00
committed by GitHub
co-authored by ukimsanov
parent 9940503102
commit df29fa7a5e
228 changed files with 18774 additions and 7332 deletions
@@ -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 }),
}));
+10 -2
View File
@@ -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);
});
});
+153
View File
@@ -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,
+3 -2
View File
@@ -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);
+55
View File
@@ -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);
}
+90 -1
View File
@@ -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);
});
});
+76 -13
View File
@@ -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));
}
+4
View File
@@ -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);
+37 -7
View File
@@ -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);
}
+4 -1
View File
@@ -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})`;
}