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
23 changes: 22 additions & 1 deletion packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,22 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
app.get("/icons/*", serveStudioStaticFile);
app.get("/favicon.svg", serveStudioStaticFile);

// ── Runtime env injection ───────────────────────────────────────────────
// When the studio is served as a pre-built SPA, Vite `VITE_STUDIO_*` env
// vars were baked at build time. Collect any such vars from the current
// process.env and inject them as `window.__HF_STUDIO_ENV__` so the client
// can pick them up at runtime, overriding the baked defaults.
function buildRuntimeEnvScript(): string {
const overrides: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("VITE_STUDIO_") && value !== undefined) {
overrides[key] = value;
}
}
if (Object.keys(overrides).length === 0) return "";
return `<script>window.__HF_STUDIO_ENV__=${JSON.stringify(overrides)};</script>`;
}

// SPA fallback
app.get("*", (c) => {
const indexPath = resolve(studioDir, "index.html");
Expand Down Expand Up @@ -540,7 +556,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
500,
);
}
return c.html(readFileSync(indexPath, "utf-8"));
let html = readFileSync(indexPath, "utf-8");
const envScript = buildRuntimeEnvScript();
if (envScript) {
html = html.replace("<head>", `<head>${envScript}`);
}
return c.html(html);
});

return { app, watcher };
Expand Down
41 changes: 41 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import { addBlockToProject } from "./utils/blockInstaller";
import type { BlockParam } from "@hyperframes/core/registry";
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
Expand Down Expand Up @@ -80,6 +81,7 @@ export function StudioApp() {
params: BlockParam[];
compositionPath: string;
} | null>(null);
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);

const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
const activeCompPathRef = useRef(activeCompPath);
Expand Down Expand Up @@ -190,6 +192,8 @@ export function StudioApp() {
projectId,
blockName,
activeCompPath,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
timelineElements,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
Expand Down Expand Up @@ -233,6 +237,40 @@ export function StudioApp() {
blockName,
activeCompPath,
placement,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
timelineElements,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
refreshFileTree: fileManager.refreshFileTree,
reloadPreview,
showToast,
});
},
[
projectId,
activeCompPath,
timelineElements,
fileManager.readProjectFile,
fileManager.writeProjectFile,
fileManager.refreshFileTree,
editHistory.recordEdit,
reloadPreview,
showToast,
],
);

const handlePreviewBlockDrop = useCallback(
(blockName: string, position: { left: number; top: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
activeCompPath,
visualPosition: position,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
timelineElements,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
Expand Down Expand Up @@ -532,6 +570,7 @@ export function StudioApp() {
leftSidebarRef={leftSidebarRef}
onSelectComposition={handleSelectComposition}
onAddBlock={handleAddBlock}
onPreviewBlock={setBlockPreview}
onLint={handleLint}
linting={linting}
/>
Expand All @@ -541,13 +580,15 @@ export function StudioApp() {
handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
handleTimelineBlockDrop={handleTimelineBlockDrop}
handlePreviewBlockDrop={handlePreviewBlockDrop}
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
blockPreview={blockPreview}
/>

{!panelLayout.rightCollapsed && (
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/components/StudioLeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useStudioContext } from "../contexts/StudioContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { getPersistedRenderSettings } from "./renders/renderSettings";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";

export interface StudioLeftSidebarProps {
leftSidebarRef: RefObject<LeftSidebarHandle | null>;
onSelectComposition: (comp: string) => void;
onAddBlock: (blockName: string) => void;
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
onLint: () => void;
linting: boolean;
}
Expand All @@ -21,6 +23,7 @@ export function StudioLeftSidebar({
leftSidebarRef,
onSelectComposition,
onAddBlock,
onPreviewBlock,
onLint,
linting,
}: StudioLeftSidebarProps) {
Expand Down Expand Up @@ -128,6 +131,7 @@ export function StudioLeftSidebar({
linting={linting}
onToggleCollapse={toggleLeftSidebar}
onAddBlock={onAddBlock}
onPreviewBlock={onPreviewBlock}
/>
<div
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
Expand Down
30 changes: 29 additions & 1 deletion packages/studio/src/components/StudioPreviewArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "./editor/manualEditingAvailability";
import { useStudioContext } from "../contexts/StudioContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";

export interface StudioPreviewAreaProps {
timelineToolbar: ReactNode;
Expand All @@ -29,6 +30,10 @@ export interface StudioPreviewAreaProps {
blockName: string,
placement: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
handlePreviewBlockDrop?: (
blockName: string,
position: { left: number; top: number },
) => Promise<void> | void;
handleTimelineFileDrop: (
files: File[],
placement?: Pick<TimelineElement, "start" | "track">,
Expand All @@ -45,6 +50,7 @@ export interface StudioPreviewAreaProps {
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
}

export function StudioPreviewArea({
Expand All @@ -53,13 +59,15 @@ export function StudioPreviewArea({
handleTimelineElementDelete,
handleTimelineAssetDrop,
handleTimelineBlockDrop,
handlePreviewBlockDrop,
handleTimelineFileDrop,
handleTimelineElementMove,
handleTimelineElementResize,
handleBlockedTimelineEdit,
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
blockPreview,
}: StudioPreviewAreaProps) {
const {
projectId,
Expand Down Expand Up @@ -104,6 +112,7 @@ export function StudioPreviewArea({
onDeleteElement={handleTimelineElementDelete}
onAssetDrop={handleTimelineAssetDrop}
onBlockDrop={handleTimelineBlockDrop}
onPreviewBlockDrop={handlePreviewBlockDrop}
onFileDrop={handleTimelineFileDrop}
onMoveElement={handleTimelineElementMove}
onResizeElement={handleTimelineElementResize}
Expand All @@ -123,7 +132,26 @@ export function StudioPreviewArea({
}}
onIframeRef={handlePreviewIframeRef}
previewOverlay={
captionEditMode ? (
blockPreview ? (
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
{blockPreview.videoUrl ? (
<video
src={blockPreview.videoUrl}
autoPlay
muted
loop
playsInline
className="w-full h-full object-contain"
/>
) : blockPreview.posterUrl ? (
<img
src={blockPreview.posterUrl}
alt={blockPreview.title}
className="w-full h-full object-contain"
/>
) : null}
</div>
) : captionEditMode ? (
<CaptionOverlay iframeRef={previewIframeRef} />
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
<DomEditOverlay
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ export const PropertyPanel = memo(function PropertyPanel({
</div>
<div className="mt-3">
<MetricField
label="Layer"
label="Z-index"
value={String(parseInt(styles["z-index"] || "auto", 10) || 0)}
scrub
onCommit={(next) => onSetStyle("z-index", next)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,16 @@ export function resolveStudioBooleanEnvFlag(
// and downstream `env[name]` reads would crash. Fall back to `{}` so
// every flag resolves to its declared default outside Vite. Direct
// property access keeps Vite's compile-time transform happy.
const env = (import.meta.env ?? {}) as StudioFeatureFlagEnv;
//
// When the studio is served as a pre-built SPA by the embedded Hono server,
// `import.meta.env` values were baked at build time. The server injects
// `window.__HF_STUDIO_ENV__` with any `VITE_STUDIO_*` env vars from the
// user's shell, so runtime overrides take precedence over baked defaults.
const runtimeEnv =
typeof window !== "undefined"
? ((window as Window & { __HF_STUDIO_ENV__?: StudioFeatureFlagEnv }).__HF_STUDIO_ENV__ ?? {})
: {};
const env = { ...(import.meta.env ?? {}), ...runtimeEnv } as StudioFeatureFlagEnv;

export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag(
env,
Expand Down
34 changes: 33 additions & 1 deletion packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import {
useState,
useCallback,
Expand All @@ -13,6 +13,7 @@
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { NLEPreview } from "./NLEPreview";
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
import { useCompositionStack } from "./useCompositionStack";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
Expand Down Expand Up @@ -54,6 +55,10 @@
blockName: string,
placement: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
onPreviewBlockDrop?: (
blockName: string,
position: { left: number; top: number },
) => Promise<void> | void;
/** Persist timeline move actions back into source HTML */
onMoveElement?: (
element: TimelineElement,
Expand Down Expand Up @@ -107,6 +112,7 @@
onDeleteElement,
onAssetDrop,
onBlockDrop,
onPreviewBlockDrop,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
Expand All @@ -131,6 +137,22 @@
usePlayerStore.getState().reset();
}

const stageRefForDrop = useRef<HTMLDivElement | null>(null);
const handleStageRef = useCallback((ref: React.RefObject<HTMLDivElement | null>) => {
stageRefForDrop.current = ref.current;
}, []);

const {
isDragOver: previewDragOver,
handleDragOver: handlePreviewDragOver,
handleDragLeave: handlePreviewDragLeave,
handleDrop: handlePreviewDrop,
} = usePreviewBlockDrop({
portrait,
stageRef: stageRefForDrop as React.RefObject<HTMLDivElement | null>,
onBlockDrop: onPreviewBlockDrop,
});

// Lightweight reload: change iframe src instead of destroying the Player.
// refreshPlayer() saves the seek position and appends a cache-busting _t
// param — the Player instance stays alive so the adapter is available for
Expand Down Expand Up @@ -344,7 +366,13 @@
>
{/* Preview + player controls */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 relative" data-preview-pan-surface="true">
<div
className="flex-1 min-h-0 relative"
data-preview-pan-surface="true"
onDragOver={handlePreviewDragOver}
onDragLeave={handlePreviewDragLeave}
onDrop={handlePreviewDrop}
>
<NLEPreview
projectId={projectId}
iframeRef={iframeRef}
Expand All @@ -353,7 +381,11 @@
portrait={portrait}
directUrl={directUrl}
suppressLoadingOverlay={hasLoadedOnceRef.current}
onStageRef={handleStageRef}
/>
{previewDragOver && (
<div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
)}
{!isFullscreen && previewOverlay}
</div>
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
Expand Down
6 changes: 5 additions & 1 deletion packages/studio/src/components/nle/NLEPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
type PreviewZoomState,
} from "./previewZoom";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";

interface NLEPreviewProps {
projectId: string;
iframeRef: Ref<HTMLIFrameElement>;
Expand All @@ -21,6 +20,7 @@ interface NLEPreviewProps {
portrait?: boolean;
directUrl?: string;
suppressLoadingOverlay?: boolean;
onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
}

export function getPreviewPlayerKey({
Expand Down Expand Up @@ -90,10 +90,14 @@ export const NLEPreview = memo(function NLEPreview({
portrait,
directUrl,
suppressLoadingOverlay,
onStageRef,
}: NLEPreviewProps) {
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
const viewportRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
onStageRef?.(stageRef);
}, [onStageRef]);
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));

const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
Expand Down
Loading
Loading