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
6 changes: 4 additions & 2 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,9 +510,11 @@ async function scaffoldProject(
): Promise<void> {
mkdirSync(destDir, { recursive: true });

// Use bundled template if available, otherwise fetch from GitHub
// Use bundled template if available, otherwise fetch from GitHub.
// Check for index.html inside the dir — an empty directory left by the
// build toolchain should not prevent the remote fetch fallback.
const templateDir = getStaticTemplateDir(templateId);
if (existsSync(templateDir)) {
if (existsSync(join(templateDir, "index.html"))) {
cpSync(templateDir, destDir, { recursive: true });
} else {
await fetchRemoteTemplate(templateId, destDir);
Expand Down
16 changes: 16 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@
"import": "./src/studio-api/helpers/screenshotClip.ts",
"types": "./src/studio-api/helpers/screenshotClip.ts"
},
"./studio-api/manual-edits-render-script": {
"import": "./src/studio-api/helpers/manualEditsRenderScript.ts",
"types": "./src/studio-api/helpers/manualEditsRenderScript.ts"
},
"./studio-api/studio-motion-render-script": {
"import": "./src/studio-api/helpers/studioMotionRenderScript.ts",
"types": "./src/studio-api/helpers/studioMotionRenderScript.ts"
},
"./text": {
"import": "./src/text/index.ts",
"types": "./src/text/index.ts"
Expand Down Expand Up @@ -81,6 +89,14 @@
"import": "./dist/studio-api/helpers/screenshotClip.js",
"types": "./dist/studio-api/helpers/screenshotClip.d.ts"
},
"./studio-api/manual-edits-render-script": {
"import": "./dist/studio-api/helpers/manualEditsRenderScript.js",
"types": "./dist/studio-api/helpers/manualEditsRenderScript.d.ts"
},
"./studio-api/studio-motion-render-script": {
"import": "./dist/studio-api/helpers/studioMotionRenderScript.js",
"types": "./dist/studio-api/helpers/studioMotionRenderScript.d.ts"
},
"./text": {
"import": "./dist/text/index.js",
"types": "./dist/text/index.d.ts"
Expand Down
3 changes: 3 additions & 0 deletions packages/engine/src/services/streamingEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,9 @@ export async function spawnStreamingEncoder(
exitPromiseResolve?.();
});

ffmpeg.stdin?.on("error", () => {});
ffmpeg.stdout?.on("error", () => {});

// Handle abort signal
const onAbort = () => {
if (exitStatus === "running") {
Expand Down
5 changes: 3 additions & 2 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4106,6 +4106,7 @@ export function StudioApp() {
projectId={projectId}
assets={assets}
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
multiSelectCount={domEditGroupSelections.length}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onSetStyle={handleDomStyleCommit}
Expand Down Expand Up @@ -4135,9 +4136,9 @@ export function StudioApp() {
projectId={projectId}
onDelete={renderQueue.deleteRender}
onClearCompleted={renderQueue.clearCompleted}
onStartRender={async (format, quality, resolution) => {
onStartRender={async (format, quality, resolution, fps) => {
await waitForPendingDomEditSaves();
await renderQueue.startRender({ fps: 30, quality, format, resolution });
await renderQueue.startRender({ fps, quality, format, resolution });
}}
isRendering={renderQueue.isRendering}
/>
Expand Down
31 changes: 25 additions & 6 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface PropertyPanelProps {
projectId: string;
assets: string[];
element: DomEditSelection | null;
multiSelectCount?: number;
copiedAgentPrompt: boolean;
onClearSelection: () => void;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
Expand Down Expand Up @@ -2212,6 +2213,7 @@ export const PropertyPanel = memo(function PropertyPanel({
projectId,
assets,
element,
multiSelectCount = 0,
copiedAgentPrompt,
onClearSelection,
onSetStyle,
Expand Down Expand Up @@ -2258,12 +2260,29 @@ export const PropertyPanel = memo(function PropertyPanel({
if (!element) {
return (
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
<Eye size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">Select an element in the preview.</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
The inspector is tuned for element edits with safer geometry controls, color picking, and
cleaner grouped layer controls.
</p>
{multiSelectCount > 1 ? (
<>
<Layers size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">
{multiSelectCount} elements selected
</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
Select a single element to edit its properties. Click an element in the preview or use
the timeline layer panel.
</p>
</>
) : (
<>
<Eye size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">
Select an element in the preview.
</p>
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
The inspector is tuned for element edits with safer geometry controls, color picking,
and cleaner grouped layer controls.
</p>
</>
)}
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const env = import.meta.env as StudioFeatureFlagEnv;
export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag(
env,
[STUDIO_PREVIEW_MANUAL_DRAGGING_ENV, "VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED"],
false,
true,
);

export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
Expand All @@ -44,7 +44,7 @@ export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
export const STUDIO_MOTION_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
env,
[STUDIO_MOTION_PANEL_ENV, "VITE_STUDIO_MOTION_PANEL_ENABLED"],
false,
true,
);

export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED =
Expand Down
15 changes: 14 additions & 1 deletion packages/studio/src/components/renders/RenderQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type StartRenderHandler = (
format: "mp4" | "webm" | "mov",
quality: "draft" | "standard" | "high",
resolution: ResolutionPreset | "auto",
fps: 24 | 30 | 60,
) => void | Promise<void>;

interface RenderQueueProps {
Expand Down Expand Up @@ -133,6 +134,7 @@ function FormatExportButton({
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
const [fps, setFps] = useState<24 | 30 | 60>(30);

// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
const showQuality = format !== "mov";
Expand Down Expand Up @@ -172,6 +174,17 @@ function FormatExportButton({
))}
</select>
)}
<select
value={fps}
onChange={(e) => setFps(Number(e.target.value) as 24 | 30 | 60)}
disabled={isRendering}
title="Frames per second"
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
<option value={24}>24fps</option>
<option value={30}>30fps</option>
<option value={60}>60fps</option>
</select>
<select
value={format}
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
Expand All @@ -184,7 +197,7 @@ function FormatExportButton({
</select>
<button
onClick={() => {
void onStartRender(format, quality, resolution);
void onStartRender(format, quality, resolution, fps);
}}
disabled={isRendering}
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
Expand Down
148 changes: 81 additions & 67 deletions packages/studio/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,75 +334,89 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
bufferPromise = (async () => {
const browser = await getSharedBrowser();
if (!browser) return null;
const page = await browser.newPage();
await page.setViewport({
width: opts.width,
height: opts.height,
deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => {
document.documentElement.style.background = "#000";
document.body.style.background = "#000";
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
});
await page
.waitForFunction(
`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`,
{ timeout: 5000 },
)
.catch(() => {});
await seekThumbnailPreview(page, opts.seekTime);
await applyStudioRenderBodyScriptsToThumbnailPage(page, opts.project.dir, opts.compPath);
await page.evaluate("document.fonts?.ready");
await new Promise((r) => setTimeout(r, 200));
await reapplyStudioRenderBodyScriptsToThumbnailPage(page);
let clip: ScreenshotClip | undefined;
if (opts.selector) {
clip = await page.evaluate(
(selector: string, selectorIndex: number | undefined) => {
const matches = Array.from(document.querySelectorAll(selector)).filter(
(el): el is HTMLElement => el instanceof HTMLElement,
);
const safeIndex = Math.max(
0,
Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)),
);
const el = matches[safeIndex] ?? null;
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
const pad = 8;
const x = Math.max(0, rect.left - pad);
const y = Math.max(0, rect.top - pad);
const maxWidth = window.innerWidth - x;
const maxHeight = window.innerHeight - y;
return {
x,
y,
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
};
},
opts.selector,
opts.selectorIndex,
let page: Awaited<ReturnType<typeof browser.newPage>> | null = null;
try {
page = await browser.newPage();
await page.setViewport({
width: opts.width,
height: opts.height,
deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => {
document.documentElement.style.background = "#000";
document.body.style.background = "#000";
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
});
await page
.waitForFunction(
`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`,
{ timeout: 5000 },
)
.catch(() => {});
await seekThumbnailPreview(page, opts.seekTime);
await applyStudioRenderBodyScriptsToThumbnailPage(
page,
opts.project.dir,
opts.compPath,
);
}
const buf = await page.screenshot(
opts.format === "png"
? {
type: "png",
...(clip ? { clip } : {}),
}
: {
type: "jpeg",
quality: 75,
...(clip ? { clip } : {}),
await page.evaluate("document.fonts?.ready");
await new Promise((r) => setTimeout(r, 200));
await reapplyStudioRenderBodyScriptsToThumbnailPage(page);
let clip: ScreenshotClip | undefined;
if (opts.selector) {
clip = await page.evaluate(
(selector: string, selectorIndex: number | undefined) => {
const matches = Array.from(document.querySelectorAll(selector)).filter(
(el): el is HTMLElement => el instanceof HTMLElement,
);
const safeIndex = Math.max(
0,
Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)),
);
const el = matches[safeIndex] ?? null;
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
const pad = 8;
const x = Math.max(0, rect.left - pad);
const y = Math.max(0, rect.top - pad);
const maxWidth = window.innerWidth - x;
const maxHeight = window.innerHeight - y;
return {
x,
y,
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
};
},
);
await page.close();
return buf as Buffer;
opts.selector,
opts.selectorIndex,
);
}
const buf = await page.screenshot(
opts.format === "png"
? {
type: "png",
...(clip ? { clip } : {}),
}
: {
type: "jpeg",
quality: 75,
...(clip ? { clip } : {}),
},
);
await page.close();
return buf as Buffer;
} catch (err) {
if (page) await page.close().catch(() => {});
console.warn(
"[Studio] Thumbnail generation failed:",
err instanceof Error ? err.message : err,
);
return null;
}
})();
_thumbnailInflight.set(cacheKey, bufferPromise);
bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey));
Expand Down
Loading