summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 19:41:14 +0200
committercashmere <cashmere@autistici.org>2026-07-28 19:41:14 +0200
commite837e7504379e100ff3b56282577ec67bb88bec9 (patch)
treeeec7e6f964725a783a9e907e931862591bb46b55 /apps/web/src/timeline/components
parentda99dec8a2de1f3849aebbf5695c1e812b2dbdf9 (diff)
downloadopencut-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.tsx78
-rw-r--r--apps/web/src/timeline/components/video-filmstrip.tsx208
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%",
+ }}
+ />
+ );
+}