diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc
index ccb30a1f32..ba82d3950b 100644
--- a/.fallowrc.jsonc
+++ b/.fallowrc.jsonc
@@ -55,6 +55,16 @@
"packages/studio/src/hooks/gsapRuntimePreview.ts",
// TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
// the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
+ "packages/studio/src/components/nle/NLEContext.tsx",
+ "packages/studio/src/components/nle/PreviewPane.tsx",
+ "packages/studio/src/components/nle/AssetPreviewOverlay.tsx",
+ "packages/studio/src/player/components/TimelineOverlays.tsx",
+ "packages/studio/src/player/components/timelineClipChildren.tsx",
+ "packages/studio/src/player/components/timelineClipDragTypes.ts",
+ "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts",
+ "packages/studio/src/utils/assetPreviewStore.ts",
+ // TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
+ // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
"packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
@@ -446,6 +456,9 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
+ // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
+ // removed by studio-dnd/pr22 when the final config lands.
+ "packages/studio/src/player/components/TimelineOverlays.tsx",
// TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
// removed by studio-dnd/pr22 when the final config lands.
"packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
diff --git a/packages/studio/src/components/nle/AssetPreviewOverlay.tsx b/packages/studio/src/components/nle/AssetPreviewOverlay.tsx
new file mode 100644
index 0000000000..5364289f15
--- /dev/null
+++ b/packages/studio/src/components/nle/AssetPreviewOverlay.tsx
@@ -0,0 +1,147 @@
+/**
+ * CapCut-style asset preview overlay rendered inside PreviewPane.
+ *
+ * Shown when the user clicks an asset card that has NOT yet been added to the
+ * timeline. Displays the media (image / video / audio) without modifying the
+ * composition — no undo entry, no file mutation.
+ *
+ * Dismiss: X button, Escape key, or click on the scrim.
+ * Switching to another not-added asset replaces the current preview.
+ */
+import { useEffect, useCallback } from "react";
+import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
+import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
+
+function basename(path: string): string {
+ return path.split("/").pop() ?? path;
+}
+
+type AssetKind = "image" | "video" | "audio";
+
+function resolveAssetKind(path: string): AssetKind {
+ if (VIDEO_EXT.test(path)) return "video";
+ if (IMAGE_EXT.test(path)) return "image";
+ return "audio";
+}
+
+/** The media element for a previewed asset, chosen by kind. */
+function AssetPreviewMedia({
+ kind,
+ serveUrl,
+ name,
+}: {
+ kind: AssetKind;
+ serveUrl: string;
+ name: string;
+}) {
+ if (kind === "image") {
+ return (
+
+ );
+ }
+ if (kind === "video") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+export function AssetPreviewOverlay() {
+ const previewAsset = useAssetPreviewStore((s) => s.previewAsset);
+ const previewProjectId = useAssetPreviewStore((s) => s.previewProjectId);
+ const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === "Escape") clearPreviewAsset();
+ },
+ [clearPreviewAsset],
+ );
+
+ useEffect(() => {
+ if (!previewAsset) return;
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [previewAsset, handleKeyDown]);
+
+ if (!previewAsset || !previewProjectId) return null;
+
+ const encodedAsset = previewAsset.split("/").map(encodeURIComponent).join("/");
+ const serveUrl = `/api/projects/${previewProjectId}/preview/${encodedAsset}`;
+ const name = basename(previewAsset);
+
+ return (
+
+ {/* Close button */}
+
+
+ {/* Media */}
+
e.stopPropagation()}
+ >
+
+
+ {/* Filename label */}
+
+ {name}
+
+
+
+ );
+}
diff --git a/packages/studio/src/components/nle/NLEContext.test.ts b/packages/studio/src/components/nle/NLEContext.test.ts
new file mode 100644
index 0000000000..9c9617f7dc
--- /dev/null
+++ b/packages/studio/src/components/nle/NLEContext.test.ts
@@ -0,0 +1,88 @@
+// @vitest-environment happy-dom
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { shouldDisableTimelineWhileCompositionLoading, NLEProvider } from "./NLEContext";
+import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
+import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
+
+installReactActEnvironment();
+
+// Render NLEProvider into a fresh detached host and settle the mount effect.
+async function mountNleProvider(props: {
+ projectId: string;
+ refreshKey?: number;
+}): Promise<{ host: HTMLDivElement; root: Root }> {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ await renderNleProvider(root, props);
+ return { host, root };
+}
+
+// Re-render NLEProvider into an existing root and settle effects.
+async function renderNleProvider(
+ root: Root,
+ props: { projectId: string; refreshKey?: number },
+): Promise {
+ await act(async () => {
+ root.render(React.createElement(NLEProvider, props, React.createElement("div")));
+ await Promise.resolve();
+ });
+}
+
+describe("timeline loading disable state", () => {
+ it("disables the timeline while the composition loading overlay is visible", () => {
+ expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true);
+ });
+
+ it("reenables the timeline after composition loading finishes", () => {
+ expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false);
+ });
+});
+
+describe("NLEProvider — asset preview scoping", () => {
+ beforeEach(() => {
+ // No project API in this unit test — stub fetch so the compIdToSrc mount
+ // effect's request rejects quietly instead of hitting the network.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.reject(new Error("no network in tests"))),
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ useAssetPreviewStore.getState().clearPreviewAsset();
+ });
+
+ it("clears a stale cross-project asset preview when the active project changes", async () => {
+ const { host, root } = await mountNleProvider({ projectId: "project-a" });
+
+ useAssetPreviewStore.getState().setPreviewAsset("assets/photo.png", "project-a");
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/photo.png");
+
+ await renderNleProvider(root, { projectId: "project-b" });
+
+ // The overlay stays mounted across the project switch (EditorShell isn't
+ // keyed by projectId) — the store itself must be the thing that clears.
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
+ expect(useAssetPreviewStore.getState().previewProjectId).toBeNull();
+
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ it("does not clear the preview on a re-render that keeps the same projectId", async () => {
+ const { host, root } = await mountNleProvider({ projectId: "project-a" });
+
+ useAssetPreviewStore.getState().setPreviewAsset("assets/photo.png", "project-a");
+
+ await renderNleProvider(root, { projectId: "project-a", refreshKey: 1 });
+
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/photo.png");
+
+ act(() => root.unmount());
+ host.remove();
+ });
+});
diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx
new file mode 100644
index 0000000000..13db697f2e
--- /dev/null
+++ b/packages/studio/src/components/nle/NLEContext.tsx
@@ -0,0 +1,312 @@
+import {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ useRef,
+ useEffect,
+ type ReactNode,
+} from "react";
+import { useMountEffect } from "../../hooks/useMountEffect";
+import { useTimelinePlayer, usePlayerStore } from "../../player";
+import type { TimelineElement } from "../../player";
+import type { CompositionLevel } from "./CompositionBreadcrumb";
+import { useCompositionStack } from "./useCompositionStack";
+import { MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
+import { setCompositionSourceMap } from "../editor/domEditingDom";
+import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
+import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
+import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
+
+// Timeline gets a generous default height so the preview isn't oversized and the
+// tracks have room to breathe (CapCut-style). Users can still drag the divider.
+const DEFAULT_TIMELINE_H = 340;
+
+export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
+ return compositionLoading;
+}
+
+export interface NLEContextValue {
+ projectId: string;
+ // player (from useTimelinePlayer — single instance for the whole shell)
+ iframeRef: React.MutableRefObject;
+ togglePlay: () => void;
+ seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
+ refreshPlayer: () => void;
+ onIframeLoad: () => void;
+ // composition stack (from useCompositionStack)
+ compositionStack: CompositionLevel[];
+ updateCompositionStack: React.Dispatch>;
+ handleNavigateComposition: (index: number) => void;
+ handleDrillDown: (element: TimelineElement) => void;
+ compIdToSrc: Map;
+ // layout state
+ timelineH: number;
+ setTimelineH: React.Dispatch>;
+ persistTimelineH: (height: number) => void;
+ containerRef: React.RefObject;
+ // composition loading
+ compositionLoading: boolean;
+ setCompositionLoading: (loading: boolean) => void;
+ timelineDisabled: boolean;
+ hasLoadedOnceRef: React.MutableRefObject;
+ // preview composition size (for preview block drop)
+ previewCompositionSize: { width: number; height: number } | null;
+ setPreviewCompositionSize: (size: { width: number; height: number } | null) => void;
+}
+
+const NLEContext = createContext(null);
+
+export function useNLEContext(): NLEContextValue {
+ const ctx = useContext(NLEContext);
+ if (!ctx) throw new Error("useNLEContext must be used within an NLEProvider");
+ return ctx;
+}
+
+export interface NLEProviderProps {
+ projectId: string;
+ refreshKey?: number;
+ activeCompositionPath?: string | null;
+ onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
+ onCompositionChange?: (compositionPath: string | null) => void;
+ onCompIdToSrcChange?: (map: Map) => void;
+ onCompositionLoadingChange?: (loading: boolean) => void;
+ children: ReactNode;
+}
+
+export function NLEProvider({
+ projectId,
+ refreshKey,
+ activeCompositionPath,
+ onIframeRef,
+ onCompositionChange,
+ onCompIdToSrcChange,
+ onCompositionLoadingChange,
+ children,
+}: NLEProviderProps) {
+ const {
+ iframeRef,
+ togglePlay,
+ seek,
+ onIframeLoad: baseOnIframeLoad,
+ refreshPlayer,
+ } = useTimelinePlayer();
+
+ // Reset timeline state when the project changes. Done in an effect, not during
+ // render: reset() updates the player store, and updating another store/component
+ // mid-render triggers React's "Cannot update a component while rendering a
+ // different component" warning. The effect runs right after commit, so the new
+ // project's first frame may briefly show prior timeline state before it clears.
+ //
+ // Also clears the asset preview overlay store: it is project-scoped (see its
+ // doc-comment) but nothing else clears it on project change — EditorShell isn't
+ // keyed by projectId and the overlay stays mounted, so a preview opened in one
+ // project would otherwise keep rendering (and re-fetching from) the old project
+ // after switching.
+ useEffect(() => {
+ usePlayerStore.getState().reset();
+ useAssetPreviewStore.getState().clearPreviewAsset();
+ }, [projectId]);
+
+ // Authored composition size measured from the loaded preview — drives drop
+ // coordinate mapping so blocks land where the user pointed on any comp size.
+ const [previewCompositionSize, setPreviewCompositionSize] = useState<{
+ width: number;
+ height: number;
+ } | null>(null);
+
+ // Lightweight reload: change iframe src instead of destroying the Player.
+ const prevRefreshKeyRef = useRef(refreshKey);
+ useEffect(() => {
+ if (refreshKey === prevRefreshKeyRef.current) return;
+ prevRefreshKeyRef.current = refreshKey;
+ refreshPlayer();
+ }, [refreshKey, refreshPlayer]);
+
+ const onIframeLoad = useCallback(() => {
+ baseOnIframeLoad();
+ // Pre-load + register MotionPathPlugin once so adding a motion path in the
+ // studio doesn't take the async plugin-load flash path on the first soft
+ // reload (the comp may not ship the plugin until it actually uses one).
+ ensureMotionPathPluginLoaded(iframeRef.current);
+ onIframeRef?.(iframeRef.current);
+ }, [baseOnIframeLoad, iframeRef, onIframeRef]);
+
+ const {
+ compositionStack,
+ updateCompositionStack,
+ handleNavigateComposition,
+ handleDrillDown: drillDown,
+ compIdToSrc,
+ setCompIdToSrc,
+ } = useCompositionStack({
+ projectId,
+ activeCompositionPath,
+ onCompositionChange,
+ });
+
+ // Wrap handleDrillDown to also scan the iframe DOM for data-composition-src
+ const iframeRef_ = iframeRef;
+ const handleDrillDown = useCallback(
+ (element: TimelineElement) => {
+ if (!element.compositionSrc) return;
+ usePlayerStore.getState().setSelectedElementId(null);
+ // Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown
+ const compId = element.id;
+ let resolvedPath = compIdToSrc.get(compId);
+ if (!resolvedPath) {
+ try {
+ const doc = iframeRef_.current?.contentDocument;
+ if (doc) {
+ const host = doc.querySelector(
+ `[data-composition-id="${CSS.escape(compId)}"][data-composition-src]`,
+ );
+ if (host) {
+ resolvedPath = host.getAttribute("data-composition-src") || undefined;
+ }
+ }
+ } catch {
+ /* cross-origin */
+ }
+ }
+ // Delegate with the resolved compositionSrc (may be same as original)
+ drillDown({
+ id: compId,
+ compositionSrc: resolvedPath ?? element.compositionSrc,
+ });
+ },
+ [compIdToSrc, drillDown, iframeRef_],
+ );
+
+ // Composition ID → file path map from raw index.html
+ const compIdToSrcRef = useRef(compIdToSrc);
+ compIdToSrcRef.current = compIdToSrc;
+
+ useMountEffect(() => {
+ fetch(`/api/projects/${projectId}/files/index.html`)
+ .then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+ })
+ .then((data: { content?: string }) => {
+ const html = data.content || "";
+ const map = new Map();
+ const re =
+ /data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g;
+ let match;
+ while ((match = re.exec(html)) !== null) {
+ const id = match[1] || match[4];
+ const src = match[2] || match[3];
+ if (id && src) map.set(id, src);
+ }
+ setCompIdToSrc(map);
+ // Let DOM source-resolution recover a subcomposition element's source file
+ // (the runtime drops the linkage when inlining — see getSourceFileForElement).
+ setCompositionSourceMap(map);
+ onCompIdToSrcChange?.(map);
+ })
+ .catch((err: unknown) => {
+ // Non-fatal: drill-down still works via the iframe DOM scan; without
+ // the map only source-file resolution for sub-comps degrades.
+ console.warn("[studio] Couldn't load composition source map from index.html:", err);
+ });
+ });
+
+ // Patch elements with compositionSrc whenever elements or compIdToSrc change.
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (compIdToSrc.size === 0) return;
+ const patchElements = (elements: TimelineElement[]): TimelineElement[] | null => {
+ const map = compIdToSrcRef.current;
+ if (map.size === 0) return null;
+ let patched = false;
+ const updated = elements.map((el) => {
+ if (el.compositionSrc) return el;
+ const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
+ if (src) {
+ patched = true;
+ return { ...el, compositionSrc: src };
+ }
+ return el;
+ });
+ return patched ? updated : null;
+ };
+ const patched = patchElements(usePlayerStore.getState().elements);
+ if (patched) usePlayerStore.getState().setElements(patched);
+ let patching = false;
+ return usePlayerStore.subscribe((state, prev) => {
+ if (patching) return;
+ if (state.elements === prev.elements || state.elements.length === 0) return;
+ if (state.elements.every((el) => el.compositionSrc)) return;
+ patching = true;
+ const result = patchElements(state.elements);
+ if (result) state.setElements(result);
+ patching = false;
+ });
+ }, [compIdToSrc]);
+
+ // Resizable timeline height — persisted alongside zoom/pan so the user's
+ // workspace layout survives reloads.
+ const [timelineH, setTimelineH] = useState(() => {
+ const stored = readStudioUiPreferences().timelineHeight;
+ return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H;
+ });
+ const persistTimelineH = useCallback((height: number) => {
+ writeStudioUiPreferences({ timelineHeight: Math.round(height) });
+ }, []);
+ const containerRef = useRef(null);
+ // A height persisted on a tall window can exceed this window's container and
+ // collapse the flex-1 preview to 0px — clamp once the container is measurable
+ // (the drag/keyboard paths already clamp; the restore path must too).
+ useEffect(() => {
+ const containerH = containerRef.current?.getBoundingClientRect().height;
+ if (!containerH) return;
+ const max = containerH - MIN_PREVIEW_H;
+ setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
+ }, []);
+
+ const hasLoadedOnceRef = useRef(false);
+ const [compositionLoading, setCompositionLoadingRaw] = useState(true);
+ const setCompositionLoading = useCallback((loading: boolean) => {
+ if (!loading) hasLoadedOnceRef.current = true;
+ if (loading && hasLoadedOnceRef.current) return;
+ setCompositionLoadingRaw(loading);
+ }, []);
+ const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
+
+ useEffect(() => {
+ onCompositionLoadingChange?.(compositionLoading);
+ }, [compositionLoading, onCompositionLoadingChange]);
+
+ const onIframeRefStable = useRef(onIframeRef);
+ onIframeRefStable.current = onIframeRef;
+ useEffect(() => {
+ onIframeRefStable.current?.(iframeRef.current);
+ }, [compositionStack.length, refreshKey, iframeRef]);
+
+ const value: NLEContextValue = {
+ projectId,
+ iframeRef,
+ togglePlay,
+ seek,
+ refreshPlayer,
+ onIframeLoad,
+ compositionStack,
+ updateCompositionStack,
+ handleNavigateComposition,
+ handleDrillDown,
+ compIdToSrc,
+ timelineH,
+ setTimelineH,
+ persistTimelineH,
+ containerRef,
+ compositionLoading,
+ setCompositionLoading,
+ timelineDisabled,
+ hasLoadedOnceRef,
+ previewCompositionSize,
+ setPreviewCompositionSize,
+ };
+
+ return {children};
+}
diff --git a/packages/studio/src/components/nle/PreviewPane.tsx b/packages/studio/src/components/nle/PreviewPane.tsx
new file mode 100644
index 0000000000..530f4ba95f
--- /dev/null
+++ b/packages/studio/src/components/nle/PreviewPane.tsx
@@ -0,0 +1,163 @@
+import { useCallback, useRef, useSyncExternalStore, type ReactNode } from "react";
+import { PlayerControls } from "../../player";
+import type { TimelineElement } from "../../player";
+import { NLEPreview } from "./NLEPreview";
+import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
+import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
+import { useNLEContext } from "./NLEContext";
+import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
+
+function subscribeFullscreen(cb: () => void) {
+ document.addEventListener("fullscreenchange", cb);
+ return () => document.removeEventListener("fullscreenchange", cb);
+}
+
+function getFullscreenElement() {
+ return document.fullscreenElement;
+}
+
+// Clear the timeline selection when a pointer lands outside the composition
+// frame (clicks *inside* the frame are handled by the DOM-edit overlay).
+// fallow-ignore-next-line complexity
+function deselectIfPointerOutsideFrame(
+ e: React.PointerEvent,
+ iframe: HTMLIFrameElement | null,
+ onDeselect?: (element: null) => void,
+): void {
+ const el = iframe?.parentElement ?? iframe;
+ if (!el) return;
+ const rect = el.getBoundingClientRect();
+ const outside =
+ e.clientX < rect.left ||
+ e.clientX > rect.right ||
+ e.clientY < rect.top ||
+ e.clientY > rect.bottom;
+ if (outside) onDeselect?.(null);
+}
+
+export interface PreviewPaneProps {
+ portrait?: boolean;
+ /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
+ previewOverlay?: ReactNode;
+ onSelectTimelineElement?: (element: TimelineElement | null) => void;
+ onPreviewBlockDrop?: (
+ blockName: string,
+ position: { left: number; top: number },
+ ) => Promise | void;
+}
+
+// fallow-ignore-next-line complexity
+export function PreviewPane({
+ portrait,
+ previewOverlay,
+ onSelectTimelineElement,
+ onPreviewBlockDrop,
+}: PreviewPaneProps) {
+ const {
+ projectId,
+ iframeRef,
+ togglePlay,
+ seek,
+ onIframeLoad,
+ compositionStack,
+ handleNavigateComposition,
+ setCompositionLoading,
+ timelineDisabled,
+ hasLoadedOnceRef,
+ previewCompositionSize,
+ setPreviewCompositionSize,
+ } = useNLEContext();
+
+ const stageRefForDrop = useRef(null);
+ const handleStageRef = useCallback((ref: React.RefObject) => {
+ stageRefForDrop.current = ref.current;
+ }, []);
+
+ const {
+ isDragOver: previewDragOver,
+ handleDragEnter: handlePreviewDragEnter,
+ handleDragOver: handlePreviewDragOver,
+ handleDragLeave: handlePreviewDragLeave,
+ handleDrop: handlePreviewDrop,
+ } = usePreviewBlockDrop({
+ portrait,
+ compositionSize: previewCompositionSize,
+ stageRef: stageRefForDrop as React.RefObject,
+ onBlockDrop: onPreviewBlockDrop,
+ });
+
+ // Preview-only fullscreen: fullscreen targets THIS pane's container, so the
+ // browser shows only the preview (sidebars + timeline are excluded naturally).
+ const containerRef = useRef(null);
+ const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement);
+ const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null;
+
+ const toggleFullscreen = useCallback(() => {
+ if (!containerRef.current) return;
+ if (document.fullscreenElement) {
+ void document.exitFullscreen();
+ } else {
+ void containerRef.current.requestFullscreen();
+ }
+ }, []);
+
+ const currentLevel = compositionStack[compositionStack.length - 1];
+ const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
+
+ return (
+
+
+ deselectIfPointerOutsideFrame(e, iframeRef.current, onSelectTimelineElement)
+ }
+ onDragEnter={handlePreviewDragEnter}
+ onDragOver={handlePreviewDragOver}
+ onDragLeave={handlePreviewDragLeave}
+ onDrop={handlePreviewDrop}
+ >
+
+
+ {previewDragOver && (
+
+ )}
+
+
+ {!isFullscreen && previewOverlay}
+
+ {/* Transport row: no own background or border — the controls sit flat on
+ the preview panel's surface (CapCut-style). */}
+
+ {!isFullscreen && compositionStack.length > 1 && (
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/studio/src/player/components/TimelineOverlays.tsx b/packages/studio/src/player/components/TimelineOverlays.tsx
new file mode 100644
index 0000000000..0f0efcb73d
--- /dev/null
+++ b/packages/studio/src/player/components/TimelineOverlays.tsx
@@ -0,0 +1,122 @@
+import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
+import type { TimelineTheme } from "./timelineTheme";
+import type { TimelineRangeSelection } from "./timelineEditing";
+import type { TimelineEditCallbacks } from "./timelineCallbacks";
+import { EditPopover } from "./EditModal";
+import {
+ KeyframeDiamondContextMenu,
+ type KeyframeDiamondContextMenuState,
+} from "./KeyframeDiamondContextMenu";
+import { ClipContextMenu } from "./ClipContextMenu";
+import { TimelineShortcutHint } from "./TimelineShortcutHint";
+
+interface ClipContextMenuState {
+ x: number;
+ y: number;
+ element: TimelineElement;
+}
+
+interface TimelineOverlaysProps {
+ theme: TimelineTheme;
+ showShortcutHint: boolean;
+ showPopover: boolean;
+ rangeSelection: TimelineRangeSelection | null;
+ setShowPopover: (value: boolean) => void;
+ setRangeSelection: (value: TimelineRangeSelection | null) => void;
+ kfContextMenu: KeyframeDiamondContextMenuState | null;
+ setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
+ onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
+ onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
+ onChangeKeyframeEase: TimelineEditCallbacks["onChangeKeyframeEase"];
+ onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
+ keyframeCache: Map;
+ clipContextMenu: ClipContextMenuState | null;
+ setClipContextMenu: (value: ClipContextMenuState | null) => void;
+ currentTime: number;
+ onSplitElement: TimelineEditCallbacks["onSplitElement"];
+ pinZoomBeforeEdit: () => void;
+ onDeleteElement?: (element: TimelineElement) => Promise | void;
+}
+
+// The timeline's floating overlays, rendered as siblings above the scroll area:
+// the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
+// and the clip context menu.
+export function TimelineOverlays({
+ theme,
+ showShortcutHint,
+ showPopover,
+ rangeSelection,
+ setShowPopover,
+ setRangeSelection,
+ kfContextMenu,
+ setKfContextMenu,
+ onDeleteKeyframe,
+ onDeleteAllKeyframes,
+ onChangeKeyframeEase,
+ onMoveKeyframeToPlayhead,
+ keyframeCache,
+ clipContextMenu,
+ setClipContextMenu,
+ currentTime,
+ onSplitElement,
+ pinZoomBeforeEdit,
+ onDeleteElement,
+}: TimelineOverlaysProps) {
+ return (
+ <>
+ {showShortcutHint && !showPopover && !rangeSelection && (
+
+ )}
+
+ {showPopover && rangeSelection && (
+ {
+ setShowPopover(false);
+ setRangeSelection(null);
+ }}
+ />
+ )}
+
+ {kfContextMenu && (
+ setKfContextMenu(null)}
+ onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
+ onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
+ onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
+ onMoveToPlayhead={
+ onMoveKeyframeToPlayhead
+ ? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
+ : undefined
+ }
+ onCopyProperties={(elId, pct) => {
+ const kfData = keyframeCache.get(elId);
+ const kf = kfData?.keyframes.find((k) => k.percentage === pct);
+ if (kf) {
+ void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2));
+ }
+ }}
+ />
+ )}
+
+ {clipContextMenu && (
+ setClipContextMenu(null)}
+ onSplit={(el, time) => onSplitElement?.(el, time)}
+ onDelete={(el) => {
+ pinZoomBeforeEdit();
+ onDeleteElement?.(el);
+ }}
+ />
+ )}
+ >
+ );
+}
diff --git a/packages/studio/src/player/components/timelineClipChildren.tsx b/packages/studio/src/player/components/timelineClipChildren.tsx
new file mode 100644
index 0000000000..3482fbdc00
--- /dev/null
+++ b/packages/studio/src/player/components/timelineClipChildren.tsx
@@ -0,0 +1,39 @@
+import { type ReactNode } from "react";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
+import type { TrackVisualStyle } from "./timelineIcons";
+
+function ClipLintDot({ element }: { element: TimelineElement }) {
+ const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
+ if (!lint || lint.count === 0) return null;
+ return (
+
+ );
+}
+
+export function renderClipChildren(
+ element: TimelineElement,
+ clipStyle: TrackVisualStyle,
+ renderClipContent:
+ | ((element: TimelineElement, style: { clip: string; label: string }) => ReactNode)
+ | undefined,
+ renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined,
+): ReactNode {
+ return (
+ <>
+ {renderClipOverlay?.(element)}
+ {!renderClipContent && }
+ {renderClipContent && (
+ // borderRadius: inherit — the clip itself is overflow-visible (keyframe
+ // diamonds hang outside its bounds), so the thumbnail layer must clip
+ // itself to the clip's rounded corners or sharp corners poke out.
+
+ {renderClipContent(element, clipStyle)}
+
+ )}
+ >
+ );
+}
diff --git a/packages/studio/src/player/components/timelineClipDragTypes.ts b/packages/studio/src/player/components/timelineClipDragTypes.ts
new file mode 100644
index 0000000000..8ac36c9a68
--- /dev/null
+++ b/packages/studio/src/player/components/timelineClipDragTypes.ts
@@ -0,0 +1,65 @@
+import type { TimelineElement } from "../store/playerStore";
+import type { TimelineSnapType } from "./timelineSnapping";
+import type { BlockedTimelineEditIntent } from "./timelineEditing";
+
+/* ── Shared clip-drag state types ───────────────────────────────── */
+export interface DraggedClipState {
+ element: TimelineElement;
+ originClientX: number;
+ originClientY: number;
+ originScrollLeft: number;
+ originScrollTop: number;
+ pointerClientX: number;
+ pointerClientY: number;
+ pointerOffsetX: number;
+ pointerOffsetY: number;
+ previewStart: number;
+ previewTrack: number;
+ /**
+ * The lane the POINTER aims at (from the drag's vertical position), before
+ * collision resolution. `previewTrack` is where the clip actually LANDS —
+ * which may differ from `desiredTrack` when the aimed span is occupied and the
+ * collision rules bump the dragged clip to a free lane. The commit reads this
+ * to tell a deliberate VERTICAL lane change (pointer aimed at another lane →
+ * stacking sync allowed) from a plain HORIZONTAL drag whose clip merely got
+ * bumped sideways (never touches z). Optional: when absent the commit falls
+ * back to `previewTrack` (pre-existing behaviour).
+ */
+ desiredTrack?: number;
+ /**
+ * When non-null, the drop inserts a NEW track at this visual row boundary
+ * (0 = above the top lane, trackOrder.length = below the bottom) instead of
+ * landing on previewTrack. Drives the insertion-line indicator.
+ */
+ insertRow: number | null;
+ /** Snap target the clip will land on, for the guide highlight. */
+ snapTime: number | null;
+ snapType: TimelineSnapType | null;
+ started: boolean;
+}
+
+export interface ResizingClipState {
+ element: TimelineElement;
+ edge: "start" | "end";
+ originClientX: number;
+ /**
+ * scrollLeft at gesture start. Edge auto-scroll moves the content under a
+ * stationary pointer, so the trim math folds (current − origin) scrollLeft
+ * into the pointer x — mirroring resolveTimelineMove's scroll compensation.
+ * Optional so pre-existing constructors/tests stay valid; when absent the
+ * first preview update captures the current scrollLeft.
+ */
+ originScrollLeft?: number;
+ previewStart: number;
+ previewDuration: number;
+ previewPlaybackStart?: number;
+ started: boolean;
+}
+
+export interface BlockedClipState {
+ element: TimelineElement;
+ intent: BlockedTimelineEditIntent;
+ originClientX: number;
+ originClientY: number;
+ started: boolean;
+}
diff --git a/packages/studio/src/player/hooks/useTimelinePlayerLoop.ts b/packages/studio/src/player/hooks/useTimelinePlayerLoop.ts
new file mode 100644
index 0000000000..bda3d50601
--- /dev/null
+++ b/packages/studio/src/player/hooks/useTimelinePlayerLoop.ts
@@ -0,0 +1,85 @@
+/**
+ * The forward playback loop for the timeline player.
+ *
+ * Owns the three requestAnimationFrame lifecycle callbacks that drive (and stop)
+ * playback:
+ * - startRAFLoop — the forward tick: advance liveTime, honour in/out loop
+ * points + loopEnabled, and pause + sync the store at the end.
+ * - stopRAFLoop — cancel the forward tick.
+ * - stopReverseLoop — cancel the reverse-shuttle tick (owned by the parent hook).
+ *
+ * Called unconditionally at the top level of useTimelinePlayer so its useCallback
+ * hooks run in a stable order; every dependency is passed in as an argument.
+ */
+
+import { useCallback } from "react";
+import { liveTime, usePlayerStore } from "../store/playerStore";
+import type { PlaybackAdapter } from "../lib/playbackTypes";
+
+interface UseTimelinePlayerLoopParams {
+ rafRef: React.MutableRefObject;
+ reverseRafRef: React.MutableRefObject;
+ getAdapter: () => PlaybackAdapter | null;
+ setCurrentTime: (v: number) => void;
+ setIsPlaying: (v: boolean) => void;
+}
+
+interface UseTimelinePlayerLoopResult {
+ startRAFLoop: () => void;
+ stopRAFLoop: () => void;
+ stopReverseLoop: () => void;
+}
+
+export function useTimelinePlayerLoop({
+ rafRef,
+ reverseRafRef,
+ getAdapter,
+ setCurrentTime,
+ setIsPlaying,
+}: UseTimelinePlayerLoopParams): UseTimelinePlayerLoopResult {
+ const stopReverseLoop = useCallback(() => {
+ cancelAnimationFrame(reverseRafRef.current);
+ }, [reverseRafRef]);
+
+ const startRAFLoop = useCallback(() => {
+ // fallow-ignore-next-line complexity
+ const tick = () => {
+ const adapter = getAdapter();
+ if (adapter) {
+ const rawTime = adapter.getTime();
+ const dur = adapter.getDuration();
+ const time = dur > 0 ? Math.min(rawTime, dur) : rawTime;
+ liveTime.notify(time); // direct DOM updates, no React re-render
+ const { inPoint, outPoint } = usePlayerStore.getState();
+ const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur;
+ const rawLoopStart = inPoint !== null ? inPoint : 0;
+ const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
+ const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
+ if (time >= loopEnd) {
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
+ // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
+ adapter.seek(loopStart, { keepPlaying: true });
+ liveTime.notify(loopStart);
+ adapter.play();
+ setIsPlaying(true);
+ rafRef.current = requestAnimationFrame(tick);
+ return;
+ }
+ if (adapter.isPlaying()) adapter.pause();
+ setCurrentTime(time); // sync Zustand once at end
+ setIsPlaying(false);
+ cancelAnimationFrame(rafRef.current);
+ return;
+ }
+ }
+ rafRef.current = requestAnimationFrame(tick);
+ };
+ rafRef.current = requestAnimationFrame(tick);
+ }, [rafRef, getAdapter, setCurrentTime, setIsPlaying]);
+
+ const stopRAFLoop = useCallback(() => {
+ cancelAnimationFrame(rafRef.current);
+ }, [rafRef]);
+
+ return { startRAFLoop, stopRAFLoop, stopReverseLoop };
+}
diff --git a/packages/studio/src/utils/assetPreviewStore.ts b/packages/studio/src/utils/assetPreviewStore.ts
new file mode 100644
index 0000000000..44e807bc54
--- /dev/null
+++ b/packages/studio/src/utils/assetPreviewStore.ts
@@ -0,0 +1,33 @@
+/**
+ * Tiny Zustand slice that carries the "asset preview overlay" state.
+ *
+ * When a user clicks an asset card that has NOT yet been added to the
+ * timeline the overlay fires up: a dark scrim + centered media element
+ * (img / video / audio) + filename label rendered inside PreviewPane.
+ *
+ * State lives here so AssetsTab (sidebar) and PreviewPane (preview column)
+ * can communicate without prop-drilling through the multi-layer EditorShell
+ * tree. The store is project-scoped: NLEProvider (NLEContext.tsx) clears it
+ * whenever `projectId` changes, so a preview opened in one project can't
+ * bleed into another (the overlay itself stays mounted across project
+ * switches — EditorShell isn't keyed by projectId).
+ */
+import { create } from "zustand";
+
+interface AssetPreviewState {
+ /** Project-relative asset path currently being previewed, or null. */
+ previewAsset: string | null;
+ /** projectId for which the preview was opened (used to build the serve URL). */
+ previewProjectId: string | null;
+ /** Open a media preview for the given asset. */
+ setPreviewAsset: (asset: string, projectId: string) => void;
+ /** Close the preview overlay. */
+ clearPreviewAsset: () => void;
+}
+
+export const useAssetPreviewStore = create((set) => ({
+ previewAsset: null,
+ previewProjectId: null,
+ setPreviewAsset: (asset, projectId) => set({ previewAsset: asset, previewProjectId: projectId }),
+ clearPreviewAsset: () => set({ previewAsset: null, previewProjectId: null }),
+}));