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 (
+
+ );
+ })}
+
+ )}
+
+ );
+});