summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline
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
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')
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx78
-rw-r--r--apps/web/src/timeline/components/video-filmstrip.tsx208
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts37
-rw-r--r--apps/web/src/timeline/group-resize/__tests__/compute-resize.test.ts559
-rw-r--r--apps/web/src/timeline/group-resize/compute-resize.ts73
-rw-r--r--apps/web/src/timeline/group-resize/index.ts1
-rw-r--r--apps/web/src/timeline/group-resize/types.ts8
7 files changed, 935 insertions, 29 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%",
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
index a1098915..a657c695 100644
--- a/apps/web/src/timeline/controllers/resize-controller.ts
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -109,18 +109,30 @@ export function buildResizeMembers({
? elementEnd
: maxMediaTime({ a: bound, b: elementEnd });
}, null);
- const rightNeighborBound = otherElements
- .filter(
- (el) =>
- el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }),
- )
- .reduce<MediaTime | null>(
- (bound, el) =>
- bound === null
- ? el.startTime
- : minMediaTime({ a: bound, b: el.startTime }),
- null,
- );
+ const rightElements = otherElements.filter(
+ (el) =>
+ el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }),
+ );
+ const rightNeighborBound = rightElements.reduce<MediaTime | null>(
+ (bound, el) =>
+ bound === null
+ ? el.startTime
+ : minMediaTime({ a: bound, b: el.startTime }),
+ null,
+ );
+ const rightPushChain =
+ element.sourceDuration == null
+ ? rightElements
+ .sort((a, b) => (a.startTime > b.startTime ? 1 : -1))
+ .map((el) => ({
+ trackId,
+ elementId: el.id,
+ startTime: el.startTime,
+ duration: el.duration,
+ trimStart: el.trimStart,
+ trimEnd: el.trimEnd,
+ }))
+ : undefined;
return [
{
@@ -134,6 +146,7 @@ export function buildResizeMembers({
retime: isRetimableElement(element) ? element.retime : undefined,
leftNeighborBound,
rightNeighborBound,
+ rightPushChain,
},
];
});
diff --git a/apps/web/src/timeline/group-resize/__tests__/compute-resize.test.ts b/apps/web/src/timeline/group-resize/__tests__/compute-resize.test.ts
new file mode 100644
index 00000000..a678b18d
--- /dev/null
+++ b/apps/web/src/timeline/group-resize/__tests__/compute-resize.test.ts
@@ -0,0 +1,559 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import type { FrameRate } from "opencut-wasm";
+import { EditorCore } from "@/core";
+import { UpdateElementsCommand } from "@/commands";
+import { buildDefaultScene } from "@/timeline/scenes";
+import type { ImageElement, TimelineElement, VideoElement } from "@/timeline";
+import { buildResizeMembers } from "@/timeline/controllers/resize-controller";
+import { computeGroupResize } from "@/timeline/group-resize";
+import type {
+ GroupResizeMember,
+ GroupResizePushTarget,
+ GroupResizeUpdate,
+} from "@/timeline/group-resize";
+import {
+ addMediaTime,
+ type MediaTime,
+ mediaTime,
+ mediaTimeFromSeconds,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
+
+const FPS: FrameRate = { numerator: 30, denominator: 1 };
+
+function seconds({ value }: { value: number }): MediaTime {
+ return mediaTimeFromSeconds({ seconds: value });
+}
+
+function buildPushTarget({
+ elementId,
+ startTime,
+ duration,
+}: {
+ elementId: string;
+ startTime: MediaTime;
+ duration: MediaTime;
+}): GroupResizePushTarget {
+ return {
+ trackId: "track-1",
+ elementId,
+ startTime,
+ duration,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ };
+}
+
+function buildStaticMember({
+ startTime,
+ duration,
+ leftNeighborBound = null,
+ rightNeighborBound,
+ rightPushChain,
+}: {
+ startTime: MediaTime;
+ duration: MediaTime;
+ leftNeighborBound?: MediaTime | null;
+ rightNeighborBound: MediaTime | null;
+ rightPushChain?: GroupResizePushTarget[];
+}): GroupResizeMember {
+ return {
+ trackId: "track-1",
+ elementId: "element-1",
+ startTime,
+ duration,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ leftNeighborBound,
+ rightNeighborBound,
+ rightPushChain,
+ };
+}
+
+function buildVideoMember({
+ startTime,
+ duration,
+ sourceDuration,
+ rightNeighborBound,
+}: {
+ startTime: MediaTime;
+ duration: MediaTime;
+ sourceDuration: MediaTime;
+ rightNeighborBound: MediaTime | null;
+}): GroupResizeMember {
+ return {
+ trackId: "track-1",
+ elementId: "element-1",
+ startTime,
+ duration,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ sourceDuration,
+ leftNeighborBound: null,
+ rightNeighborBound,
+ };
+}
+
+function findUpdate({
+ updates,
+ elementId,
+}: {
+ updates: GroupResizeUpdate[];
+ elementId: string;
+}): GroupResizeUpdate | undefined {
+ return updates.find((update) => update.elementId === elementId);
+}
+
+describe("computeGroupResize ripple push", () => {
+ test("extends into the gap before pushing anything", () => {
+ const member = buildStaticMember({
+ startTime: ZERO_MEDIA_TIME,
+ duration: seconds({ value: 5 }),
+ rightNeighborBound: seconds({ value: 10 }),
+ rightPushChain: [
+ buildPushTarget({
+ elementId: "neighbor-1",
+ startTime: seconds({ value: 10 }),
+ duration: seconds({ value: 10 }),
+ }),
+ ],
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "right",
+ deltaTime: seconds({ value: 3 }),
+ fps: FPS,
+ });
+
+ expect(result.deltaTime).toBe(seconds({ value: 3 }));
+ expect(result.updates).toHaveLength(1);
+ expect(
+ findUpdate({ updates: result.updates, elementId: "element-1" })?.patch
+ .duration,
+ ).toBe(seconds({ value: 8 }));
+ });
+
+ test("pushes the flush right neighbor by the overflow", () => {
+ const member = buildStaticMember({
+ startTime: ZERO_MEDIA_TIME,
+ duration: seconds({ value: 5 }),
+ rightNeighborBound: seconds({ value: 5 }),
+ rightPushChain: [
+ buildPushTarget({
+ elementId: "neighbor-1",
+ startTime: seconds({ value: 5 }),
+ duration: seconds({ value: 10 }),
+ }),
+ ],
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "right",
+ deltaTime: seconds({ value: 3 }),
+ fps: FPS,
+ });
+
+ expect(result.deltaTime).toBe(seconds({ value: 3 }));
+ expect(result.updates).toHaveLength(2);
+ expect(
+ findUpdate({ updates: result.updates, elementId: "element-1" })?.patch
+ .duration,
+ ).toBe(seconds({ value: 8 }));
+ const push = findUpdate({ updates: result.updates, elementId: "neighbor-1" });
+ expect(push?.patch.startTime).toBe(seconds({ value: 8 }));
+ expect(push?.patch.duration).toBe(seconds({ value: 10 }));
+ expect(push?.patch.trimStart).toBe(ZERO_MEDIA_TIME);
+ expect(push?.patch.trimEnd).toBe(ZERO_MEDIA_TIME);
+ });
+
+ test("pushes a chain of several neighbors by the same overflow", () => {
+ const member = buildStaticMember({
+ startTime: ZERO_MEDIA_TIME,
+ duration: seconds({ value: 5 }),
+ rightNeighborBound: seconds({ value: 5 }),
+ rightPushChain: [
+ buildPushTarget({
+ elementId: "neighbor-1",
+ startTime: seconds({ value: 5 }),
+ duration: seconds({ value: 10 }),
+ }),
+ buildPushTarget({
+ elementId: "neighbor-2",
+ startTime: seconds({ value: 15 }),
+ duration: seconds({ value: 5 }),
+ }),
+ buildPushTarget({
+ elementId: "neighbor-3",
+ startTime: seconds({ value: 25 }),
+ duration: seconds({ value: 2 }),
+ }),
+ ],
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "right",
+ deltaTime: seconds({ value: 4 }),
+ fps: FPS,
+ });
+
+ expect(result.deltaTime).toBe(seconds({ value: 4 }));
+ expect(result.updates).toHaveLength(4);
+ expect(
+ findUpdate({ updates: result.updates, elementId: "neighbor-1" })?.patch
+ .startTime,
+ ).toBe(seconds({ value: 9 }));
+ expect(
+ findUpdate({ updates: result.updates, elementId: "neighbor-2" })?.patch
+ .startTime,
+ ).toBe(seconds({ value: 19 }));
+ expect(
+ findUpdate({ updates: result.updates, elementId: "neighbor-3" })?.patch
+ .startTime,
+ ).toBe(seconds({ value: 29 }));
+ });
+
+ test("push stops at the end of the track", () => {
+ const member = buildStaticMember({
+ startTime: ZERO_MEDIA_TIME,
+ duration: seconds({ value: 5 }),
+ rightNeighborBound: null,
+ rightPushChain: [],
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "right",
+ deltaTime: seconds({ value: 3 }),
+ fps: FPS,
+ });
+
+ expect(result.deltaTime).toBe(seconds({ value: 3 }));
+ expect(result.updates).toHaveLength(1);
+ expect(
+ findUpdate({ updates: result.updates, elementId: "element-1" })?.patch
+ .duration,
+ ).toBe(seconds({ value: 8 }));
+ });
+
+ test("video element clamps at its right neighbor instead of pushing", () => {
+ const member = buildVideoMember({
+ startTime: ZERO_MEDIA_TIME,
+ duration: seconds({ value: 5 }),
+ sourceDuration: seconds({ value: 6 }),
+ rightNeighborBound: seconds({ value: 5 }),
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "right",
+ deltaTime: seconds({ value: 10 }),
+ fps: FPS,
+ });
+
+ expect(result.deltaTime).toBe(ZERO_MEDIA_TIME);
+ expect(result.updates).toHaveLength(1);
+ expect(
+ findUpdate({ updates: result.updates, elementId: "element-1" })?.patch
+ .duration,
+ ).toBe(seconds({ value: 5 }));
+ });
+
+ test("video element clamps at its source extent when there is no neighbor", () => {
+ const member = buildVideoMember({
+ startTime: ZERO_MEDIA_TIME,
+ duration: seconds({ value: 5 }),
+ sourceDuration: seconds({ value: 6 }),
+ rightNeighborBound: null,
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "right",
+ deltaTime: seconds({ value: 10 }),
+ fps: FPS,
+ });
+
+ expect(result.deltaTime).toBe(seconds({ value: 1 }));
+ expect(result.updates).toHaveLength(1);
+ expect(
+ findUpdate({ updates: result.updates, elementId: "element-1" })?.patch
+ .duration,
+ ).toBe(seconds({ value: 6 }));
+ });
+
+ test("static element without a push chain keeps the old neighbor clamp", () => {
+ const member = buildStaticMember({
+ startTime: ZERO_MEDIA_TIME,
+ duration: seconds({ value: 5 }),
+ rightNeighborBound: seconds({ value: 5 }),
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "right",
+ deltaTime: seconds({ value: 3 }),
+ fps: FPS,
+ });
+
+ expect(result.deltaTime).toBe(ZERO_MEDIA_TIME);
+ expect(result.updates).toHaveLength(1);
+ });
+
+ test("shortening still respects the one-frame minimum duration", () => {
+ const oneFrame = mediaTime({
+ ticks: Math.round(TICKS_PER_SECOND / 30),
+ });
+ const member = buildStaticMember({
+ startTime: ZERO_MEDIA_TIME,
+ duration: seconds({ value: 5 }),
+ rightNeighborBound: null,
+ rightPushChain: [],
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "right",
+ deltaTime: seconds({ value: -10 }),
+ fps: FPS,
+ });
+
+ expect(result.updates).toHaveLength(1);
+ expect(
+ findUpdate({ updates: result.updates, elementId: "element-1" })?.patch
+ .duration,
+ ).toBe(oneFrame);
+ });
+
+ test("left-edge resize still clamps at the left neighbor and pushes nothing", () => {
+ const member = buildStaticMember({
+ startTime: seconds({ value: 5 }),
+ duration: seconds({ value: 5 }),
+ leftNeighborBound: seconds({ value: 2 }),
+ rightNeighborBound: null,
+ rightPushChain: [
+ buildPushTarget({
+ elementId: "neighbor-1",
+ startTime: seconds({ value: 10 }),
+ duration: seconds({ value: 10 }),
+ }),
+ ],
+ });
+
+ const result = computeGroupResize({
+ members: [member],
+ side: "left",
+ deltaTime: seconds({ value: -10 }),
+ fps: FPS,
+ });
+
+ expect(result.deltaTime).toBe(seconds({ value: -3 }));
+ expect(result.updates).toHaveLength(1);
+ const update = findUpdate({ updates: result.updates, elementId: "element-1" });
+ expect(update?.patch.startTime).toBe(seconds({ value: 2 }));
+ expect(update?.patch.duration).toBe(seconds({ value: 8 }));
+ });
+});
+
+const IMAGE_DURATION = seconds({ value: 5 });
+const VIDEO_DURATION = seconds({ value: 10 });
+const TAIL_DURATION = seconds({ value: 5 });
+const PUSH_DELTA = seconds({ value: 3 });
+
+function buildImageElement(): ImageElement {
+ return {
+ id: "image-1",
+ type: "image",
+ name: "image-1",
+ startTime: ZERO_MEDIA_TIME,
+ duration: IMAGE_DURATION,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ mediaId: "media-image-1",
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
+ };
+}
+
+function buildVideoElement({
+ id,
+ startTime,
+ duration,
+}: {
+ id: string;
+ startTime: MediaTime;
+ duration: MediaTime;
+}): VideoElement {
+ return {
+ id,
+ type: "video",
+ name: id,
+ startTime,
+ duration,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ sourceDuration: duration,
+ mediaId: `media-${id}`,
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
+ };
+}
+
+function seedScene(): { editor: EditorCore; trackId: string } {
+ const editor = EditorCore.getInstance();
+ const scene = buildDefaultScene({ name: "Main scene", isMain: true });
+ scene.tracks.main.elements = [
+ buildImageElement(),
+ buildVideoElement({
+ id: "video-1",
+ startTime: IMAGE_DURATION,
+ duration: VIDEO_DURATION,
+ }),
+ buildVideoElement({
+ id: "video-2",
+ startTime: addMediaTime({ a: IMAGE_DURATION, b: VIDEO_DURATION }),
+ duration: TAIL_DURATION,
+ }),
+ ];
+ editor.project.setActiveProject({
+ project: {
+ metadata: {
+ id: "project-1",
+ name: "Test project",
+ duration: addMediaTime({
+ a: IMAGE_DURATION,
+ b: addMediaTime({ a: VIDEO_DURATION, b: TAIL_DURATION }),
+ }),
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ },
+ scenes: [scene],
+ currentSceneId: scene.id,
+ settings: {
+ fps: FPS,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ version: 1,
+ },
+ });
+ editor.scenes.setScenes({ scenes: [scene], activeSceneId: scene.id });
+ return { editor, trackId: scene.tracks.main.id };
+}
+
+function getElementById({
+ editor,
+ elementId,
+}: {
+ editor: EditorCore;
+ elementId: string;
+}): TimelineElement | undefined {
+ return editor.scenes
+ .getActiveScene()
+ .tracks.main.elements.find((element) => element.id === elementId);
+}
+
+function commitResizeResult({
+ editor,
+ trackId,
+}: {
+ editor: EditorCore;
+ trackId: string;
+}): void {
+ const members = buildResizeMembers({
+ tracks: editor.scenes.getActiveScene().tracks,
+ selectedElements: [{ trackId, elementId: "image-1" }],
+ });
+ const result = computeGroupResize({
+ members,
+ side: "right",
+ deltaTime: PUSH_DELTA,
+ fps: FPS,
+ });
+ editor.command.execute({
+ command: new UpdateElementsCommand({
+ updates: result.updates.map(
+ ({ trackId: updateTrackId, elementId, patch }) => ({
+ trackId: updateTrackId,
+ elementId,
+ patch: patch as Partial<TimelineElement>,
+ }),
+ ),
+ }),
+ });
+}
+
+function expectPushedLayout({ editor }: { editor: EditorCore }): void {
+ expect(getElementById({ editor, elementId: "image-1" })?.duration).toBe(
+ addMediaTime({ a: IMAGE_DURATION, b: PUSH_DELTA }),
+ );
+ expect(getElementById({ editor, elementId: "video-1" })?.startTime).toBe(
+ addMediaTime({ a: IMAGE_DURATION, b: PUSH_DELTA }),
+ );
+ expect(getElementById({ editor, elementId: "video-2" })?.startTime).toBe(
+ addMediaTime({
+ a: addMediaTime({ a: IMAGE_DURATION, b: VIDEO_DURATION }),
+ b: PUSH_DELTA,
+ }),
+ );
+}
+
+function expectOriginalLayout({ editor }: { editor: EditorCore }): void {
+ expect(getElementById({ editor, elementId: "image-1" })?.duration).toBe(
+ IMAGE_DURATION,
+ );
+ expect(getElementById({ editor, elementId: "video-1" })?.startTime).toBe(
+ IMAGE_DURATION,
+ );
+ expect(getElementById({ editor, elementId: "video-2" })?.startTime).toBe(
+ addMediaTime({ a: IMAGE_DURATION, b: VIDEO_DURATION }),
+ );
+}
+
+describe("ripple push commit", () => {
+ beforeEach(() => {
+ EditorCore.reset();
+ });
+
+ test("resize and push land in one undoable step", () => {
+ const { editor, trackId } = seedScene();
+
+ commitResizeResult({ editor, trackId });
+
+ expectPushedLayout({ editor });
+ });
+
+ test("undo restores the original positions", () => {
+ const { editor, trackId } = seedScene();
+
+ commitResizeResult({ editor, trackId });
+ editor.command.undo();
+
+ expectOriginalLayout({ editor });
+ });
+
+ test("redo after undo re-applies the resize and push", () => {
+ const { editor, trackId } = seedScene();
+
+ commitResizeResult({ editor, trackId });
+ editor.command.undo();
+ editor.command.redo();
+
+ expectPushedLayout({ editor });
+ });
+});
diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts
index ed335997..2674aa8d 100644
--- a/apps/web/src/timeline/group-resize/compute-resize.ts
+++ b/apps/web/src/timeline/group-resize/compute-resize.ts
@@ -18,6 +18,7 @@ import {
import type {
ComputeGroupResizeArgs,
GroupResizeMember,
+ GroupResizePushTarget,
GroupResizeResult,
GroupResizeUpdate,
ResizeSide,
@@ -103,16 +104,72 @@ export function computeGroupResize({
return {
deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime,
- updates: members.map((member) =>
- buildResizeUpdate({
- member,
- side,
- deltaTime: finalDeltaTime,
- }),
- ),
+ updates: [
+ ...members.map((member) =>
+ buildResizeUpdate({
+ member,
+ side,
+ deltaTime: finalDeltaTime,
+ }),
+ ),
+ ...buildRipplePushUpdates({ members, side, deltaTime: finalDeltaTime }),
+ ],
};
}
+function buildRipplePushUpdates({
+ members,
+ side,
+ deltaTime,
+}: {
+ members: GroupResizeMember[];
+ side: ResizeSide;
+ deltaTime: MediaTime;
+}): GroupResizeUpdate[] {
+ if (side !== "right" || deltaTime <= 0) {
+ return [];
+ }
+
+ const overflowByTarget = new Map<
+ string,
+ { target: GroupResizePushTarget; overflow: MediaTime }
+ >();
+ for (const member of members) {
+ if (
+ member.sourceDuration != null ||
+ member.rightPushChain === undefined ||
+ member.rightNeighborBound === null
+ ) {
+ continue;
+ }
+ const neighborCeiling = subMediaTime({
+ a: member.rightNeighborBound,
+ b: addMediaTime({ a: member.startTime, b: member.duration }),
+ });
+ const overflow = subMediaTime({ a: deltaTime, b: neighborCeiling });
+ if (overflow <= 0) {
+ continue;
+ }
+ for (const target of member.rightPushChain) {
+ const existing = overflowByTarget.get(target.elementId);
+ if (existing === undefined || overflow > existing.overflow) {
+ overflowByTarget.set(target.elementId, { target, overflow });
+ }
+ }
+ }
+
+ return [...overflowByTarget.values()].map(({ target, overflow }) => ({
+ trackId: target.trackId,
+ elementId: target.elementId,
+ patch: {
+ trimStart: target.trimStart,
+ trimEnd: target.trimEnd,
+ startTime: addMediaTime({ a: target.startTime, b: overflow }),
+ duration: target.duration,
+ },
+ }));
+}
+
function buildResizeUpdate({
member,
side,
@@ -219,7 +276,7 @@ function getMaximumAllowedDeltaTime({
b: addMediaTime({ a: member.startTime, b: member.duration }),
});
if (member.sourceDuration == null) {
- return rightNeighborCeiling;
+ return member.rightPushChain === undefined ? rightNeighborCeiling : null;
}
const maximumVisibleSourceSpan = subMediaTime({
diff --git a/apps/web/src/timeline/group-resize/index.ts b/apps/web/src/timeline/group-resize/index.ts
index bc0a7b35..662ae24e 100644
--- a/apps/web/src/timeline/group-resize/index.ts
+++ b/apps/web/src/timeline/group-resize/index.ts
@@ -3,6 +3,7 @@ export { computeGroupResize } from "./compute-resize";
export type {
ComputeGroupResizeArgs,
GroupResizeMember,
+ GroupResizePushTarget,
GroupResizeResult,
GroupResizeUpdate,
ResizeSide,
diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts
index 5c0f77f6..f483d156 100644
--- a/apps/web/src/timeline/group-resize/types.ts
+++ b/apps/web/src/timeline/group-resize/types.ts
@@ -4,6 +4,13 @@ import type { MediaTime } from "@/wasm";
export type ResizeSide = "left" | "right";
+export interface GroupResizePushTarget extends ElementRef {
+ startTime: MediaTime;
+ duration: MediaTime;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+}
+
export interface GroupResizeMember extends ElementRef {
startTime: MediaTime;
duration: MediaTime;
@@ -13,6 +20,7 @@ export interface GroupResizeMember extends ElementRef {
retime?: RetimeConfig;
leftNeighborBound: MediaTime | null;
rightNeighborBound: MediaTime | null;
+ rightPushChain?: GroupResizePushTarget[];
}
export interface GroupResizeUpdate extends ElementRef {