mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio): render queue, layout restructure, home page, hover preview (#95)
## Summary - Add render queue panel with progress tracking, download, and delete actions - Restructure App layout: home page with project picker, session-based routing - Add ExpandOnHover component for preview-on-hover interactions (uses motion/react) - CompositionsTab now supports hover preview with expanded iframe view - Vite config: guard setInterval cleanup to dev-only (fixes CI build timeout) - Add favicon and update studio package deps ## Test plan - [x] Render queue shows progress, completes, and allows download - [x] Home page lists projects and navigates to session view - [x] ExpandOnHover shows expanded preview on mouse hover with spring animation - [x] `vite build` exits cleanly (no hanging process from setInterval) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
label: string;
|
||||
labelColor: string;
|
||||
}
|
||||
|
||||
const BAR_W = 2;
|
||||
const GAP = 1;
|
||||
const STEP = BAR_W + GAP;
|
||||
|
||||
/** Downsample PCM channel data into peak amplitudes (0–1). */
|
||||
function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
||||
const peaks: number[] = [];
|
||||
const samplesPerBar = Math.floor(channelData.length / barCount);
|
||||
if (samplesPerBar === 0) return Array(barCount).fill(0);
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
let max = 0;
|
||||
const start = i * samplesPerBar;
|
||||
const end = Math.min(start + samplesPerBar, channelData.length);
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(channelData[j] ?? 0);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
peaks.push(max);
|
||||
}
|
||||
const maxPeak = Math.max(...peaks, 0.001);
|
||||
return peaks.map((p) => p / maxPeak);
|
||||
}
|
||||
|
||||
/** Deterministic fake waveform as fallback (matches demo app). */
|
||||
function fakePeaks(url: string, count: number): number[] {
|
||||
let seed = 0;
|
||||
for (let i = 0; i < url.length; i++) seed = ((seed << 5) - seed + url.charCodeAt(i)) | 0;
|
||||
seed = Math.abs(seed) || 42;
|
||||
const rand = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return (seed & 0x7fffffff) / 2147483647;
|
||||
};
|
||||
const peaks: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i / count;
|
||||
const envelope = 0.3 + 0.3 * Math.sin(t * Math.PI * 3.2) + 0.2 * Math.sin(t * Math.PI * 7.1);
|
||||
peaks.push(Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * rand()))));
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
// Module-level cache so decoded audio persists across re-renders and re-mounts
|
||||
const peaksCache = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Audio waveform rendered from real PCM data via Web Audio API.
|
||||
* Falls back to a deterministic fake pattern if decoding fails.
|
||||
* Bars grow from bottom to top, rendered as CSS divs for zoom resilience.
|
||||
*/
|
||||
export const AudioWaveform = memo(function AudioWaveform({
|
||||
audioUrl,
|
||||
label,
|
||||
labelColor,
|
||||
}: AudioWaveformProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const barsRef = useRef<HTMLDivElement | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(audioUrl) ?? null);
|
||||
|
||||
// Fetch + decode audio once
|
||||
useEffect(() => {
|
||||
if (peaks || !audioUrl) return;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
fetch(audioUrl, { signal: ctrl.signal })
|
||||
.then((r) => r.arrayBuffer())
|
||||
.then((buf) => {
|
||||
const ctx = new AudioContext();
|
||||
return ctx.decodeAudioData(buf).finally(() => ctx.close());
|
||||
})
|
||||
.then((decoded) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const channel = decoded.getChannelData(0);
|
||||
// Extract enough peaks for wide clips (up to 4000 bars)
|
||||
const p = extractPeaks(channel, 4000);
|
||||
peaksCache.set(audioUrl, p);
|
||||
setPeaks(p);
|
||||
})
|
||||
.catch(() => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Fallback to fake waveform
|
||||
const p = fakePeaks(audioUrl, 4000);
|
||||
peaksCache.set(audioUrl, p);
|
||||
setPeaks(p);
|
||||
});
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, [audioUrl, peaks]);
|
||||
|
||||
// Draw bars into the container using innerHTML (fast, zoom-resilient)
|
||||
const draw = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const barsEl = barsRef.current;
|
||||
if (!container || !barsEl || !peaks) return;
|
||||
|
||||
const w = container.clientWidth || 400;
|
||||
const barCount = Math.min(Math.floor(w / STEP), peaks.length);
|
||||
|
||||
let html = "";
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
// Map bar index to peak index (resample)
|
||||
const peakIdx = Math.floor((i / barCount) * peaks.length);
|
||||
const amp = peaks[peakIdx] ?? 0;
|
||||
const pct = Math.max(3, Math.round(amp * 100));
|
||||
const opacity = (0.45 + amp * 0.4).toFixed(2);
|
||||
html += `<div style="position:absolute;bottom:0;left:${i * STEP}px;width:${BAR_W}px;height:${pct}%;background:rgba(75,163,210,${opacity})"></div>`;
|
||||
}
|
||||
barsEl.innerHTML = html;
|
||||
}, [peaks]);
|
||||
|
||||
// Observe container size and redraw
|
||||
const setContainerRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
containerRef.current = el;
|
||||
if (!el) return;
|
||||
draw();
|
||||
roRef.current = new ResizeObserver(() => draw());
|
||||
roRef.current.observe(el);
|
||||
},
|
||||
[draw],
|
||||
);
|
||||
|
||||
// Redraw when peaks arrive
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
roRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
||||
<div ref={barsRef} className="absolute left-0 right-0 bottom-0" style={{ top: 16 }} />
|
||||
{/* Shimmer while decoding */}
|
||||
{!peaks && (
|
||||
<div
|
||||
className="absolute left-0 right-0 bottom-0 animate-pulse"
|
||||
style={{
|
||||
top: 16,
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
|
||||
<span
|
||||
className="text-[9px] font-semibold truncate block leading-tight"
|
||||
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -5,11 +5,11 @@
|
||||
* and tiles them horizontally — like VideoThumbnail does for video clips.
|
||||
* Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
|
||||
*
|
||||
* Lazy-loaded via IntersectionObserver. Uses ResizeObserver to adapt frame count
|
||||
* when the clip width changes (zoom).
|
||||
* Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
|
||||
*/
|
||||
|
||||
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { memo, useRef, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
const CLIP_HEIGHT = 66;
|
||||
const MAX_UNIQUE_FRAMES = 6;
|
||||
@@ -33,18 +33,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
}: CompositionThumbnailProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [loadedFrames, setLoadedFrames] = useState<Set<number>>(new Set());
|
||||
const ioRef = useRef<IntersectionObserver | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const setRef = useCallback((el: HTMLDivElement | null) => {
|
||||
ioRef.current?.disconnect();
|
||||
roRef.current?.disconnect();
|
||||
if (!el) return;
|
||||
|
||||
// Walk up to data-clip parent for accurate width (max 5 levels to avoid overshoot)
|
||||
// Walk up to data-clip parent for accurate width
|
||||
let target: HTMLElement = el;
|
||||
let parent = el.parentElement;
|
||||
let depth = 0;
|
||||
@@ -59,32 +55,13 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
if (w > 0) setContainerWidth(w);
|
||||
});
|
||||
|
||||
ioRef.current = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
ioRef.current?.disconnect();
|
||||
requestAnimationFrame(() => {
|
||||
const w = target.clientWidth || target.getBoundingClientRect().width;
|
||||
if (w > 0) setContainerWidth(w);
|
||||
});
|
||||
}
|
||||
},
|
||||
{ rootMargin: "300px" },
|
||||
);
|
||||
ioRef.current.observe(el);
|
||||
|
||||
roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
|
||||
roRef.current.observe(target);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
ioRef.current?.disconnect();
|
||||
roRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
// Convert preview URL to thumbnail base URL
|
||||
const thumbnailBase = previewUrl
|
||||
@@ -97,63 +74,51 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
||||
const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
|
||||
|
||||
// Generate timestamps spread across the clip duration.
|
||||
// Start at 30% into the scene to skip entrance animations (opacity:0 → 1).
|
||||
// End at 90% to avoid catching exit animations.
|
||||
// Each frame tile represents a real position in the clip.
|
||||
// Offset slightly (0.5s) into each segment to avoid landing on transition
|
||||
// points where content is invisible due to fade-in/fade-out animations.
|
||||
const timestamps: number[] = [];
|
||||
const startOffset = duration * 0.3;
|
||||
const endOffset = duration * 0.9;
|
||||
const range = endOffset - startOffset;
|
||||
const pad = Math.min(0.5, duration * 0.05);
|
||||
for (let i = 0; i < uniqueFrames; i++) {
|
||||
const frac = uniqueFrames === 1 ? 0 : i / (uniqueFrames - 1);
|
||||
timestamps.push(seekTime + startOffset + frac * range);
|
||||
const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1);
|
||||
const raw = seekTime + frac * duration;
|
||||
// Clamp to [pad, duration - pad] to stay inside visible content
|
||||
timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime)));
|
||||
}
|
||||
|
||||
const hasAnyFrame = loadedFrames.size > 0;
|
||||
|
||||
return (
|
||||
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
|
||||
{/* Film strip */}
|
||||
{visible && (
|
||||
<div className="absolute inset-0 flex">
|
||||
{Array.from({ length: frameCount }).map((_, i) => {
|
||||
const uniqueIdx = i % uniqueFrames;
|
||||
const t = timestamps[uniqueIdx];
|
||||
const url = `${thumbnailBase}?t=${t.toFixed(2)}`;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
|
||||
style={{ width: frameW }}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoadedFrames((prev) => new Set(prev).add(uniqueIdx))}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{
|
||||
opacity: loadedFrames.has(uniqueIdx) ? 1 : 0,
|
||||
transition: "opacity 200ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shimmer while loading */}
|
||||
{(!visible || !hasAnyFrame) && (
|
||||
<div
|
||||
className="absolute inset-0 animate-pulse"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Film strip — each tile maps to its real timeline position */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{Array.from({ length: frameCount }).map((_, i) => {
|
||||
// Map this tile's visual position to a timestamp
|
||||
const tileFrac = frameCount === 1 ? 0.5 : i / (frameCount - 1);
|
||||
const t = seekTime + tileFrac * duration;
|
||||
// Use the nearest cached unique frame
|
||||
const uniqueIdx = Math.min(Math.round(tileFrac * (uniqueFrames - 1)), uniqueFrames - 1);
|
||||
const cachedT = timestamps[uniqueIdx];
|
||||
const url = `${thumbnailBase}?t=${(cachedT ?? t).toFixed(2)}`;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
|
||||
style={{ width: frameW }}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "1";
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { formatTime } from "../lib/time";
|
||||
|
||||
interface EditPopoverProps {
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const start = Math.min(rangeStart, rangeEnd);
|
||||
const end = Math.max(rangeStart, rangeEnd);
|
||||
|
||||
const elementsInRange = useMemo(() => {
|
||||
return elements.filter((el) => {
|
||||
const elEnd = el.start + el.duration;
|
||||
return el.start < end && elEnd > start;
|
||||
});
|
||||
}, [elements, start, end]);
|
||||
|
||||
useMountEffect(() => {
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
});
|
||||
|
||||
useMountEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
});
|
||||
|
||||
useMountEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
setTimeout(() => window.addEventListener("mousedown", handleClick), 100);
|
||||
return () => window.removeEventListener("mousedown", handleClick);
|
||||
});
|
||||
|
||||
const buildClipboardText = useCallback(() => {
|
||||
const elementLines = elementsInRange
|
||||
.map(
|
||||
(el) =>
|
||||
`- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `Edit the following HyperFrames composition:
|
||||
|
||||
Time range: ${formatTime(start)} — ${formatTime(end)}
|
||||
|
||||
Elements in range:
|
||||
${elementLines || "(none)"}
|
||||
|
||||
User request:
|
||||
${prompt.trim() || "(no prompt provided)"}
|
||||
|
||||
Instructions:
|
||||
Modify only the elements listed above within the specified time range.
|
||||
The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations.
|
||||
Preserve all other elements and timing outside this range.`;
|
||||
}, [start, end, elementsInRange, prompt]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildClipboardText());
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = buildClipboardText();
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
onClose();
|
||||
}, 800);
|
||||
}, [buildClipboardText, onClose]);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)),
|
||||
top: Math.max(8, anchorY - 280),
|
||||
zIndex: 200,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={popoverRef} style={style}>
|
||||
<div className="w-80 bg-neutral-900 border border-neutral-700/60 rounded-xl shadow-2xl shadow-black/40 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
<span className="text-[11px] font-medium text-neutral-300">
|
||||
{formatTime(start)} — {formatTime(end)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-neutral-600">
|
||||
{elementsInRange.length} element{elementsInRange.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Elements */}
|
||||
{elementsInRange.length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
|
||||
{elementsInRange.map((el) => (
|
||||
<div key={el.id} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-[10px] font-mono text-blue-400/80">#{el.id}</span>
|
||||
<span className="text-[10px] text-neutral-600">{el.tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="p-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleCopy();
|
||||
}
|
||||
}}
|
||||
placeholder="What should change?"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
|
||||
copied
|
||||
? "bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
: "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy to Agent"}
|
||||
{!copied && <span className="text-[9px] text-blue-400/50 ml-1.5">Cmd+Enter</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, useRef, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../lib/useMountEffect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
const NATIVE_W = 1920;
|
||||
const NATIVE_H = 1080;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
||||
import { useRef, useState, useCallback, memo } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
|
||||
@@ -30,7 +31,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const updateProgress = (t: number) => {
|
||||
currentTimeRef.current = t;
|
||||
const dur = durationRef.current;
|
||||
@@ -57,7 +58,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
unsub();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateTicks, formatTick } from "./Timeline";
|
||||
import { generateTicks } from "./Timeline";
|
||||
import { formatTime } from "../lib/time";
|
||||
|
||||
describe("generateTicks", () => {
|
||||
it("returns empty arrays for duration <= 0", () => {
|
||||
@@ -70,40 +71,40 @@ describe("generateTicks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTick", () => {
|
||||
describe("formatTime", () => {
|
||||
it("formats 0 seconds as 0:00", () => {
|
||||
expect(formatTick(0)).toBe("0:00");
|
||||
expect(formatTime(0)).toBe("0:00");
|
||||
});
|
||||
|
||||
it("formats seconds below a minute", () => {
|
||||
expect(formatTick(5)).toBe("0:05");
|
||||
expect(formatTick(30)).toBe("0:30");
|
||||
expect(formatTick(59)).toBe("0:59");
|
||||
expect(formatTime(5)).toBe("0:05");
|
||||
expect(formatTime(30)).toBe("0:30");
|
||||
expect(formatTime(59)).toBe("0:59");
|
||||
});
|
||||
|
||||
it("formats exactly one minute", () => {
|
||||
expect(formatTick(60)).toBe("1:00");
|
||||
expect(formatTime(60)).toBe("1:00");
|
||||
});
|
||||
|
||||
it("formats minutes and seconds", () => {
|
||||
expect(formatTick(90)).toBe("1:30");
|
||||
expect(formatTick(125)).toBe("2:05");
|
||||
expect(formatTime(90)).toBe("1:30");
|
||||
expect(formatTime(125)).toBe("2:05");
|
||||
});
|
||||
|
||||
it("floors fractional seconds", () => {
|
||||
expect(formatTick(5.7)).toBe("0:05");
|
||||
expect(formatTick(59.9)).toBe("0:59");
|
||||
expect(formatTick(90.5)).toBe("1:30");
|
||||
expect(formatTime(5.7)).toBe("0:05");
|
||||
expect(formatTime(59.9)).toBe("0:59");
|
||||
expect(formatTime(90.5)).toBe("1:30");
|
||||
});
|
||||
|
||||
it("handles large values", () => {
|
||||
expect(formatTick(600)).toBe("10:00");
|
||||
expect(formatTick(3661)).toBe("61:01");
|
||||
expect(formatTime(600)).toBe("10:00");
|
||||
expect(formatTime(3661)).toBe("61:01");
|
||||
});
|
||||
|
||||
it("zero-pads seconds to two digits", () => {
|
||||
expect(formatTick(1)).toBe("0:01");
|
||||
expect(formatTick(9)).toBe("0:09");
|
||||
expect(formatTick(61)).toBe("1:01");
|
||||
expect(formatTime(1)).toBe("0:01");
|
||||
expect(formatTime(9)).toBe("0:09");
|
||||
expect(formatTime(61)).toBe("1:01");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useMemo, useCallback, useState, memo, type ReactNode, useEffect } from "react";
|
||||
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
import { useMountEffect } from "../lib/useMountEffect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { EditPopover } from "../../components/timeline/EditModal";
|
||||
import { EditPopover } from "./EditModal";
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||
const GUTTER = 32;
|
||||
@@ -151,11 +152,8 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
export function formatTick(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
/** @deprecated Use formatTime from '../lib/time' instead */
|
||||
export const formatTick = formatTime;
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
interface TimelineProps {
|
||||
@@ -200,17 +198,19 @@ export const Timeline = memo(function Timeline({
|
||||
const isDragging = useRef(false);
|
||||
// Range selection (Shift+drag)
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
|
||||
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
|
||||
const blur = () => setShiftHeld(false);
|
||||
window.addEventListener("keydown", down);
|
||||
window.addEventListener("keyup", up);
|
||||
window.addEventListener("blur", () => setShiftHeld(false));
|
||||
window.addEventListener("blur", blur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", down);
|
||||
window.removeEventListener("keyup", up);
|
||||
window.removeEventListener("blur", blur);
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
const isRangeSelecting = useRef(false);
|
||||
const rangeAnchorTime = useRef(0);
|
||||
const [rangeSelection, setRangeSelection] = useState<{
|
||||
@@ -242,12 +242,9 @@ export const Timeline = memo(function Timeline({
|
||||
}, []);
|
||||
|
||||
// Clean up ResizeObserver on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
roRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
// Effective duration: max of store duration and the furthest element end.
|
||||
// processTimelineMessage updates elements but not duration, so elements can
|
||||
@@ -593,7 +590,7 @@ export const Timeline = memo(function Timeline({
|
||||
style={{ left: t * pps }}
|
||||
>
|
||||
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
|
||||
{formatTick(t)}
|
||||
{formatTime(t)}
|
||||
</span>
|
||||
<div className="w-px h-[5px] bg-neutral-600/60" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
interface VideoThumbnailProps {
|
||||
videoSrc: string;
|
||||
@@ -55,15 +56,15 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
||||
roRef.current.observe(target);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
ioRef.current?.disconnect();
|
||||
roRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
useMountEffect(() => () => {
|
||||
ioRef.current?.disconnect();
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
// Extract frames progressively — each frame appears as soon as it's ready
|
||||
// Extract frames progressively — each frame appears as soon as it's ready.
|
||||
// Note: useEffect with deps is acceptable — syncs with external video element API,
|
||||
// requires cleanup (cancel extraction, revoke URLs) when inputs change.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!visible || extractingRef.current) return;
|
||||
extractingRef.current = true;
|
||||
|
||||
Reference in New Issue
Block a user