diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ce4afc1c6..043f84697 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -51,6 +51,7 @@ import { import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture"; import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting"; import { Camera } from "./icons/SystemIcons"; +import { AspectRatioSelector } from "./components/AspectRatioSelector"; interface EditingFile { path: string; @@ -151,6 +152,8 @@ export function StudioApp() { const captionEditMode = useCaptionStore((s) => s.isEditMode); const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0); const captionSync = useCaptionSync(projectId); + const [compositionWidth, setCompositionWidth] = useState(1920); + const [compositionHeight, setCompositionHeight] = useState(1080); // Resizable and collapsible panel widths const [leftWidth, setLeftWidth] = useState(240); @@ -321,6 +324,56 @@ export function StudioApp() { setCaptureFrameTime(usePlayerStore.getState().currentTime); }, []); + const handleAspectRatioChange = useCallback( + async (nextWidth: number, nextHeight: number) => { + const pid = projectIdRef.current; + if (!pid) return; + const targetPath = activeCompPath || "index.html"; + try { + const response = await fetch( + `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, + ); + if (!response.ok) return; + const data = (await response.json()) as { content?: string }; + if (typeof data.content !== "string") return; + + const root = + previewIframeRef.current?.contentDocument?.querySelector("[data-composition-id]"); + const compId = root?.getAttribute("data-composition-id"); + if (!compId) return; + + const target = { selector: `[data-composition-id="${compId}"]` }; + let patched = applyPatchByTarget(data.content, target, { + type: "attribute", + property: "width", + value: String(nextWidth), + }); + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: "height", + value: String(nextHeight), + }); + if (patched === data.content) return; + + const saveResponse = await fetch( + `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, + { method: "PUT", headers: { "Content-Type": "text/plain" }, body: patched }, + ); + if (!saveResponse.ok) return; + + setCompositionWidth(nextWidth); + setCompositionHeight(nextHeight); + if (editingPathRef.current === targetPath) { + setEditingFile({ path: targetPath, content: patched }); + } + setRefreshKey((k) => k + 1); + } catch { + // network error — fail silently + } + }, + [activeCompPath], + ); + useMountEffect(() => { setCaptureFrameTime(usePlayerStore.getState().currentTime); return liveTime.subscribe(setCaptureFrameTime); @@ -353,6 +406,18 @@ export function StudioApp() { }; }); + useMountEffect(() => { + const handleStageSize = (e: MessageEvent) => { + const data = e.data; + if (data?.type === "stage-size" && data.width > 0 && data.height > 0) { + setCompositionWidth(data.width); + setCompositionHeight(data.height); + } + }; + window.addEventListener("message", handleStageSize); + return () => window.removeEventListener("message", handleStageSize); + }); + const syncPreviewTimelineHotkey = useCallback( (iframe: HTMLIFrameElement | null) => { const nextWindow = iframe?.contentWindow ?? null; @@ -1439,6 +1504,11 @@ export function StudioApp() { {/* Right: toolbar buttons */}
+ { + try { + const root = iframe.contentDocument?.querySelector("[data-composition-id]"); + if (!root) return; + const w = parseInt(root.getAttribute("data-width") || "0", 10); + const h = parseInt(root.getAttribute("data-height") || "0", 10); + if (w > 0 && h > 0) { + setCompositionWidth(w); + setCompositionHeight(h); + } + } catch { + // cross-origin + } + }; // Attach now (iframe may already be loaded) and on future loads attachErrorCapture(); + detectDimensions(); iframe.addEventListener("load", () => { consoleErrorsRef.current = []; setConsoleErrors(null); attachErrorCapture(); + detectDimensions(); }); }} previewOverlay={ diff --git a/packages/studio/src/components/AspectRatioSelector.tsx b/packages/studio/src/components/AspectRatioSelector.tsx new file mode 100644 index 000000000..cf22d7d60 --- /dev/null +++ b/packages/studio/src/components/AspectRatioSelector.tsx @@ -0,0 +1,133 @@ +import { useState, useCallback, useEffect, useRef, memo } from "react"; + +export interface AspectRatioPreset { + label: string; + ratio: string; + width: number; + height: number; +} + +const PRESETS: AspectRatioPreset[] = [ + { label: "Landscape", ratio: "16:9", width: 1920, height: 1080 }, + { label: "Portrait", ratio: "9:16", width: 1080, height: 1920 }, + { label: "Square", ratio: "1:1", width: 1080, height: 1080 }, + { label: "Instagram", ratio: "4:5", width: 1080, height: 1350 }, + { label: "Classic", ratio: "4:3", width: 1440, height: 1080 }, + { label: "Cinematic", ratio: "21:9", width: 2560, height: 1080 }, +]; + +function matchPreset(width: number, height: number): AspectRatioPreset | null { + const targetRatio = width / height; + for (const preset of PRESETS) { + const presetRatio = preset.width / preset.height; + if (Math.abs(targetRatio - presetRatio) < 0.01) return preset; + } + return null; +} + +function formatDimensions(width: number, height: number): string { + const preset = matchPreset(width, height); + if (preset) return preset.ratio; + return `${width}×${height}`; +} + +interface AspectRatioSelectorProps { + width: number; + height: number; + onChange: (width: number, height: number) => void; +} + +export const AspectRatioSelector = memo(function AspectRatioSelector({ + width, + height, + onChange, +}: AspectRatioSelectorProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + const handleSelect = useCallback( + (preset: AspectRatioPreset) => { + setOpen(false); + if (preset.width === width && preset.height === height) return; + onChange(preset.width, preset.height); + }, + [width, height, onChange], + ); + + useEffect(() => { + if (!open) return; + const handleMouseDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleMouseDown); + return () => document.removeEventListener("mousedown", handleMouseDown); + }, [open]); + + const activePreset = matchPreset(width, height); + + return ( +
+ + {open && ( +
+ {PRESETS.map((preset) => { + const isActive = + activePreset?.width === preset.width && activePreset?.height === preset.height; + return ( + + ); + })} +
+ )} +
+ ); +});