From e837e7504379e100ff3b56282577ec67bb88bec9 Mon Sep 17 00:00:00 2001 From: cashmere Date: Tue, 28 Jul 2026 19:41:14 +0200 Subject: 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 --- .../src/timeline/components/timeline-element.tsx | 78 +++++++- .../src/timeline/components/video-filmstrip.tsx | 208 +++++++++++++++++++++ 2 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/timeline/components/video-filmstrip.tsx (limited to 'apps/web/src/timeline/components') 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(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 ( + + {element.name} + + ); + } + + const trackHeight = getTrackHeight({ type: track.type }); + const fallbackTileWidth = trackHeight * MEDIA_TILE_ASPECT_RATIO; + + return ( + <> + {mediaAsset.thumbnailUrl && ( +
+ )} + + + ) : 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 ; case "video": + return ; case "image": - return ; + return ; } } 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(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 ( +
+ {slots.map((slot) => ( + + ))} +
+ ); +} + +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 ( +
+ ); +} -- cgit v1.3.1