mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
add transform + few fixes
feat: transform feat: batch command fix: slider undo fix: timeline zoom perf refactor: make updateElement support multiple
This commit is contained in:
@@ -8,6 +8,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
|||||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||||
import { buildScene } from "@/services/renderer/scene-builder";
|
import { buildScene } from "@/services/renderer/scene-builder";
|
||||||
import { getLastFrameTime } from "@/lib/time";
|
import { getLastFrameTime } from "@/lib/time";
|
||||||
|
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
||||||
|
|
||||||
function usePreviewSize() {
|
function usePreviewSize() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
@@ -57,7 +58,7 @@ export function PreviewPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PreviewCanvas() {
|
function PreviewCanvas() {
|
||||||
const ref = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const lastFrameRef = useRef(-1);
|
const lastFrameRef = useRef(-1);
|
||||||
const lastSceneRef = useRef<RootNode | null>(null);
|
const lastSceneRef = useRef<RootNode | null>(null);
|
||||||
const renderingRef = useRef(false);
|
const renderingRef = useRef(false);
|
||||||
@@ -76,7 +77,7 @@ function PreviewCanvas() {
|
|||||||
const renderTree = editor.renderer.getRenderTree();
|
const renderTree = editor.renderer.getRenderTree();
|
||||||
|
|
||||||
const render = useCallback(() => {
|
const render = useCallback(() => {
|
||||||
if (ref.current && renderTree && !renderingRef.current) {
|
if (canvasRef.current && renderTree && !renderingRef.current) {
|
||||||
const time = editor.playback.getCurrentTime();
|
const time = editor.playback.getCurrentTime();
|
||||||
const lastFrameTime = getLastFrameTime({
|
const lastFrameTime = getLastFrameTime({
|
||||||
duration: renderTree.duration,
|
duration: renderTree.duration,
|
||||||
@@ -96,7 +97,7 @@ function PreviewCanvas() {
|
|||||||
.renderToCanvas({
|
.renderToCanvas({
|
||||||
node: renderTree,
|
node: renderTree,
|
||||||
time: renderTime,
|
time: renderTime,
|
||||||
targetCanvas: ref.current,
|
targetCanvas: canvasRef.current,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
renderingRef.current = false;
|
renderingRef.current = false;
|
||||||
@@ -108,17 +109,20 @@ function PreviewCanvas() {
|
|||||||
useRafLoop(render);
|
useRafLoop(render);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<div className="relative">
|
||||||
ref={ref}
|
<canvas
|
||||||
width={width}
|
ref={canvasRef}
|
||||||
height={height}
|
width={width}
|
||||||
className="block max-h-full max-w-full border"
|
height={height}
|
||||||
style={{
|
className="block max-h-full max-w-full border"
|
||||||
background:
|
style={{
|
||||||
activeProject.settings.background.type === "blur"
|
background:
|
||||||
? "transparent"
|
activeProject.settings.background.type === "blur"
|
||||||
: activeProject?.settings.background.color,
|
? "transparent"
|
||||||
}}
|
: activeProject?.settings.background.color,
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
<PreviewInteractionOverlay canvasRef={canvasRef} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
|
||||||
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
|
export function PreviewInteractionOverlay({
|
||||||
|
canvasRef,
|
||||||
|
}: {
|
||||||
|
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||||
|
}) {
|
||||||
|
const { onPointerDown, onPointerMove, onPointerUp } =
|
||||||
|
usePreviewInteraction({ canvasRef });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("absolute inset-0 pointer-events-auto")}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,8 @@ export function TextProperties({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||||
|
const initialFontSizeRef = useRef<number | null>(null);
|
||||||
|
const initialOpacityRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const handleFontSizeChange = ({ value }: { value: string }) => {
|
const handleFontSizeChange = ({ value }: { value: string }) => {
|
||||||
setFontSizeInput(value);
|
setFontSizeInput(value);
|
||||||
@@ -59,10 +61,8 @@ export function TextProperties({
|
|||||||
const fontSize = Number.isNaN(parsed)
|
const fontSize = Number.isNaN(parsed)
|
||||||
? element.fontSize
|
? element.fontSize
|
||||||
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
|
||||||
elementId: element.id,
|
|
||||||
updates: { fontSize },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -73,10 +73,8 @@ export function TextProperties({
|
|||||||
? element.fontSize
|
? element.fontSize
|
||||||
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||||
setFontSizeInput(fontSize.toString());
|
setFontSizeInput(fontSize.toString());
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
|
||||||
elementId: element.id,
|
|
||||||
updates: { fontSize },
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,10 +86,14 @@ export function TextProperties({
|
|||||||
const opacityPercent = Number.isNaN(parsed)
|
const opacityPercent = Number.isNaN(parsed)
|
||||||
? Math.round(element.opacity * 100)
|
? Math.round(element.opacity * 100)
|
||||||
: clamp({ value: parsed, min: 0, max: 100 });
|
: clamp({ value: parsed, min: 0, max: 100 });
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: { opacity: opacityPercent / 100 },
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { opacity: opacityPercent / 100 },
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -102,10 +104,14 @@ export function TextProperties({
|
|||||||
? Math.round(element.opacity * 100)
|
? Math.round(element.opacity * 100)
|
||||||
: clamp({ value: parsed, min: 0, max: 100 });
|
: clamp({ value: parsed, min: 0, max: 100 });
|
||||||
setOpacityInput(opacityPercent.toString());
|
setOpacityInput(opacityPercent.toString());
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: { opacity: opacityPercent / 100 },
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { opacity: opacityPercent / 100 },
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,10 +119,14 @@ export function TextProperties({
|
|||||||
if (color !== "transparent") {
|
if (color !== "transparent") {
|
||||||
lastSelectedColor.current = color;
|
lastSelectedColor.current = color;
|
||||||
}
|
}
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: { backgroundColor: color },
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { backgroundColor: color },
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,10 +136,14 @@ export function TextProperties({
|
|||||||
isTransparent: boolean;
|
isTransparent: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
|
const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: { backgroundColor: newColor },
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { backgroundColor: newColor },
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,10 +168,14 @@ export function TextProperties({
|
|||||||
defaultValue={element.content}
|
defaultValue={element.content}
|
||||||
className="bg-panel-accent min-h-20"
|
className="bg-panel-accent min-h-20"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: { content: e.target.value },
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { content: e.target.value },
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -167,10 +185,14 @@ export function TextProperties({
|
|||||||
<FontPicker
|
<FontPicker
|
||||||
defaultValue={element.fontFamily}
|
defaultValue={element.fontFamily}
|
||||||
onValueChange={(value: FontFamily) =>
|
onValueChange={(value: FontFamily) =>
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: { fontFamily: value },
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { fontFamily: value },
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -186,13 +208,19 @@ export function TextProperties({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: {
|
trackId,
|
||||||
fontWeight:
|
elementId: element.id,
|
||||||
element.fontWeight === "bold" ? "normal" : "bold",
|
updates: {
|
||||||
},
|
fontWeight:
|
||||||
|
element.fontWeight === "bold"
|
||||||
|
? "normal"
|
||||||
|
: "bold",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-8 px-3 font-bold"
|
className="h-8 px-3 font-bold"
|
||||||
@@ -205,15 +233,19 @@ export function TextProperties({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: {
|
trackId,
|
||||||
fontStyle:
|
elementId: element.id,
|
||||||
element.fontStyle === "italic"
|
updates: {
|
||||||
? "normal"
|
fontStyle:
|
||||||
: "italic",
|
element.fontStyle === "italic"
|
||||||
},
|
? "normal"
|
||||||
|
: "italic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-8 px-3 italic"
|
className="h-8 px-3 italic"
|
||||||
@@ -228,15 +260,19 @@ export function TextProperties({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: {
|
trackId,
|
||||||
textDecoration:
|
elementId: element.id,
|
||||||
element.textDecoration === "underline"
|
updates: {
|
||||||
? "none"
|
textDecoration:
|
||||||
: "underline",
|
element.textDecoration === "underline"
|
||||||
},
|
? "none"
|
||||||
|
: "underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-8 px-3 underline"
|
className="h-8 px-3 underline"
|
||||||
@@ -251,15 +287,19 @@ export function TextProperties({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: {
|
trackId,
|
||||||
textDecoration:
|
elementId: element.id,
|
||||||
element.textDecoration === "line-through"
|
updates: {
|
||||||
? "none"
|
textDecoration:
|
||||||
: "line-through",
|
element.textDecoration === "line-through"
|
||||||
},
|
? "none"
|
||||||
|
: "line-through",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="h-8 px-3 line-through"
|
className="h-8 px-3 line-through"
|
||||||
@@ -279,13 +319,48 @@ export function TextProperties({
|
|||||||
max={MAX_FONT_SIZE}
|
max={MAX_FONT_SIZE}
|
||||||
step={1}
|
step={1}
|
||||||
onValueChange={([value]) => {
|
onValueChange={([value]) => {
|
||||||
editor.timeline.updateElement({
|
if (initialFontSizeRef.current === null) {
|
||||||
trackId,
|
initialFontSizeRef.current = element.fontSize;
|
||||||
elementId: element.id,
|
}
|
||||||
updates: { fontSize: value },
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { fontSize: value },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pushHistory: false,
|
||||||
});
|
});
|
||||||
setFontSizeInput(value.toString());
|
setFontSizeInput(value.toString());
|
||||||
}}
|
}}
|
||||||
|
onValueCommit={([value]) => {
|
||||||
|
if (initialFontSizeRef.current !== null) {
|
||||||
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: {
|
||||||
|
fontSize: initialFontSizeRef.current,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pushHistory: false,
|
||||||
|
});
|
||||||
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { fontSize: value },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pushHistory: true,
|
||||||
|
});
|
||||||
|
initialFontSizeRef.current = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -310,10 +385,14 @@ export function TextProperties({
|
|||||||
string: (element.color || "FFFFFF").replace("#", ""),
|
string: (element.color || "FFFFFF").replace("#", ""),
|
||||||
})}
|
})}
|
||||||
onChange={(color) => {
|
onChange={(color) => {
|
||||||
editor.timeline.updateElement({
|
editor.timeline.updateElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId: element.id,
|
{
|
||||||
updates: { color: `#${color}` },
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { color: `#${color}` },
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
@@ -330,13 +409,46 @@ export function TextProperties({
|
|||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
onValueChange={([value]) => {
|
onValueChange={([value]) => {
|
||||||
editor.timeline.updateElement({
|
if (initialOpacityRef.current === null) {
|
||||||
trackId,
|
initialOpacityRef.current = element.opacity;
|
||||||
elementId: element.id,
|
}
|
||||||
updates: { opacity: value / 100 },
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { opacity: value / 100 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pushHistory: false,
|
||||||
});
|
});
|
||||||
setOpacityInput(value.toString());
|
setOpacityInput(value.toString());
|
||||||
}}
|
}}
|
||||||
|
onValueCommit={([value]) => {
|
||||||
|
if (initialOpacityRef.current !== null) {
|
||||||
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { opacity: initialOpacityRef.current },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pushHistory: false,
|
||||||
|
});
|
||||||
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId: element.id,
|
||||||
|
updates: { opacity: value / 100 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pushHistory: true,
|
||||||
|
});
|
||||||
|
initialOpacityRef.current = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ export function Timeline() {
|
|||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||||
rulerRef={rulerRef}
|
rulerRef={rulerRef}
|
||||||
|
tracksScrollRef={tracksScrollRef}
|
||||||
handleWheel={handleWheel}
|
handleWheel={handleWheel}
|
||||||
handleTimelineContentClick={handleRulerClick}
|
handleTimelineContentClick={handleRulerClick}
|
||||||
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
|||||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
|
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
|
||||||
|
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
|
||||||
import { TimelineTick } from "./timeline-tick";
|
import { TimelineTick } from "./timeline-tick";
|
||||||
|
|
||||||
interface TimelineRulerProps {
|
interface TimelineRulerProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
dynamicTimelineWidth: number;
|
dynamicTimelineWidth: number;
|
||||||
rulerRef: React.Ref<HTMLDivElement>;
|
rulerRef: React.Ref<HTMLDivElement>;
|
||||||
|
tracksScrollRef: React.RefObject<HTMLElement | null>;
|
||||||
handleWheel: (e: React.WheelEvent) => void;
|
handleWheel: (e: React.WheelEvent) => void;
|
||||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||||
@@ -19,6 +21,7 @@ export function TimelineRuler({
|
|||||||
zoomLevel,
|
zoomLevel,
|
||||||
dynamicTimelineWidth,
|
dynamicTimelineWidth,
|
||||||
rulerRef,
|
rulerRef,
|
||||||
|
tracksScrollRef,
|
||||||
handleWheel,
|
handleWheel,
|
||||||
handleTimelineContentClick,
|
handleTimelineContentClick,
|
||||||
handleRulerTrackingMouseDown,
|
handleRulerTrackingMouseDown,
|
||||||
@@ -37,8 +40,29 @@ export function TimelineRuler({
|
|||||||
});
|
});
|
||||||
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
|
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
|
||||||
|
|
||||||
|
const { scrollLeft, viewportWidth } = useScrollPosition({
|
||||||
|
scrollRef: tracksScrollRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bufferPx = 200;
|
||||||
|
const visibleStartTime = Math.max(
|
||||||
|
0,
|
||||||
|
(scrollLeft - bufferPx) / pixelsPerSecond,
|
||||||
|
);
|
||||||
|
const visibleEndTime =
|
||||||
|
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
|
||||||
|
|
||||||
|
const startTickIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(visibleStartTime / tickIntervalSeconds),
|
||||||
|
);
|
||||||
|
const endTickIndex = Math.min(
|
||||||
|
tickCount - 1,
|
||||||
|
Math.ceil(visibleEndTime / tickIntervalSeconds),
|
||||||
|
);
|
||||||
|
|
||||||
const timelineTicks: Array<JSX.Element> = [];
|
const timelineTicks: Array<JSX.Element> = [];
|
||||||
for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
|
for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) {
|
||||||
const time = tickIndex * tickIntervalSeconds;
|
const time = tickIndex * tickIntervalSeconds;
|
||||||
if (time > effectiveDuration) break;
|
if (time > effectiveDuration) break;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
UpdateElementStartTimeCommand,
|
UpdateElementStartTimeCommand,
|
||||||
MoveElementCommand,
|
MoveElementCommand,
|
||||||
} from "@/lib/commands/timeline";
|
} from "@/lib/commands/timeline";
|
||||||
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||||
|
|
||||||
export class TimelineManager {
|
export class TimelineManager {
|
||||||
@@ -198,17 +199,27 @@ export class TimelineManager {
|
|||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
updateElement({
|
updateElements({
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
updates,
|
updates,
|
||||||
|
pushHistory = true,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
updates: Array<{
|
||||||
elementId: string;
|
trackId: string;
|
||||||
updates: Partial<Record<string, unknown>>;
|
elementId: string;
|
||||||
|
updates: Partial<Record<string, unknown>>;
|
||||||
|
}>;
|
||||||
|
pushHistory?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
const command = new UpdateElementCommand(trackId, elementId, updates);
|
const commands = updates.map(
|
||||||
this.editor.command.execute({ command });
|
({ trackId, elementId, updates: elementUpdates }) =>
|
||||||
|
new UpdateElementCommand(trackId, elementId, elementUpdates),
|
||||||
|
);
|
||||||
|
const command = commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||||
|
if (pushHistory) {
|
||||||
|
this.editor.command.execute({ command });
|
||||||
|
} else {
|
||||||
|
command.execute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicateElements({
|
duplicateElements({
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
|
interface UseScrollPositionReturn {
|
||||||
|
scrollLeft: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollPosition({
|
||||||
|
scrollRef,
|
||||||
|
}: {
|
||||||
|
scrollRef: React.RefObject<HTMLElement | null>;
|
||||||
|
}): UseScrollPositionReturn {
|
||||||
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
|
const [viewportWidth, setViewportWidth] = useState(0);
|
||||||
|
const rafIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollElement = scrollRef.current;
|
||||||
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafIdRef.current = requestAnimationFrame(() => {
|
||||||
|
setScrollLeft(scrollElement.scrollLeft);
|
||||||
|
setViewportWidth(scrollElement.clientWidth);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
scrollElement.addEventListener("scroll", updatePosition, { passive: true });
|
||||||
|
resizeObserver.observe(scrollElement);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollElement.removeEventListener("scroll", updatePosition);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [scrollRef]);
|
||||||
|
|
||||||
|
return { scrollLeft, viewportWidth };
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import type { Transform, TimelineTrack } from "@/types/timeline";
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
tracksSnapshot: TimelineTrack[];
|
||||||
|
elements: Array<{
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
initialTransform: Transform;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePreviewInteraction({
|
||||||
|
canvasRef,
|
||||||
|
}: {
|
||||||
|
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||||
|
}) {
|
||||||
|
const editor = useEditor();
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragStateRef = useRef<DragState | null>(null);
|
||||||
|
|
||||||
|
const selectedElements = useSyncExternalStore(
|
||||||
|
(listener) => editor.selection.subscribe(listener),
|
||||||
|
() => editor.selection.getSelectedElements(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCanvasCoordinates = useCallback(
|
||||||
|
({ clientX, clientY }: { clientX: number; clientY: number }) => {
|
||||||
|
if (!canvasRef.current) return { x: 0, y: 0 };
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const logicalWidth = canvasRef.current.width;
|
||||||
|
const logicalHeight = canvasRef.current.height;
|
||||||
|
const scaleX = logicalWidth / rect.width;
|
||||||
|
const scaleY = logicalHeight / rect.height;
|
||||||
|
|
||||||
|
const canvasX = (clientX - rect.left) * scaleX;
|
||||||
|
const canvasY = (clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
return { x: canvasX, y: canvasY };
|
||||||
|
},
|
||||||
|
[canvasRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(event: React.PointerEvent) => {
|
||||||
|
if (selectedElements.length === 0) return;
|
||||||
|
|
||||||
|
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||||
|
elements: selectedElements,
|
||||||
|
});
|
||||||
|
|
||||||
|
const draggableElements = elementsWithTracks.filter(
|
||||||
|
({ element }) =>
|
||||||
|
element.type === "video" ||
|
||||||
|
element.type === "image" ||
|
||||||
|
element.type === "text" ||
|
||||||
|
element.type === "sticker",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (draggableElements.length === 0) return;
|
||||||
|
|
||||||
|
const startPos = getCanvasCoordinates({
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
dragStateRef.current = {
|
||||||
|
startX: startPos.x,
|
||||||
|
startY: startPos.y,
|
||||||
|
tracksSnapshot: editor.timeline.getTracks(),
|
||||||
|
elements: draggableElements.map(({ track, element }) => ({
|
||||||
|
trackId: track.id,
|
||||||
|
elementId: element.id,
|
||||||
|
initialTransform: (element as { transform: Transform }).transform,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsDragging(true);
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
},
|
||||||
|
[selectedElements, editor, getCanvasCoordinates],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(event: React.PointerEvent) => {
|
||||||
|
if (!dragStateRef.current || !isDragging) return;
|
||||||
|
|
||||||
|
const currentPos = getCanvasCoordinates({
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deltaX = currentPos.x - dragStateRef.current.startX;
|
||||||
|
const deltaY = currentPos.y - dragStateRef.current.startY;
|
||||||
|
|
||||||
|
for (const { trackId, elementId, initialTransform } of dragStateRef
|
||||||
|
.current.elements) {
|
||||||
|
const newPosition = {
|
||||||
|
x: initialTransform.position.x + deltaX,
|
||||||
|
y: initialTransform.position.y + deltaY,
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.timeline.updateElements({
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
updates: {
|
||||||
|
transform: {
|
||||||
|
...initialTransform,
|
||||||
|
position: newPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pushHistory: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDragging, getCanvasCoordinates, editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(
|
||||||
|
(event: React.PointerEvent) => {
|
||||||
|
if (!dragStateRef.current || !isDragging) return;
|
||||||
|
|
||||||
|
const currentPos = getCanvasCoordinates({
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deltaX = currentPos.x - dragStateRef.current.startX;
|
||||||
|
const deltaY = currentPos.y - dragStateRef.current.startY;
|
||||||
|
|
||||||
|
// revert to pre-drag state so the command captures the correct undo snapshot
|
||||||
|
editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
|
||||||
|
|
||||||
|
const updates = dragStateRef.current.elements.map(
|
||||||
|
({ trackId, elementId, initialTransform }) => {
|
||||||
|
const newPosition = {
|
||||||
|
x: initialTransform.position.x + deltaX,
|
||||||
|
y: initialTransform.position.y + deltaY,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
updates: {
|
||||||
|
transform: {
|
||||||
|
...initialTransform,
|
||||||
|
position: newPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
editor.timeline.updateElements({ updates });
|
||||||
|
|
||||||
|
dragStateRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
},
|
||||||
|
[isDragging, getCanvasCoordinates, editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPointerDown: handlePointerDown,
|
||||||
|
onPointerMove: handlePointerMove,
|
||||||
|
onPointerUp: handlePointerUp,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Command } from "./base-command";
|
||||||
|
|
||||||
|
export class BatchCommand extends Command {
|
||||||
|
constructor(private commands: Command[]) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
for (const command of this.commands) {
|
||||||
|
command.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
for (const command of [...this.commands].reverse()) {
|
||||||
|
command.undo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redo(): void {
|
||||||
|
for (const command of this.commands) {
|
||||||
|
command.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { Command } from "./base-command";
|
export { Command } from "./base-command";
|
||||||
|
export { BatchCommand } from "./batch-command";
|
||||||
|
|
||||||
export * from "./timeline";
|
export * from "./timeline";
|
||||||
export * from "./media";
|
export * from "./media";
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import type { CanvasRenderer } from "../canvas-renderer";
|
import type { CanvasRenderer } from "../canvas-renderer";
|
||||||
import { BaseNode } from "./base-node";
|
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||||
|
|
||||||
const IMAGE_EPSILON = 1 / 1000;
|
export interface ImageNodeParams extends VisualNodeParams {
|
||||||
|
|
||||||
export interface ImageNodeParams {
|
|
||||||
url: string;
|
url: string;
|
||||||
duration: number;
|
|
||||||
timeOffset: number;
|
|
||||||
trimStart: number;
|
|
||||||
trimEnd: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
opacity?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageNode extends BaseNode<ImageNodeParams> {
|
export class ImageNode extends VisualNode<ImageNodeParams> {
|
||||||
private image?: HTMLImageElement;
|
private image?: HTMLImageElement;
|
||||||
private readyPromise: Promise<void>;
|
private readyPromise: Promise<void>;
|
||||||
|
|
||||||
@@ -36,18 +25,6 @@ export class ImageNode extends BaseNode<ImageNodeParams> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getImageTime(time: number) {
|
|
||||||
return time - this.params.timeOffset + this.params.trimStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isInRange(time: number) {
|
|
||||||
const imageTime = this.getImageTime(time);
|
|
||||||
return (
|
|
||||||
imageTime >= this.params.trimStart - IMAGE_EPSILON &&
|
|
||||||
imageTime < this.params.trimStart + this.params.duration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||||
await super.render({ renderer, time });
|
await super.render({ renderer, time });
|
||||||
|
|
||||||
@@ -61,40 +38,14 @@ export class ImageNode extends BaseNode<ImageNodeParams> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.context.save();
|
const mediaW = this.image.naturalWidth || renderer.width;
|
||||||
|
const mediaH = this.image.naturalHeight || renderer.height;
|
||||||
|
|
||||||
if (this.params.opacity !== undefined) {
|
this.renderVisual({
|
||||||
renderer.context.globalAlpha = this.params.opacity;
|
renderer,
|
||||||
}
|
source: this.image,
|
||||||
|
sourceWidth: mediaW,
|
||||||
if (
|
sourceHeight: mediaH,
|
||||||
this.params.x !== undefined &&
|
});
|
||||||
this.params.y !== undefined &&
|
|
||||||
this.params.width !== undefined &&
|
|
||||||
this.params.height !== undefined
|
|
||||||
) {
|
|
||||||
renderer.context.drawImage(
|
|
||||||
this.image,
|
|
||||||
this.params.x,
|
|
||||||
this.params.y,
|
|
||||||
this.params.width,
|
|
||||||
this.params.height,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const mediaW = this.image.naturalWidth || renderer.width;
|
|
||||||
const mediaH = this.image.naturalHeight || renderer.height;
|
|
||||||
const containScale = Math.min(
|
|
||||||
renderer.width / mediaW,
|
|
||||||
renderer.height / mediaH,
|
|
||||||
);
|
|
||||||
const drawW = mediaW * containScale;
|
|
||||||
const drawH = mediaH * containScale;
|
|
||||||
const drawX = (renderer.width - drawW) / 2;
|
|
||||||
const drawY = (renderer.height - drawH) / 2;
|
|
||||||
|
|
||||||
renderer.context.drawImage(this.image, drawX, drawY, drawW, drawH);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.context.restore();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import type { CanvasRenderer } from "../canvas-renderer";
|
import type { CanvasRenderer } from "../canvas-renderer";
|
||||||
import { BaseNode, type BaseNodeParams } from "./base-node";
|
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||||
import type { Transform } from "@/types/timeline";
|
|
||||||
|
|
||||||
const STICKER_EPSILON = 1 / 1000;
|
export interface StickerNodeParams extends VisualNodeParams {
|
||||||
|
|
||||||
export type StickerNodeParams = BaseNodeParams & {
|
|
||||||
iconName: string;
|
iconName: string;
|
||||||
duration: number;
|
|
||||||
timeOffset: number;
|
|
||||||
trimStart: number;
|
|
||||||
trimEnd: number;
|
|
||||||
transform: Transform;
|
|
||||||
opacity: number;
|
|
||||||
color?: string;
|
color?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export class StickerNode extends BaseNode<StickerNodeParams> {
|
export class StickerNode extends VisualNode<StickerNodeParams> {
|
||||||
private image?: HTMLImageElement;
|
private image?: HTMLImageElement;
|
||||||
private readyPromise: Promise<void>;
|
private readyPromise: Promise<void>;
|
||||||
|
|
||||||
@@ -40,18 +31,6 @@ export class StickerNode extends BaseNode<StickerNodeParams> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStickerTime(time: number) {
|
|
||||||
return time - this.params.timeOffset + this.params.trimStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isInRange(time: number) {
|
|
||||||
const stickerTime = this.getStickerTime(time);
|
|
||||||
return (
|
|
||||||
stickerTime >= this.params.trimStart - STICKER_EPSILON &&
|
|
||||||
stickerTime < this.params.trimStart + this.params.duration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||||
await super.render({ renderer, time });
|
await super.render({ renderer, time });
|
||||||
|
|
||||||
@@ -65,23 +44,11 @@ export class StickerNode extends BaseNode<StickerNodeParams> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { transform, opacity } = this.params;
|
this.renderVisual({
|
||||||
const size = 200 * transform.scale;
|
renderer,
|
||||||
const x = renderer.width / 2 + transform.position.x - size / 2;
|
source: this.image,
|
||||||
const y = renderer.height / 2 + transform.position.y - size / 2;
|
sourceWidth: 200,
|
||||||
|
sourceHeight: 200,
|
||||||
renderer.context.save();
|
});
|
||||||
renderer.context.globalAlpha = opacity;
|
|
||||||
|
|
||||||
if (transform.rotate !== 0) {
|
|
||||||
const centerX = x + size / 2;
|
|
||||||
const centerY = y + size / 2;
|
|
||||||
renderer.context.translate(centerX, centerY);
|
|
||||||
renderer.context.rotate((transform.rotate * Math.PI) / 180);
|
|
||||||
renderer.context.translate(-centerX, -centerY);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.context.drawImage(this.image, x, y, size, size);
|
|
||||||
renderer.context.restore();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,14 @@
|
|||||||
import type { CanvasRenderer } from "../canvas-renderer";
|
import type { CanvasRenderer } from "../canvas-renderer";
|
||||||
import { BaseNode } from "./base-node";
|
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||||
import { videoCache } from "@/services/video-cache/service";
|
import { videoCache } from "@/services/video-cache/service";
|
||||||
|
|
||||||
const VIDEO_EPSILON = 1 / 1000;
|
export interface VideoNodeParams extends VisualNodeParams {
|
||||||
|
|
||||||
export interface VideoNodeParams {
|
|
||||||
url: string;
|
url: string;
|
||||||
file: File;
|
file: File;
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
duration: number;
|
|
||||||
timeOffset: number;
|
|
||||||
trimStart: number;
|
|
||||||
trimEnd: number;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
opacity?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VideoNode extends BaseNode<VideoNodeParams> {
|
export class VideoNode extends VisualNode<VideoNodeParams> {
|
||||||
private getVideoTime(time: number) {
|
|
||||||
return time - this.params.timeOffset + this.params.trimStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isInRange(time: number) {
|
|
||||||
const videoTime = this.getVideoTime(time);
|
|
||||||
return (
|
|
||||||
videoTime >= this.params.trimStart - VIDEO_EPSILON &&
|
|
||||||
videoTime < this.params.trimStart + this.params.duration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||||
await super.render({ renderer, time });
|
await super.render({ renderer, time });
|
||||||
|
|
||||||
@@ -39,7 +16,7 @@ export class VideoNode extends BaseNode<VideoNodeParams> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoTime = this.getVideoTime(time);
|
const videoTime = this.getLocalTime(time);
|
||||||
const frame = await videoCache.getFrameAt({
|
const frame = await videoCache.getFrameAt({
|
||||||
mediaId: this.params.mediaId,
|
mediaId: this.params.mediaId,
|
||||||
file: this.params.file,
|
file: this.params.file,
|
||||||
@@ -47,36 +24,12 @@ export class VideoNode extends BaseNode<VideoNodeParams> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (frame) {
|
if (frame) {
|
||||||
renderer.context.save();
|
this.renderVisual({
|
||||||
|
renderer,
|
||||||
if (this.params.opacity !== undefined) {
|
source: frame.canvas,
|
||||||
renderer.context.globalAlpha = this.params.opacity;
|
sourceWidth: frame.canvas.width,
|
||||||
}
|
sourceHeight: frame.canvas.height,
|
||||||
|
});
|
||||||
if (
|
|
||||||
this.params.x !== undefined &&
|
|
||||||
this.params.y !== undefined &&
|
|
||||||
this.params.width !== undefined &&
|
|
||||||
this.params.height !== undefined
|
|
||||||
) {
|
|
||||||
renderer.context.drawImage(
|
|
||||||
frame.canvas,
|
|
||||||
this.params.x,
|
|
||||||
this.params.y,
|
|
||||||
this.params.width,
|
|
||||||
this.params.height,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
renderer.context.drawImage(
|
|
||||||
frame.canvas,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
renderer.width,
|
|
||||||
renderer.height,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.context.restore();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { CanvasRenderer } from "../canvas-renderer";
|
||||||
|
import { BaseNode } from "./base-node";
|
||||||
|
import type { Transform } from "@/types/timeline";
|
||||||
|
|
||||||
|
const VISUAL_EPSILON = 1 / 1000;
|
||||||
|
|
||||||
|
export interface VisualNodeParams {
|
||||||
|
duration: number;
|
||||||
|
timeOffset: number;
|
||||||
|
trimStart: number;
|
||||||
|
trimEnd: number;
|
||||||
|
transform: Transform;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class VisualNode<
|
||||||
|
Params extends VisualNodeParams = VisualNodeParams,
|
||||||
|
> extends BaseNode<Params> {
|
||||||
|
protected getLocalTime(time: number): number {
|
||||||
|
return time - this.params.timeOffset + this.params.trimStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isInRange(time: number): boolean {
|
||||||
|
const localTime = this.getLocalTime(time);
|
||||||
|
return (
|
||||||
|
localTime >= this.params.trimStart - VISUAL_EPSILON &&
|
||||||
|
localTime < this.params.trimStart + this.params.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renderVisual({
|
||||||
|
renderer,
|
||||||
|
source,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight,
|
||||||
|
}: {
|
||||||
|
renderer: CanvasRenderer;
|
||||||
|
source: CanvasImageSource;
|
||||||
|
sourceWidth: number;
|
||||||
|
sourceHeight: number;
|
||||||
|
}): void {
|
||||||
|
renderer.context.save();
|
||||||
|
|
||||||
|
const { transform, opacity } = this.params;
|
||||||
|
const containScale = Math.min(
|
||||||
|
renderer.width / sourceWidth,
|
||||||
|
renderer.height / sourceHeight,
|
||||||
|
);
|
||||||
|
const scaledWidth = sourceWidth * containScale * transform.scale;
|
||||||
|
const scaledHeight = sourceHeight * containScale * transform.scale;
|
||||||
|
const x = renderer.width / 2 + transform.position.x - scaledWidth / 2;
|
||||||
|
const y = renderer.height / 2 + transform.position.y - scaledHeight / 2;
|
||||||
|
|
||||||
|
renderer.context.globalAlpha = opacity;
|
||||||
|
|
||||||
|
if (transform.rotate !== 0) {
|
||||||
|
const centerX = x + scaledWidth / 2;
|
||||||
|
const centerY = y + scaledHeight / 2;
|
||||||
|
renderer.context.translate(centerX, centerY);
|
||||||
|
renderer.context.rotate((transform.rotate * Math.PI) / 180);
|
||||||
|
renderer.context.translate(-centerX, -centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
|
||||||
|
renderer.context.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ export function buildScene(params: BuildSceneParams) {
|
|||||||
timeOffset: element.startTime,
|
timeOffset: element.startTime,
|
||||||
trimStart: element.trimStart,
|
trimStart: element.trimStart,
|
||||||
trimEnd: element.trimEnd,
|
trimEnd: element.trimEnd,
|
||||||
|
transform: element.transform,
|
||||||
|
opacity: element.opacity,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,8 @@ export function buildScene(params: BuildSceneParams) {
|
|||||||
timeOffset: element.startTime,
|
timeOffset: element.startTime,
|
||||||
trimStart: element.trimStart,
|
trimStart: element.trimStart,
|
||||||
trimEnd: element.trimEnd,
|
trimEnd: element.trimEnd,
|
||||||
|
transform: element.transform,
|
||||||
|
opacity: element.opacity,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user