add: performance fix in timeline editor and warning removal

This commit is contained in:
Alan Trebugeais
2026-05-10 14:20:17 +02:00
parent 902f42d07d
commit fb4cd6236b
8 changed files with 181 additions and 64 deletions
+38 -7
View File
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const skeletonVariants = cva(
"relative overflow-hidden rounded-md transition-shadow",
"relative overflow-hidden rounded-md transition-shadow flex items-center justify-center",
{
variants: {
variant: {
@@ -11,12 +11,17 @@ const skeletonVariants = cva(
glass: "bg-white/5 backdrop-blur-sm border border-white/10",
dark: "bg-black/20",
subtle: "bg-foreground/[0.03]",
clip: "bg-primary/5 border border-primary/10 shadow-inner",
},
animation: {
none: "",
pulse: "animate-pulse",
shimmer: "before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-foreground/[0.04] before:to-transparent",
"shimmer-glass": "before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-white/[0.08] before:to-transparent",
shimmer:
"before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-foreground/[0.05] before:to-transparent",
"shimmer-glass":
"before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-white/[0.08] before:to-transparent",
"shimmer-premium":
"before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/[0.1] before:to-transparent",
},
},
defaultVariants: {
@@ -28,20 +33,46 @@ const skeletonVariants = cva(
export interface SkeletonProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof skeletonVariants> {}
VariantProps<typeof skeletonVariants> {
label?: string;
}
/**
* A magnificent Skeleton component designed with Apple-inspired aesthetics.
* Supports shimmer animations, pulse effects, and glassmorphism.
* Supports shimmer animations, pulse effects, and labels.
*/
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
({ className, variant, animation, ...props }, ref) => {
({ className, variant, animation, label, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(skeletonVariants({ variant, animation, className }))}
{...props}
/>
>
{(label || children) && (
<div className="relative z-10 flex flex-col items-center gap-2 px-4 py-2">
{label && (
<div className="flex items-center">
{label.split("").map((char, i) => (
<span
key={i}
className={cn(
"text-[11px] font-medium tracking-tight bg-gradient-to-r from-foreground/30 via-foreground/70 to-foreground/30 bg-clip-text text-transparent animate-text-shimmer whitespace-pre",
)}
style={{
animationDelay: `${i * 0.05}s`,
animationDuration: "2.5s",
}}
>
{char}
</span>
))}
</div>
)}
{children}
</div>
)}
</div>
);
},
);
+7 -1
View File
@@ -6356,15 +6356,17 @@ export default function VideoEditor() {
>
<TimelineEditor
ref={timelineRef}
hideToolbar
videoDuration={timelineDuration}
currentTime={currentTime}
playheadTime={timelinePlayheadTime}
onSeek={handleTimelineSeek}
videoPath={videoPath}
videoSourcePath={videoSourcePath}
cursorTelemetrySourcePath={cursorTelemetrySourcePath}
cursorTelemetry={normalizedCursorTelemetry}
autoSuggestZoomsTrigger={autoSuggestZoomsTrigger}
onAutoSuggestZoomsConsumed={handleAutoSuggestZoomsConsumed}
disableSuggestedZooms={!autoApplyFreshRecordingAutoZooms}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
@@ -6391,6 +6393,10 @@ export default function VideoEditor() {
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
onOpenCropEditor={handleOpenCropEditor}
isCropped={isCropped}
hideToolbar={timelineCollapsed}
showSourceAudioTrack={clipRegions.some((c) => c.showSourceAudio)}
sourceAudioTrackSettings={audio.activeSourceAudioTrackSettings}
getSourceAudioTrackSettingsForClip={
+35 -6
View File
@@ -12,6 +12,7 @@ import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import AudioWaveform from "./components/waveform/AudioWaveform";
import type { AudioPeaksData } from "./core/timelineTypes";
import glassStyles from "./ItemGlass.module.css";
@@ -34,6 +35,8 @@ interface ItemProps {
waveformNormalize?: boolean;
muted?: boolean;
variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
isLoading?: boolean;
loadingLabel?: string;
}
// Map zoom depth to multiplier labels
@@ -73,15 +76,46 @@ export default function Item({
waveformNormalize = false,
muted = false,
variant = "zoom",
isLoading = false,
loadingLabel,
children,
}: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
disabled,
disabled: disabled || isLoading,
data: { rowId },
});
const timeLabel = useMemo(
() => `${formatMs(span.start)} – ${formatMs(span.end)}`,
[span.start, span.end],
);
if (isLoading) {
return (
<div
ref={setNodeRef}
style={{
...itemStyle,
height: "100%",
display: "flex",
alignItems: "center",
}}
{...listeners}
{...attributes}
>
<Skeleton
variant="clip"
animation="shimmer-premium"
label={loadingLabel || "Loading..."}
className="w-full"
style={{ height: "85%", minHeight: 22 }}
/>
</div>
);
}
const isZoom = variant === "zoom";
const isTrim = variant === "trim";
const isClip = variant === "clip";
@@ -101,11 +135,6 @@ export default function Item({
? glassStyles.glassDarkGreen
: glassStyles.glassYellow;
const timeLabel = useMemo(
() => `${formatMs(span.start)} – ${formatMs(span.end)}`,
[span.start, span.end],
);
const MIN_ITEM_PX = 6;
const handleSelect = () => {
onSelect?.();
@@ -40,7 +40,6 @@ import { calculateTimelineScale } from "./core/time";
import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime";
import { useTimelineRange } from "./hooks/useTimelineRange";
import TimelineCanvas from "./components/viewport/TimelineCanvas";
import TimelineToolbar from "./components/toolbar/TimelineToolbar";
export interface TimelineEditorProps {
videoDuration: number;
@@ -85,6 +84,8 @@ export interface TimelineEditorProps {
onOpenCropEditor?: () => void;
isCropped?: boolean;
videoPath?: string | null;
videoSourcePath?: string | null;
cursorTelemetrySourcePath?: string | null;
hideToolbar?: boolean;
showSourceAudioTrack?: boolean;
onSourceAudioAvailabilityChange?: (available: boolean) => void;
@@ -175,6 +176,8 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onOpenCropEditor,
isCropped = false,
videoPath,
videoSourcePath,
cursorTelemetrySourcePath,
hideToolbar = false,
showSourceAudioTrack = false,
onSourceAudioAvailabilityChange,
@@ -272,7 +275,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
return { previewSpans, hiddenZoomIds };
}, [clipRegions, liveSpanPreviewById, zoomRegions]);
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
const sourceAudioPeaks = useTimelineAudioPeaks(videoPath, {
const { peaks: sourceAudioPeaks, loading: sourceAudioLoading } = useTimelineAudioPeaks(videoPath, {
enableSourceSidecarFallback: true,
});
const localSourcePath = useMemo(() => {
@@ -290,8 +293,8 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
() => (localSourcePath ? buildSourceSidecarPath(localSourcePath, "system") : null),
[localSourcePath],
);
const micSidecarPeaks = useTimelineAudioPeaks(micSidecarPath);
const systemSidecarPeaks = useTimelineAudioPeaks(systemSidecarPath);
const { peaks: micSidecarPeaks, loading: micSidecarLoading } = useTimelineAudioPeaks(micSidecarPath);
const { peaks: systemSidecarPeaks, loading: systemSidecarLoading } = useTimelineAudioPeaks(systemSidecarPath);
const sourceAudioTracks = useMemo<SourceAudioTrackWithPeaks[]>(() => {
if (systemSidecarPeaks || micSidecarPeaks) {
const tracks: SourceAudioTrackWithPeaks[] = [];
@@ -319,6 +322,17 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
]
: [];
}, [micSidecarPeaks, sourceAudioPeaks, systemSidecarPeaks, t]);
const isLoading = useMemo(() => {
// If we are still actively trying to load audio peaks (main or sidecars)
if (videoPath && (sourceAudioLoading || micSidecarLoading || systemSidecarLoading)) return true;
// Robust telemetry loading detection:
// If a source path is set but telemetry hasn't arrived (or failed/retried) for it yet.
if (videoSourcePath && cursorTelemetrySourcePath !== videoSourcePath) return true;
return false;
}, [videoPath, videoSourcePath, cursorTelemetrySourcePath, sourceAudioLoading, micSidecarLoading, systemSidecarLoading]);
useEffect(() => {
onSourceAudioTracksMetaChange?.(sourceAudioTracks.map((t) => ({ id: t.id, label: t.label })));
}, [onSourceAudioTracksMetaChange, sourceAudioTracks]);
@@ -441,12 +455,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
keyShortcuts,
isTimelineFocusedRef,
});
const handleToolbarAddAnnotation = useCallback(() => {
handleAddAnnotation();
}, [handleAddAnnotation]);
const handleToolbarAddAudio = useCallback(() => {
void handleAddAudio();
}, [handleAddAudio]);
if (!videoDuration || videoDuration === 0) {
return (
@@ -466,32 +474,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
return (
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
{hideToolbar ? null : (
<TimelineToolbar
aspectRatio={aspectRatio}
isCropped={isCropped}
scrollLabels={scrollLabels}
customAspectWidth={customAspectWidth}
customAspectHeight={customAspectHeight}
onCustomAspectWidthChange={setCustomAspectWidth}
onCustomAspectHeightChange={setCustomAspectHeight}
onCustomAspectRatioKeyDown={handleCustomAspectRatioKeyDown}
onApplyCustomAspectRatio={applyCustomAspectRatio}
onAspectRatioChange={onAspectRatioChange}
onOpenCropEditor={onOpenCropEditor}
onAddZoom={handleAddZoom}
onSuggestZooms={handleSuggestZooms}
onAddAnnotation={handleToolbarAddAnnotation}
onAddAudio={handleToolbarAddAudio}
onSplitClip={handleSplitClip}
cropLabel={t("sections.crop", "Crop")}
addZoomLabel={tTimeline("zoom.addZoom", "Add Zoom (Z)")}
suggestZoomsLabel={tTimeline("zoom.suggestZooms", "Suggest Zooms from Cursor")}
addAnnotationLabel={tTimeline("annotation.addAnnotation", "Add Annotation (A)")}
addAudioLabel={tTimeline("audio.label", "Audio")}
splitClipLabel={tEditor("toolbar.splitClip", "Split Clip (C)")}
/>
)}
<div
ref={timelineContainerRef}
className="flex-1 min-h-0 overflow-auto bg-editor-bg relative"
@@ -573,6 +555,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
showSourceAudioTrack={showSourceAudioTrack}
liveSpanPreviewById={liveZoomPreview.previewSpans}
liveHiddenItemIds={Array.from(liveZoomPreview.hiddenZoomIds)}
isLoading={isLoading}
/>
</TimelineWrapper>
</div>
@@ -9,6 +9,7 @@ interface PlaybackCursorProps {
onSeek?: (time: number) => void;
timelineRef: RefObject<HTMLDivElement>;
keyframes?: { id: string; time: number }[];
isLoading?: boolean;
}
export default function PlaybackCursor({
@@ -17,6 +18,7 @@ export default function PlaybackCursor({
onSeek,
timelineRef,
keyframes = [],
isLoading = false,
}: PlaybackCursorProps) {
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
@@ -100,11 +102,27 @@ export default function PlaybackCursor({
</div>
<div
className={cn(
"absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg pointer-events-none",
isDragging ? "opacity-100" : "opacity-0",
"absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg pointer-events-none transition-opacity",
(isDragging || isLoading) ? "opacity-100" : "opacity-0",
)}
>
<span className="leading-5">{formatPlayheadTime(clampedTime)}</span>
<div className="flex items-center">
{formatPlayheadTime(clampedTime).split("").map((char, i) => (
<span
key={i}
className={cn(
"leading-5 whitespace-pre",
isLoading && "bg-gradient-to-r from-white/40 via-white to-white/40 bg-clip-text text-transparent animate-text-shimmer"
)}
style={isLoading ? {
animationDelay: `${i * 0.05}s`,
animationDuration: "2.5s",
} : undefined}
>
{char}
</span>
))}
</div>
</div>
</div>
</div>
@@ -68,6 +68,7 @@ interface TimelineCanvasProps {
showSourceAudioTrack?: boolean;
liveSpanPreviewById?: Record<string, { start: number; end: number }>;
liveHiddenItemIds?: string[];
isLoading?: boolean;
}
interface TimelineHoverParams {
@@ -244,6 +245,7 @@ interface TimelineCanvasRowsProps {
onZoomRowMouseMove: MouseEventHandler<HTMLDivElement>;
onZoomRowMouseLeave: MouseEventHandler<HTMLDivElement>;
onZoomRowClick: MouseEventHandler<HTMLDivElement>;
isLoading?: boolean;
}
interface AudioItemWithWaveformProps {
@@ -261,7 +263,7 @@ function AudioItemWithWaveform({
isSelected,
onSelectAudio,
}: AudioItemWithWaveformProps) {
const peaks = useTimelineAudioPeaks(item.audioPath ?? null);
const { peaks } = useTimelineAudioPeaks(item.audioPath ?? null);
const normalizedWaveformSpan = useMemo(() => {
const duration = Math.max(0, waveformSpan.end - waveformSpan.start);
return { start: 0, end: duration };
@@ -310,6 +312,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
onZoomRowMouseMove,
onZoomRowMouseLeave,
onZoomRowClick,
isLoading = false,
}: TimelineCanvasRowsProps) {
const hiddenIds = useMemo(() => new Set(liveHiddenItemIds ?? []), [liveHiddenItemIds]);
const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => {
@@ -376,6 +379,8 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
isSelected={selectAllBlocksActive || item.id === selectedClipId}
onSelectId={onSelectClip}
variant="clip"
isLoading={isLoading}
loadingLabel="Analyzing..."
>
{item.label}
</Item>
@@ -522,6 +527,7 @@ export default function TimelineCanvas({
showSourceAudioTrack = false,
liveSpanPreviewById,
liveHiddenItemIds,
isLoading = false,
}: TimelineCanvasProps) {
const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } =
useTimelineContext();
@@ -726,6 +732,7 @@ export default function TimelineCanvas({
onSeek={onSeek}
timelineRef={localTimelineRef}
keyframes={keyframes}
isLoading={isLoading}
/>
{canShowGhostPlayhead && (
<div
@@ -765,6 +772,7 @@ export default function TimelineCanvas({
onZoomRowMouseMove={handleZoomRowMouseMove}
onZoomRowMouseLeave={handleZoomRowMouseLeave}
onZoomRowClick={handleZoomRowClick}
isLoading={isLoading}
/>
</div>
</div>
@@ -40,20 +40,30 @@ interface TimelineAudioPeaksOptions {
peakCount?: number;
}
export interface TimelineAudioPeaksResult {
peaks: AudioPeaksData | null;
loading: boolean;
}
export function useTimelineAudioPeaks(
mediaResource: string | null | undefined,
options: TimelineAudioPeaksOptions = {},
): AudioPeaksData | null {
const [data, setData] = useState<AudioPeaksData | null>(null);
): TimelineAudioPeaksResult {
const [peaks, setPeaks] = useState<AudioPeaksData | null>(null);
const [loading, setLoading] = useState(false);
const sourceRef = useRef(mediaResource);
const enableSourceSidecarFallback = options.enableSourceSidecarFallback ?? false;
const peakCount = options.peakCount ?? WAVEFORM_DEFAULT_PEAK_COUNT;
useEffect(() => {
sourceRef.current = mediaResource;
setData(null);
if (!mediaResource) return;
setPeaks(null);
if (!mediaResource) {
setLoading(false);
return;
}
setLoading(true);
let cancelled = false;
const run = async () => {
@@ -64,29 +74,50 @@ export function useTimelineAudioPeaks(
try {
const result = await tryGenerate(mediaResource);
if (!cancelled && sourceRef.current === mediaResource) setData(result);
if (!cancelled && sourceRef.current === mediaResource) {
setPeaks(result);
setLoading(false);
}
return;
} catch {
// fallthrough
}
if (!enableSourceSidecarFallback) return;
if (!enableSourceSidecarFallback) {
if (!cancelled && sourceRef.current === mediaResource) {
setLoading(false);
}
return;
}
const localPathFromServer = extractLocalPathFromMediaServerUrl(mediaResource);
const localSourcePath =
localPathFromServer ||
(/^file:\/\//i.test(mediaResource) ? fromFileUrl(mediaResource) : mediaResource);
if (!localSourcePath) return;
if (!localSourcePath) {
if (!cancelled && sourceRef.current === mediaResource) {
setLoading(false);
}
return;
}
for (const candidate of buildSidecarAudioCandidates(localSourcePath)) {
const candidates = buildSidecarAudioCandidates(localSourcePath);
for (const candidate of candidates) {
try {
const result = await tryGenerate(candidate);
if (!cancelled && sourceRef.current === mediaResource) setData(result);
if (!cancelled && sourceRef.current === mediaResource) {
setPeaks(result);
setLoading(false);
}
return;
} catch {
// try next
}
}
if (!cancelled && sourceRef.current === mediaResource) {
setLoading(false);
}
};
void run();
@@ -96,5 +127,5 @@ export function useTimelineAudioPeaks(
};
}, [mediaResource, enableSourceSidecarFallback, peakCount]);
return data;
return { peaks, loading };
}
+11
View File
@@ -18,11 +18,22 @@ module.exports = {
transform: "translateX(100%)",
},
},
"text-shimmer": {
"0%, 100%": {
"background-size": "200% 200%",
"background-position": "left center",
},
"50%": {
"background-size": "200% 200%",
"background-position": "right center",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
shimmer: "shimmer 2s infinite",
"text-shimmer": "text-shimmer 2.5s ease-out infinite",
},
borderRadius: {
lg: "var(--radius)",