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 ++++++++ .../src/timeline/controllers/resize-controller.ts | 37 +- .../group-resize/__tests__/compute-resize.test.ts | 559 +++++++++++++++++++++ .../src/timeline/group-resize/compute-resize.ts | 73 ++- apps/web/src/timeline/group-resize/index.ts | 1 + apps/web/src/timeline/group-resize/types.ts | 8 + 7 files changed, 935 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/timeline/components/video-filmstrip.tsx create mode 100644 apps/web/src/timeline/group-resize/__tests__/compute-resize.test.ts (limited to 'apps/web/src/timeline') 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 ( +
+ ); +} 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( - (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( + (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, + }), + ), + }), + }); +} + +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 { -- cgit v1.3.1