refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate

This commit is contained in:
Maze Winther
2026-04-07 01:09:13 +02:00
parent 79df736431
commit e4b67094e7
102 changed files with 4977 additions and 3707 deletions
@@ -5,6 +5,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import {
getElementsAtTime,
@@ -110,10 +111,11 @@ export function useEditorActions() {
"frame-step-forward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + 1 / fps,
editor.playback.getCurrentTime() + ticksPerFrame,
),
});
},
@@ -124,8 +126,9 @@ export function useEditorActions() {
"frame-step-backward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
});
},
undefined,
@@ -10,8 +10,9 @@ import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@@ -67,7 +68,8 @@ function getClickOffsetTime({
zoomLevel: number;
}): number {
const clickOffsetX = clientX - elementRect.left;
return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
return Math.round(seconds * TICKS_PER_SECOND);
}
function getVerticalDragDirection({
@@ -297,15 +299,17 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
const adjustedTime =
mouseTime - pendingDragRef.current.clickOffsetTime;
const snappedTime = snapTimeToFrame({
time: adjustedTime,
fps: activeProject.settings.fps,
});
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = roundToFrame({
time: adjustedTime,
rate: activeProject.settings.fps,
}) ?? adjustedTime;
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
@@ -343,9 +347,9 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
const adjustedTime = mouseTime - dragState.clickOffsetTime;
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps });
const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { roundToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
@@ -185,13 +186,14 @@ export function useTimelineElementResize({
({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
let deltaTime =
deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const deltaX = clientX - resizing.startX;
let deltaTime = Math.round(
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = editor.project.getActive().settings.fps;
const minDurationSeconds = 1 / projectFps;
const projectFps = editor.project.getActive().settings.fps;
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
const tracks = editor.timeline.getTracks();
@@ -277,19 +279,19 @@ export function useTimelineElementResize({
const maxAllowed =
sourceDuration -
resizing.initialTrimEnd -
getVisibleSourceSpanForDuration(minDurationSeconds);
getVisibleSourceSpanForDuration(minDuration);
const calculated =
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated));
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta;
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -311,8 +313,8 @@ export function useTimelineElementResize({
)
: Math.min(extensionAmount, maxExtension),
);
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
@@ -338,8 +340,8 @@ export function useTimelineElementResize({
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -366,7 +368,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -376,7 +378,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -395,17 +397,17 @@ export function useTimelineElementResize({
const maxTrimEnd =
sourceDuration -
resizing.initialTrimStart -
getVisibleSourceSpanForDuration(minDurationSeconds);
getVisibleSourceSpanForDuration(minDuration);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
@@ -8,9 +8,10 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { getKeyframeById } from "@/lib/animation";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
@@ -144,9 +145,9 @@ export function useKeyframeDrag({
if (!dragState.isDragging) return;
const startX = mouseDownXRef.current ?? clientX;
const rawDelta = (clientX - startX) / pixelsPerSecond;
const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
const startX = mouseDownXRef.current ?? clientX;
const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND);
const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
};
@@ -255,7 +256,7 @@ export function useKeyframeDrag({
if (wasDrag) return;
const duration = editor.timeline.getTotalDuration();
const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime;
editor.playback.seek({ time: seekTime });
if (event.shiftKey) {
@@ -8,7 +8,7 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
findSnapPoints,
@@ -148,7 +148,7 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
const frameSnappedTime = roundToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), rate: activeProject.settings.fps }) ?? Math.max(0, Math.min(mouseTime, duration));
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
@@ -176,9 +176,9 @@ export function useBookmarkDrag({
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
const frameSnappedTime = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime;
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
});
@@ -3,9 +3,9 @@ import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import {
buildTextElement,
buildGraphicElement,
@@ -47,7 +47,7 @@ export function useTimelineDragDrop({
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
const projectFps = editor.project.getActive().settings.fps;
return snapTimeToFrame({ time, fps: projectFps });
return roundToFrame({ time, rate: projectFps }) ?? time;
},
[editor],
);
@@ -83,14 +83,14 @@ export function useTimelineDragDrop({
elementType === "sticker" ||
elementType === "effect"
) {
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return DEFAULT_NEW_ELEMENT_DURATION;
}
if (mediaId) {
const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
}
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return DEFAULT_NEW_ELEMENT_DURATION;
},
[editor],
);
@@ -331,7 +331,7 @@ export function useTimelineDragDrop({
dragData.mediaType === "audio" ? "audio" : "video";
const duration =
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: mediaAsset.id,
mediaType: mediaAsset.type,
@@ -446,7 +446,7 @@ export function useTimelineDragDrop({
const duration =
createdAsset.duration ??
DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
DEFAULT_NEW_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];
@@ -1,4 +1,5 @@
import { getSnappedSeekTime } from "opencut-wasm";
import { snappedSeekTime } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
@@ -6,6 +7,7 @@ import { useShiftKey } from "@/hooks/use-shift-key";
import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
import {
getCenteredLineLeft,
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
@@ -66,24 +68,24 @@ export function useTimelinePlayhead({
const rulerRect = ruler.getBoundingClientRect();
const relativeMouseX = event.clientX - rulerRect.left;
const timelineContentWidth =
duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
const clampedMouseX = Math.max(
0,
Math.min(timelineContentWidth, relativeMouseX),
);
const clampedMouseX = Math.max(
0,
Math.min(timelineContentWidth, relativeMouseX),
);
const rawTime = Math.max(
0,
Math.min(
duration,
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTimeSeconds = Math.max(
0,
Math.min(
duration / TICKS_PER_SECOND,
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
const framesPerSecond = activeProject.settings.fps;
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
const rate = activeProject.settings.fps;
const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
@@ -161,7 +163,7 @@ export function useTimelinePlayhead({
getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
});
useEffect(() => {
@@ -247,8 +249,10 @@ export function useTimelinePlayhead({
const tracksViewport = tracksScrollRef.current;
if (!rulerViewport || !tracksViewport) return;
const playheadPixels =
time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
const playheadPixels = timelineTimeToPixels({
time,
zoomLevel: zoomLevelRef.current,
});
const viewportWidth = rulerViewport.clientWidth;
const scrollMinimum = 0;
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
@@ -1,7 +1,8 @@
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { getSnappedSeekTime } from "opencut-wasm";
import { snappedSeekTime } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
@@ -127,17 +128,18 @@ export function useTimelineSeek({
const mouseX = event.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
const rawTime = Math.max(
0,
Math.min(
duration,
(mouseX + scrollLeft) /
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTimeSeconds = Math.max(
0,
Math.min(
duration / TICKS_PER_SECOND,
(mouseX + scrollLeft) /
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
const rate = activeProject?.settings.fps;
const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime;
seek(time);
editor.project.setTimelineViewState({
viewState: {
@@ -154,7 +156,8 @@ export function useTimelineSeek({
tracksScrollRef,
seek,
editor,
activeProject?.settings.fps,
activeProject?.settings.fps.numerator,
activeProject?.settings.fps.denominator,
],
);
@@ -13,8 +13,8 @@ import {
import {
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
@@ -186,10 +186,8 @@ export function useTimelineZoom({
}
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
const playheadPixelsAfter =
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom });
const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel });
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
+114 -114
View File
@@ -1,114 +1,114 @@
import { useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
function isMediaMimeType({ type }: { type: string }): boolean {
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
}
function extractMediaFilesFromClipboard({
clipboardData,
}: {
clipboardData: DataTransfer | null;
}): File[] {
if (!clipboardData?.items) return [];
const files: File[] = [];
for (const item of clipboardData.items) {
if (item.kind !== "file") continue;
if (!isMediaMimeType({ type: item.type })) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
}
export function usePasteMedia() {
const editor = useEditor();
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
if (files.length === 0) {
event.preventDefault();
invokeAction("paste-copied");
return;
}
event.preventDefault();
const activeProject = editor.project.getActive();
if (!activeProject) return;
try {
await showMediaUploadToast({
filesCount: files.length,
promise: async () => {
const processedAssets = await processMediaAssets({ files });
const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) {
const addMediaCmd = new AddMediaAssetCommand(
activeProject.metadata.id,
asset,
);
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "auto", trackType },
});
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
editor.command.execute({ command: batchCmd });
}
return {
uploadedCount: processedAssets.length,
assetNames: processedAssets.map((asset) => asset.name),
};
},
});
} catch (error) {
console.error("Failed to paste media:", error);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor]);
}
import { useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
function isMediaMimeType({ type }: { type: string }): boolean {
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
}
function extractMediaFilesFromClipboard({
clipboardData,
}: {
clipboardData: DataTransfer | null;
}): File[] {
if (!clipboardData?.items) return [];
const files: File[] = [];
for (const item of clipboardData.items) {
if (item.kind !== "file") continue;
if (!isMediaMimeType({ type: item.type })) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
}
export function usePasteMedia() {
const editor = useEditor();
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
if (files.length === 0) {
event.preventDefault();
invokeAction("paste-copied");
return;
}
event.preventDefault();
const activeProject = editor.project.getActive();
if (!activeProject) return;
try {
await showMediaUploadToast({
filesCount: files.length,
promise: async () => {
const processedAssets = await processMediaAssets({ files });
const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) {
const addMediaCmd = new AddMediaAssetCommand(
activeProject.metadata.id,
asset,
);
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "auto", trackType },
});
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
editor.command.execute({ command: batchCmd });
}
return {
uploadedCount: processedAssets.length,
assetNames: processedAssets.map((asset) => asset.name),
};
},
});
} catch (error) {
console.error("Failed to paste media:", error);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor]);
}