Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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",
Expand Down
147 changes: 147 additions & 0 deletions packages/studio/src/components/nle/AssetPreviewOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<img
src={serveUrl}
alt={name}
className="max-w-full max-h-[70vh] rounded-md object-contain shadow-2xl"
/>
);
}
if (kind === "video") {
return (
<video
src={serveUrl}
controls
autoPlay
muted
playsInline
className="max-w-full max-h-[70vh] rounded-md shadow-2xl"
/>
);
}
return (
<div className="flex flex-col items-center gap-4 px-6 py-8 rounded-xl bg-neutral-900 border border-neutral-800">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-neutral-500"
>
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
<audio src={serveUrl} controls className="w-64" />
</div>
);
}

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 (
<div
className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80"
onClick={clearPreviewAsset}
role="dialog"
aria-label={`Preview: ${name}`}
aria-modal="true"
>
{/* Close button */}
<button
className="absolute top-3 right-3 w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
clearPreviewAsset();
}}
aria-label="Close preview"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>

{/* Media */}
<div
className="flex flex-col items-center gap-3 max-w-[90%] max-h-[85%]"
onClick={(e) => e.stopPropagation()}
>
<AssetPreviewMedia kind={resolveAssetKind(previewAsset)} serveUrl={serveUrl} name={name} />

{/* Filename label */}
<span className="text-[12px] text-neutral-400 truncate max-w-full px-2 text-center">
{name}
</span>
</div>
</div>
);
}
88 changes: 88 additions & 0 deletions packages/studio/src/components/nle/NLEContext.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
});
});
Loading
Loading