Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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