mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(studio): instant, flicker-free manual editing (#1605)
* chore(producer): shim __filename/__dirname in the CJS banner Bundled CJS deps like wawoff2 call __dirname; without the shim they throw "__dirname is not defined in ES module" at render time. Also ignore .zed/. * chore(producer): use a template literal for the CJS banner (review nit) * feat(core): add GSAP keyframe + motion-path source mutations Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification. * fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up). * feat(core): route motion-path mutations through studio-api + fix clip stamping Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand. Hide in-flow timed clips with `display:none` only when they are LEAF clips (no nested timed clips). `display:none` on a container removes its whole subtree, hiding descendants that are still inside their own visibility window — e.g. an in-flow composition root whose effective window clamps to the timeline end would black out a child video that should still show (the hdr-hlg regression). Containers keep `visibility:hidden`, which a visible descendant can override; only leaves leave the flow, which is all the split-overlap case needs. * feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule A position or rotation add/set mutation makes the GSAP timeline the single source of truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both channels, and the add-strip fires for the position AND rotation property groups. Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio cannot save drag/resize edits to elements in a registered timeline — the exact premise the single-source work inverts (the timeline is now the edit target). Removed the rule, its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests. * fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel - HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta, shift-positions, scale-positions, split-animations. (add stays out: flat tweens only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.) - init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change; visible/hidden branches both go through isTimedClipInFlow (was .get() by accident). - keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set strips the stale --hf-studio-rotation channel. * feat(studio): GSAP runtime read layer + shared helpers * fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages. * feat(studio): GSAP drag/commit/bridge editing infra * fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element. * feat(studio): motion-path geometry + commit helpers * docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics * feat(studio): on-canvas motion-path overlay * fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold * feat(studio): keyframes flag, gesture recording + timeline/selection refinements * fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. * feat(studio): single-source manual offset + rotation via the GSAP timeline Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. * fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). * feat(studio): patchRuntimeTweenInPlace — update a tween's values in place Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. * fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. * feat(studio): instantPatch fast path in runCommit A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent. * feat(studio): route static position/rotation set drags through instantPatch Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred). * fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure - commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.) * feat(studio): no full iframe remount for soft-reloadable edits A softReload edit (and the SDK single-script refresh) no longer escalates to a full reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set already shows the value, and a remount is the worst flash + re-inlines subcomps (reverting their keyframes). verifyTimelinesPopulated now checks the expected target keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty. Full reload stays only for the structural (no-softReload) and ambiguous-script paths. * feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the timeline. So when a user adds a motion path to a composition that didn't originally use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN async path (the flash). Idempotent + defensive; the existing async fallback stays for genuine cold-start/CDN-failure. * fix(studio): don't re-save + reload when source editor syncs externally The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged — including the programmatic dispatch that syncs external content (e.g. a manual-edit commit writing the source back into the open editor). That made the editor re-save the file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe edit — defeating the in-place instant patch and causing the flash. Annotate the programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save. * fix(core): inject MotionPathPlugin into preview when a composition uses motionPath A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it. * fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact) * fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' | 'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script, or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome (origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted. - SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused). - Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append). * fix(core): align __clipTree and __clipManifest ids via stableClipId Timeline inline expansion was dead for nested children inside index.html: the tree keyed id-less elements by a synthetic __clip-N while the manifest keyed them null, so parent<->child never joined. Both now resolve identity through stableClipId (id || data-hf-id), which every generated element has. * fix(core): strip baked runtime + tag comp root in preview assembly Comps that ship a baked inline runtime were double-loaded (preview injects its own) and the baked copy failed to parse inline (Unexpected token '<'). Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also tag the comp root with data-composition-file so the studio resolves a comp's top-level elements to the right source file instead of defaulting to index.html (which made the GSAP panel parse the wrong, multi-timeline file). * feat(studio): set motion-path destination from a toolbar toggle Replaces the double-click-on-canvas UX (which painted text over the preview) with a 'Set motion destination' toggle next to Snap/Grid, shown only when the selected element can take a path. While armed, one canvas press places the destination. Also removes the dead TimelinePropertyRows component. * fix(studio): center timeline keyframe diamonds on their percentage Dropped clampDiamondLeft, which forced boundary keyframes fully inside the clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's midpoint now sits exactly on its % (the clip is overflow-visible). * fix(studio): resize static elements via tl.set, not a single-stop keyframes tween Resizing an element with no size animation wrote keyframes:{ <playhead%>: {width,height} } — one mid-point stop GSAP can't interpolate, so it rendered NaN/0 dimensions at every other frame and the element vanished (worst off 0%). Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize now writes tl.set({width,height}), held at all frames; re-resizing updates it in place. * fix(studio): negative-cache failed media probes Only successful probes were cached, so CORS/404 cross-origin media was re-probed every rAF-driven timeline re-derive, flooding the console. Remember failed URLs and skip them. * fix(studio): type window.setTimeout handle as number ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is present and clashes with the DOM number the call returns. Type it number. * fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps - Fix soft-reload clearProps destroying element inline styles — save cssText, clear, restore, strip only transform - Fix resize no-op on re-resize: delete+add instead of two update-property - Route set tweens through static resize path (convertToKeyframes skips sets) - Re-fetch animation ID before drag commit to prevent stale-ID duplicates - Guard editDebugLog for Node test environments - Fix NLELayout setState-during-render (move reset to useEffect) - Stop SnapToolbar pointer events propagating to canvas deselect handler - Enable click-to-add waypoints on cubic motion paths - Add whole-path drag offset (Alt+drag shifts all keyframes together) - Add Canvas shortcuts section to ShortcutsPanel - Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance) - Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
This commit is contained in:
@@ -50,6 +50,14 @@ const SHORTCUT_SECTIONS = [
|
||||
{ key: "⌘ Scroll", label: "Record scale" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Canvas",
|
||||
hints: [
|
||||
{ key: "Drag", label: "Move element / add keyframe" },
|
||||
{ key: "⌥ Drag", label: "Move entire animation path" },
|
||||
{ key: "⇧ Drag", label: "Uniform resize" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Panels",
|
||||
hints: [
|
||||
|
||||
@@ -42,13 +42,10 @@ interface TimelineClipDiamondsProps {
|
||||
const DIAMOND_RATIO = 0.8;
|
||||
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
||||
// slightly outside [0, 100] (from rounding or stale cache during the async
|
||||
// persist → reload cycle) are clamped to the clip edge rather than hidden.
|
||||
export const KF_MIN_PCT = -5;
|
||||
export const KF_MAX_PCT = 105;
|
||||
|
||||
function clampDiamondLeft(rawLeft: number, diamondSize: number, clipWidth: number): number {
|
||||
return Math.max(0, Math.min(clipWidth - diamondSize, rawLeft));
|
||||
}
|
||||
// persist → reload cycle) are still rendered (the clip is overflow-visible) at
|
||||
// their true position rather than hidden.
|
||||
const KF_MIN_PCT = -5;
|
||||
const KF_MAX_PCT = 105;
|
||||
|
||||
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
keyframesData,
|
||||
@@ -225,11 +222,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
})}
|
||||
|
||||
{sorted.map((kf, i) => {
|
||||
const leftPx = clampDiamondLeft(
|
||||
(effPct(kf.percentage) / 100) * clipWidthPx - half,
|
||||
diamondSize,
|
||||
clipWidthPx,
|
||||
);
|
||||
// Center the diamond ON its keyframe %: left = (% · width) − half so the
|
||||
// diamond's midpoint sits exactly at the percentage. At 0% the midpoint
|
||||
// is the clip's left edge (the diamond's left half overflows, which the
|
||||
// overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
|
||||
// boundary keyframes (0% / 100%) would render off-center.
|
||||
const leftPx = (effPct(kf.percentage) / 100) * clipWidthPx - half;
|
||||
const kfKey = `${elementId}:${kf.percentage}`;
|
||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { memo } from "react";
|
||||
import type { KeyframeCacheEntry } from "../store/playerStore";
|
||||
import { KF_MIN_PCT, KF_MAX_PCT } from "./TimelineClipDiamonds";
|
||||
|
||||
const SUB_TRACK_H = 24;
|
||||
const DIAMOND_SIZE = 6;
|
||||
const HALF = DIAMOND_SIZE / 2;
|
||||
|
||||
interface TimelinePropertyRowsProps {
|
||||
keyframesData: KeyframeCacheEntry;
|
||||
clipWidthPx: number;
|
||||
clipLeftPx: number;
|
||||
accentColor: string;
|
||||
isSelected: boolean;
|
||||
currentPercentage: number;
|
||||
elementId: string;
|
||||
selectedKeyframes: Set<string>;
|
||||
onClickKeyframe?: (percentage: number) => void;
|
||||
}
|
||||
|
||||
function extractProperties(data: KeyframeCacheEntry): string[] {
|
||||
const props = new Set<string>();
|
||||
for (const kf of data.keyframes) {
|
||||
for (const key of Object.keys(kf.properties)) {
|
||||
props.add(key);
|
||||
}
|
||||
}
|
||||
return Array.from(props).sort();
|
||||
}
|
||||
|
||||
export const TimelinePropertyRows = memo(function TimelinePropertyRows({
|
||||
keyframesData,
|
||||
clipWidthPx,
|
||||
clipLeftPx,
|
||||
accentColor,
|
||||
isSelected,
|
||||
currentPercentage,
|
||||
elementId,
|
||||
selectedKeyframes,
|
||||
onClickKeyframe,
|
||||
}: TimelinePropertyRowsProps) {
|
||||
const properties = extractProperties(keyframesData);
|
||||
if (properties.length === 0 || clipWidthPx < 20) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{properties.map((prop) => {
|
||||
const propKeyframes = keyframesData.keyframes
|
||||
.filter((kf) => prop in kf.properties)
|
||||
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT);
|
||||
if (propKeyframes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={prop} className="relative flex items-center" style={{ height: SUB_TRACK_H }}>
|
||||
<span className="absolute left-1 text-[8px] font-medium text-neutral-600 z-10 select-none">
|
||||
{prop}
|
||||
</span>
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{ left: clipLeftPx, width: clipWidthPx, height: SUB_TRACK_H }}
|
||||
viewBox={`0 0 ${clipWidthPx} ${SUB_TRACK_H}`}
|
||||
>
|
||||
<line
|
||||
x1={0}
|
||||
y1={SUB_TRACK_H / 2}
|
||||
x2={clipWidthPx}
|
||||
y2={SUB_TRACK_H / 2}
|
||||
stroke={isSelected ? accentColor : "#525252"}
|
||||
strokeOpacity={0.15}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{propKeyframes.map((kf) => {
|
||||
const x = Math.max(
|
||||
HALF,
|
||||
Math.min(clipWidthPx - HALF, (kf.percentage / 100) * clipWidthPx),
|
||||
);
|
||||
const y = SUB_TRACK_H / 2;
|
||||
const key = `${elementId}:${kf.percentage}`;
|
||||
const isKfSelected = selectedKeyframes.has(key);
|
||||
const isHold = kf.ease === "steps(1)";
|
||||
const fillColor =
|
||||
isKfSelected || (isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5)
|
||||
? accentColor
|
||||
: isSelected
|
||||
? `${accentColor}80`
|
||||
: "#737373";
|
||||
|
||||
return (
|
||||
<g
|
||||
key={kf.percentage}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClickKeyframe?.(kf.percentage);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{isHold ? (
|
||||
<rect
|
||||
x={x - HALF}
|
||||
y={y - HALF}
|
||||
width={DIAMOND_SIZE}
|
||||
height={DIAMOND_SIZE}
|
||||
fill={fillColor}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={x - HALF}
|
||||
y={y - HALF}
|
||||
width={DIAMOND_SIZE}
|
||||
height={DIAMOND_SIZE}
|
||||
fill={fillColor}
|
||||
transform={`rotate(45, ${x}, ${y})`}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { SUB_TRACK_H };
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExpandedElements } from "./useExpandedTimelineElements";
|
||||
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
|
||||
@@ -88,4 +89,37 @@ describe("buildExpandedElements", () => {
|
||||
expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
|
||||
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
|
||||
});
|
||||
|
||||
// Regression: an expanded child must share one identity (`key`) with the flat
|
||||
// store element for the same DOM id. Before the fix the child key fell back to
|
||||
// the colon form (`index.html:eyebrow:N`) while the store/selection used the
|
||||
// hash form (`index.html#eyebrow`), so clicking an expanded child never
|
||||
// highlighted it (isSelected compares the two keys).
|
||||
it("keys expanded children in hash form, matching the flat store element", () => {
|
||||
// Single composition (no sub-comps): scene `s1` with same-file children.
|
||||
const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })];
|
||||
const manifest = [
|
||||
clip({ id: "s1", start: 0, duration: 14 }),
|
||||
clip({ id: "eyebrow", start: 0, duration: 14 }),
|
||||
clip({ id: "title", start: 0, duration: 14 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["eyebrow", "s1"],
|
||||
["title", "s1"],
|
||||
]);
|
||||
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
|
||||
const child = out.find((e) => e.domId === "eyebrow")!;
|
||||
|
||||
const expectedStoreKey = buildTimelineElementKey({
|
||||
id: "eyebrow",
|
||||
fallbackIndex: 0,
|
||||
domId: "eyebrow",
|
||||
selector: "#eyebrow",
|
||||
sourceFile: undefined,
|
||||
});
|
||||
expect(expectedStoreKey).toBe("index.html#eyebrow");
|
||||
expect(child.key).toBe("index.html#eyebrow");
|
||||
expect(child.key).toBe(expectedStoreKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
||||
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
||||
|
||||
function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
|
||||
let current = parentMap.get(id);
|
||||
@@ -78,14 +79,31 @@ function buildChildElements(
|
||||
clip: child,
|
||||
fallbackIndex: result.length,
|
||||
});
|
||||
const domId = child.id ?? undefined;
|
||||
const selector = child.id ? `#${child.id}` : undefined;
|
||||
// `base.key` was built without a hostEl, so it fell back to the colon form
|
||||
// (`index.html:<id>:<idx>`) even though we set domId below. Recompute it from
|
||||
// the same inputs the store uses (`<sourceFile>#<domId>`) so an expanded
|
||||
// child shares one identity with its flat store element — otherwise selecting
|
||||
// it sets `selectedElementId` to the store's hash key while the rendered row
|
||||
// is keyed by the colon form, and `isSelected` never matches (no highlight).
|
||||
const key = buildTimelineElementKey({
|
||||
id: base.id,
|
||||
fallbackIndex: result.length,
|
||||
domId,
|
||||
selector,
|
||||
selectorIndex: base.selectorIndex,
|
||||
sourceFile: editBasis.sourceFile,
|
||||
});
|
||||
result.push({
|
||||
...base,
|
||||
key,
|
||||
start: clamped.start,
|
||||
duration: clamped.duration,
|
||||
track: display.track + result.length,
|
||||
expandedParentStart: editBasis.start,
|
||||
domId: child.id ?? undefined,
|
||||
selector: child.id ? `#${child.id}` : undefined,
|
||||
domId,
|
||||
selector,
|
||||
sourceFile: editBasis.sourceFile,
|
||||
timingSource: "authored" as const,
|
||||
});
|
||||
|
||||
@@ -174,8 +174,16 @@ export function useTimelineSyncCallbacks({
|
||||
if (!adapter || adapter.getDuration() <= 0) return false;
|
||||
|
||||
adapter.pause();
|
||||
const seekTo = pendingSeekRef.current;
|
||||
// Honor a seek requested before the adapter was ready. It may sit in either
|
||||
// place: `pendingSeekRef` if the store subscription was mounted when requestSeek
|
||||
// fired, or only in the store's `requestedSeekTime` if it fired earlier still
|
||||
// (deep-link hydration runs before the player subscription mounts, so the request
|
||||
// never reaches pendingSeekRef). Reconciling with the store here is what makes a
|
||||
// deep-linked `?t=` land instead of starting at 0.
|
||||
const storeSeek = usePlayerStore.getState().requestedSeekTime;
|
||||
const seekTo = pendingSeekRef.current ?? storeSeek;
|
||||
pendingSeekRef.current = null;
|
||||
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
|
||||
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
||||
|
||||
adapter.seek(startTime);
|
||||
|
||||
@@ -8,6 +8,9 @@ interface MediaProbeResult {
|
||||
|
||||
const cache = new Map<string, MediaProbeResult>();
|
||||
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
|
||||
// URLs whose probe failed (CORS, 404, non-media). Remembered so the rAF-driven
|
||||
// timeline re-derive doesn't re-fetch them every frame and flood the console.
|
||||
const failed = new Set<string>();
|
||||
|
||||
let mediabunnyModule: typeof import("mediabunny") | null | false = null;
|
||||
|
||||
@@ -97,7 +100,8 @@ export async function probeMissingSourceDurations<
|
||||
el.src &&
|
||||
el.sourceDuration == null &&
|
||||
["video", "audio"].includes(el.tag.toLowerCase()) &&
|
||||
!getCachedProbe(el.src),
|
||||
!getCachedProbe(el.src) &&
|
||||
!failed.has(normalizeUrl(el.src)),
|
||||
);
|
||||
if (needs.length === 0) return;
|
||||
await Promise.allSettled(
|
||||
@@ -112,6 +116,7 @@ async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
||||
const key = normalizeUrl(url);
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
if (failed.has(key)) return null;
|
||||
|
||||
let pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
@@ -119,6 +124,7 @@ async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
||||
pending = probeOne(key).then((result) => {
|
||||
inflight.delete(key);
|
||||
if (result) cache.set(key, result);
|
||||
else failed.add(key);
|
||||
return result;
|
||||
});
|
||||
inflight.set(key, pending);
|
||||
|
||||
@@ -96,6 +96,14 @@ interface PlayerState {
|
||||
* (drag, resize, rotate) target this instead of recomputing from playhead. */
|
||||
activeKeyframePct: number | null;
|
||||
setActiveKeyframePct: (pct: number | null) => void;
|
||||
/** Motion-path "set destination" mode. Armed from the preview toolbar (replaces
|
||||
* the old double-click-on-canvas UX); while armed, one canvas click places the
|
||||
* new path's destination. `available` is published by MotionPathOverlay so the
|
||||
* toolbar shows the button only when the selected element can take a path. */
|
||||
motionPathArmed: boolean;
|
||||
setMotionPathArmed: (armed: boolean) => void;
|
||||
motionPathCreateAvailable: boolean;
|
||||
setMotionPathCreateAvailable: (available: boolean) => void;
|
||||
|
||||
/** Multi-select: additional selected elements beyond selectedElementId. */
|
||||
selectedElementIds: Set<string>;
|
||||
@@ -209,6 +217,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
|
||||
activeKeyframePct: null,
|
||||
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
||||
motionPathArmed: false,
|
||||
setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
|
||||
motionPathCreateAvailable: false,
|
||||
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
|
||||
|
||||
selectedElementIds: new Set<string>(),
|
||||
toggleSelectedElementId: (id: string) =>
|
||||
@@ -327,7 +339,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
||||
setBeatDragging: (dragging) => set({ beatDragging: dragging }),
|
||||
setElements: (elements) => set({ elements }),
|
||||
setSelectedElementId: (id) => set({ selectedElementId: id }),
|
||||
setSelectedElementId: (id) =>
|
||||
set((s) =>
|
||||
// Selecting a different element drops any active keyframe selection — otherwise
|
||||
// a stale activeKeyframePct from a prior diamond click would force the next drag
|
||||
// to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
|
||||
// calling setSelectedElementId, so this never clobbers a genuine keyframe select.
|
||||
id !== s.selectedElementId
|
||||
? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false }
|
||||
: { selectedElementId: id },
|
||||
),
|
||||
updateElement: (elementId, updates) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) =>
|
||||
@@ -361,3 +382,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
clipParentMap: new Map(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Bug-bash aid: expose the store so a reproduction can dump live state from the
|
||||
// console, e.g. `__playerStore.getState().selectedElementId`. Harmless read
|
||||
// handle; no behavioural effect.
|
||||
// Only in dev. `import.meta.env` may be undefined in non-Vite bundlers (Next.js
|
||||
// Turbopack), so guard the access like the telemetry client does.
|
||||
function isDevBuild(): boolean {
|
||||
try {
|
||||
return import.meta.env.DEV === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isDevBuild() && typeof window !== "undefined") {
|
||||
(window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user