diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 19:41:14 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 19:41:14 +0200 |
| commit | e837e7504379e100ff3b56282577ec67bb88bec9 (patch) | |
| tree | eec7e6f964725a783a9e907e931862591bb46b55 /apps/web/src/timeline/components | |
| parent | da99dec8a2de1f3849aebbf5695c1e812b2dbdf9 (diff) | |
| download | opencut-e837e7504379e100ff3b56282577ec67bb88bec9.tar.gz | |
feat: ripple-extend resize, timeline filmstrip, export hardening
Yesterday's verified web session (gates green: 316 tests, tsc, build):
- ripple-extend pushes right-hand chain on static-element resize
- per-position video filmstrip thumbnails with zoom-adaptive density
- fps-aware export bitrate, 2s keyframes, even-dim export canvas
- freeze frame uses fixed 3s duration, prompt removed
Diffstat (limited to 'apps/web/src/timeline/components')
| -rw-r--r-- | apps/web/src/timeline/components/timeline-element.tsx | 78 | ||||
| -rw-r--r-- | apps/web/src/timeline/components/video-filmstrip.tsx | 208 |
2 files changed, 277 insertions, 9 deletions
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index 9dfeab8b..b25a7a28 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -5,6 +5,8 @@ import { useEditor } from "@/editor/use-editor"; import { FEATURES } from "@/features"; import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store"; import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform"; +import { VideoFilmstrip } from "./video-filmstrip"; +import { MEDIA_TILE_ASPECT_RATIO } from "@/services/thumbnail-cache/tiling"; import { AudioVolumeLine } from "./audio-volume-line"; import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { @@ -95,7 +97,6 @@ const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; const ELEMENT_RING_WIDTH_PX = 1.5; const PixelsPerSecondContext = createContext<number | null>(null); -const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { time: MediaTime; @@ -1082,20 +1083,78 @@ function EffectsButton({ ); } -function TiledMediaContent({ +function VideoElementContent({ element, track, }: { - element: VideoElement | ImageElement; + element: VideoElement; + track: TimelineTrack; +}) { + const pixelsPerSecond = useContext(PixelsPerSecondContext); + if (pixelsPerSecond === null) { + throw new Error( + "VideoElementContent must be rendered inside PixelsPerSecondContext.Provider", + ); + } + const mediaAssets = useEditor((e) => e.media.getAssets()); + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + + if (!mediaAsset) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + const trackHeight = getTrackHeight({ type: track.type }); + const fallbackTileWidth = trackHeight * MEDIA_TILE_ASPECT_RATIO; + + return ( + <> + {mediaAsset.thumbnailUrl && ( + <div + className="absolute inset-0" + style={{ + backgroundColor: "var(--muted)", + backgroundImage: `url(${mediaAsset.thumbnailUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${fallbackTileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + /> + )} + <VideoFilmstrip + element={element} + mediaAsset={mediaAsset} + pixelsPerSecond={pixelsPerSecond} + trackHeight={trackHeight} + /> + <MediaElementHeader + name={mediaAsset.name} + leading={ + FEATURES.effects && hasElementEffects({ element }) ? ( + <EffectsButton element={element} track={track} /> + ) : null + } + hasFade={true} + /> + </> + ); +} + +function ImageElementContent({ + element, + track, +}: { + element: ImageElement; track: TimelineTrack; }) { const mediaAssets = useEditor((e) => e.media.getAssets()); const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - const imageUrl = - element.type === "video" - ? mediaAsset?.thumbnailUrl - : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); + const imageUrl = mediaAsset?.thumbnailUrl ?? mediaAsset?.url; if (!imageUrl) { return ( @@ -1106,7 +1165,7 @@ function TiledMediaContent({ } const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; + const tileWidth = trackHeight * MEDIA_TILE_ASPECT_RATIO; return ( <> @@ -1177,8 +1236,9 @@ function ElementContent({ element, track }: ElementContentProps) { case "audio": return <AudioElementContent element={element} trackId={track.id} />; case "video": + return <VideoElementContent element={element} track={track} />; case "image": - return <TiledMediaContent element={element} track={track} />; + return <ImageElementContent element={element} track={track} />; } } diff --git a/apps/web/src/timeline/components/video-filmstrip.tsx b/apps/web/src/timeline/components/video-filmstrip.tsx new file mode 100644 index 00000000..e05f6bf4 --- /dev/null +++ b/apps/web/src/timeline/components/video-filmstrip.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useResizeObserver } from "@/hooks/use-resize-observer"; +import type { MediaAsset } from "@/media/types"; +import { getEffectiveRateAt } from "@/retime/resolve"; +import { thumbnailCache } from "@/services/thumbnail-cache/service"; +import { + getFilmstripInterval, + getFilmstripSlotCount, + getFilmstripSlotSourceTime, + getVisibleSlotRange, + MEDIA_TILE_ASPECT_RATIO, +} from "@/services/thumbnail-cache/tiling"; +import type { VideoElement } from "@/timeline"; +import { findScrollParent } from "@/utils/browser"; +import { mediaTimeToSeconds } from "@/wasm"; + +interface VideoFilmstripProps { + element: VideoElement; + mediaAsset: MediaAsset; + pixelsPerSecond: number; + trackHeight: number; +} + +export function VideoFilmstrip({ + element, + mediaAsset, + pixelsPerSecond, + trackHeight, +}: VideoFilmstripProps) { + const containerRef = useRef<HTMLDivElement>(null); + const [visibleRange, setVisibleRange] = useState({ left: 0, right: 0 }); + + const clipDurationSec = mediaTimeToSeconds({ time: element.duration }); + const trimStartSec = mediaTimeToSeconds({ time: element.trimStart }); + const rate = getEffectiveRateAt({ retime: element.retime }); + const minTileWidthPx = trackHeight * MEDIA_TILE_ASPECT_RATIO; + const interval = getFilmstripInterval({ pixelsPerSecond, minTileWidthPx }); + const tileWidthPx = interval * pixelsPerSecond; + const slotCount = getFilmstripSlotCount({ clipDurationSec, interval }); + + const updateVisibleRange = useCallback(() => { + const container = containerRef.current; + if (!container) { + return; + } + const rect = container.getBoundingClientRect(); + if (rect.width <= 0) { + return; + } + const scrollParent = findScrollParent({ element: container }); + let left = 0; + let right = rect.width; + if (scrollParent) { + const parentRect = scrollParent.getBoundingClientRect(); + left = Math.max(0, parentRect.left - rect.left); + right = Math.min(rect.width, parentRect.right - rect.left); + } else { + left = Math.max(0, -rect.left); + right = Math.min(rect.width, window.innerWidth - rect.left); + } + setVisibleRange((previous) => + previous.left === left && previous.right === right + ? previous + : { left, right }, + ); + }, []); + + useLayoutEffect(() => { + updateVisibleRange(); + }, [updateVisibleRange, pixelsPerSecond, clipDurationSec, interval]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + const scrollParent = findScrollParent({ element: container }); + if (!scrollParent) { + return; + } + scrollParent.addEventListener("scroll", updateVisibleRange, { + passive: true, + }); + return () => scrollParent.removeEventListener("scroll", updateVisibleRange); + }, [updateVisibleRange]); + + useResizeObserver({ ref: containerRef, onResize: updateVisibleRange }); + + const { firstSlot, lastSlot } = getVisibleSlotRange({ + slotCount, + tileWidthPx, + visibleLeftPx: visibleRange.left, + visibleRightPx: visibleRange.right, + }); + + const bitmapSize = useMemo(() => { + const dpr = + typeof window === "undefined" ? 1 : window.devicePixelRatio || 1; + return { + width: Math.max(1, Math.round(minTileWidthPx * dpr)), + height: Math.max(1, Math.round(trackHeight * dpr)), + }; + }, [minTileWidthPx, trackHeight]); + + const slots: number[] = []; + for (let slot = firstSlot; slot <= lastSlot; slot++) { + slots.push(slot); + } + + return ( + <div + ref={containerRef} + className="pointer-events-none absolute inset-0 overflow-hidden" + > + {slots.map((slot) => ( + <FilmstripTile + key={slot} + mediaId={mediaAsset.id} + file={mediaAsset.file} + sourceTimeSec={getFilmstripSlotSourceTime({ + slot, + interval, + trimStartSec, + rate, + assetDurationSec: mediaAsset.duration, + })} + leftPx={slot * tileWidthPx} + widthPx={tileWidthPx} + heightPx={trackHeight} + bitmapWidth={bitmapSize.width} + bitmapHeight={bitmapSize.height} + /> + ))} + </div> + ); +} + +function FilmstripTile({ + mediaId, + file, + sourceTimeSec, + leftPx, + widthPx, + heightPx, + bitmapWidth, + bitmapHeight, +}: { + mediaId: string; + file: File; + sourceTimeSec: number; + leftPx: number; + widthPx: number; + heightPx: number; + bitmapWidth: number; + bitmapHeight: number; +}) { + const requestKey = `${mediaId}:${sourceTimeSec}:${bitmapWidth}x${bitmapHeight}`; + const [loaded, setLoaded] = useState<{ + requestKey: string; + url: string; + } | null>(null); + + useEffect(() => { + let isCancelled = false; + void thumbnailCache + .getTile({ + mediaId, + file, + time: sourceTimeSec, + width: bitmapWidth, + height: bitmapHeight, + }) + .then((url) => { + if (!isCancelled && url) { + setLoaded({ requestKey, url }); + } + }); + return () => { + isCancelled = true; + }; + }, [mediaId, file, sourceTimeSec, bitmapWidth, bitmapHeight, requestKey]); + + if (!loaded || loaded.requestKey !== requestKey) { + return null; + } + + return ( + <div + className="absolute top-0" + style={{ + left: `${leftPx}px`, + width: `${widthPx}px`, + height: `${heightPx}px`, + backgroundImage: `url(${loaded.url})`, + backgroundSize: "100% 100%", + }} + /> + ); +} |
