summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/group-resize
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
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')
-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
4 files changed, 633 insertions, 8 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 });
+ });
+});
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 {