diff --git a/.gitignore b/.gitignore index 37c0f754..141dcc1c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,9 +48,5 @@ packages/producer/src/services/fontData.generated.ts # Test artifacts my-video/ packages/studio/data/ - -# QA artifacts -qa-*.webm -scorecard.png -.worktrees/ .desloppify/ +.worktrees/ diff --git a/bun.lock b/bun.lock index bac954be..2961df1e 100644 --- a/bun.lock +++ b/bun.lock @@ -136,6 +136,7 @@ "@hyperframes/core": "workspace:*", "@phosphor-icons/react": "^2.1.10", "codemirror": "^6.0.1", + "motion": "^12.38.0", }, "devDependencies": { "@types/react": "^19.0.0", @@ -926,6 +927,8 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1110,6 +1113,12 @@ "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "8.16.0", "pathe": "2.0.3", "pkg-types": "1.3.1", "ufo": "1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], + + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], + + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "1.3.0", "object-assign": "4.1.1", "thenify-all": "1.6.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 2f397612..750a3b56 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -38,7 +38,8 @@ "@codemirror/view": "^6.40.0", "@hyperframes/core": "workspace:*", "@phosphor-icons/react": "^2.1.10", - "codemirror": "^6.0.1" + "codemirror": "^6.0.1", + "motion": "^12.38.0" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index a4f0799a..63fd5db1 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,18 +1,15 @@ import { useState, useCallback, useRef, useEffect, type ReactNode } from "react"; +import { useMountEffect } from "./hooks/useMountEffect"; import { NLELayout } from "./components/nle/NLELayout"; import { SourceEditor } from "./components/editor/SourceEditor"; import { FileTree } from "./components/editor/FileTree"; import { LeftSidebar } from "./components/sidebar/LeftSidebar"; -import { CompositionThumbnail } from "./player/components/CompositionThumbnail"; -import { VideoThumbnail } from "./player/components/VideoThumbnail"; -import type { TimelineElement } from "./player/store/playerStore"; -import { - XIcon, - CodeIcon, - WarningIcon, - CheckCircleIcon, - CaretRightIcon, -} from "@phosphor-icons/react"; +import { RenderQueue } from "./components/renders/RenderQueue"; +import { useRenderQueue } from "./components/renders/useRenderQueue"; +import { CompositionThumbnail, VideoThumbnail } from "./player"; +import { AudioWaveform } from "./player/components/AudioWaveform"; +import type { TimelineElement } from "./player"; +import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react"; interface EditingFile { path: string; @@ -32,13 +29,251 @@ interface LintFinding { fixHint?: string; } +import { ExpandOnHover } from "./components/ui/ExpandOnHover"; + +// ── Media file detection and preview ── + +const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i; +const VIDEO_EXT = /\.(mp4|webm|mov)$/i; +const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i; +const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i; + +function isMediaFile(path: string): boolean { + return ( + IMAGE_EXT.test(path) || VIDEO_EXT.test(path) || AUDIO_EXT.test(path) || FONT_EXT.test(path) + ); +} + +function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) { + const serveUrl = `/api/projects/${projectId}/preview/${filePath}`; + const name = filePath.split("/").pop() ?? filePath; + + if (IMAGE_EXT.test(filePath)) { + return ( +
+ {name} + {filePath} +
+ ); + } + + if (VIDEO_EXT.test(filePath)) { + return ( +
+
+ ); + } + + if (AUDIO_EXT.test(filePath)) { + return ( +
+ + + + + +
+ ); + } + + // Fonts and other binary — show info instead of binary dump + return ( +
+ + + + + {name} + {filePath} + Binary file — preview not available +
+ ); +} + +// ── Project Card with hover-to-preview ── + +function ExpandedPreviewIframe({ src }: { src: string }) { + const containerRef = useRef(null); + const iframeRef = useRef(null); + const [dims, setDims] = useState({ w: 1920, h: 1080 }); + const [scale, setScale] = useState(1); + + // Recalculate scale when container resizes or dims change. + // Note: useEffect with [dims] dep — syncs with ResizeObserver (external system). + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const update = () => { + const cw = el.clientWidth; + const ch = el.clientHeight; + // Fit the composition inside the container (contain, not cover) + const s = Math.min(cw / dims.w, ch / dims.h); + setScale(s); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, [dims]); + + // After iframe loads: detect composition dimensions, seek, and play + const handleLoad = useCallback(() => { + const iframe = iframeRef.current; + if (!iframe) return; + let attempts = 0; + const interval = setInterval(() => { + try { + const doc = iframe.contentDocument; + if (doc) { + const comp = doc.querySelector("[data-composition-id]") as HTMLElement | null; + if (comp) { + const w = parseInt(comp.getAttribute("data-width") ?? "0", 10); + const h = parseInt(comp.getAttribute("data-height") ?? "0", 10); + if (w > 0 && h > 0) setDims({ w, h }); + } + } + const win = iframe.contentWindow as Window & { + __player?: { seek: (t: number) => void; play: () => void }; + }; + if (win?.__player) { + win.__player.seek(2); + win.__player.play(); + clearInterval(interval); + } + } catch { + /* cross-origin */ + } + if (++attempts > 25) clearInterval(interval); + }, 200); + }, []); + + // Center the scaled iframe + const offsetX = containerRef.current + ? (containerRef.current.clientWidth - dims.w * scale) / 2 + : 0; + const offsetY = containerRef.current + ? (containerRef.current.clientHeight - dims.h * scale) / 2 + : 0; + + return ( +
+