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
1 change: 1 addition & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ export function initSandboxRuntimeModular(): void {
string,
RuntimeTimelineLike | undefined
>,
includeAuthoredTimingAttrs: true,
});
return resolver.resolveDurationForElement(element);
};
Expand Down
85 changes: 47 additions & 38 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
import { useMountEffect } from "./hooks/useMountEffect";
import { NLELayout } from "./components/nle/NLELayout";
import { TimelineEditorNotice } from "./components/nle/TimelineEditorNotice";
import { SourceEditor } from "./components/editor/SourceEditor";
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
import { RenderQueue } from "./components/renders/RenderQueue";
Expand Down Expand Up @@ -28,7 +29,6 @@ import {
getTimelineZoomPercent,
} from "./player/components/timelineZoom";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineEditorHintDismissed,
getTimelineToggleTitle,
setTimelineEditorHintDismissed,
Expand All @@ -40,6 +40,11 @@ interface EditingFile {
content: string | null;
}

interface AppToast {
message: string;
tone: "error" | "info";
}

// ── Main App ──

export function StudioApp() {
Expand Down Expand Up @@ -201,12 +206,14 @@ export function StudioApp() {
}
}, [captionHasSelection, captionEditMode]);
const [globalDragOver, setGlobalDragOver] = useState(false);
const [uploadToast, setUploadToast] = useState<string | null>(null);
const [appToast, setAppToast] = useState<AppToast | null>(null);
const [timelineVisible, setTimelineVisible] = useState(true);
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
getTimelineEditorHintDismissed,
);
const dragCounterRef = useRef(0);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastBlockedTimelineToastAtRef = useRef(0);
const previewHotkeyWindowRef = useRef<Window | null>(null);
const panelDragRef = useRef<{
side: "left" | "right";
Expand Down Expand Up @@ -238,6 +245,9 @@ export function StudioApp() {
const toggleTimelineVisibility = useCallback(() => {
setTimelineVisible((visible) => !visible);
}, []);
useMountEffect(() => () => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
});
const dismissTimelineEditorHint = useCallback(() => {
setTimelineEditorHintState(true);
setTimelineEditorHintDismissed(true);
Expand Down Expand Up @@ -380,31 +390,6 @@ export function StudioApp() {
);
const timelineToolbar = (
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
{timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
<div className="px-3 pt-3">
<div className="flex items-start justify-between gap-3 rounded-xl border border-studio-accent/20 bg-studio-accent/[0.07] px-3 py-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
<p className="mt-1 text-[11px] leading-5 text-neutral-300">
Drag clips to move timing, and drag clip edges to resize them when handles are
available. Hide the panel anytime and bring it back with{" "}
<span className="font-mono text-[10px] text-studio-accent">
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
</span>
.
</p>
</div>
<button
type="button"
onClick={dismissTimelineEditorHint}
className="flex-shrink-0 rounded-md border border-neutral-700 px-2 py-1 text-[10px] font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100"
>
Dismiss
</button>
</div>
</div>
)}

<div className="flex items-center justify-between px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
Timeline
Expand Down Expand Up @@ -855,11 +840,22 @@ export function StudioApp() {

const handleMoveFile = handleRenameFile;

const showUploadToast = useCallback((msg: string) => {
setUploadToast(msg);
setTimeout(() => setUploadToast(null), 4000);
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
setAppToast({ message, tone });
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
}, []);

const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
const now = Date.now();
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
lastBlockedTimelineToastAtRef.current = now;
showToast("This clip can’t be moved or resized from the timeline yet.", "info");
},
[showToast],
);

const handleImportFiles = useCallback(
async (files: FileList, dir?: string) => {
const pid = projectIdRef.current;
Expand All @@ -879,20 +875,20 @@ export function StudioApp() {
if (res.ok) {
const data = await res.json();
if (data.skipped?.length) {
showUploadToast(`Skipped (too large): ${data.skipped.join(", ")}`);
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
}
await refreshFileTree();
setRefreshKey((k) => k + 1);
} else if (res.status === 413) {
showUploadToast("Upload rejected: payload too large");
showToast("Upload rejected: payload too large");
} else {
showUploadToast(`Upload failed (${res.status})`);
showToast(`Upload failed (${res.status})`);
}
} catch {
showUploadToast("Upload failed: network error");
showToast("Upload failed: network error");
}
},
[refreshFileTree, showUploadToast],
[refreshFileTree, showToast],
);

const handleLint = useCallback(async () => {
Expand Down Expand Up @@ -1157,6 +1153,7 @@ export function StudioApp() {
renderClipContent={renderClipContent}
onMoveElement={handleTimelineElementMove}
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
Expand Down Expand Up @@ -1267,6 +1264,12 @@ export function StudioApp() {
)}
</div>

{timelineElements.length > 0 && !timelineEditorHintDismissed && (
<div className="pointer-events-none absolute bottom-5 left-5 z-[140]">
<TimelineEditorNotice onDismiss={dismissTimelineEditorHint} />
</div>
)}

{/* Lint modal */}
{lintModal !== null && projectId && (
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
Expand Down Expand Up @@ -1306,9 +1309,15 @@ export function StudioApp() {
</div>
</div>
)}
{uploadToast && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg bg-red-900/90 border border-red-700/50 text-sm text-red-200 shadow-lg animate-in fade-in slide-in-from-bottom-2">
{uploadToast}
{appToast && (
<div
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
appToast.tone === "error"
? "bg-red-900/90 border-red-700/50 text-red-200"
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
}`}
>
{appToast.message}
</div>
)}
</div>
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "
import { useMountEffect } from "../../hooks/useMountEffect";
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { NLEPreview } from "./NLEPreview";
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";

Expand Down Expand Up @@ -36,6 +37,7 @@ interface NLELayoutProps {
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
onCompIdToSrcChange?: (map: Map<string, string>) => void;
/** Whether the timeline panel is visible (default: true) */
Expand All @@ -61,6 +63,7 @@ export const NLELayout = memo(function NLELayout({
renderClipContent,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onCompIdToSrcChange,
timelineVisible,
onToggleTimeline,
Expand Down Expand Up @@ -392,6 +395,7 @@ export const NLELayout = memo(function NLELayout({
renderClipContent={renderClipContent}
onMoveElement={onMoveElement}
onResizeElement={onResizeElement}
onBlockedEditAttempt={onBlockedEditAttempt}
/>
</div>
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
Expand Down
156 changes: 156 additions & 0 deletions packages/studio/src/components/nle/TimelineEditorNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";

interface TimelineEditorNoticeProps {
onDismiss: () => void;
}

export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
return (
<aside
aria-live="polite"
className="pointer-events-none relative w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-white/10 bg-[#0f1115]/88 text-neutral-100 shadow-[0_18px_40px_rgba(0,0,0,0.3),0_4px_14px_rgba(0,0,0,0.18)] backdrop-blur-xl"
>
<style>{`
@keyframes hfTimelineNoticeClipNudge {
0%, 100% { transform: translate3d(0, 0, 0); }
20% { transform: translate3d(0, 0, 0); }
52% { transform: translate3d(12px, 0, 0); }
72% { transform: translate3d(12px, 0, 0); }
100% { transform: translate3d(0, 0, 0); }
}

@keyframes hfTimelineNoticePlayheadSweep {
0% { transform: translateX(0); opacity: 0; }
10% { opacity: 1; }
75% { opacity: 1; }
100% { transform: translateX(218px); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
.hf-timeline-notice-clip,
.hf-timeline-notice-playhead {
animation: none !important;
}
}
`}</style>

<button
type="button"
onClick={onDismiss}
aria-label="Dismiss timeline editor notice"
className="pointer-events-auto absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors duration-150 hover:bg-white/[0.06] hover:text-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-studio-accent/50"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>

<div className="flex items-start gap-3 px-4 py-3.5">
<div className="min-w-0 flex-1">
<div
aria-hidden="true"
className="mb-3 overflow-hidden rounded-[14px] bg-[#0d1117] p-2.5"
>
<div className="relative overflow-hidden rounded-[11px] bg-[#0f141c] px-2.5 pb-2 pt-1.5">
<div className="mb-1.5 flex items-center justify-between pl-6 pr-1 text-[8px] font-medium text-[#7f8796]">
<span>0:00</span>
<span>0:05</span>
<span>0:10</span>
</div>

<div className="pointer-events-none absolute inset-x-0 top-[18px] h-px bg-white/[0.04]" />
<div
className="hf-timeline-notice-playhead pointer-events-none absolute left-[31px] top-[18px] h-[70px] w-0"
style={{
animation:
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
}}
>
<div
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 2,
marginLeft: -1,
background: "var(--hf-accent, #3CE6AC)",
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
}}
/>
<div
className="absolute"
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
}}
/>
</div>
</div>

<div className="flex flex-col gap-1.5">
{[0, 1, 2].map((trackIndex) => (
<div
key={trackIndex}
className="relative h-6 overflow-hidden rounded-[10px] bg-white/[0.035]"
>
<div className="absolute inset-y-0 left-[24px] w-px bg-white/[0.035]" />
<div className="absolute inset-y-0 left-[100px] w-px bg-white/[0.035]" />
<div className="absolute inset-y-0 left-[176px] w-px bg-white/[0.035]" />
</div>
))}
</div>

<div className="pointer-events-none absolute inset-x-0 top-[21px] h-[70px]">
<div className="absolute left-[34px] top-[3px] h-[18px] w-[56px] rounded-[9px] bg-white/[0.07]" />
<div
className="hf-timeline-notice-clip absolute left-[82px] top-[27px] h-[18px] w-[110px] rounded-[9px] bg-studio-accent/18 ring-1 ring-inset ring-studio-accent/28"
style={{
animation:
"hfTimelineNoticeClipNudge 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
}}
/>
<div className="absolute left-[52px] top-[51px] h-[18px] w-[72px] rounded-[9px] bg-white/[0.07]" />
</div>
</div>
</div>

<div className="min-w-0 pr-9">
<p className="text-[12px] font-semibold leading-none tracking-tight text-neutral-100">
Timeline editing is on
</p>
<p className="mt-1.5 text-[12px] leading-5 text-neutral-300">
Drag clips to move timing, use{" "}
<span className="font-mono text-[11px] text-studio-accent">Shift</span> + click to
edit a full clip range, and watch for resize handles only on clips Studio can patch
safely. Toggle the timeline with{" "}
<span className="rounded-md border border-white/8 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-studio-accent">
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
</span>
.
</p>
</div>

<div className="mt-2 text-[10px] leading-none text-neutral-500">
Dismiss once and it stays hidden.
</div>
</div>
</div>
</aside>
);
}
Loading
Loading