summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/group-resize/__tests__
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/group-resize/__tests__
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/group-resize/__tests__')
-rw-r--r--apps/web/src/timeline/group-resize/__tests__/compute-resize.test.ts559
1 files changed, 559 insertions, 0 deletions
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 });
+ });
+});