diff --git a/packages/core/src/runtime/picker.ts b/packages/core/src/runtime/picker.ts index 733c01a40..6f12cd118 100644 --- a/packages/core/src/runtime/picker.ts +++ b/packages/core/src/runtime/picker.ts @@ -58,12 +58,28 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule { }); } + function isEffectivelyHidden(el: HTMLElement): boolean { + const win = el.ownerDocument.defaultView; + if (!win) return false; + let current: HTMLElement | null = el; + while (current && current !== document.body && current !== document.documentElement) { + const computed = win.getComputedStyle(current); + if (computed.display === "none" || computed.visibility === "hidden") return true; + if (computed.pointerEvents === "none") return true; + const opacity = Number.parseFloat(computed.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return true; + current = current.parentElement; + } + return false; + } + function isPickableElement(el: Element | null): el is Element { if (!el || el === document.body || el === document.documentElement) return false; const tag = el.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") return false; if (el.classList.contains("__hf-pick-highlight")) return false; if (el.closest(PICKER_IGNORE_SELECTOR)) return false; + if (isEffectivelyHidden(el as HTMLElement)) return false; return true; } diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index e802a84d2..447d080f1 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -125,11 +125,23 @@ function injectStudioPreviewAugmentations( } export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void { + const previewCacheHeaders = (etag: string) => ({ + "Cache-Control": "private, no-cache", + ETag: etag, + }); + // Bundled composition preview api.get("/projects/:id/preview", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); + const signature = resolveProjectSignature(adapter, project.dir); + const etag = `"preview:${signature}"`; + const ifNoneMatch = c.req.header("If-None-Match"); + if (ifNoneMatch === etag) { + return new Response(null, { status: 304, headers: previewCacheHeaders(etag) }); + } + try { let bundled = await adapter.bundle(project.dir); if (!bundled) { @@ -156,7 +168,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi } bundled = injectStudioPreviewAugmentations(bundled, adapter, project.dir, "index.html"); - return c.html(bundled); + return c.html(bundled, 200, previewCacheHeaders(etag)); } catch { const file = resolve(project.dir, "index.html"); if (existsSync(file)) { @@ -167,6 +179,8 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi project.dir, "index.html", ), + 200, + previewCacheHeaders(etag), ); } return c.text("not found", 404); @@ -177,6 +191,8 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi api.get("/projects/:id/preview/comp/*", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); + + const signature = resolveProjectSignature(adapter, project.dir); const compPath = decodeURIComponent( c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "", ); @@ -188,10 +204,21 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi ) { return c.text("not found", 404); } + + const etag = `"comp:${compPath}:${signature}"`; + const ifNoneMatch = c.req.header("If-None-Match"); + if (ifNoneMatch === etag) { + return new Response(null, { status: 304, headers: previewCacheHeaders(etag) }); + } + const baseHref = `/api/projects/${project.id}/preview/`; let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref); if (!html) return c.text("not found", 404); - return c.html(injectStudioPreviewAugmentations(html, adapter, project.dir, compPath)); + return c.html( + injectStudioPreviewAugmentations(html, adapter, project.dir, compPath), + 200, + previewCacheHeaders(etag), + ); }); // Static asset serving (with range request support for audio/video seeking) @@ -202,11 +229,25 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "", ); const file = resolve(project.dir, subPath); - if (!isSafePath(project.dir, file) || !existsSync(file) || !statSync(file).isFile()) { + const stat = existsSync(file) ? statSync(file) : null; + if (!isSafePath(project.dir, file) || !stat?.isFile()) { return c.text("not found", 404); } const contentType = getMimeType(subPath); const isText = /\.(html|css|js|json|svg|txt|md)$/i.test(subPath); + + const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`; + const cacheHeaders: Record = isText + ? { "Cache-Control": "no-store" } + : { "Cache-Control": "private, max-age=3600, must-revalidate", ETag: etag }; + + if (!isText) { + const ifNoneMatch = c.req.header("If-None-Match"); + if (ifNoneMatch === etag) { + return new Response(null, { status: 304, headers: cacheHeaders }); + } + } + const buffer: Buffer = isText ? Buffer.from(readFileSync(file, "utf-8"), "utf-8") : readFileSync(file); @@ -224,6 +265,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi return new Response(new Uint8Array(buffer.slice(start, safeEnd + 1)), { status: 206, headers: { + ...cacheHeaders, "Content-Type": contentType, "Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`, "Accept-Ranges": "bytes", @@ -235,6 +277,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi return new Response(new Uint8Array(buffer), { headers: { + ...cacheHeaders, "Content-Type": contentType, "Accept-Ranges": "bytes", "Content-Length": String(totalSize), diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 006a40d95..34ca3074b 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -74,24 +74,19 @@ import { DomEditOverlay, type DomEditGroupPathOffsetCommit, } from "./components/editor/DomEditOverlay"; -import { TimelineLayerPanel } from "./components/editor/TimelineLayerPanel"; import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_MANUAL_EDITING_DISABLED_TITLE, STUDIO_MOTION_PANEL_ENABLED, STUDIO_PREVIEW_MANUAL_EDITING_ENABLED, STUDIO_PREVIEW_SELECTION_ENABLED, - STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED, } from "./components/editor/manualEditingAvailability"; import { buildDomEditStylePatchOperation, buildDomEditTextPatchOperation, buildElementAgentPrompt, - collectDomEditLayerItems, - countDomEditChildLayers, findElementForSelection, findElementForTimelineElement, - getDomEditLayerKey, getDomEditTargetKey, isLargeRasterDomEditSelection, isTextEditableSelection, @@ -99,7 +94,6 @@ import { serializeDomEditTextFields, resolveDomEditSelection, type DomEditViewport, - type DomEditLayerItem, type DomEditTextField, type DomEditSelection, buildDefaultDomEditTextField, @@ -134,14 +128,7 @@ import { upsertStudioGsapMotion, } from "./components/editor/studioMotion"; import { saveProjectFilesWithHistory } from "./utils/studioFileHistory"; -import { - canInspectTimelineElement, - getTimelineElementKey, - getTimelineLayerVisibilityInPreview, - isTimelineElementActiveAtTime, - isTimelineLayerVisibleInPreview, - shouldShowTimelineInspectorBounds, -} from "./utils/timelineInspector"; +import { getTimelineElementKey, isTimelineElementActiveAtTime } from "./utils/timelineInspector"; interface EditingFile { path: string; @@ -543,105 +530,6 @@ function readPlaybackTime(target: object | null, key: string): number | null { } } -interface PreviewPlayerCompat { - getTime: () => number; - renderSeek: (timeSeconds: number) => void; -} - -function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null { - const player = objectLike(win ? Reflect.get(win, "__player") : null); - if (!player) return null; - const getTime = Reflect.get(player, "getTime"); - const renderSeek = Reflect.get(player, "renderSeek"); - if (typeof getTime !== "function" || typeof renderSeek !== "function") return null; - return { - getTime: () => { - const value = getTime.call(player); - return typeof value === "number" && Number.isFinite(value) ? value : 0; - }, - renderSeek: (timeSeconds: number) => { - renderSeek.call(player, timeSeconds); - }, - }; -} - -function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean { - const player = getPreviewPlayer(iframe?.contentWindow); - if (!player) return false; - const nextTime = Math.max(0, timeSeconds); - player.renderSeek(nextTime); - usePlayerStore.getState().setCurrentTime(nextTime); - liveTime.notify(nextTime); - return true; -} - -function parseFiniteSeconds(value: string | null): number | null { - if (value == null || value.trim() === "") return null; - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : null; -} - -function resolveLayerVisibleSeekTime( - layerElement: HTMLElement, - timelineElement: TimelineElement | null, - player: PreviewPlayerCompat | null, -): number | null { - if (!timelineElement || !player) return null; - const originalTime = player.getTime(); - - const clipStart = Math.max(0, timelineElement.start); - const clipEnd = Math.max(clipStart, clipStart + Math.max(0, timelineElement.duration)); - const authoredStart = parseFiniteSeconds( - layerElement.getAttribute("data-start") ?? - layerElement.closest("[data-start]")?.getAttribute("data-start") ?? - null, - ); - const preferredTime = - authoredStart == null - ? clipStart - : Math.min(clipEnd, Math.max(clipStart, clipStart + authoredStart)); - const candidates = [preferredTime, clipStart]; - const duration = clipEnd - clipStart; - if (duration > 0) { - const maxSamples = 24; - const frameStep = 1 / 24; - const step = Math.max(frameStep, duration / maxSamples); - for (let time = clipStart; time <= clipEnd + 0.0001; time += step) { - candidates.push(Math.min(clipEnd, time)); - } - } - candidates.push(clipEnd); - - let lastTried = preferredTime; - let clearestVisibleTime: number | null = null; - let clearestVisibleOpacity = 0; - let resolvedTime: number | null = null; - const seen = new Set(); - try { - for (const candidate of candidates) { - const time = Math.min(clipEnd, Math.max(clipStart, candidate)); - const key = time.toFixed(4); - if (seen.has(key)) continue; - seen.add(key); - lastTried = time; - player.renderSeek(time); - const visibility = getTimelineLayerVisibilityInPreview(layerElement); - if (visibility.visible && visibility.compositeOpacity > clearestVisibleOpacity) { - clearestVisibleTime = time; - clearestVisibleOpacity = visibility.compositeOpacity; - } - if (isTimelineLayerVisibleInPreview(layerElement, { minCompositeOpacity: 0.9 })) { - resolvedTime = time; - break; - } - } - } finally { - player.renderSeek(originalTime); - } - - return resolvedTime ?? clearestVisibleTime ?? lastTried; -} - function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null { const win = iframe?.contentWindow; if (!win) return null; @@ -901,9 +789,8 @@ export function StudioApp() { const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false); const [agentModalOpen, setAgentModalOpen] = useState(false); const [previewIframe, setPreviewIframe] = useState(null); - const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState(null); const [compositionLoading, setCompositionLoading] = useState(true); - const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0); + const [, setPreviewDocumentVersion] = useState(0); const refreshPreviewDocumentVersion = useCallback(() => { setPreviewDocumentVersion((version) => version + 1); window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 80); @@ -2156,10 +2043,8 @@ export function StudioApp() { const writeHistoryProjectFile = useCallback( async (path: string, content: string): Promise => { + domEditSaveTimestampRef.current = Date.now(); await writeProjectFile(path, content); - if (path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH) { - domEditSaveTimestampRef.current = Date.now(); - } }, [writeProjectFile], ); @@ -2453,6 +2338,19 @@ export function StudioApp() { return; } + // Reload the iframe in-place rather than recreating the Player component. + // This preserves the web component and its shader + // transition cache — only the iframe document reloads, so transitions that + // weren't touched by the undo/redo don't need to rebuild from scratch. + const iframe = previewIframeRef.current; + if (iframe?.contentWindow) { + try { + iframe.contentWindow.location.reload(); + return; + } catch { + // Cross-origin or detached — fall through to full refresh + } + } setRefreshKey((key) => key + 1); }, [applyStudioManualEditsToPreview, applyStudioMotionToPreview], @@ -2615,148 +2513,20 @@ export function StudioApp() { [activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView], ); - const inspectedTimelineElement = useMemo( - () => - timelineElements.find( - (element) => getTimelineElementKey(element) === inspectedTimelineElementId, - ) ?? null, - [inspectedTimelineElementId, timelineElements], - ); - - const timelineLayerChildCounts = useMemo(() => { - void previewDocumentVersion; - const counts = new Map(); - if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return counts; - - const key = getTimelineElementKey(inspectedTimelineElement); - if (key) { - const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement); - const count = countDomEditChildLayers(selection?.element, { - activeCompositionPath: activeCompPath, - isMasterView, - }); - if (count > 0) counts.set(key, count); - } - - return counts; - }, [ - activeCompPath, - buildDomSelectionForTimelineElement, - inspectedTimelineElement, - isMasterView, - previewDocumentVersion, - ]); - - const inspectedTimelineLayers = useMemo(() => { - void previewDocumentVersion; - if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return []; - const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement); - return collectDomEditLayerItems(selection?.element, { - activeCompositionPath: activeCompPath, - isMasterView, - }); - }, [ - activeCompPath, - buildDomSelectionForTimelineElement, - inspectedTimelineElement, - isMasterView, - previewDocumentVersion, - ]); - - const selectedTimelineLayerKey = useMemo( - () => (domEditSelection ? getDomEditLayerKey(domEditSelection) : null), - [domEditSelection], - ); - const handleTimelineElementSelect = useCallback( (element: TimelineElement | null) => { if (!STUDIO_INSPECTOR_PANELS_ENABLED) return; if (!element) { applyDomSelection(null, { revealPanel: false }); - setInspectedTimelineElementId(null); return; } const selection = buildDomSelectionForTimelineElement(element); if (selection) applyDomSelection(selection); - - const key = getTimelineElementKey(element); - if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) { - setInspectedTimelineElementId(key); - setLeftCollapsed(false); - - const iframe = previewIframeRef.current; - if (!shouldShowTimelineInspectorBounds(currentTime, element)) { - seekStudioPreview(iframe, element.start); - } - } else { - setInspectedTimelineElementId(null); - } }, - [applyDomSelection, buildDomSelectionForTimelineElement, currentTime], + [applyDomSelection, buildDomSelectionForTimelineElement], ); - const handleTimelineElementInspect = useCallback( - (element: TimelineElement) => { - if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !STUDIO_INSPECTOR_PANELS_ENABLED) return; - if (!canInspectTimelineElement(element)) { - showToast("Audio clips do not have visual layers.", "info"); - return; - } - - const key = getTimelineElementKey(element); - if (!key) return; - setInspectedTimelineElementId((current) => (current === key ? null : key)); - setLeftCollapsed(false); - - const iframe = previewIframeRef.current; - if (!shouldShowTimelineInspectorBounds(currentTime, element)) { - seekStudioPreview(iframe, element.start); - } - - const selection = buildDomSelectionForTimelineElement(element); - if (selection) applyDomSelection(selection); - }, - [applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast], - ); - - const handleTimelineLayerSelect = useCallback( - (layer: DomEditLayerItem) => { - if (!STUDIO_INSPECTOR_PANELS_ENABLED) return; - - const iframe = previewIframeRef.current; - const player = getPreviewPlayer(iframe?.contentWindow); - const visibleTime = resolveLayerVisibleSeekTime( - layer.element, - inspectedTimelineElement, - player, - ); - if (visibleTime != null) { - seekStudioPreview(iframe, visibleTime); - } - - const selection = buildDomSelectionFromTarget(layer.element, { preferClipAncestor: false }); - if (!selection) { - showToast("Studio could not resolve this nested layer.", "error"); - return; - } - - applyDomSelection(selection); - requestAnimationFrame(refreshPreviewDocumentVersion); - }, - [ - applyDomSelection, - buildDomSelectionFromTarget, - inspectedTimelineElement, - refreshPreviewDocumentVersion, - showToast, - ], - ); - - const handleTimelineLayerPanelClose = useCallback(() => { - setInspectedTimelineElementId(null); - }, []); - const preloadAgentPromptSnippet = useCallback( async (selection: DomEditSelection) => { const pid = projectIdRef.current; @@ -4030,26 +3800,15 @@ export function StudioApp() { const motionPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion"; const inspectorPanelActive = designPanelActive || motionPanelActive; + const isPlaying = usePlayerStore((s) => s.isPlaying); const shouldShowSelectedDomBounds = inspectorPanelActive && !rightCollapsed && + !isPlaying && (!selectedTimelineElement || isTimelineElementActiveAtTime(currentTime, selectedTimelineElement)); const inspectorButtonActive = STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive; - const timelineLayerPanel = - STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && - inspectedTimelineElement && - inspectedTimelineLayers.length > 0 ? ( - - ) : null; - if (resolving || !projectId) { return (
@@ -4263,7 +4022,6 @@ export function StudioApp() { onLint={handleLint} linting={linting} onToggleCollapse={toggleLeftSidebar} - takeoverContent={timelineLayerPanel} /> )} @@ -4295,16 +4053,12 @@ export function StudioApp() { onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} onSelectTimelineElement={handleTimelineElementSelect} - onInspectTimelineElement={handleTimelineElementInspect} - inspectedTimelineElementId={inspectedTimelineElementId} - timelineLayerChildCounts={timelineLayerChildCounts} onCompIdToSrcChange={setCompIdToSrc} onCompositionLoadingChange={setCompositionLoading} onCompositionChange={(compPath) => { // Sync activeCompPath when user drills down via timeline double-click // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync. setActiveCompPath(compPath); - setInspectedTimelineElementId(null); refreshPreviewDocumentVersion(); }} onIframeRef={handlePreviewIframeRef} @@ -4316,7 +4070,10 @@ export function StudioApp() { iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} hoverSelection={ - STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode + STUDIO_PREVIEW_SELECTION_ENABLED && + !captionEditMode && + !compositionLoading && + !isPlaying ? domEditHoverSelection : null } diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index a645a124d..440df093b 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -85,6 +85,20 @@ interface DomEditOverlayProps { onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise | void; } +function isElementVisibleForOverlay(el: HTMLElement): boolean { + const win = el.ownerDocument.defaultView; + if (!win) return true; + let current: HTMLElement | null = el; + while (current) { + const computed = win.getComputedStyle(current); + if (computed.display === "none" || computed.visibility === "hidden") return false; + const opacity = Number.parseFloat(computed.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + current = current.parentElement; + } + return true; +} + function toOverlayRect( overlayEl: HTMLDivElement, iframe: HTMLIFrameElement, @@ -534,7 +548,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ if (sel) { const el = resolveElement(doc, sel, resolvedElementRef); - if (el) { + if (el && isElementVisibleForOverlay(el)) { setNextOverlayRect(toOverlayRect(overlayEl, iframe, el)); } else { clearOverlayRect(); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 71cce70a0..7085e78c3 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -547,18 +547,65 @@ function MetricField({ value, disabled, liveCommit, + scrub, onCommit, }: { label: string; value: string; disabled?: boolean; liveCommit?: boolean; + scrub?: boolean; onCommit: (nextValue: string) => void; }) { + const scrubRef = useRef<{ + startX: number; + startValue: number; + pointerId: number; + } | null>(null); + + const handleScrubPointerDown = useCallback( + (e: React.PointerEvent) => { + if (disabled || !scrub) return; + const parsed = parseFloat(value); + if (!Number.isFinite(parsed)) return; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + scrubRef.current = { startX: e.clientX, startValue: parsed, pointerId: e.pointerId }; + }, + [disabled, scrub, value], + ); + + const handleScrubPointerMove = useCallback( + (e: React.PointerEvent) => { + const state = scrubRef.current; + if (!state) return; + const delta = e.clientX - state.startX; + const next = Math.round(state.startValue + delta); + onCommit(String(next)); + }, + [onCommit], + ); + + const handleScrubPointerUp = useCallback(() => { + scrubRef.current = null; + }, []); + + const scrubProps = + scrub && !disabled + ? ({ + className: + "flex-shrink-0 text-[11px] font-medium text-neutral-500 cursor-ew-resize select-none", + onPointerDown: handleScrubPointerDown, + onPointerMove: handleScrubPointerMove, + onPointerUp: handleScrubPointerUp, + } as const) + : ({ + className: "flex-shrink-0 text-[11px] font-medium text-neutral-500", + } as const); + return (
- {label} + {label} 0; const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none"; const borderColorValue = styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)"; @@ -2570,24 +2627,28 @@ export const PropertyPanel = memo(function PropertyPanel({ label="X" value={formatPxMetricValue(manualOffset.x)} disabled={manualOffsetEditingDisabled} + scrub onCommit={(next) => commitManualOffset("x", next)} /> commitManualOffset("y", next)} /> commitManualSize("width", next)} /> commitManualSize("height", next)} />
@@ -2640,18 +2701,20 @@ export const PropertyPanel = memo(function PropertyPanel({ {showEditableSections && ( <> -
}> - `${formatNumericValue(next)}px`} - onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)} - /> -
+ {hasVisualBackground && ( +
}> + `${formatNumericValue(next)}px`} + onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)} + /> +
+ )}
}>
diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts index 6d40006db..34db1f16f 100644 --- a/packages/studio/src/components/editor/domEditing.ts +++ b/packages/studio/src/components/editor/domEditing.ts @@ -453,16 +453,49 @@ function getElementDepth(el: HTMLElement): number { return depth; } +const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]); + +function isElementComputedVisible(el: HTMLElement): boolean { + const win = el.ownerDocument.defaultView; + if (!win) return true; + let current: HTMLElement | null = el; + while (current) { + const computed = win.getComputedStyle(current); + if (computed.display === "none" || computed.visibility === "hidden") return false; + const opacity = Number.parseFloat(computed.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return false; + current = current.parentElement; + } + return true; +} + +function isEmptyVisualContainer(el: HTMLElement): boolean { + const tag = el.tagName.toLowerCase(); + if (VISUAL_LEAF_TAGS.has(tag)) return false; + + const children = el.children; + if (children.length === 0) { + const text = (el.textContent ?? "").trim(); + return text.length === 0; + } + + for (let i = 0; i < children.length; i += 1) { + const child = children[i]; + if (!isHtmlElement(child)) continue; + if (VISUAL_LEAF_TAGS.has(child.tagName.toLowerCase())) return false; + if (isElementComputedVisible(child)) return false; + } + + return true; +} + function hasRenderedBox(el: HTMLElement): boolean { const rect = el.getBoundingClientRect(); if (rect.width <= 1 || rect.height <= 1) return false; - const computed = el.ownerDocument.defaultView?.getComputedStyle(el); - if (!computed) return true; - if (computed.display === "none" || computed.visibility === "hidden") return false; + if (!isElementComputedVisible(el)) return false; - const opacity = Number.parseFloat(computed.opacity); - if (Number.isFinite(opacity) && opacity <= 0.01) return false; + if (isEmptyVisualContainer(el)) return false; return true; } diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index 3d30fcbca..7fde62f30 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -16,10 +16,10 @@ describe("manual editing availability", () => { vi.resetModules(); }); - it("enables inspector selection by default while motion and manual dragging stay opt-in", async () => { + it("enables inspector selection and manual dragging by default while motion stays opt-in", async () => { const availability = await loadAvailabilityWithEnv({}); - expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(false); + expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(true); expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(true); expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true); expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index f31e151d9..5fd276639 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -32,7 +32,7 @@ const env = import.meta.env as StudioFeatureFlagEnv; export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag( env, [STUDIO_PREVIEW_MANUAL_DRAGGING_ENV, "VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED"], - false, + true, ); export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag( diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index c2662fca7..1e5f65714 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -52,9 +52,6 @@ interface NLELayoutProps { ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; - onInspectTimelineElement?: (element: TimelineElement) => void; - inspectedTimelineElementId?: string | null; - timelineLayerChildCounts?: ReadonlyMap; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ @@ -91,9 +88,6 @@ export const NLELayout = memo(function NLELayout({ onResizeElement, onBlockedEditAttempt, onSelectTimelineElement, - onInspectTimelineElement, - inspectedTimelineElementId, - timelineLayerChildCounts, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -460,10 +454,6 @@ export const NLELayout = memo(function NLELayout({ onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} - onInspectElement={onInspectTimelineElement} - inspectedElementId={inspectedTimelineElementId} - layerChildCounts={timelineLayerChildCounts} - disabled={timelineDisabled} />
{timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 113b3b2d0..892ca730b 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -36,6 +36,8 @@ function getShaderTransitionLoading(event: Event): boolean | null { return state.loading === true && state.ready !== true; } +const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400; + export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean { return compositionLoading; } @@ -124,6 +126,20 @@ export const Player = forwardRef( const [assetOverlayFading, setAssetOverlayFading] = useState(false); const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false); const [compositionLoading, setCompositionLoading] = useState(true); + const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!compositionLoading) { + setCompositionOverlayDeferred(true); + return; + } + const timer = setTimeout( + () => setCompositionOverlayDeferred(false), + COMPOSITION_LOADING_OVERLAY_DELAY_MS, + ); + return () => clearTimeout(timer); + }, [compositionLoading]); useMountEffect(() => { const container = containerRef.current; @@ -281,7 +297,9 @@ export const Player = forwardRef( }, [assetsLoading]); const showCompositionOverlay = - !suppressLoadingOverlay && shouldShowCompositionLoadingOverlay(compositionLoading); + !suppressLoadingOverlay && + !compositionOverlayDeferred && + shouldShowCompositionLoadingOverlay(compositionLoading); const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay; diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 2306958b9..6b9390e63 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -12,8 +12,6 @@ import { shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, } from "./Timeline"; -import { TIMELINE_CLIP_CONTROL_Z_INDEX } from "./TimelineClip"; -import { COMPOSITION_THUMBNAIL_LABEL_Z_INDEX } from "./CompositionThumbnail"; import { formatTime } from "../lib/time"; describe("generateTicks", () => { @@ -166,12 +164,6 @@ describe("shouldAutoScrollTimeline", () => { }); }); -describe("timeline clip controls", () => { - it("renders layer controls above composition thumbnail chrome", () => { - expect(TIMELINE_CLIP_CONTROL_Z_INDEX).toBeGreaterThan(COMPOSITION_THUMBNAIL_LABEL_Z_INDEX); - }); -}); - describe("getTimelineScrollLeftForZoomTransition", () => { it("resets horizontal scroll when switching from manual zoom back to fit", () => { expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index a3270a9ac..8aeec5039 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -28,11 +28,6 @@ import { } from "./timelineTheme"; import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom"; import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; -import { - canInspectTimelineElement, - getTimelineElementKey, - isAudioTimelineElement, -} from "../../utils/timelineInspector"; /* ── Layout ─────────────────────────────────────────────────────── */ const GUTTER = 32; @@ -335,12 +330,6 @@ interface TimelineProps { intent: BlockedTimelineEditIntent, ) => void; onSelectElement?: (element: import("../store/playerStore").TimelineElement | null) => void; - onInspectElement?: (element: import("../store/playerStore").TimelineElement) => void; - inspectedElementId?: string | null; - layerChildCounts?: ReadonlyMap; - thumbnailedElementIds?: ReadonlySet; - onToggleElementThumbnail?: (element: import("../store/playerStore").TimelineElement) => void; - disabled?: boolean; theme?: Partial; } @@ -389,12 +378,6 @@ export const Timeline = memo(function Timeline({ onResizeElement, onBlockedEditAttempt, onSelectElement, - onInspectElement, - inspectedElementId, - layerChildCounts, - thumbnailedElementIds, - onToggleElementThumbnail, - disabled = false, theme: themeOverrides, }: TimelineProps = {}) { const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); @@ -414,8 +397,6 @@ export const Timeline = memo(function Timeline({ const scrollRef = useRef(null); const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); - const disabledRef = useRef(disabled); - disabledRef.current = disabled; const shiftClickClipRef = useRef<{ element: TimelineElement; anchorX: number; @@ -501,19 +482,6 @@ export const Timeline = memo(function Timeline({ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current); }); - useEffect(() => { - if (!disabled) return; - stopClipDragAutoScrollRef.current(); - isDragging.current = false; - isRangeSelecting.current = false; - blockedClipRef.current = null; - setDraggedClip(null); - setResizingClip(null); - setRangeSelection(null); - setShowPopover(false); - setIsDragOver(false); - }, [disabled]); - // Effective duration: max of store duration and the furthest element end. // processTimelineMessage updates elements but not duration, so elements can // extend beyond the store's duration — this ensures fit mode shows everything. @@ -740,7 +708,6 @@ export const Timeline = memo(function Timeline({ const seekFromX = useCallback( (clientX: number) => { - if (disabledRef.current) return; const el = scrollRef.current; if (!el || effectiveDuration <= 0) return; const rect = el.getBoundingClientRect(); @@ -798,7 +765,6 @@ export const Timeline = memo(function Timeline({ }; const handleWindowPointerMove = (e: PointerEvent) => { - if (disabledRef.current) return; const drag = draggedClipRef.current; const resize = resizingClipRef.current; const blocked = blockedClipRef.current; @@ -883,7 +849,6 @@ export const Timeline = memo(function Timeline({ const handleWindowPointerUp = () => { stopClipDragAutoScrollRef.current(); - if (disabledRef.current) return; const resize = resizingClipRef.current; if (resize) { resizingClipRef.current = null; @@ -989,10 +954,6 @@ export const Timeline = memo(function Timeline({ const handlePointerDown = useCallback( (e: React.PointerEvent) => { - if (disabledRef.current) { - e.preventDefault(); - return; - } if (e.button !== 0) return; // Shift+click starts range selection — even on clips @@ -1024,7 +985,6 @@ export const Timeline = memo(function Timeline({ ); const handlePointerMove = useCallback( (e: React.PointerEvent) => { - if (disabledRef.current) return; if (isRangeSelecting.current) { const rect = scrollRef.current?.getBoundingClientRect(); if (rect) { @@ -1095,7 +1055,6 @@ export const Timeline = memo(function Timeline({ const [isDragOver, setIsDragOver] = useState(false); const handleAssetDragOver = useCallback((e: React.DragEvent) => { - if (disabledRef.current) return; const hasFiles = e.dataTransfer.files.length > 0; const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME); if (!hasFiles && !hasAsset) return; @@ -1110,7 +1069,6 @@ export const Timeline = memo(function Timeline({ (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); - if (disabledRef.current) return; if (onFileDrop && e.dataTransfer.files.length > 0) { const scroll = scrollRef.current; const rect = scroll?.getBoundingClientRect(); @@ -1167,7 +1125,6 @@ export const Timeline = memo(function Timeline({ const handlePinchWheel = useCallback( (e: WheelEvent) => { - if (disabledRef.current) return; if (!e.ctrlKey) return; const scroll = scrollRef.current; if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) { @@ -1223,7 +1180,6 @@ export const Timeline = memo(function Timeline({ className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${ isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50" }`} - aria-disabled={disabled || undefined} onDragOver={handleAssetDragOver} onDragLeave={() => setIsDragOver(false)} onDrop={handleAssetDrop} @@ -1379,13 +1335,8 @@ export const Timeline = memo(function Timeline({
setHoveredClip(clipKey)} onHoverEnd={() => setHoveredClip(null)} - onInspectorClick={ - canInspectClip && onInspectElement - ? (e) => { - e.stopPropagation(); - if (suppressClickRef.current) return; - setSelectedElementId(elementKey); - onSelectElement?.(el); - onInspectElement(el); - } - : undefined - } - onThumbnailClick={ - onToggleElementThumbnail && canInspectClip - ? (e) => { - e.stopPropagation(); - if (suppressClickRef.current) return; - onToggleElementThumbnail(el); - } - : undefined - } onResizeStart={(edge, e) => { if (e.button !== 0 || e.shiftKey || !onResizeElement) return; if (edge === "start" && !capabilities.canTrimStart) return; diff --git a/packages/studio/src/player/components/TimelineClip.test.ts b/packages/studio/src/player/components/TimelineClip.test.ts deleted file mode 100644 index 1ea987170..000000000 --- a/packages/studio/src/player/components/TimelineClip.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getTimelineClipControlPresentation } from "./TimelineClip"; - -describe("getTimelineClipControlPresentation", () => { - it("collapses persistent controls for compact clips until the clip is interactive", () => { - expect( - getTimelineClipControlPresentation({ - widthPx: 42, - isHovered: false, - isSelected: false, - isInspectorActive: false, - isThumbnailActive: false, - isDragging: false, - }), - ).toMatchObject({ - compact: true, - showControls: false, - }); - }); - - it("shows compact controls when the clip is hovered, selected, or active", () => { - expect( - getTimelineClipControlPresentation({ - widthPx: 42, - isHovered: true, - isSelected: false, - isInspectorActive: false, - isThumbnailActive: false, - isDragging: false, - }), - ).toMatchObject({ - compact: true, - showControls: true, - }); - - expect( - getTimelineClipControlPresentation({ - widthPx: 42, - isHovered: false, - isSelected: false, - isInspectorActive: true, - isThumbnailActive: false, - isDragging: false, - }).showControls, - ).toBe(true); - }); - - it("keeps controls visible on wide clips", () => { - expect( - getTimelineClipControlPresentation({ - widthPx: 120, - isHovered: false, - isSelected: false, - isInspectorActive: false, - isThumbnailActive: false, - isDragging: false, - }), - ).toMatchObject({ - compact: false, - showControls: true, - }); - }); - - it("treats medium-width clips as compact so dense tracks do not turn into icon grids", () => { - expect( - getTimelineClipControlPresentation({ - widthPx: 96, - isHovered: false, - isSelected: false, - isInspectorActive: false, - isThumbnailActive: false, - isDragging: false, - }), - ).toMatchObject({ - compact: true, - showControls: false, - }); - }); - - it("hides controls while dragging", () => { - expect( - getTimelineClipControlPresentation({ - widthPx: 120, - isHovered: true, - isSelected: true, - isInspectorActive: true, - isThumbnailActive: true, - isDragging: true, - }).showControls, - ).toBe(false); - }); -}); diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 6d7df4ad1..0fbef33e0 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -1,5 +1,4 @@ import type { TimelineTrackStyle } from "./timelineTheme"; -// TimelineClip — Visual clip component for the NLE timeline. import { memo, type ReactNode } from "react"; import type { TimelineElement } from "../store/playerStore"; @@ -17,67 +16,15 @@ interface TimelineClipProps { theme?: TimelineTheme; trackStyle: TimelineTrackStyle; isComposition: boolean; - isInspectorActive?: boolean; - isThumbnailActive?: boolean; - thumbnailLabel?: string; - childCount?: number; onHoverStart: () => void; onHoverEnd: () => void; onPointerDown?: (e: React.PointerEvent) => void; onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void; - onInspectorClick?: (e: React.MouseEvent) => void; - onThumbnailClick?: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void; onDoubleClick: (e: React.MouseEvent) => void; children?: ReactNode; } -export const TIMELINE_CLIP_CONTROL_Z_INDEX = 20; - -const COMPACT_CLIP_CONTROL_WIDTH = 112; - -interface TimelineClipControlPresentationInput { - widthPx: number; - isSelected: boolean; - isHovered: boolean; - isInspectorActive: boolean; - isThumbnailActive: boolean; - isDragging: boolean; -} - -export interface TimelineClipControlPresentation { - compact: boolean; - showControls: boolean; - containerClassName: string; - buttonClassName: string; - iconSize: number; -} - -export function getTimelineClipControlPresentation({ - widthPx, - isSelected, - isHovered, - isInspectorActive, - isThumbnailActive, - isDragging, -}: TimelineClipControlPresentationInput): TimelineClipControlPresentation { - const compact = widthPx < COMPACT_CLIP_CONTROL_WIDTH; - const isInteractive = isHovered || isSelected || isInspectorActive || isThumbnailActive; - const showControls = !isDragging && (!compact || isInteractive); - - return { - compact, - showControls, - containerClassName: compact - ? "absolute right-1 top-1 flex items-center gap-1" - : "absolute right-2 top-2 flex items-center gap-1", - buttonClassName: compact - ? "flex h-5 w-5 items-center justify-center rounded-[7px]" - : "flex h-6 w-6 items-center justify-center rounded-md", - iconSize: compact ? 12 : 14, - }; -} - export const TimelineClip = memo(function TimelineClip({ el, pps, @@ -89,16 +36,10 @@ export const TimelineClip = memo(function TimelineClip({ theme = defaultTimelineTheme, trackStyle, isComposition, - isInspectorActive = false, - isThumbnailActive = false, - thumbnailLabel = "thumbnail", - childCount = 0, onHoverStart, onHoverEnd, onPointerDown, onResizeStart, - onInspectorClick, - onThumbnailClick, onClick, onDoubleClick, children, @@ -120,38 +61,7 @@ export const TimelineClip = memo(function TimelineClip({ : theme.clipShadow; const capabilities = getTimelineEditCapabilities(el); const displayLabel = el.label || el.id || el.tag; - const inspectorLabel = - childCount > 0 - ? `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}` - : "Inspect clip layer"; const showHandles = handleOpacity > 0.01; - const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground; - const controlPresentation = getTimelineClipControlPresentation({ - widthPx, - isSelected, - isHovered, - isInspectorActive, - isThumbnailActive, - isDragging, - }); - const glossBackgroundImage = isSelected - ? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))" - : "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))"; - const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${ - isSelected ? "22" : "1e" - }, transparent 28%)`; - const compositionStripeBackgroundImage = - isComposition && !hasCustomContent - ? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)" - : undefined; - const clipBackgroundImage = [ - compositionStripeBackgroundImage, - glossBackgroundImage, - accentBackgroundImage, - baseBackgroundImage, - ] - .filter(Boolean) - .join(", "); return (
- {childCount > 0 && controlPresentation.showControls && ( - - )} - {onInspectorClick && - controlPresentation.compact && - !controlPresentation.showControls && - !isDragging && ( - - )} - {(onThumbnailClick || onInspectorClick) && controlPresentation.showControls && ( -
- {onThumbnailClick && ( - - )} - {onInspectorClick && ( - - )} -
- )}