mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* 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)
598 lines
25 KiB
TypeScript
598 lines
25 KiB
TypeScript
import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
|
|
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
|
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
import { usePlayerStore } from "./player";
|
|
import { StudioOverlays } from "./components/StudioOverlays";
|
|
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
|
import { useCaptionStore } from "./captions/store";
|
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
import { usePanelLayout } from "./hooks/usePanelLayout";
|
|
import { useFileManager } from "./hooks/useFileManager";
|
|
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
|
import { useSdkSession } from "./hooks/useSdkSession";
|
|
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
|
import { useBlockHandlers } from "./hooks/useBlockHandlers";
|
|
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
|
import { useClipboard } from "./hooks/useClipboard";
|
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
|
|
import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection";
|
|
import { useCaptionDetection } from "./hooks/useCaptionDetection";
|
|
import { useRenderClipContent } from "./hooks/useRenderClipContent";
|
|
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
|
|
import { useFrameCapture } from "./hooks/useFrameCapture";
|
|
import { useLintModal } from "./hooks/useLintModal";
|
|
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
|
|
import { useToast } from "./hooks/useToast";
|
|
import { useStudioUrlState } from "./hooks/useStudioUrlState";
|
|
import {
|
|
buildStudioContextValue,
|
|
useDragOverlay,
|
|
useInspectorState,
|
|
} from "./hooks/useStudioContextValue";
|
|
import type { DomEditSelection } from "./components/editor/domEditing";
|
|
import { StudioHeader } from "./components/StudioHeader";
|
|
import { useGestureCommit } from "./hooks/useGestureCommit";
|
|
import {
|
|
STUDIO_KEYFRAMES_ENABLED,
|
|
STUDIO_STORYBOARD_ENABLED,
|
|
} from "./components/editor/manualEditingAvailability";
|
|
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
|
|
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
|
|
import { StudioPreviewArea } from "./components/StudioPreviewArea";
|
|
import { StudioRightPanel } from "./components/StudioRightPanel";
|
|
import { TimelineToolbar } from "./components/TimelineToolbar";
|
|
import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext";
|
|
import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
|
|
import { ViewModeProvider, useViewModeState } from "./contexts/ViewModeContext";
|
|
import { StoryboardView } from "./components/storyboard/StoryboardView";
|
|
import { FileManagerProvider } from "./contexts/FileManagerContext";
|
|
import { DomEditProvider } from "./contexts/DomEditContext";
|
|
import { StudioSplash } from "./components/StudioSplash";
|
|
import { useServerConnection } from "./hooks/useServerConnection";
|
|
import {
|
|
normalizeStudioCompositionPath,
|
|
readStudioUrlStateFromWindow,
|
|
} from "./utils/studioUrlState";
|
|
import { trackStudioSessionStart } from "./telemetry/events";
|
|
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
|
|
|
type CanvasRect = { left: number; top: number; width: number; height: number };
|
|
// fallow-ignore-next-line complexity
|
|
export function StudioApp() {
|
|
const { projectId, resolving, waitingForServer } = useServerConnection();
|
|
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
|
|
const viewModeValue = useViewModeState(STUDIO_STORYBOARD_ENABLED);
|
|
|
|
// sessionStorage-backed: fires once per tab, survives HMR remounts
|
|
useEffect(() => {
|
|
if (resolving || waitingForServer) return;
|
|
if (hasFiredSessionStart()) return;
|
|
markSessionStartFired();
|
|
trackStudioSessionStart({ has_project: projectId != null });
|
|
}, [projectId, resolving, waitingForServer]);
|
|
|
|
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
|
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
|
|
() => initialUrlStateRef.current.activeCompPath == null,
|
|
);
|
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const [, setPreviewDocumentVersion] = useState(0);
|
|
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
const activeCompPathRef = useRef(activeCompPath);
|
|
activeCompPathRef.current = activeCompPath;
|
|
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
|
|
const renderQueue = useRenderQueue(projectId);
|
|
const captionEditMode = useCaptionStore((s) => s.isEditMode);
|
|
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
|
|
const captionSync = useCaptionSync(projectId);
|
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
const activePreviewUrl = activeCompPath
|
|
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
: null;
|
|
const effectiveTimelineDuration = useMemo(() => {
|
|
const maxEnd =
|
|
timelineElements.length > 0
|
|
? Math.max(...timelineElements.map((el) => el.start + el.duration))
|
|
: 0;
|
|
return Math.max(timelineDuration, maxEnd);
|
|
}, [timelineDuration, timelineElements]);
|
|
const refreshTimersRef = useRef<number[]>([]);
|
|
const refreshPreviewDocumentVersion = useCallback(() => {
|
|
for (const id of refreshTimersRef.current) clearTimeout(id);
|
|
refreshTimersRef.current = [];
|
|
setPreviewDocumentVersion((v) => v + 1);
|
|
refreshTimersRef.current.push(
|
|
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80),
|
|
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300),
|
|
);
|
|
}, []);
|
|
useEffect(
|
|
() => () => {
|
|
for (const id of refreshTimersRef.current) clearTimeout(id);
|
|
},
|
|
[],
|
|
);
|
|
const [timelineVisible, setTimelineVisible] = useState(
|
|
() =>
|
|
initialUrlStateRef.current.timelineVisible ??
|
|
readStudioUiPreferences().timelineVisible ??
|
|
true,
|
|
);
|
|
const toggleTimelineVisibility = useCallback(() => {
|
|
setTimelineVisible((v) => {
|
|
writeStudioUiPreferences({ timelineVisible: !v });
|
|
return !v;
|
|
});
|
|
}, []);
|
|
const { appToast, showToast, dismissToast } = useToast();
|
|
const panelLayout = usePanelLayout({
|
|
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
|
|
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
|
|
});
|
|
const editHistory = usePersistentEditHistory({ projectId });
|
|
const domEditSaveTimestampRef = useRef(0);
|
|
const pendingTimelineEditPathRef = useRef(new Set<string>());
|
|
const isGestureRecordingRef = useRef(false);
|
|
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
|
|
const fileManager = useFileManager({
|
|
projectId,
|
|
showToast,
|
|
recordEdit: editHistory.recordEdit,
|
|
domEditSaveTimestampRef,
|
|
setRefreshKey,
|
|
});
|
|
const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef);
|
|
useEffect(() => {
|
|
if (activeCompPathHydrated) return;
|
|
if (!fileManager.fileTreeLoaded) return;
|
|
const nextCompPath = normalizeStudioCompositionPath(
|
|
initialUrlStateRef.current.activeCompPath,
|
|
fileManager.fileTree,
|
|
);
|
|
setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
|
|
setActiveCompPathHydrated(true);
|
|
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
|
|
const previewPersistence = usePreviewPersistence({
|
|
projectId,
|
|
showToast,
|
|
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
|
writeProjectFile: fileManager.writeProjectFile,
|
|
recordEdit: editHistory.recordEdit,
|
|
previewIframeRef,
|
|
activeCompPathRef,
|
|
domEditSaveTimestampRef,
|
|
reloadPreview: () => setRefreshKey((k) => k + 1),
|
|
pendingTimelineEditPathRef,
|
|
});
|
|
const timelineEditing = useTimelineEditing({
|
|
projectId,
|
|
activeCompPath,
|
|
timelineElements,
|
|
showToast,
|
|
writeProjectFile: fileManager.writeProjectFile,
|
|
recordEdit: editHistory.recordEdit,
|
|
domEditSaveTimestampRef,
|
|
reloadPreview,
|
|
previewIframeRef,
|
|
pendingTimelineEditPathRef,
|
|
uploadProjectFiles: fileManager.uploadProjectFiles,
|
|
isRecordingRef: isGestureRecordingRef,
|
|
sdkSession: sdkHandle.session,
|
|
forceReloadSdkSession: sdkHandle.forceReload,
|
|
});
|
|
const {
|
|
activeBlockParams,
|
|
setActiveBlockParams,
|
|
handleAddBlock,
|
|
handleTimelineBlockDrop,
|
|
handlePreviewBlockDrop,
|
|
} = useBlockHandlers({
|
|
projectId,
|
|
blockCtxDeps: {
|
|
activeCompPath,
|
|
timelineElements,
|
|
readProjectFile: fileManager.readProjectFile,
|
|
writeProjectFile: fileManager.writeProjectFile,
|
|
recordEdit: editHistory.recordEdit,
|
|
refreshFileTree: fileManager.refreshFileTree,
|
|
reloadPreview,
|
|
showToast,
|
|
},
|
|
previewIframeRef,
|
|
setRightCollapsed: panelLayout.setRightCollapsed,
|
|
setRightPanelTab: panelLayout.setRightPanelTab,
|
|
});
|
|
const clearDomSelectionRef = useRef<() => void>(() => {});
|
|
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
|
|
const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
|
|
async () => {},
|
|
);
|
|
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
|
|
const resetKeyframesRef = useRef<() => boolean>(() => false);
|
|
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
|
|
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
|
const { handleCopy, handlePaste, handleCut } = useClipboard({
|
|
projectId,
|
|
activeCompPath,
|
|
domEditSelectionRef: domEditSelectionBridgeRef,
|
|
showToast,
|
|
writeProjectFile: fileManager.writeProjectFile,
|
|
recordEdit: editHistory.recordEdit,
|
|
domEditSaveTimestampRef,
|
|
reloadPreview,
|
|
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
|
|
handleDomEditElementDelete: domEditDeleteBridge,
|
|
previewIframeRef,
|
|
});
|
|
const appHotkeys = useAppHotkeys({
|
|
toggleTimelineVisibility,
|
|
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
|
|
handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
|
|
handleDomEditElementDelete: domEditDeleteBridge,
|
|
domEditSelectionRef: domEditSelectionBridgeRef,
|
|
clearDomSelectionRef,
|
|
editHistory,
|
|
readOptionalProjectFile: fileManager.readOptionalProjectFile,
|
|
readProjectFile: fileManager.readProjectFile,
|
|
writeProjectFile: fileManager.writeProjectFile,
|
|
domEditSaveTimestampRef,
|
|
showToast,
|
|
syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
|
|
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
|
leftSidebarRef,
|
|
handleCopy,
|
|
handlePaste,
|
|
handleCut,
|
|
onResetKeyframes: () => resetKeyframesRef.current(),
|
|
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
|
|
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
|
|
activeCompPath,
|
|
forceReloadSdkSession: sdkHandle.forceReload,
|
|
onToggleRecording: STUDIO_KEYFRAMES_ENABLED
|
|
? () => handleToggleRecordingRef.current()
|
|
: undefined,
|
|
});
|
|
const sidebarTabRef = useRef({
|
|
select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t),
|
|
get: () => leftSidebarRef.current?.getTab() ?? "compositions",
|
|
});
|
|
const domEditSession = useDomEditSession({
|
|
projectId,
|
|
activeCompPath,
|
|
isMasterView,
|
|
compIdToSrc,
|
|
captionEditMode,
|
|
compositionLoading,
|
|
previewIframeRef,
|
|
timelineElements,
|
|
setSelectedTimelineElementId,
|
|
setRightCollapsed: panelLayout.setRightCollapsed,
|
|
setRightPanelTab: panelLayout.setRightPanelTab,
|
|
showToast,
|
|
refreshPreviewDocumentVersion,
|
|
queueDomEditSave: previewPersistence.queueDomEditSave,
|
|
readProjectFile: fileManager.readProjectFile,
|
|
writeProjectFile: fileManager.writeProjectFile,
|
|
updateEditingFileContent: fileManager.updateEditingFileContent,
|
|
domEditSaveTimestampRef,
|
|
editHistory: { recordEdit: editHistory.recordEdit },
|
|
fileTree: fileManager.fileTree,
|
|
importedFontAssetsRef: fileManager.importedFontAssetsRef,
|
|
projectDir: fileManager.projectDir,
|
|
projectIdRef: fileManager.projectIdRef,
|
|
previewIframe,
|
|
refreshKey,
|
|
rightPanelTab: panelLayout.rightPanelTab,
|
|
applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef,
|
|
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
|
|
reloadPreview,
|
|
setRefreshKey,
|
|
openSourceForSelection: fileManager.openSourceForSelection,
|
|
selectSidebarTab: sidebarTabRef.current.select,
|
|
getSidebarTab: sidebarTabRef.current.get,
|
|
sdkSession: sdkHandle.session,
|
|
forceReloadSdkSession: sdkHandle.forceReload,
|
|
});
|
|
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
|
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
|
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
|
|
resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
|
|
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
|
|
deleteSelectedKeyframesRef.current = () => {
|
|
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
|
|
const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes);
|
|
if (!a) return;
|
|
// Only the active element's keyframes; a stale cross-element selection must not delete here.
|
|
for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
|
|
domEditSession.handleGsapRemoveKeyframe(a.id, p);
|
|
}
|
|
};
|
|
useSdkSelectionSync(
|
|
sdkHandle.session,
|
|
domEditSession.domEditSelection,
|
|
domEditSession.domEditGroupSelections,
|
|
);
|
|
|
|
useCaptionDetection({
|
|
projectId,
|
|
activeCompPath,
|
|
compIdToSrc,
|
|
captionEditMode,
|
|
captionHasSelection,
|
|
previewIframeRef,
|
|
captionSync,
|
|
setRightCollapsed: panelLayout.setRightCollapsed,
|
|
});
|
|
const renderClipContent = useRenderClipContent({
|
|
projectIdRef: fileManager.projectIdRef,
|
|
compIdToSrc,
|
|
activePreviewUrl,
|
|
effectiveTimelineDuration,
|
|
});
|
|
const compositionDimensions = useCompositionDimensions();
|
|
const { lintModal, linting, handleLint, closeLintModal, findingsByFile } = useLintModal(
|
|
projectId,
|
|
refreshKey,
|
|
);
|
|
const frameCapture = useFrameCapture({
|
|
projectId,
|
|
activeCompPath,
|
|
showToast,
|
|
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
|
});
|
|
const {
|
|
consoleErrors,
|
|
setConsoleErrors,
|
|
resetErrors: resetConsoleErrors,
|
|
} = useConsoleErrorCapture(previewIframe);
|
|
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
|
|
// Gesture recording
|
|
const handleToggleRecordingRef = useRef<() => void>(() => {});
|
|
const domEditSessionRef = useRef(domEditSession);
|
|
domEditSessionRef.current = domEditSession;
|
|
const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
|
|
domEditSessionRef,
|
|
previewIframeRef,
|
|
showToast,
|
|
isGestureRecordingRef,
|
|
});
|
|
handleToggleRecordingRef.current = handleToggleRecording;
|
|
const canvasRectRef = useRef<CanvasRect | null>(null);
|
|
useLayoutEffect(() => {
|
|
if (gestureState !== "recording" || !previewIframe) {
|
|
canvasRectRef.current = null;
|
|
return;
|
|
}
|
|
const r = previewIframe.getBoundingClientRect();
|
|
canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
}, [gestureState, previewIframe]);
|
|
|
|
const handlePreviewIframeRef = useCallback(
|
|
(iframe: HTMLIFrameElement | null) => {
|
|
previewIframeRef.current = iframe;
|
|
setPreviewIframe(iframe);
|
|
appHotkeys.syncPreviewTimelineHotkey(iframe);
|
|
appHotkeys.syncPreviewHistoryHotkey(iframe);
|
|
resetConsoleErrors();
|
|
refreshPreviewDocumentVersion();
|
|
},
|
|
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
|
|
);
|
|
const handleSelectComposition = useCallback(
|
|
(comp: string) => {
|
|
setActiveCompPath(comp.endsWith(".html") ? comp : null);
|
|
fileManager.setEditingFile({ path: comp, content: null });
|
|
fetch(`/api/projects/${projectId}/files/${comp}`)
|
|
.then((r) => r.json())
|
|
.then((data) => fileManager.setEditingFile({ path: comp, content: data.content }))
|
|
.catch(() => {});
|
|
},
|
|
[projectId, fileManager],
|
|
);
|
|
const {
|
|
designPanelActive,
|
|
inspectorPanelActive,
|
|
inspectorButtonActive,
|
|
shouldShowSelectedDomBounds,
|
|
} = useInspectorState(
|
|
panelLayout.rightPanelTab,
|
|
panelLayout.rightInspectorPanes,
|
|
panelLayout.rightCollapsed,
|
|
isPlaying,
|
|
gestureState === "recording",
|
|
);
|
|
useStudioUrlState({
|
|
projectId,
|
|
activeCompPath,
|
|
duration: effectiveTimelineDuration,
|
|
isPlaying,
|
|
compositionLoading,
|
|
refreshKey,
|
|
previewIframeRef,
|
|
rightPanelTab: panelLayout.rightPanelTab,
|
|
rightCollapsed: panelLayout.rightCollapsed,
|
|
timelineVisible,
|
|
activeCompPathHydrated,
|
|
domEditSelection: domEditSession.domEditSelection,
|
|
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
|
|
applyDomSelection: domEditSession.applyDomSelection,
|
|
setRightPanelTab: panelLayout.setRightPanelTab,
|
|
initialState: initialUrlStateRef.current,
|
|
});
|
|
const studioCtxValue = buildStudioContextValue({
|
|
projectId: projectId!,
|
|
activeCompPath,
|
|
setActiveCompPath,
|
|
showToast,
|
|
previewIframeRef,
|
|
captionEditMode,
|
|
compositionLoading,
|
|
refreshKey,
|
|
setRefreshKey,
|
|
timelineElements,
|
|
isPlaying,
|
|
editHistory,
|
|
handleUndo: appHotkeys.handleUndo,
|
|
handleRedo: appHotkeys.handleRedo,
|
|
renderQueue,
|
|
compositionDimensions,
|
|
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
|
handlePreviewIframeRef,
|
|
refreshPreviewDocumentVersion,
|
|
timelineVisible,
|
|
toggleTimelineVisibility,
|
|
});
|
|
const timelineToolbar = useMemo(
|
|
() => (
|
|
<TimelineToolbar
|
|
toggleTimelineVisibility={toggleTimelineVisibility}
|
|
domEditSession={domEditSession}
|
|
onSplitElement={timelineEditing.handleTimelineElementSplit}
|
|
/>
|
|
),
|
|
[toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit],
|
|
);
|
|
if (resolving || waitingForServer || !projectId)
|
|
return <StudioSplash waiting={waitingForServer} />;
|
|
return (
|
|
<StudioShellProvider value={studioCtxValue}>
|
|
<StudioPlaybackProvider value={studioCtxValue}>
|
|
<ViewModeProvider value={viewModeValue}>
|
|
<PanelLayoutProvider value={panelLayout}>
|
|
<FileManagerProvider value={fileManager}>
|
|
<DomEditProvider value={domEditSession}>
|
|
<div
|
|
className="flex flex-col h-full w-full bg-neutral-950 relative"
|
|
onDragOver={dragOverlay.onDragOver}
|
|
onDragEnter={dragOverlay.onDragEnter}
|
|
onDragLeave={dragOverlay.onDragLeave}
|
|
onDrop={dragOverlay.onDrop}
|
|
>
|
|
<StudioHeader
|
|
captureFrameHref={frameCapture.captureFrameHref}
|
|
captureFrameFilename={frameCapture.captureFrameFilename}
|
|
handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
|
|
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
|
|
inspectorButtonActive={inspectorButtonActive}
|
|
inspectorPanelActive={inspectorPanelActive}
|
|
onExport={() => void renderQueue.startRender(undefined)}
|
|
/>
|
|
{previewPersistence.domEditSaveQueuePaused && (
|
|
<SaveQueuePausedBanner
|
|
message={previewPersistence.domEditSaveQueuePaused}
|
|
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
|
|
/>
|
|
)}
|
|
{viewModeValue.viewMode === "storyboard" && (
|
|
<StoryboardView
|
|
projectId={projectId}
|
|
onSelectComposition={handleSelectComposition}
|
|
/>
|
|
)}
|
|
{/* Timeline stage stays mounted (just hidden) in storyboard mode,
|
|
so preview/player/gesture/render state survives the toggle. */}
|
|
<div
|
|
className={`flex flex-1 min-h-0${
|
|
viewModeValue.viewMode === "storyboard" ? " hidden" : ""
|
|
}`}
|
|
>
|
|
<StudioLeftSidebar
|
|
leftSidebarRef={leftSidebarRef}
|
|
onSelectComposition={handleSelectComposition}
|
|
onAddBlock={handleAddBlock}
|
|
onPreviewBlock={setBlockPreview}
|
|
onLint={handleLint}
|
|
linting={linting}
|
|
lintFindingCount={lintModal?.length ?? findingsByFile.size}
|
|
lintFindingsByFile={findingsByFile}
|
|
/>
|
|
<StudioPreviewArea
|
|
timelineToolbar={timelineToolbar}
|
|
renderClipContent={renderClipContent}
|
|
handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
|
|
handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
|
|
handleTimelineBlockDrop={handleTimelineBlockDrop}
|
|
handlePreviewBlockDrop={handlePreviewBlockDrop}
|
|
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
|
|
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
|
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
|
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
|
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
|
handleRazorSplit={timelineEditing.handleRazorSplit}
|
|
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
|
setCompIdToSrc={setCompIdToSrc}
|
|
setCompositionLoading={setCompositionLoading}
|
|
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
isGestureRecording={gestureState === "recording"}
|
|
recordingState={gestureState}
|
|
onToggleRecording={
|
|
STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
|
|
}
|
|
blockPreview={blockPreview}
|
|
gestureOverlay={
|
|
gestureState === "recording" && previewIframe ? (
|
|
<GestureTrailOverlay
|
|
samples={gestureRecording.samplesRef.current}
|
|
sampleCount={gestureRecording.samplesRef.current.length}
|
|
trail={gestureRecording.trailRef.current}
|
|
canvasRect={canvasRectRef.current!}
|
|
compositionSize={compositionDimensions ?? undefined}
|
|
mode="recording"
|
|
/>
|
|
) : undefined
|
|
}
|
|
/>
|
|
{!panelLayout.rightCollapsed && (
|
|
<StudioRightPanel
|
|
designPanelActive={designPanelActive}
|
|
activeBlockParams={activeBlockParams}
|
|
onCloseBlockParams={() => {
|
|
setActiveBlockParams(null);
|
|
panelLayout.setRightPanelTab("design");
|
|
}}
|
|
recordingState={gestureState}
|
|
recordingDuration={gestureRecording.recordingDuration}
|
|
onToggleRecording={
|
|
STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
|
|
}
|
|
sdkSession={sdkHandle.session}
|
|
reloadPreview={reloadPreview}
|
|
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
|
recordEdit={editHistory.recordEdit}
|
|
/>
|
|
)}
|
|
</div>
|
|
<StudioOverlays
|
|
projectId={projectId}
|
|
lintModal={lintModal}
|
|
closeLintModal={closeLintModal}
|
|
consoleErrors={consoleErrors}
|
|
clearConsoleErrors={() => setConsoleErrors(null)}
|
|
domEditSession={domEditSession}
|
|
activeCompPath={activeCompPath}
|
|
dragOverlayActive={dragOverlay.active}
|
|
appToast={appToast}
|
|
dismissToast={dismissToast}
|
|
/>
|
|
</div>
|
|
</DomEditProvider>
|
|
</FileManagerProvider>
|
|
</PanelLayoutProvider>
|
|
</ViewModeProvider>
|
|
</StudioPlaybackProvider>
|
|
</StudioShellProvider>
|
|
);
|
|
}
|