mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
|
||||
|
||||
const v8ProjectWithText = {
|
||||
id: "project-v8-text",
|
||||
version: 8,
|
||||
metadata: {
|
||||
id: "project-v8-text",
|
||||
name: "V8 Project with Text",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: 30,
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
currentSceneId: "scene-main",
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
tracks: [
|
||||
{
|
||||
id: "track-text",
|
||||
type: "text",
|
||||
name: "Text Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: "text",
|
||||
content: "With color",
|
||||
startTime: 0,
|
||||
duration: 5,
|
||||
background: {
|
||||
color: "#ff0000",
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "el-2",
|
||||
type: "text",
|
||||
content: "Transparent",
|
||||
startTime: 5,
|
||||
duration: 5,
|
||||
background: {
|
||||
color: "transparent",
|
||||
paddingX: 30,
|
||||
paddingY: 42,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookmarks: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
describe("V8 to V9 Migration", () => {
|
||||
test("adds background.enabled from color (transparent => false, otherwise true)", () => {
|
||||
const result = transformProjectV8ToV9({ project: v8ProjectWithText });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(9);
|
||||
|
||||
const track = (
|
||||
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
|
||||
)[0].tracks[0];
|
||||
const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
|
||||
|
||||
expect(elements[0].background.enabled).toBe(true);
|
||||
expect(elements[0].background.color).toBe("#ff0000");
|
||||
|
||||
expect(elements[1].background.enabled).toBe(false);
|
||||
expect(elements[1].background.color).toBe("transparent");
|
||||
});
|
||||
|
||||
test("preserves existing background.enabled if already present", () => {
|
||||
const projectWithEnabled = {
|
||||
...v8ProjectWithText,
|
||||
scenes: [
|
||||
{
|
||||
...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
|
||||
tracks: [
|
||||
{
|
||||
id: "track-text",
|
||||
type: "text",
|
||||
name: "Text Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: "text",
|
||||
content: "Already has enabled",
|
||||
startTime: 0,
|
||||
duration: 5,
|
||||
background: {
|
||||
enabled: false,
|
||||
color: "#00ff00",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
const result = transformProjectV8ToV9({ project: projectWithEnabled });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
const elements = (
|
||||
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
|
||||
)[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
|
||||
expect(elements[0].background.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("skips non-text elements and tracks", () => {
|
||||
const projectWithVideoOnly = {
|
||||
...v8ProjectWithText,
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
tracks: [
|
||||
{
|
||||
id: "track-video",
|
||||
type: "video",
|
||||
name: "Video Track",
|
||||
isMain: true,
|
||||
elements: [],
|
||||
},
|
||||
],
|
||||
bookmarks: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
const result = transformProjectV8ToV9({ project: projectWithVideoOnly });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(9);
|
||||
});
|
||||
|
||||
test("skips projects that are already v9", () => {
|
||||
const result = transformProjectV8ToV9({
|
||||
project: { ...v8ProjectWithText, version: 9 },
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v9");
|
||||
});
|
||||
|
||||
test("skips projects with no id", () => {
|
||||
const result = transformProjectV8ToV9({
|
||||
project: {
|
||||
version: 8,
|
||||
scenes: [],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("no project id");
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,11 @@ import { V4toV5Migration } from "./v4-to-v5";
|
||||
import { V5toV6Migration } from "./v5-to-v6";
|
||||
import { V6toV7Migration } from "./v6-to-v7";
|
||||
import { V7toV8Migration } from "./v7-to-v8";
|
||||
import { V8toV9Migration } from "./v8-to-v9";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 8;
|
||||
export const CURRENT_PROJECT_VERSION = 9;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -21,4 +22,5 @@ export const migrations = [
|
||||
new V5toV6Migration(),
|
||||
new V6toV7Migration(),
|
||||
new V7toV8Migration(),
|
||||
new V8toV9Migration(),
|
||||
];
|
||||
|
||||
@@ -388,7 +388,7 @@ async function transformMediaTrack({
|
||||
);
|
||||
|
||||
const validElements = transformedElements.filter(
|
||||
(el): el is VideoElement | ImageElement => el !== null,
|
||||
(element): element is VideoElement | ImageElement => element !== null,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -450,17 +450,18 @@ function transformTextTrack({
|
||||
value: textElement.color,
|
||||
fallback: "#000000",
|
||||
}),
|
||||
background: {
|
||||
color: getStringValue({
|
||||
value: textElement.backgroundColor,
|
||||
fallback: "transparent",
|
||||
}),
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
background: {
|
||||
enabled: false,
|
||||
color: getStringValue({
|
||||
value: textElement.backgroundColor,
|
||||
fallback: "transparent",
|
||||
}),
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
textAlign: (getStringValue({
|
||||
value: textElement.textAlign,
|
||||
fallback: "left",
|
||||
@@ -486,7 +487,7 @@ function transformTextTrack({
|
||||
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
|
||||
};
|
||||
})
|
||||
.filter((el): el is TextElement => el !== null);
|
||||
.filter((element): element is TextElement => element !== null);
|
||||
|
||||
return {
|
||||
id: getStringValue({ value: track.id, fallback: "" }),
|
||||
@@ -529,7 +530,7 @@ function transformAudioTrack({
|
||||
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
|
||||
};
|
||||
})
|
||||
.filter((el): el is AudioElement => el !== null);
|
||||
.filter((element): element is AudioElement => element !== null);
|
||||
|
||||
return {
|
||||
id: getStringValue({ value: track.id, fallback: "" }),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV8ToV9({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
if (isV9Project({ project })) {
|
||||
return { project, skipped: true, reason: "already v9" };
|
||||
}
|
||||
|
||||
const migratedProject = migrateProjectTextElements({ project });
|
||||
|
||||
return {
|
||||
project: { ...migratedProject, version: 9 },
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProjectTextElements({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const scenesValue = project.scenes;
|
||||
if (!Array.isArray(scenesValue)) return project;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedScenes = scenesValue.map((scene) => {
|
||||
const migrated = migrateSceneTextElements({ scene });
|
||||
if (migrated !== scene) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return project;
|
||||
return { ...project, scenes: migratedScenes };
|
||||
}
|
||||
|
||||
function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
|
||||
if (!isRecord(scene)) return scene;
|
||||
|
||||
const tracksValue = scene.tracks;
|
||||
if (!Array.isArray(tracksValue)) return scene;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedTracks = tracksValue.map((track) => {
|
||||
const migrated = migrateTrackTextElements({ track });
|
||||
if (migrated !== track) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return scene;
|
||||
return { ...scene, tracks: migratedTracks };
|
||||
}
|
||||
|
||||
function migrateTrackTextElements({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track)) return track;
|
||||
if (track.type !== "text") return track;
|
||||
|
||||
const elementsValue = track.elements;
|
||||
if (!Array.isArray(elementsValue)) return track;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedElements = elementsValue.map((element) => {
|
||||
const migrated = migrateTextElement({ element });
|
||||
if (migrated !== element) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return track;
|
||||
return { ...track, elements: migratedElements };
|
||||
}
|
||||
|
||||
function migrateTextElement({ element }: { element: unknown }): unknown {
|
||||
if (!isRecord(element)) return element;
|
||||
if (element.type !== "text") return element;
|
||||
|
||||
const bg = element.background;
|
||||
if (!isRecord(bg)) return element;
|
||||
if (typeof bg.enabled === "boolean") return element;
|
||||
|
||||
const color = typeof bg.color === "string" ? bg.color : "transparent";
|
||||
const enabled = color !== "transparent";
|
||||
|
||||
return {
|
||||
...element,
|
||||
background: { ...bg, enabled },
|
||||
};
|
||||
}
|
||||
|
||||
function isV9Project({ project }: { project: ProjectRecord }): boolean {
|
||||
return typeof project.version === "number" && project.version >= 9;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV8ToV9 } from "./transformers/v8-to-v9";
|
||||
|
||||
export class V8toV9Migration extends StorageMigration {
|
||||
from = 8;
|
||||
to = 9;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV8ToV9({ project });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user