summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/commands
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
commite4b67094e757917588fb624a30aa046667403add (patch)
tree55452692eac69a5a63b58f2a5cdb701dd7023d68 /apps/web/src/lib/commands
parent79df73643185c02493364a077402726d1fa7b9ec (diff)
downloadopencut-e4b67094e757917588fb624a30aa046667403add.tar.gz
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'apps/web/src/lib/commands')
-rw-r--r--apps/web/src/lib/commands/media/add-media-asset.ts12
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts529
2 files changed, 272 insertions, 269 deletions
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts
index c12d7ea1..8b3067a1 100644
--- a/apps/web/src/lib/commands/media/add-media-asset.ts
+++ b/apps/web/src/lib/commands/media/add-media-asset.ts
@@ -4,16 +4,17 @@ import { toast } from "sonner";
import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
+import type { FrameRate } from "opencut-wasm";
import { hasMediaId } from "@/lib/timeline/element-utils";
-import { getHighestImportedVideoFps } from "@/lib/fps/utils";
+import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
export class AddMediaAssetCommand extends Command {
private assetId: string;
private savedAssets: MediaAsset[] | null = null;
private createdAsset: MediaAsset | null = null;
- private previousProjectFps: number | null = null;
- private appliedProjectFps: number | null = null;
+ private previousProjectFps: FrameRate | null = null;
+ private appliedProjectFps: FrameRate | null = null;
constructor(
private projectId: string,
@@ -112,14 +113,15 @@ export class AddMediaAssetCommand extends Command {
const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
- if (activeProject.settings.fps !== this.appliedProjectFps) return;
+ if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
const highestRemainingVideoFps = getHighestImportedVideoFps({
mediaAssets: editor.media.getAssets(),
});
+ const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
if (
highestRemainingVideoFps !== null &&
- highestRemainingVideoFps >= this.appliedProjectFps
+ highestRemainingVideoFps >= appliedFpsFloat
) {
return;
}
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index fbef1613..9f0bf941 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -1,264 +1,265 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type {
- CreateTimelineElement,
- TimelineTrack,
- TimelineElement,
- TrackType,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { requiresMediaId } from "@/lib/timeline/element-utils";
-import type { MediaAsset } from "@/lib/media/types";
-import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
-import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
-import {
- applyPlacement,
- canElementGoOnTrack,
- resolveTrackPlacement,
- validateElementTrackCompatibility,
-} from "@/lib/timeline/placement";
-
-type InsertElementPlacement =
- | { mode: "explicit"; trackId: string }
- | { mode: "auto"; trackType?: TrackType; insertIndex?: number };
-
-export interface InsertElementParams {
- element: CreateTimelineElement;
- placement: InsertElementPlacement;
-}
-
-export class InsertElementCommand extends Command {
- private elementId: string;
- private savedState: TimelineTrack[] | null = null;
- private targetTrackId: string | null = null;
-
- constructor({ element, placement }: InsertElementParams) {
- super();
- this.elementId = generateUUID();
- this.element = element;
- this.placement = placement;
- }
-
- private element: CreateTimelineElement;
- private placement: InsertElementPlacement;
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- if (!this.savedState) {
- console.error("Tracks not available");
- return;
- }
-
- if (!this.validateElementBasics({ element: this.element })) {
- return;
- }
-
- const totalElementsInTimeline = this.savedState.reduce(
- (total, t) => total + t.elements.length,
- 0,
- );
- const isFirstElement = totalElementsInTimeline === 0;
-
- const newElement = this.buildElement({ element: this.element });
- const updateResult = this.applyPlacementResult({
- tracks: this.savedState,
- element: newElement,
- });
-
- if (!updateResult) {
- return;
- }
-
- const { updatedTracks, targetTrackId } = updateResult;
- this.targetTrackId = targetTrackId;
-
- const isVisualMedia =
- newElement.type === "video" || newElement.type === "image";
-
- if (isFirstElement && isVisualMedia) {
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
- const asset = mediaAssets.find(
- (item: MediaAsset) => item.id === newElement.mediaId,
- );
-
- if (asset?.width && asset?.height) {
- const nextCanvasSize = { width: asset.width, height: asset.height };
- const shouldSetOriginalCanvasSize =
- !activeProject?.settings.originalCanvasSize;
- editor.project.updateSettings({
- settings: {
- canvasSize: nextCanvasSize,
- ...(shouldSetOriginalCanvasSize
- ? { originalCanvasSize: nextCanvasSize }
- : {}),
- },
- pushHistory: false,
- });
- }
-
- if (asset?.type === "video" && asset?.fps) {
- editor.project.updateSettings({
- settings: { fps: asset.fps },
- pushHistory: false,
- });
- }
- }
-
- editor.timeline.updateTracks(updatedTracks);
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getElementId(): string {
- return this.elementId;
- }
-
- getTrackId(): string | null {
- return this.targetTrackId;
- }
-
- private buildElement({
- element,
- }: {
- element: CreateTimelineElement;
- }): TimelineElement {
- return {
- ...element,
- id: this.elementId,
- startTime: element.startTime,
- trimStart: element.trimStart ?? 0,
- trimEnd: element.trimEnd ?? 0,
- duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
- } as TimelineElement;
- }
-
- private validateElementBasics({
- element,
- }: {
- element: CreateTimelineElement;
- }): boolean {
- if (requiresMediaId({ element }) && !("mediaId" in element)) {
- console.error("Element requires mediaId");
- return false;
- }
-
- if (
- element.type === "audio" &&
- element.sourceType === "library" &&
- !element.sourceUrl
- ) {
- console.error("Library audio element must have sourceUrl");
- return false;
- }
-
- if (element.type === "sticker" && !element.stickerId) {
- console.error("Sticker element must have stickerId");
- return false;
- }
-
- if (element.type === "graphic") {
- registerDefaultGraphics();
- if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
- console.error("Graphic element must have a valid definitionId");
- return false;
- }
- }
-
- if (element.type === "text" && !element.content) {
- console.error("Text element must have content");
- return false;
- }
-
- if (element.type === "effect" && !element.effectType) {
- console.error("Effect element must have effectType");
- return false;
- }
-
- return true;
- }
-
- private applyPlacementResult({
- tracks,
- element,
- }: {
- tracks: TimelineTrack[];
- element: TimelineElement;
- }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
- const placement = this.placement;
-
- if (
- placement.mode === "auto" &&
- placement.trackType &&
- !canElementGoOnTrack({
- elementType: element.type,
- trackType: placement.trackType,
- })
- ) {
- console.error(
- `${element.type} elements cannot be placed on ${placement.trackType} tracks`,
- );
- return null;
- }
-
- const placementResult = resolveTrackPlacement({
- tracks,
- ...(placement.mode === "auto" && placement.trackType
- ? { trackType: placement.trackType }
- : { elementType: element.type }),
- timeSpans: [
- {
- startTime: element.startTime,
- duration: element.duration,
- },
- ],
- strategy:
- placement.mode === "explicit"
- ? { type: "explicit", trackId: placement.trackId }
- : { type: "firstAvailable" },
- });
- if (!placementResult) {
- if (placement.mode === "explicit") {
- const targetTrack = tracks.find((track) => track.id === placement.trackId);
- if (!targetTrack) {
- console.error("Track not found:", placement.trackId);
- return null;
- }
-
- const validation = validateElementTrackCompatibility({
- element,
- track: targetTrack,
- });
- console.error(validation.errorMessage);
- }
-
- return null;
- }
-
- const elementToPlace =
- placementResult.kind === "existingTrack"
- ? {
- ...element,
- startTime:
- placementResult.adjustedStartTime ?? element.startTime,
- }
- : element;
-
- return applyPlacement({
- tracks,
- placementResult,
- elements: [elementToPlace],
- newTrackInsertIndexOverride:
- placement.mode === "auto" && typeof placement.insertIndex === "number"
- ? placement.insertIndex
- : undefined,
- });
- }
-}
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ CreateTimelineElement,
+ TimelineTrack,
+ TimelineElement,
+ TrackType,
+} from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import { requiresMediaId } from "@/lib/timeline/element-utils";
+import type { MediaAsset } from "@/lib/media/types";
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
+import { floatToFrameRate } from "@/lib/fps/utils";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import {
+ applyPlacement,
+ canElementGoOnTrack,
+ resolveTrackPlacement,
+ validateElementTrackCompatibility,
+} from "@/lib/timeline/placement";
+
+type InsertElementPlacement =
+ | { mode: "explicit"; trackId: string }
+ | { mode: "auto"; trackType?: TrackType; insertIndex?: number };
+
+export interface InsertElementParams {
+ element: CreateTimelineElement;
+ placement: InsertElementPlacement;
+}
+
+export class InsertElementCommand extends Command {
+ private elementId: string;
+ private savedState: TimelineTrack[] | null = null;
+ private targetTrackId: string | null = null;
+
+ constructor({ element, placement }: InsertElementParams) {
+ super();
+ this.elementId = generateUUID();
+ this.element = element;
+ this.placement = placement;
+ }
+
+ private element: CreateTimelineElement;
+ private placement: InsertElementPlacement;
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ if (!this.savedState) {
+ console.error("Tracks not available");
+ return;
+ }
+
+ if (!this.validateElementBasics({ element: this.element })) {
+ return;
+ }
+
+ const totalElementsInTimeline = this.savedState.reduce(
+ (total, t) => total + t.elements.length,
+ 0,
+ );
+ const isFirstElement = totalElementsInTimeline === 0;
+
+ const newElement = this.buildElement({ element: this.element });
+ const updateResult = this.applyPlacementResult({
+ tracks: this.savedState,
+ element: newElement,
+ });
+
+ if (!updateResult) {
+ return;
+ }
+
+ const { updatedTracks, targetTrackId } = updateResult;
+ this.targetTrackId = targetTrackId;
+
+ const isVisualMedia =
+ newElement.type === "video" || newElement.type === "image";
+
+ if (isFirstElement && isVisualMedia) {
+ const mediaAssets = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+ const asset = mediaAssets.find(
+ (item: MediaAsset) => item.id === newElement.mediaId,
+ );
+
+ if (asset?.width && asset?.height) {
+ const nextCanvasSize = { width: asset.width, height: asset.height };
+ const shouldSetOriginalCanvasSize =
+ !activeProject?.settings.originalCanvasSize;
+ editor.project.updateSettings({
+ settings: {
+ canvasSize: nextCanvasSize,
+ ...(shouldSetOriginalCanvasSize
+ ? { originalCanvasSize: nextCanvasSize }
+ : {}),
+ },
+ pushHistory: false,
+ });
+ }
+
+ if (asset?.type === "video" && asset?.fps) {
+ editor.project.updateSettings({
+ settings: { fps: floatToFrameRate(asset.fps) },
+ pushHistory: false,
+ });
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getElementId(): string {
+ return this.elementId;
+ }
+
+ getTrackId(): string | null {
+ return this.targetTrackId;
+ }
+
+ private buildElement({
+ element,
+ }: {
+ element: CreateTimelineElement;
+ }): TimelineElement {
+ return {
+ ...element,
+ id: this.elementId,
+ startTime: element.startTime,
+ trimStart: element.trimStart ?? 0,
+ trimEnd: element.trimEnd ?? 0,
+ duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
+ } as TimelineElement;
+ }
+
+ private validateElementBasics({
+ element,
+ }: {
+ element: CreateTimelineElement;
+ }): boolean {
+ if (requiresMediaId({ element }) && !("mediaId" in element)) {
+ console.error("Element requires mediaId");
+ return false;
+ }
+
+ if (
+ element.type === "audio" &&
+ element.sourceType === "library" &&
+ !element.sourceUrl
+ ) {
+ console.error("Library audio element must have sourceUrl");
+ return false;
+ }
+
+ if (element.type === "sticker" && !element.stickerId) {
+ console.error("Sticker element must have stickerId");
+ return false;
+ }
+
+ if (element.type === "graphic") {
+ registerDefaultGraphics();
+ if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
+ console.error("Graphic element must have a valid definitionId");
+ return false;
+ }
+ }
+
+ if (element.type === "text" && !element.content) {
+ console.error("Text element must have content");
+ return false;
+ }
+
+ if (element.type === "effect" && !element.effectType) {
+ console.error("Effect element must have effectType");
+ return false;
+ }
+
+ return true;
+ }
+
+ private applyPlacementResult({
+ tracks,
+ element,
+ }: {
+ tracks: TimelineTrack[];
+ element: TimelineElement;
+ }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
+ const placement = this.placement;
+
+ if (
+ placement.mode === "auto" &&
+ placement.trackType &&
+ !canElementGoOnTrack({
+ elementType: element.type,
+ trackType: placement.trackType,
+ })
+ ) {
+ console.error(
+ `${element.type} elements cannot be placed on ${placement.trackType} tracks`,
+ );
+ return null;
+ }
+
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ ...(placement.mode === "auto" && placement.trackType
+ ? { trackType: placement.trackType }
+ : { elementType: element.type }),
+ timeSpans: [
+ {
+ startTime: element.startTime,
+ duration: element.duration,
+ },
+ ],
+ strategy:
+ placement.mode === "explicit"
+ ? { type: "explicit", trackId: placement.trackId }
+ : { type: "firstAvailable" },
+ });
+ if (!placementResult) {
+ if (placement.mode === "explicit") {
+ const targetTrack = tracks.find((track) => track.id === placement.trackId);
+ if (!targetTrack) {
+ console.error("Track not found:", placement.trackId);
+ return null;
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element,
+ track: targetTrack,
+ });
+ console.error(validation.errorMessage);
+ }
+
+ return null;
+ }
+
+ const elementToPlace =
+ placementResult.kind === "existingTrack"
+ ? {
+ ...element,
+ startTime:
+ placementResult.adjustedStartTime ?? element.startTime,
+ }
+ : element;
+
+ return applyPlacement({
+ tracks,
+ placementResult,
+ elements: [elementToPlace],
+ newTrackInsertIndexOverride:
+ placement.mode === "auto" && typeof placement.insertIndex === "number"
+ ? placement.insertIndex
+ : undefined,
+ });
+ }
+}