Merge branch 'OpenCut-app:main' into main
This commit is contained in:
commit
8d81b78a04
|
|
@ -3820,7 +3820,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.5",
|
||||
"opencut-wasm": "^0.2.6",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
getDbFromLinePos,
|
||||
getLinePosFromDb,
|
||||
} from "@/lib/timeline/audio-display";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { hasAnimatedVolume } from "@/lib/timeline/audio-state";
|
||||
import type { AudioElement } from "@/lib/timeline/types";
|
||||
import {
|
||||
clamp,
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
isNearlyEqual,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const HIT_AREA_HEIGHT_PX = 14;
|
||||
const TOOLTIP_OFFSET_PX = 10;
|
||||
const VOLUME_STEP = 0.1;
|
||||
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||
|
||||
function clampVolume({ value }: { value: number }): number {
|
||||
return clamp({
|
||||
value: snapToStep({ value, step: VOLUME_STEP }),
|
||||
min: VOLUME_DB_MIN,
|
||||
max: VOLUME_DB_MAX,
|
||||
});
|
||||
}
|
||||
|
||||
function getVolumeFromPointer({
|
||||
clientY,
|
||||
rect,
|
||||
}: {
|
||||
clientY: number;
|
||||
rect: DOMRect;
|
||||
}): number {
|
||||
const clampedOffset = clamp({
|
||||
value: clientY - rect.top,
|
||||
min: 0,
|
||||
max: rect.height,
|
||||
});
|
||||
const progressPercent =
|
||||
rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100;
|
||||
return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) });
|
||||
}
|
||||
|
||||
export function AudioVolumeLine({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: AudioElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
const activePointerIdRef = useRef<number | null>(null);
|
||||
const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
|
||||
const lastPreviewVolumeRef = useRef(
|
||||
element.volume ?? DEFAULTS.element.volume,
|
||||
);
|
||||
const hasChangedRef = useRef(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [tooltipClientPos, setTooltipClientPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const hasAnimatedEnvelope = hasAnimatedVolume({ element });
|
||||
const currentVolume = element.volume ?? DEFAULTS.element.volume;
|
||||
const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
|
||||
|
||||
const volumeLabel = `${formatNumberForDisplay({
|
||||
value: currentVolume,
|
||||
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||
})} dB`;
|
||||
|
||||
const previewVolume = useCallback(
|
||||
(nextVolume: number) => {
|
||||
if (
|
||||
isNearlyEqual({
|
||||
leftValue: nextVolume,
|
||||
rightValue: lastPreviewVolumeRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { volume: nextVolume },
|
||||
},
|
||||
],
|
||||
});
|
||||
lastPreviewVolumeRef.current = nextVolume;
|
||||
hasChangedRef.current = !isNearlyEqual({
|
||||
leftValue: startVolumeRef.current,
|
||||
rightValue: nextVolume,
|
||||
});
|
||||
},
|
||||
[editor, element.id, trackId],
|
||||
);
|
||||
|
||||
const finishDrag = useCallback(
|
||||
({ shouldCommit }: { shouldCommit: boolean }) => {
|
||||
activePointerIdRef.current = null;
|
||||
setIsDragging(false);
|
||||
|
||||
if (shouldCommit && hasChangedRef.current) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
editor.timeline.discardPreview();
|
||||
}
|
||||
|
||||
hasChangedRef.current = false;
|
||||
lastPreviewVolumeRef.current = startVolumeRef.current;
|
||||
setTooltipClientPos(null);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const updateFromPointer = useCallback(
|
||||
({ clientX, clientY }: { clientX: number; clientY: number }) => {
|
||||
const rect = surfaceRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTooltipClientPos({
|
||||
x: clientX + TOOLTIP_OFFSET_PX,
|
||||
y: clientY - TOOLTIP_OFFSET_PX,
|
||||
});
|
||||
previewVolume(getVolumeFromPointer({ clientY, rect }));
|
||||
},
|
||||
[previewVolume],
|
||||
);
|
||||
|
||||
const handleClick = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [{ trackId, elementId: element.id }],
|
||||
});
|
||||
activePointerIdRef.current = event.pointerId;
|
||||
startVolumeRef.current = currentVolume;
|
||||
lastPreviewVolumeRef.current = currentVolume;
|
||||
hasChangedRef.current = false;
|
||||
setIsDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
updateFromPointer({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
},
|
||||
[currentVolume, editor.selection, element.id, trackId, updateFromPointer],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (activePointerIdRef.current !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
updateFromPointer({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
},
|
||||
[updateFromPointer],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (activePointerIdRef.current !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
finishDrag({ shouldCommit: true });
|
||||
},
|
||||
[finishDrag],
|
||||
);
|
||||
|
||||
const handlePointerCancel = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (activePointerIdRef.current !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
finishDrag({ shouldCommit: false });
|
||||
},
|
||||
[finishDrag],
|
||||
);
|
||||
|
||||
const handleLostPointerCapture = useCallback(() => {
|
||||
if (activePointerIdRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
finishDrag({ shouldCommit: hasChangedRef.current });
|
||||
}, [finishDrag]);
|
||||
|
||||
if (hasAnimatedEnvelope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div ref={surfaceRef} className="absolute inset-0">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 -translate-y-1/2 border-t transition-colors",
|
||||
isDragging
|
||||
? "border-white"
|
||||
: "border-white/50 group-hover/audio:border-white/80",
|
||||
)}
|
||||
style={{ top: lineTop }}
|
||||
/>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */}
|
||||
<div
|
||||
className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto"
|
||||
style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onLostPointerCapture={handleLostPointerCapture}
|
||||
title="Drag to adjust clip volume"
|
||||
/>
|
||||
{isDragging &&
|
||||
tooltipClientPos &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed left-0 top-0 z-50 -translate-y-full rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white whitespace-nowrap"
|
||||
style={{
|
||||
transform: `translate(${tooltipClientPos.x}px, ${tooltipClientPos.y}px)`,
|
||||
}}
|
||||
>
|
||||
{volumeLabel}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useResizeObserver } from "@/hooks/use-resize-observer";
|
||||
import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
|
||||
import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme";
|
||||
import {
|
||||
buildWaveformSampleBuckets,
|
||||
sampleSourceWaveformSummary,
|
||||
type SourceWaveformSummary,
|
||||
} from "@/lib/media/waveform-summary";
|
||||
import type { RetimeConfig } from "@/lib/timeline";
|
||||
import { getBarFractionFromOutputAmplitude } from "@/lib/timeline/audio-display";
|
||||
import { waveformCache } from "@/services/waveform-cache/service";
|
||||
import { findScrollParent } from "@/utils/browser";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const BAR_WIDTH = 2;
|
||||
const BAR_WIDTH = 1;
|
||||
const BAR_GAP = 1;
|
||||
const BAR_STEP = BAR_WIDTH + BAR_GAP;
|
||||
const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)";
|
||||
export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
|
||||
|
||||
function sampleGain({
|
||||
function sampleGainAtClipTime({
|
||||
samples,
|
||||
startFraction,
|
||||
endFraction,
|
||||
barIndex,
|
||||
barCount,
|
||||
clipTimeSec,
|
||||
clipDurationSec,
|
||||
}: {
|
||||
samples: number[];
|
||||
startFraction: number;
|
||||
endFraction: number;
|
||||
barIndex: number;
|
||||
barCount: number;
|
||||
clipTimeSec: number;
|
||||
clipDurationSec: number;
|
||||
}): number {
|
||||
if (samples.length === 0) return 1;
|
||||
const progress =
|
||||
startFraction +
|
||||
((barIndex + 0.5) / barCount) * (endFraction - startFraction);
|
||||
const rawIndex = Math.max(0, Math.min(1, progress)) * (samples.length - 1);
|
||||
if (samples.length === 0 || clipDurationSec <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec));
|
||||
const rawIndex = progress * (samples.length - 1);
|
||||
const lo = Math.floor(rawIndex);
|
||||
const hi = Math.min(samples.length - 1, lo + 1);
|
||||
return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
|
||||
}
|
||||
|
||||
interface AudioWaveformProps {
|
||||
sourceKey: string;
|
||||
sourceFile?: File;
|
||||
audioUrl?: string;
|
||||
audioBuffer?: AudioBuffer;
|
||||
gainSamples?: number[];
|
||||
pixelsPerSecond: number;
|
||||
clipDurationSec: number;
|
||||
retime?: RetimeConfig;
|
||||
sourceStartSec: number;
|
||||
color?: string;
|
||||
burnColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AudioWaveform({
|
||||
sourceKey,
|
||||
sourceFile,
|
||||
audioUrl,
|
||||
audioBuffer,
|
||||
gainSamples,
|
||||
color = "rgba(255, 255, 255, 0.7)",
|
||||
pixelsPerSecond,
|
||||
clipDurationSec,
|
||||
retime,
|
||||
sourceStartSec,
|
||||
color = TIMELINE_AUDIO_WAVEFORM_COLOR,
|
||||
burnColor = WAVEFORM_BURN_COLOR,
|
||||
className = "",
|
||||
}: AudioWaveformProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bufferRef = useRef<AudioBuffer | null>(null);
|
||||
const globalMaxRef = useRef<number>(1);
|
||||
const summaryRef = useRef<SourceWaveformSummary | null>(null);
|
||||
const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
|
||||
const pixelsPerSecondRef = useRef<number>(pixelsPerSecond);
|
||||
const clipDurationSecRef = useRef<number>(clipDurationSec);
|
||||
const retimeRef = useRef<RetimeConfig | undefined>(retime);
|
||||
const sourceStartSecRef = useRef<number>(sourceStartSec);
|
||||
const scrollParentRef = useRef<HTMLElement | null>(null);
|
||||
const heightRef = useRef<number>(0);
|
||||
const lastRenderSignatureRef = useRef<string | null>(null);
|
||||
|
||||
gainSamplesRef.current = gainSamples;
|
||||
pixelsPerSecondRef.current = pixelsPerSecond;
|
||||
clipDurationSecRef.current = clipDurationSec;
|
||||
retimeRef.current = retime;
|
||||
sourceStartSecRef.current = sourceStartSec;
|
||||
|
||||
const clearCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
lastRenderSignatureRef.current = null;
|
||||
}, []);
|
||||
|
||||
const drawVisible = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const buffer = bufferRef.current;
|
||||
const summary = summaryRef.current;
|
||||
const height = heightRef.current;
|
||||
|
||||
if (!container || !canvas || !buffer || height <= 0) return;
|
||||
|
||||
const elementWidth = container.offsetWidth;
|
||||
if (elementWidth <= 0) return;
|
||||
if (!container || !canvas || !summary || height <= 0) {
|
||||
clearCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementWidth = containerRect.width;
|
||||
if (elementWidth <= 0) {
|
||||
clearCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollParent = scrollParentRef.current;
|
||||
|
||||
let clipLeft: number;
|
||||
|
|
@ -86,13 +137,43 @@ export function AudioWaveform({
|
|||
}
|
||||
|
||||
const visibleWidth = clipRight - clipLeft;
|
||||
if (visibleWidth <= 0) return;
|
||||
if (visibleWidth <= 0) {
|
||||
clearCanvas();
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const canvasW = Math.round(visibleWidth * dpr);
|
||||
const canvasH = Math.round(height * dpr);
|
||||
|
||||
if (canvasW <= 0 || canvasH <= 0) return;
|
||||
const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr));
|
||||
const canvasH = Math.max(1, Math.round(height * dpr));
|
||||
const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
|
||||
const pixelsPerSecondValue = pixelsPerSecondRef.current;
|
||||
const clipDurationSecValue = clipDurationSecRef.current;
|
||||
const samples = gainSamplesRef.current;
|
||||
const renderSignature = JSON.stringify({
|
||||
elementWidth,
|
||||
clipLeft,
|
||||
clipRight,
|
||||
visibleWidth,
|
||||
canvasW,
|
||||
canvasH,
|
||||
barCount,
|
||||
dpr,
|
||||
clipDurationSec: clipDurationSecValue,
|
||||
sourceStartSec: sourceStartSecRef.current,
|
||||
pixelsPerSecond: pixelsPerSecondValue,
|
||||
retime: retimeRef.current ?? null,
|
||||
summarySourceKey: summary.sourceKey,
|
||||
summarySampleRate: summary.sampleRate,
|
||||
summaryTotalSamples: summary.totalSamples,
|
||||
summaryBucketSize: summary.bucketSize,
|
||||
gainSamples: samples ?? null,
|
||||
color,
|
||||
burnColor,
|
||||
});
|
||||
if (lastRenderSignatureRef.current === renderSignature) {
|
||||
return;
|
||||
}
|
||||
lastRenderSignatureRef.current = renderSignature;
|
||||
|
||||
canvas.width = canvasW;
|
||||
canvas.height = canvasH;
|
||||
|
|
@ -100,97 +181,150 @@ export function AudioWaveform({
|
|||
canvas.style.height = `${height}px`;
|
||||
canvas.style.left = `${clipLeft}px`;
|
||||
|
||||
const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
|
||||
const startFraction = clipLeft / elementWidth;
|
||||
const endFraction = clipRight / elementWidth;
|
||||
const startSample = Math.floor(startFraction * buffer.length);
|
||||
const endSample = Math.min(
|
||||
buffer.length,
|
||||
Math.ceil(endFraction * buffer.length),
|
||||
);
|
||||
const backingScaleX = dpr;
|
||||
const backingScaleY = canvasH / height;
|
||||
|
||||
const peaks = extractRmsRange({
|
||||
buffer,
|
||||
count: barCount,
|
||||
startSample,
|
||||
endSample,
|
||||
globalMax: globalMaxRef.current,
|
||||
const sampleBuckets = buildWaveformSampleBuckets({
|
||||
clipLeftPx: clipLeft,
|
||||
clipRightPx: clipRight,
|
||||
barCount,
|
||||
pixelsPerSecond: pixelsPerSecondValue,
|
||||
clipDurationSec: clipDurationSecValue,
|
||||
sourceStartSec: sourceStartSecRef.current,
|
||||
retime: retimeRef.current,
|
||||
sampleRate: summary.sampleRate,
|
||||
maxSampleExclusive: summary.totalSamples,
|
||||
barStepPx: BAR_STEP,
|
||||
});
|
||||
const amplitudes = sampleSourceWaveformSummary({
|
||||
summary,
|
||||
buckets: sampleBuckets,
|
||||
});
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.clearRect(0, 0, canvasW, canvasH);
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = color;
|
||||
|
||||
const maxBarHeight = height * 0.7;
|
||||
const clipBottom = canvasH;
|
||||
|
||||
const samples = gainSamplesRef.current;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5;
|
||||
const clipCenterSec = Math.max(
|
||||
0,
|
||||
Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue),
|
||||
);
|
||||
const gain =
|
||||
samples != null
|
||||
? sampleGain({
|
||||
? sampleGainAtClipTime({
|
||||
samples,
|
||||
startFraction,
|
||||
endFraction,
|
||||
barIndex: i,
|
||||
barCount,
|
||||
clipTimeSec: clipCenterSec,
|
||||
clipDurationSec: clipDurationSecValue,
|
||||
})
|
||||
: 1;
|
||||
const scaledPeak = Math.min(1, Math.max(0, peaks[i] * gain));
|
||||
const scaled = Math.log1p(scaledPeak) / Math.log1p(1);
|
||||
const barH = Math.max(1, scaled * maxBarHeight);
|
||||
ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
|
||||
}
|
||||
}, [color]);
|
||||
const amplitude = Math.max(0, amplitudes[i] ?? 0);
|
||||
const outputAmplitude = amplitude * Math.max(0, gain);
|
||||
const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude });
|
||||
const barH = fraction > 0 ? Math.max(1, fraction * height) : 0;
|
||||
if (barH <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
gainSamplesRef.current = gainSamples;
|
||||
drawVisible();
|
||||
}, [gainSamples, drawVisible]);
|
||||
const barLeft = i * BAR_STEP;
|
||||
const barRight = barLeft + BAR_WIDTH;
|
||||
const deviceLeft = Math.round(barLeft * backingScaleX);
|
||||
const deviceRight = Math.max(
|
||||
deviceLeft + 1,
|
||||
Math.round(barRight * backingScaleX),
|
||||
);
|
||||
const deviceTop = Math.round((height - barH) * backingScaleY);
|
||||
const deviceHeight = Math.max(1, clipBottom - deviceTop);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(
|
||||
deviceLeft,
|
||||
deviceTop,
|
||||
deviceRight - deviceLeft,
|
||||
deviceHeight,
|
||||
);
|
||||
|
||||
if (outputAmplitude > 1) {
|
||||
const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY));
|
||||
ctx.fillStyle = burnColor;
|
||||
ctx.fillRect(
|
||||
deviceLeft,
|
||||
deviceTop,
|
||||
deviceRight - deviceLeft,
|
||||
burnHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [burnColor, clearCanvas, color]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
summaryRef.current = null;
|
||||
clearCanvas();
|
||||
|
||||
async function load() {
|
||||
let buffer = audioBuffer ?? null;
|
||||
|
||||
if (!buffer && audioUrl) {
|
||||
try {
|
||||
const resp = await fetch(audioUrl);
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
const actx = new AudioContext();
|
||||
buffer = await actx.decodeAudioData(arrayBuffer);
|
||||
actx.close();
|
||||
} catch {
|
||||
void waveformCache
|
||||
.getSourceSummary({
|
||||
sourceKey,
|
||||
audioBuffer,
|
||||
sourceFile,
|
||||
audioUrl,
|
||||
})
|
||||
.then((summary) => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
summaryRef.current = summary;
|
||||
drawVisible();
|
||||
})
|
||||
.catch(() => {
|
||||
// Waveform loading failed (e.g. corrupt file, unsupported format).
|
||||
// Fail silently — a missing waveform is preferable to an error state.
|
||||
if (!isCancelled) {
|
||||
clearCanvas();
|
||||
}
|
||||
});
|
||||
|
||||
if (!buffer || isCancelled) return;
|
||||
|
||||
bufferRef.current = buffer;
|
||||
globalMaxRef.current = computeGlobalMaxRms({ buffer });
|
||||
drawVisible();
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [audioUrl, audioBuffer, drawVisible]);
|
||||
}, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change.
|
||||
useLayoutEffect(() => {
|
||||
drawVisible();
|
||||
}, [
|
||||
drawVisible,
|
||||
gainSamples,
|
||||
pixelsPerSecond,
|
||||
clipDurationSec,
|
||||
retime,
|
||||
sourceStartSec,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollParentRef.current = findScrollParent({ element: container });
|
||||
const scrollParent = scrollParentRef.current;
|
||||
if (!scrollParent) return;
|
||||
if (!scrollParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollParent.addEventListener("scroll", drawVisible, { passive: true });
|
||||
return () => scrollParent.removeEventListener("scroll", drawVisible);
|
||||
const handleScroll = () => {
|
||||
drawVisible();
|
||||
};
|
||||
scrollParent.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => scrollParent.removeEventListener("scroll", handleScroll);
|
||||
}, [drawVisible]);
|
||||
|
||||
const onResize = useCallback(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { TrackType } from "@/lib/timeline";
|
||||
|
||||
export const TIMELINE_AUDIO_WAVEFORM_COLOR = "rgba(255, 255, 255, 0.7)";
|
||||
|
||||
export const TIMELINE_TRACK_THEME: Record<
|
||||
TrackType,
|
||||
{
|
||||
|
|
@ -11,7 +13,7 @@ export const TIMELINE_TRACK_THEME: Record<
|
|||
text: { elementClassName: "bg-[#5DBAA0]" },
|
||||
audio: {
|
||||
elementClassName: "bg-[#8F5DBA]",
|
||||
waveformColor: "rgba(255, 255, 255, 0.5)",
|
||||
waveformColor: TIMELINE_AUDIO_WAVEFORM_COLOR,
|
||||
},
|
||||
graphic: { elementClassName: "bg-[#BA5D7A]" },
|
||||
effect: { elementClassName: "bg-[#5d93ba]" },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
|
||||
import { AudioVolumeLine } from "./audio-volume-line";
|
||||
import { useElementPreview } from "@/hooks/use-element-preview";
|
||||
import {
|
||||
useKeyframeDrag,
|
||||
|
|
@ -45,6 +47,9 @@ import {
|
|||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { buildWaveformGainSamples } from "@/lib/timeline/audio-state";
|
||||
import { getTimelinePixelsPerSecond } from "@/lib/timeline";
|
||||
import { buildWaveformSourceKey } from "@/lib/media/waveform-summary";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm/ticks";
|
||||
import {
|
||||
getActionDefinition,
|
||||
type TAction,
|
||||
|
|
@ -89,6 +94,8 @@ import {
|
|||
|
||||
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
|
||||
const ELEMENT_RING_WIDTH_PX = 1.5;
|
||||
|
||||
const PixelsPerSecondContext = createContext<number | null>(null);
|
||||
const THUMBNAIL_ASPECT_RATIO = 16 / 9;
|
||||
|
||||
interface KeyframeIndicator {
|
||||
|
|
@ -264,6 +271,7 @@ export function TimelineElement({
|
|||
time: displayedDuration,
|
||||
zoomLevel,
|
||||
});
|
||||
const timelinePixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
|
||||
const elementLeft = timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime,
|
||||
zoomLevel,
|
||||
|
|
@ -364,137 +372,141 @@ export function TimelineElement({
|
|||
) : null;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="absolute top-0 select-none"
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
height:
|
||||
expandedRows.length > 0
|
||||
? `${baseTrackHeight + expansionHeight}px`
|
||||
: "100%",
|
||||
transform:
|
||||
isBeingDragged && dragState.isDragging
|
||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ElementInner
|
||||
element={element}
|
||||
displayElement={renderElement}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isExpanded={expandedRows.length > 0}
|
||||
baseTrackHeight={baseTrackHeight}
|
||||
expandedContent={expandedContent}
|
||||
onElementClick={onElementClick}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
onResizeStart={onResizeStart}
|
||||
isDropTarget={isDropTarget}
|
||||
/>
|
||||
{isSelected && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
|
||||
style={{ height: `${baseTrackHeight}px` }}
|
||||
>
|
||||
<KeyframeIndicators
|
||||
indicators={keyframeIndicators}
|
||||
dragState={keyframeDragState}
|
||||
displayedStartTime={displayedStartTime}
|
||||
elementLeft={elementLeft}
|
||||
onKeyframeMouseDown={handleKeyframeMouseDown}
|
||||
onKeyframeClick={handleKeyframeClick}
|
||||
getVisualOffsetPx={getVisualOffsetPx}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-64">
|
||||
<ActionMenuItem
|
||||
action="split"
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
>
|
||||
Split
|
||||
</ActionMenuItem>
|
||||
<CopyMenuItem />
|
||||
{selectedElements.length === 1 && (
|
||||
<PixelsPerSecondContext.Provider value={timelinePixelsPerSecond}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="absolute top-0 select-none"
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
height:
|
||||
expandedRows.length > 0
|
||||
? `${baseTrackHeight + expansionHeight}px`
|
||||
: "100%",
|
||||
transform:
|
||||
isBeingDragged && dragState.isDragging
|
||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ElementInner
|
||||
element={element}
|
||||
displayElement={renderElement}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isExpanded={expandedRows.length > 0}
|
||||
baseTrackHeight={baseTrackHeight}
|
||||
expandedContent={expandedContent}
|
||||
onElementClick={onElementClick}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
onResizeStart={onResizeStart}
|
||||
isDropTarget={isDropTarget}
|
||||
/>
|
||||
{isSelected && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
|
||||
style={{ height: `${baseTrackHeight}px` }}
|
||||
>
|
||||
<KeyframeIndicators
|
||||
indicators={keyframeIndicators}
|
||||
dragState={keyframeDragState}
|
||||
displayedStartTime={displayedStartTime}
|
||||
elementLeft={elementLeft}
|
||||
onKeyframeMouseDown={handleKeyframeMouseDown}
|
||||
onKeyframeClick={handleKeyframeClick}
|
||||
getVisualOffsetPx={getVisualOffsetPx}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-64">
|
||||
<ActionMenuItem
|
||||
action="duplicate-selected"
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
action="split"
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
>
|
||||
Duplicate
|
||||
Split
|
||||
</ActionMenuItem>
|
||||
)}
|
||||
{canElementHaveAudio(element) && hasAudio && (
|
||||
<MuteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
isMuted={isMuted}
|
||||
/>
|
||||
)}
|
||||
{canToggleCurrentSourceAudio && (
|
||||
<ContextMenuItem
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon}
|
||||
/>
|
||||
}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
invokeAction("toggle-source-audio");
|
||||
}}
|
||||
>
|
||||
{sourceAudioLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canElementBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
/>
|
||||
)}
|
||||
{hasKeyframes && (
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={KeyframeIcon} />}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
toggleElementExpanded(element.id);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? "Collapse keyframes" : "Expand keyframes"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{selectedElements.length === 1 && hasMediaId(element) && (
|
||||
<>
|
||||
<CopyMenuItem />
|
||||
{selectedElements.length === 1 && (
|
||||
<ActionMenuItem
|
||||
action="duplicate-selected"
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
>
|
||||
Duplicate
|
||||
</ActionMenuItem>
|
||||
)}
|
||||
{canElementHaveAudio(element) && hasAudio && (
|
||||
<MuteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
isMuted={isMuted}
|
||||
/>
|
||||
)}
|
||||
{canToggleCurrentSourceAudio && (
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Search01Icon} />}
|
||||
onClick={(event: React.MouseEvent) =>
|
||||
handleRevealInMedia({ event })
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={
|
||||
isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon
|
||||
}
|
||||
/>
|
||||
}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
invokeAction("toggle-source-audio");
|
||||
}}
|
||||
>
|
||||
Reveal media
|
||||
{sourceAudioLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canElementBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
/>
|
||||
)}
|
||||
{hasKeyframes && (
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Exchange01Icon} />}
|
||||
disabled
|
||||
icon={<HugeiconsIcon icon={KeyframeIcon} />}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
toggleElementExpanded(element.id);
|
||||
}}
|
||||
>
|
||||
Replace media
|
||||
{isExpanded ? "Collapse keyframes" : "Expand keyframes"}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<DeleteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
elementType={element.type}
|
||||
selectedCount={selectedElements.length}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{selectedElements.length === 1 && hasMediaId(element) && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Search01Icon} />}
|
||||
onClick={(event: React.MouseEvent) =>
|
||||
handleRevealInMedia({ event })
|
||||
}
|
||||
>
|
||||
Reveal media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Exchange01Icon} />}
|
||||
disabled
|
||||
>
|
||||
Replace media
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<DeleteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
elementType={element.type}
|
||||
selectedCount={selectedElements.length}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</PixelsPerSecondContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -973,7 +985,19 @@ function GraphicElementContent({
|
|||
);
|
||||
}
|
||||
|
||||
function AudioElementContent({ element }: { element: AudioElement }) {
|
||||
function AudioElementContent({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: AudioElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const pixelsPerSecond = useContext(PixelsPerSecondContext);
|
||||
if (pixelsPerSecond === null) {
|
||||
throw new Error(
|
||||
"AudioElementContent must be rendered inside PixelsPerSecondContext.Provider",
|
||||
);
|
||||
}
|
||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
const mediaAsset =
|
||||
element.sourceType === "upload"
|
||||
|
|
@ -984,29 +1008,53 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
|||
element.sourceType === "library" ? element.buffer : undefined;
|
||||
const audioUrl =
|
||||
element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
|
||||
const sourceFile =
|
||||
element.sourceType === "upload" ? mediaAsset?.file : undefined;
|
||||
const sourceKey =
|
||||
element.sourceType === "upload"
|
||||
? buildWaveformSourceKey({ kind: "media", id: element.mediaId })
|
||||
: buildWaveformSourceKey({ kind: "library", id: element.sourceUrl });
|
||||
const mediaLabel = mediaAsset?.name ?? element.name;
|
||||
const gainSamples = useMemo(
|
||||
() =>
|
||||
buildWaveformGainSamples({ element, count: WAVEFORM_GAIN_SAMPLE_COUNT }),
|
||||
buildWaveformGainSamples({
|
||||
element,
|
||||
count: WAVEFORM_GAIN_SAMPLE_COUNT,
|
||||
}),
|
||||
[element],
|
||||
);
|
||||
|
||||
if (audioBuffer || audioUrl) {
|
||||
if (audioBuffer || audioUrl || sourceFile) {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<AudioWaveform
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
gainSamples={gainSamples}
|
||||
color={TIMELINE_TRACK_THEME.audio.waveformColor}
|
||||
/>
|
||||
<div className="group/audio relative size-full">
|
||||
<MediaElementHeader name={mediaLabel} hasFade={false} />
|
||||
<div className="absolute inset-x-0 top-5 bottom-0 overflow-hidden">
|
||||
<AudioWaveform
|
||||
sourceKey={sourceKey}
|
||||
sourceFile={sourceFile}
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
gainSamples={gainSamples}
|
||||
pixelsPerSecond={pixelsPerSecond}
|
||||
clipDurationSec={element.duration / TICKS_PER_SECOND}
|
||||
retime={element.retime}
|
||||
sourceStartSec={element.trimStart / TICKS_PER_SECOND}
|
||||
color={TIMELINE_TRACK_THEME.audio.waveformColor}
|
||||
/>
|
||||
<AudioVolumeLine element={element} trackId={trackId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
|
||||
<div className="group/audio relative size-full">
|
||||
<div className="flex size-full items-center pl-2">
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{element.name}
|
||||
</span>
|
||||
</div>
|
||||
<AudioVolumeLine element={element} trackId={trackId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1108,7 +1156,7 @@ function MediaElementHeader({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
|
||||
"absolute top-0 left-0 flex h-5 w-full bg-linear-to-b pt-1",
|
||||
hasFade && "from-black/30 to-transparent",
|
||||
)}
|
||||
>
|
||||
|
|
@ -1133,7 +1181,7 @@ function ElementContent({ element, track }: ElementContentProps) {
|
|||
case "graphic":
|
||||
return <GraphicElementContent element={element} />;
|
||||
case "audio":
|
||||
return <AudioElementContent element={element} />;
|
||||
return <AudioElementContent element={element} trackId={track.id} />;
|
||||
case "video":
|
||||
case "image":
|
||||
return <TiledMediaContent element={element} track={track} />;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { MediaAsset } from "@/lib/media/types";
|
|||
import { storageService } from "@/services/storage/service";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import { waveformCache } from "@/services/waveform-cache/service";
|
||||
import { BatchCommand, RemoveMediaAssetCommand } from "@/lib/commands";
|
||||
|
||||
export class MediaManager {
|
||||
|
|
@ -94,6 +95,8 @@ export class MediaManager {
|
|||
}
|
||||
|
||||
async clearProjectMedia({ projectId }: { projectId: string }): Promise<void> {
|
||||
waveformCache.clearAll();
|
||||
|
||||
this.assets.forEach((asset) => {
|
||||
if (asset.url) {
|
||||
URL.revokeObjectURL(asset.url);
|
||||
|
|
@ -120,6 +123,7 @@ export class MediaManager {
|
|||
|
||||
clearAllAssets(): void {
|
||||
videoCache.clearAll();
|
||||
waveformCache.clearAll();
|
||||
|
||||
this.assets.forEach((asset) => {
|
||||
if (asset.url) {
|
||||
|
|
|
|||
|
|
@ -712,18 +712,31 @@ export class TimelineManager {
|
|||
updates: Partial<TimelineElement>;
|
||||
}>;
|
||||
}): void {
|
||||
let changedOverlayCount = 0;
|
||||
for (const { elementId, updates: elementUpdates } of updates) {
|
||||
const existingOverlay = this.previewOverlay.get(elementId);
|
||||
const mergedOverlay = {
|
||||
...existingOverlay,
|
||||
...elementUpdates,
|
||||
} as Partial<TimelineElement>;
|
||||
this.previewOverlay.set(elementId, mergedOverlay);
|
||||
const changed = Object.entries(elementUpdates).some(([key, value]) => {
|
||||
return !Object.is(
|
||||
existingOverlay?.[key as keyof TimelineElement],
|
||||
value,
|
||||
);
|
||||
});
|
||||
if (changed) {
|
||||
changedOverlayCount += 1;
|
||||
const mergedOverlay = {
|
||||
...existingOverlay,
|
||||
...elementUpdates,
|
||||
} as Partial<TimelineElement>;
|
||||
this.previewOverlay.set(elementId, mergedOverlay);
|
||||
}
|
||||
}
|
||||
const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
|
||||
if (!committedTracks) {
|
||||
return;
|
||||
}
|
||||
if (changedOverlayCount === 0) {
|
||||
return;
|
||||
}
|
||||
this.previewTracks = this.applyPreviewOverlay(committedTracks);
|
||||
this.notify();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,8 +89,8 @@ export function useTimelineDragDrop({
|
|||
const mediaAssets = editor.media.getAssets();
|
||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
||||
return media?.duration != null
|
||||
? Math.round(media.duration * TICKS_PER_SECOND)
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
? Math.round(media.duration * TICKS_PER_SECOND)
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
}
|
||||
return DEFAULT_NEW_ELEMENT_DURATION;
|
||||
},
|
||||
|
|
@ -468,10 +468,10 @@ export function useTimelineDragDrop({
|
|||
});
|
||||
if (!createdAsset) continue;
|
||||
|
||||
const duration =
|
||||
createdAsset.duration != null
|
||||
? Math.round(createdAsset.duration * TICKS_PER_SECOND)
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const duration =
|
||||
createdAsset.duration != null
|
||||
? Math.round(createdAsset.duration * TICKS_PER_SECOND)
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const reuseMainTrackId =
|
||||
|
|
@ -498,9 +498,22 @@ export function useTimelineDragDrop({
|
|||
const trackType: TrackType =
|
||||
createdAsset.type === "audio" ? "audio" : "video";
|
||||
|
||||
let trackId: string | undefined;
|
||||
const startTime = dropTarget?.xPosition ?? currentTime;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: createdAsset.id,
|
||||
mediaType: createdAsset.type,
|
||||
name: createdAsset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
|
||||
if (reuseMainTrackId) {
|
||||
trackId = reuseMainTrackId;
|
||||
editor.command.execute({
|
||||
command: new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: reuseMainTrackId },
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
if (!dropTarget) continue;
|
||||
if (dropTarget.isNewTrack) {
|
||||
|
|
@ -508,36 +521,33 @@ export function useTimelineDragDrop({
|
|||
trackType,
|
||||
dropTarget.trackIndex,
|
||||
);
|
||||
trackId = addTrackCmd.getTrackId();
|
||||
editor.command.execute({ command: addTrackCmd });
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([
|
||||
addTrackCmd,
|
||||
new InsertElementCommand({
|
||||
element,
|
||||
placement: {
|
||||
mode: "explicit",
|
||||
trackId: addTrackCmd.getTrackId(),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
trackId = [
|
||||
const trackId = [
|
||||
...sceneTracks.overlay,
|
||||
sceneTracks.main,
|
||||
...sceneTracks.audio,
|
||||
][dropTarget.trackIndex]?.id;
|
||||
if (!trackId) continue;
|
||||
editor.command.execute({
|
||||
command: new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId },
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!trackId) continue;
|
||||
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: createdAsset.id,
|
||||
mediaType: createdAsset.type,
|
||||
name: createdAsset.name,
|
||||
duration,
|
||||
startTime: dropTarget?.xPosition ?? currentTime,
|
||||
buffer:
|
||||
createdAsset.type === "audio"
|
||||
? new AudioBuffer({ length: 1, sampleRate: 44100 })
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId },
|
||||
});
|
||||
editor.command.execute({ command: insertCmd });
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -10,4 +10,10 @@ changes:
|
|||
- type: new
|
||||
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
|
||||
- type: fixed
|
||||
text: "Preview panning now keeps working while the Masks tab is open."
|
||||
text: "Preview panning now keeps working while the Masks tab is open."
|
||||
- type: new
|
||||
text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position."
|
||||
- type: improved
|
||||
text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top."
|
||||
- type: fixed
|
||||
text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio."
|
||||
|
|
@ -1,9 +1,23 @@
|
|||
import type { EditorSelectionPatch } from "@/lib/selection/editor-selection";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
export interface CommandResult {
|
||||
selection?: EditorSelectionPatch;
|
||||
}
|
||||
|
||||
export function createElementSelectionResult(
|
||||
selectedElements: ElementRef[],
|
||||
): CommandResult {
|
||||
return {
|
||||
selection: {
|
||||
selectedElements,
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class Command {
|
||||
abstract execute(): CommandResult | undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class BatchCommand extends Command {
|
|||
|
||||
for (const command of this.commands) {
|
||||
const result = command.execute();
|
||||
if (result?.select !== undefined) {
|
||||
if (result?.selection !== undefined) {
|
||||
latestSelectionResult = result;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export class BatchCommand extends Command {
|
|||
|
||||
for (const command of this.commands) {
|
||||
const result = command.redo();
|
||||
if (result?.select !== undefined) {
|
||||
if (result?.selection !== undefined) {
|
||||
latestSelectionResult = result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { buildWaveformSourceKey } from "@/lib/media/waveform-summary";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import { waveformCache } from "@/services/waveform-cache/service";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { SceneTracks } from "@/lib/timeline";
|
||||
|
||||
|
|
@ -41,6 +43,12 @@ export class RemoveMediaAssetCommand extends Command {
|
|||
}
|
||||
|
||||
videoCache.clearVideo({ mediaId: this.assetId });
|
||||
waveformCache.clearSource({
|
||||
sourceKey: buildWaveformSourceKey({
|
||||
kind: "media",
|
||||
id: this.assetId,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.media.setAssets({
|
||||
assets: assets.filter((media) => media.id !== this.assetId),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import {
|
||||
Command,
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||
import type { ElementClipboardItem } from "@/lib/clipboard";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { applyPlacement, resolveTrackPlacement, enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||
import {
|
||||
applyPlacement,
|
||||
resolveTrackPlacement,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/placement";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
|
|
@ -122,7 +130,7 @@ export class PasteCommand extends Command {
|
|||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
if (this.pastedElements.length > 0) {
|
||||
return { select: this.pastedElements };
|
||||
return createElementSelectionResult(this.pastedElements);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -183,4 +191,3 @@ function buildPastedElements({
|
|||
|
||||
return elementsToAdd;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import {
|
||||
Command,
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/lib/commands/base-command";
|
||||
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
import {
|
||||
applyPlacement,
|
||||
resolveTrackPlacement,
|
||||
} from "@/lib/timeline/placement";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
|
|
@ -91,9 +98,7 @@ export class DuplicateElementsCommand extends Command {
|
|||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
if (this.duplicatedElements.length > 0) {
|
||||
return {
|
||||
select: this.duplicatedElements,
|
||||
};
|
||||
return createElementSelectionResult(this.duplicatedElements);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { EditorCore } from "@/core";
|
|||
import type {
|
||||
CreateTimelineElement,
|
||||
SceneTracks,
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
TrackType,
|
||||
} from "@/lib/timeline";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import {
|
||||
Command,
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type {
|
||||
SceneTracks,
|
||||
|
|
@ -114,12 +118,12 @@ export class MoveElementCommand extends Command {
|
|||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return {
|
||||
select: this.moves.map(({ elementId, targetTrackId }) => ({
|
||||
return createElementSelectionResult(
|
||||
this.moves.map(({ elementId, targetTrackId }) => ({
|
||||
trackId: targetTrackId,
|
||||
elementId,
|
||||
})),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import {
|
||||
Command,
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/lib/commands/base-command";
|
||||
import type { SceneTracks, TimelineElement } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
|
|
@ -37,7 +41,9 @@ export class SplitElementsCommand extends Command {
|
|||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
this.rightSideElements = [];
|
||||
|
||||
const splitTrack = <TTrack extends { id: string; elements: TimelineElement[] }>(
|
||||
const splitTrack = <
|
||||
TTrack extends { id: string; elements: TimelineElement[] },
|
||||
>(
|
||||
track: TTrack,
|
||||
): TTrack => {
|
||||
const elementsToSplit = this.elements.filter(
|
||||
|
|
@ -48,7 +54,7 @@ export class SplitElementsCommand extends Command {
|
|||
return track;
|
||||
}
|
||||
|
||||
let elements = track.elements.flatMap((element) => {
|
||||
const elements = track.elements.flatMap((element) => {
|
||||
const shouldSplit = elementsToSplit.some(
|
||||
(target) => target.elementId === element.id,
|
||||
);
|
||||
|
|
@ -87,67 +93,67 @@ export class SplitElementsCommand extends Command {
|
|||
splitTime: relativeTime,
|
||||
shouldIncludeSplitBoundary: true,
|
||||
});
|
||||
let splitResult: TimelineElement[];
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
} else if (this.retainSide === "right") {
|
||||
const newId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: newId,
|
||||
});
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: secondElementId,
|
||||
});
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
{
|
||||
...element,
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (this.retainSide === "right") {
|
||||
const newId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: newId,
|
||||
});
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: secondElementId,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
{
|
||||
...element,
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
return splitResult;
|
||||
});
|
||||
|
||||
return { ...track, elements } as TTrack;
|
||||
|
|
@ -162,9 +168,7 @@ export class SplitElementsCommand extends Command {
|
|||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
if (this.rightSideElements.length > 0) {
|
||||
return {
|
||||
select: this.rightSideElements,
|
||||
};
|
||||
return createElementSelectionResult(this.rightSideElements);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import {
|
|||
canExtractSourceAudio,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { buildEmptyTrack } from "@/lib/timeline/placement";
|
||||
import {
|
||||
applyPlacement,
|
||||
resolveTrackPlacement,
|
||||
} from "@/lib/timeline/placement";
|
||||
import { updateElementInSceneTracks } from "@/lib/timeline/track-element-update";
|
||||
import type {
|
||||
AudioTrack,
|
||||
SceneTracks,
|
||||
TimelineElement,
|
||||
VideoElement,
|
||||
|
|
@ -35,9 +37,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
|||
...this.savedState.overlay,
|
||||
this.savedState.main,
|
||||
...this.savedState.audio,
|
||||
].find(
|
||||
(track) => track.id === this.params.trackId,
|
||||
);
|
||||
].find((track) => track.id === this.params.trackId);
|
||||
if (!sourceTrack) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -61,8 +61,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
|||
return;
|
||||
}
|
||||
|
||||
const mediaAsset = editor
|
||||
.media
|
||||
const mediaAsset = editor.media
|
||||
.getAssets()
|
||||
.find((asset) => asset.id === videoElement.mediaId);
|
||||
if (!canExtractSourceAudio(videoElement, mediaAsset)) {
|
||||
|
|
@ -78,20 +77,32 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
|||
}),
|
||||
id: generateUUID(),
|
||||
};
|
||||
const newAudioTrack = {
|
||||
...buildEmptyTrack({
|
||||
id: generateUUID(),
|
||||
type: "audio",
|
||||
}),
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks: this.savedState,
|
||||
trackType: "audio",
|
||||
timeSpans: [
|
||||
{
|
||||
startTime: separatedAudioElement.startTime,
|
||||
duration: separatedAudioElement.duration,
|
||||
},
|
||||
],
|
||||
strategy: { type: "firstAvailable" },
|
||||
});
|
||||
if (!placementResult) {
|
||||
return;
|
||||
}
|
||||
const appliedPlacement = applyPlacement({
|
||||
tracks: this.savedState,
|
||||
placementResult,
|
||||
elements: [separatedAudioElement],
|
||||
} as AudioTrack;
|
||||
});
|
||||
if (!appliedPlacement) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(
|
||||
updateSourceAudioEnabled({
|
||||
tracks: {
|
||||
...this.savedState,
|
||||
audio: [...this.savedState.audio, newAudioTrack],
|
||||
},
|
||||
tracks: appliedPlacement.updatedTracks,
|
||||
trackId: this.params.trackId,
|
||||
elementId: this.params.elementId,
|
||||
isSourceAudioEnabled: false,
|
||||
|
|
@ -124,7 +135,8 @@ function updateSourceAudioEnabled({
|
|||
tracks,
|
||||
trackId,
|
||||
elementId,
|
||||
elementPredicate: (element): element is VideoElement => element.type === "video",
|
||||
elementPredicate: (element): element is VideoElement =>
|
||||
element.type === "video",
|
||||
update: (element) => ({
|
||||
...element,
|
||||
isSourceAudioEnabled,
|
||||
|
|
|
|||
|
|
@ -69,10 +69,7 @@ function buildAudioTrackState({
|
|||
insertIndex: number;
|
||||
trackId: string;
|
||||
}): SceneTracks {
|
||||
const audioInsertIndex = Math.max(
|
||||
0,
|
||||
insertIndex - tracks.overlay.length - 1,
|
||||
);
|
||||
const audioInsertIndex = Math.max(0, insertIndex - tracks.overlay.length - 1);
|
||||
const newTrack = buildEmptyTrack({
|
||||
id: trackId,
|
||||
type: "audio",
|
||||
|
|
|
|||
|
|
@ -20,10 +20,13 @@ import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
|||
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
|
||||
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
import {
|
||||
computeRmsBuckets,
|
||||
type SampleBucket,
|
||||
} from "@/lib/media/waveform-summary";
|
||||
|
||||
const MAX_AUDIO_CHANNELS = 2;
|
||||
const EXPORT_SAMPLE_RATE = 44100;
|
||||
const COARSE_SAMPLE_COUNT = 2048;
|
||||
|
||||
export interface CollectedAudioElement {
|
||||
timelineElement: AudioCapableElement;
|
||||
|
|
@ -177,8 +180,8 @@ export async function collectAudioElements({
|
|||
if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
|
||||
|
||||
pendingElements.push(
|
||||
resolveAudioBufferForVideoElement({
|
||||
mediaAsset,
|
||||
resolveAudioBufferForAsset({
|
||||
asset: mediaAsset,
|
||||
audioContext,
|
||||
}).then((audioBuffer) => {
|
||||
if (!audioBuffer) return null;
|
||||
|
|
@ -222,10 +225,8 @@ async function resolveAudioBufferForElement({
|
|||
try {
|
||||
if (element.sourceType === "upload") {
|
||||
const asset = mediaMap.get(element.mediaId);
|
||||
if (!asset || asset.type !== "audio") return null;
|
||||
|
||||
const arrayBuffer = await asset.file.arrayBuffer();
|
||||
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
|
||||
if (!asset) return null;
|
||||
return await resolveAudioBufferForAsset({ asset, audioContext });
|
||||
}
|
||||
|
||||
if (element.buffer) return element.buffer;
|
||||
|
|
@ -243,15 +244,25 @@ async function resolveAudioBufferForElement({
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveAudioBufferForVideoElement({
|
||||
mediaAsset,
|
||||
async function resolveAudioBufferForAsset({
|
||||
asset,
|
||||
audioContext,
|
||||
}: {
|
||||
mediaAsset: MediaAsset;
|
||||
asset: MediaAsset;
|
||||
audioContext: AudioContext;
|
||||
}): Promise<AudioBuffer | null> {
|
||||
if (asset.type === "audio") {
|
||||
try {
|
||||
const arrayBuffer = await asset.file.arrayBuffer();
|
||||
return await audioContext.decodeAudioData(arrayBuffer.slice(0));
|
||||
} catch (error) {
|
||||
console.warn("Failed to decode audio asset:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const input = new Input({
|
||||
source: new BlobSource(mediaAsset.file),
|
||||
source: new BlobSource(asset.file),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
|
|
@ -319,7 +330,7 @@ async function resolveAudioBufferForVideoElement({
|
|||
|
||||
return await offlineContext.startRendering();
|
||||
} catch (error) {
|
||||
console.warn("Failed to decode video audio:", error);
|
||||
console.warn("Failed to decode asset audio:", error);
|
||||
return null;
|
||||
} finally {
|
||||
input.dispose();
|
||||
|
|
@ -675,51 +686,29 @@ export async function createTimelineAudioBuffer({
|
|||
return await applyAudioMasteringToBuffer({ audioBuffer: outputBuffer });
|
||||
}
|
||||
|
||||
export function computeGlobalMaxRms({
|
||||
buffer,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
}): number {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const step = Math.max(1, Math.floor(buffer.length / COARSE_SAMPLE_COUNT));
|
||||
let globalMax = 0;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = buffer.getChannelData(c);
|
||||
for (let i = 0; i + step <= buffer.length; i += step) {
|
||||
for (let j = i; j < i + step; j++) {
|
||||
const abs = Math.abs(data[j]);
|
||||
if (abs > globalMax) globalMax = abs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return globalMax || 1;
|
||||
}
|
||||
|
||||
export function extractRmsRange({
|
||||
function collectPeakRange({
|
||||
buffer,
|
||||
count,
|
||||
startSample,
|
||||
endSample,
|
||||
globalMax,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
count: number;
|
||||
startSample: number;
|
||||
endSample: number;
|
||||
globalMax: number;
|
||||
}): number[] {
|
||||
}): Float32Array {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const rangeLength = endSample - startSample;
|
||||
const step = Math.max(1, Math.floor(rangeLength / count));
|
||||
const peaks = new Float32Array(count);
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = buffer.getChannelData(c);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const start = startSample + i * step;
|
||||
const end = Math.min(start + step, endSample);
|
||||
const { bucketStart: start, bucketEnd: end } = getSampleBucketRange({
|
||||
startSample,
|
||||
endSample,
|
||||
bucketIndex: i,
|
||||
bucketCount: count,
|
||||
});
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(data[j]);
|
||||
if (abs > peaks[i]) peaks[i] = abs;
|
||||
|
|
@ -727,11 +716,100 @@ export function extractRmsRange({
|
|||
}
|
||||
}
|
||||
|
||||
const norm = 1 / globalMax;
|
||||
const result = new Array<number>(count);
|
||||
for (let i = 0; i < count; i++) result[i] = Math.min(1, peaks[i] * norm);
|
||||
return peaks;
|
||||
}
|
||||
|
||||
return result;
|
||||
export function extractPeakRange({
|
||||
buffer,
|
||||
count,
|
||||
startSample,
|
||||
endSample,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
count: number;
|
||||
startSample: number;
|
||||
endSample: number;
|
||||
}): number[] {
|
||||
return Array.from(
|
||||
collectPeakRange({
|
||||
buffer,
|
||||
count,
|
||||
startSample,
|
||||
endSample,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSampleBucketRange({
|
||||
startSample,
|
||||
endSample,
|
||||
bucketIndex,
|
||||
bucketCount,
|
||||
}: {
|
||||
startSample: number;
|
||||
endSample: number;
|
||||
bucketIndex: number;
|
||||
bucketCount: number;
|
||||
}): {
|
||||
bucketStart: number;
|
||||
bucketEnd: number;
|
||||
} {
|
||||
const rangeLength = Math.max(0, endSample - startSample);
|
||||
const bucketStart =
|
||||
startSample + Math.floor((bucketIndex * rangeLength) / bucketCount);
|
||||
const bucketEnd =
|
||||
startSample + Math.floor(((bucketIndex + 1) * rangeLength) / bucketCount);
|
||||
return {
|
||||
bucketStart,
|
||||
bucketEnd: Math.max(bucketStart, bucketEnd),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractRmsBuckets({
|
||||
buffer,
|
||||
buckets,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
buckets: SampleBucket[];
|
||||
}): number[] {
|
||||
return computeRmsBuckets({ buffer, buckets });
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes per-bucket waveform amplitude using the maximum RMS over a short
|
||||
* analysis window inside each bucket.
|
||||
*
|
||||
* A naive mean-RMS over a whole bucket averages silence together with nearby
|
||||
* sound, which smears transitions (e.g. the onset of speech) across the
|
||||
* bucket and makes the waveform respond late. Taking the max over fixed
|
||||
* short windows (~20 ms) preserves the smooth, non-jittery RMS character
|
||||
* while making transitions land where they actually happen in the audio.
|
||||
*
|
||||
* Channels are combined per-window before taking the max, so the measure
|
||||
* reflects total energy regardless of stereo layout.
|
||||
*/
|
||||
export function extractRmsRange({
|
||||
buffer,
|
||||
count,
|
||||
startSample,
|
||||
endSample,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
count: number;
|
||||
startSample: number;
|
||||
endSample: number;
|
||||
}): number[] {
|
||||
return extractRmsBuckets({
|
||||
buffer,
|
||||
buckets: Array.from({ length: count }, (_, bucketIndex) =>
|
||||
getSampleBucketRange({
|
||||
startSample,
|
||||
endSample,
|
||||
bucketIndex,
|
||||
bucketCount: count,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function mixAudioChannels({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
"use client";
|
||||
|
||||
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
||||
import type { RetimeConfig } from "@/lib/timeline";
|
||||
|
||||
const RMS_ANALYSIS_WINDOW_SECONDS = 0.02;
|
||||
const DEFAULT_SOURCE_WAVEFORM_BUCKET_SIZE = 128;
|
||||
|
||||
function computePeakBuckets({
|
||||
buffer,
|
||||
buckets,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
buckets: SampleBucket[];
|
||||
}): number[] {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const channelData: Float32Array[] = Array.from({ length: channels }, (_, c) =>
|
||||
buffer.getChannelData(c),
|
||||
);
|
||||
|
||||
return buckets.map(({ bucketStart, bucketEnd }) => {
|
||||
let peak = 0;
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = channelData[c];
|
||||
for (let j = bucketStart; j < bucketEnd; j++) {
|
||||
const abs = Math.abs(data[j] ?? 0);
|
||||
if (abs > peak) {
|
||||
peak = abs;
|
||||
}
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
});
|
||||
}
|
||||
|
||||
export interface SampleBucket {
|
||||
bucketStart: number;
|
||||
bucketEnd: number;
|
||||
}
|
||||
|
||||
export interface SourceWaveformSummary {
|
||||
sourceKey: string;
|
||||
sampleRate: number;
|
||||
totalSamples: number;
|
||||
bucketSize: number;
|
||||
amplitudes: Float32Array;
|
||||
}
|
||||
|
||||
export function buildWaveformSourceKey({
|
||||
kind,
|
||||
id,
|
||||
}: {
|
||||
kind: "media" | "library";
|
||||
id: string;
|
||||
}): string {
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
export function buildSourceWaveformSummary({
|
||||
sourceKey,
|
||||
buffer,
|
||||
bucketSize = DEFAULT_SOURCE_WAVEFORM_BUCKET_SIZE,
|
||||
}: {
|
||||
sourceKey: string;
|
||||
buffer: AudioBuffer;
|
||||
bucketSize?: number;
|
||||
}): SourceWaveformSummary {
|
||||
const safeBucketSize = Math.max(1, Math.floor(bucketSize));
|
||||
const bucketCount = Math.max(1, Math.ceil(buffer.length / safeBucketSize));
|
||||
const amplitudes = computePeakBuckets({
|
||||
buffer,
|
||||
buckets: Array.from({ length: bucketCount }, (_, bucketIndex) => {
|
||||
const bucketStart = bucketIndex * safeBucketSize;
|
||||
const bucketEnd = Math.min(buffer.length, bucketStart + safeBucketSize);
|
||||
return { bucketStart, bucketEnd };
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
sourceKey,
|
||||
sampleRate: buffer.sampleRate,
|
||||
totalSamples: buffer.length,
|
||||
bucketSize: safeBucketSize,
|
||||
amplitudes: Float32Array.from(amplitudes),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWaveformSampleBuckets({
|
||||
clipLeftPx,
|
||||
clipRightPx,
|
||||
barCount,
|
||||
pixelsPerSecond,
|
||||
clipDurationSec,
|
||||
sourceStartSec,
|
||||
retime,
|
||||
sampleRate,
|
||||
maxSampleExclusive,
|
||||
barStepPx,
|
||||
}: {
|
||||
clipLeftPx: number;
|
||||
clipRightPx: number;
|
||||
barCount: number;
|
||||
pixelsPerSecond: number;
|
||||
clipDurationSec: number;
|
||||
sourceStartSec: number;
|
||||
retime?: RetimeConfig;
|
||||
sampleRate: number;
|
||||
maxSampleExclusive: number;
|
||||
barStepPx: number;
|
||||
}): SampleBucket[] {
|
||||
return Array.from({ length: barCount }, (_, index) => {
|
||||
const bucketLeftPx = clipLeftPx + index * barStepPx;
|
||||
const bucketRightPx = Math.min(clipRightPx, bucketLeftPx + barStepPx);
|
||||
const clipStartSec = Math.max(
|
||||
0,
|
||||
Math.min(clipDurationSec, bucketLeftPx / pixelsPerSecond),
|
||||
);
|
||||
const clipEndSec = Math.max(
|
||||
clipStartSec,
|
||||
Math.min(clipDurationSec, bucketRightPx / pixelsPerSecond),
|
||||
);
|
||||
const sourceBucketStartSec =
|
||||
sourceStartSec +
|
||||
getSourceTimeAtClipTime({
|
||||
clipTime: clipStartSec,
|
||||
retime,
|
||||
});
|
||||
const sourceBucketEndSec =
|
||||
sourceStartSec +
|
||||
getSourceTimeAtClipTime({
|
||||
clipTime: clipEndSec,
|
||||
retime,
|
||||
});
|
||||
|
||||
return {
|
||||
bucketStart: Math.max(0, Math.floor(sourceBucketStartSec * sampleRate)),
|
||||
bucketEnd: Math.min(
|
||||
maxSampleExclusive,
|
||||
Math.max(0, Math.ceil(sourceBucketEndSec * sampleRate)),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function sampleSourceWaveformSummary({
|
||||
summary,
|
||||
buckets,
|
||||
}: {
|
||||
summary: SourceWaveformSummary;
|
||||
buckets: SampleBucket[];
|
||||
}): number[] {
|
||||
return buckets.map(({ bucketStart, bucketEnd }) => {
|
||||
if (bucketEnd <= bucketStart) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.floor(bucketStart / summary.bucketSize),
|
||||
);
|
||||
const endIndex = Math.min(
|
||||
summary.amplitudes.length,
|
||||
Math.max(startIndex + 1, Math.ceil(bucketEnd / summary.bucketSize)),
|
||||
);
|
||||
|
||||
let maxAmplitude = 0;
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const amplitude = summary.amplitudes[i] ?? 0;
|
||||
if (amplitude > maxAmplitude) {
|
||||
maxAmplitude = amplitude;
|
||||
}
|
||||
}
|
||||
|
||||
return maxAmplitude;
|
||||
});
|
||||
}
|
||||
|
||||
export function computeRmsBuckets({
|
||||
buffer,
|
||||
buckets,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
buckets: SampleBucket[];
|
||||
}): number[] {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const maxWindowLength = Math.max(
|
||||
1,
|
||||
Math.floor(buffer.sampleRate * RMS_ANALYSIS_WINDOW_SECONDS),
|
||||
);
|
||||
|
||||
const channelData: Float32Array[] = new Array(channels);
|
||||
for (let c = 0; c < channels; c++) {
|
||||
channelData[c] = buffer.getChannelData(c);
|
||||
}
|
||||
|
||||
const result = new Array<number>(buckets.length);
|
||||
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const { bucketStart, bucketEnd } = buckets[i];
|
||||
const bucketLength = bucketEnd - bucketStart;
|
||||
if (bucketLength <= 0) {
|
||||
result[i] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const windowLength = Math.max(1, Math.min(bucketLength, maxWindowLength));
|
||||
let maxMeanSquare = 0;
|
||||
|
||||
for (let winStart = bucketStart; winStart < bucketEnd; ) {
|
||||
const winEnd = Math.min(winStart + windowLength, bucketEnd);
|
||||
const n = winEnd - winStart;
|
||||
if (n > 0) {
|
||||
let sum = 0;
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = channelData[c];
|
||||
for (let j = winStart; j < winEnd; j++) {
|
||||
const v = data[j];
|
||||
sum += v * v;
|
||||
}
|
||||
}
|
||||
const meanSquare = sum / (n * channels);
|
||||
if (meanSquare > maxMeanSquare) {
|
||||
maxMeanSquare = meanSquare;
|
||||
}
|
||||
}
|
||||
winStart = winEnd;
|
||||
}
|
||||
|
||||
result[i] = Math.sqrt(maxMeanSquare);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { clampDb } from "./audio-state";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
|
||||
|
||||
const SLIDER_CURVE_EXPONENT = 2;
|
||||
const MIN_DISPLAY_DB = -40;
|
||||
const WAVEFORM_BAR_EXPONENT = 1.5;
|
||||
const MIN_LINEAR_GAIN = 10 ** (VOLUME_DB_MIN / 20);
|
||||
const MAX_LINEAR_GAIN = 10 ** (VOLUME_DB_MAX / 20);
|
||||
const LINEAR_GAIN_RANGE = MAX_LINEAR_GAIN - MIN_LINEAR_GAIN;
|
||||
|
||||
function getNormalizedGainFromDb({ db }: { db: number }): number {
|
||||
const clampedDb = clampDb(db);
|
||||
const linearGain = 10 ** (clampedDb / 20);
|
||||
return (linearGain - MIN_LINEAR_GAIN) / LINEAR_GAIN_RANGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the clip's volume setting to the line position. The curve is defined in
|
||||
* linear gain space so dragging near 0 dB is precise while mute compresses into
|
||||
* the bottom of the clip.
|
||||
*/
|
||||
export function getLinePosFromDb({ db }: { db: number }): number {
|
||||
const normalizedGain = Math.max(
|
||||
0,
|
||||
Math.min(1, getNormalizedGainFromDb({ db })),
|
||||
);
|
||||
const progress = normalizedGain ** (1 / SLIDER_CURVE_EXPONENT);
|
||||
return (1 - progress) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of getLinePosFromDb. Converts a drag position back into the clip's
|
||||
* volume setting without depending on the underlying audio content.
|
||||
*/
|
||||
export function getDbFromLinePos({ percent }: { percent: number }): number {
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
const progress = 1 - clampedPercent / 100;
|
||||
const normalizedGain = progress ** SLIDER_CURVE_EXPONENT;
|
||||
const linearGain = MIN_LINEAR_GAIN + normalizedGain * LINEAR_GAIN_RANGE;
|
||||
return clampDb(20 * Math.log10(linearGain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an output amplitude (raw sample amplitude × gain) to a visible waveform
|
||||
* height fraction using a dB scale. Agnostic to whether the input is peak or
|
||||
* RMS — the caller decides which measure feeds this function; the mapping
|
||||
* curve is the same either way.
|
||||
*
|
||||
* The log scale keeps quiet content visible while reserving the top of the
|
||||
* element for amplitudes that approach 0 dBFS.
|
||||
*/
|
||||
export function getBarFractionFromOutputAmplitude({
|
||||
outputAmplitude,
|
||||
}: {
|
||||
outputAmplitude: number;
|
||||
}): number {
|
||||
if (outputAmplitude <= 0) return 0;
|
||||
const db = 20 * Math.log10(outputAmplitude);
|
||||
if (db <= MIN_DISPLAY_DB) return 0;
|
||||
return Math.min(
|
||||
1,
|
||||
((db - MIN_DISPLAY_DB) / -MIN_DISPLAY_DB) ** WAVEFORM_BAR_EXPONENT,
|
||||
);
|
||||
}
|
||||
|
|
@ -44,14 +44,34 @@ export function computeGroupResize({
|
|||
? minimumDeltaTime
|
||||
: Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
|
||||
|
||||
// Snap the drag delta to a frame exactly once, then derive every patch
|
||||
// field from that single snapped value. This keeps the invariant
|
||||
// `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
|
||||
// delta is added on one side of the element and removed from the other,
|
||||
// so the rounding cancels by construction. Per-field rounding (the old
|
||||
// approach) couldn't preserve this because the individual rounds don't
|
||||
// compose when `sourceDuration` isn't frame-aligned.
|
||||
const snappedDeltaTime =
|
||||
roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime;
|
||||
// Re-clamp after rounding. Bounds derived from other elements are
|
||||
// frame-aligned, so this is normally a no-op; at the source-extent limit
|
||||
// the bound may not be frame-aligned, and honouring the bound takes
|
||||
// precedence over frame alignment (you can't extend past real content).
|
||||
const finalDeltaTime =
|
||||
minimumDeltaTime > maximumDeltaTime
|
||||
? minimumDeltaTime
|
||||
: Math.min(
|
||||
maximumDeltaTime,
|
||||
Math.max(minimumDeltaTime, snappedDeltaTime),
|
||||
);
|
||||
|
||||
return {
|
||||
deltaTime: Object.is(clampedDeltaTime, -0) ? 0 : clampedDeltaTime,
|
||||
deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
|
||||
updates: members.map((member) =>
|
||||
buildResizeUpdate({
|
||||
member,
|
||||
side,
|
||||
deltaTime: clampedDeltaTime,
|
||||
fps,
|
||||
deltaTime: finalDeltaTime,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
|
@ -61,98 +81,37 @@ function buildResizeUpdate({
|
|||
member,
|
||||
side,
|
||||
deltaTime,
|
||||
fps,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
side: ResizeSide;
|
||||
deltaTime: number;
|
||||
fps: ComputeGroupResizeArgs["fps"];
|
||||
}): GroupResizeUpdate {
|
||||
const totalSourceDuration = getSourceDuration({ member });
|
||||
const sourceDelta = getSourceDeltaForClipDelta({
|
||||
member,
|
||||
clipDelta: deltaTime,
|
||||
});
|
||||
const visibleSourceSpan = getVisibleSourceSpanForDuration({
|
||||
member,
|
||||
duration: member.duration,
|
||||
});
|
||||
|
||||
if (side === "left") {
|
||||
if (deltaTime < 0 && member.sourceDuration == null) {
|
||||
const rawStartTime = member.startTime + deltaTime;
|
||||
const rawDuration = member.duration - deltaTime;
|
||||
return {
|
||||
trackId: member.trackId,
|
||||
elementId: member.elementId,
|
||||
patch: {
|
||||
trimStart: Math.max(0, member.trimStart + sourceDelta),
|
||||
trimEnd: member.trimEnd,
|
||||
startTime:
|
||||
roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
|
||||
duration:
|
||||
roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextTrimStart = Math.max(0, member.trimStart + sourceDelta);
|
||||
const nextVisibleSourceSpan = Math.max(
|
||||
0,
|
||||
totalSourceDuration - nextTrimStart - member.trimEnd,
|
||||
);
|
||||
const rawDuration = getDurationForVisibleSourceSpan({
|
||||
member,
|
||||
sourceSpan: nextVisibleSourceSpan,
|
||||
});
|
||||
const nextDuration =
|
||||
roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration;
|
||||
const rawStartTime = member.startTime + (member.duration - nextDuration);
|
||||
return {
|
||||
trackId: member.trackId,
|
||||
elementId: member.elementId,
|
||||
patch: {
|
||||
trimStart:
|
||||
roundToFrame({ time: nextTrimStart, rate: fps }) ?? nextTrimStart,
|
||||
trimStart: Math.max(0, member.trimStart + sourceDelta),
|
||||
trimEnd: member.trimEnd,
|
||||
startTime:
|
||||
roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
|
||||
duration: nextDuration,
|
||||
startTime: member.startTime + deltaTime,
|
||||
duration: member.duration - deltaTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextVisibleSourceSpan = Math.max(0, visibleSourceSpan + sourceDelta);
|
||||
if (deltaTime > 0 && member.sourceDuration == null) {
|
||||
const rawDuration = member.duration + deltaTime;
|
||||
return {
|
||||
trackId: member.trackId,
|
||||
elementId: member.elementId,
|
||||
patch: {
|
||||
trimStart: member.trimStart,
|
||||
trimEnd: Math.max(0, member.trimEnd - sourceDelta),
|
||||
startTime: member.startTime,
|
||||
duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextTrimEnd = Math.max(
|
||||
0,
|
||||
totalSourceDuration - member.trimStart - nextVisibleSourceSpan,
|
||||
);
|
||||
const rawDuration = getDurationForVisibleSourceSpan({
|
||||
member,
|
||||
sourceSpan: nextVisibleSourceSpan,
|
||||
});
|
||||
return {
|
||||
trackId: member.trackId,
|
||||
elementId: member.elementId,
|
||||
patch: {
|
||||
trimStart: member.trimStart,
|
||||
trimEnd: roundToFrame({ time: nextTrimEnd, rate: fps }) ?? nextTrimEnd,
|
||||
trimEnd: Math.max(0, member.trimEnd - sourceDelta),
|
||||
startTime: member.startTime,
|
||||
duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
|
||||
duration: member.duration + deltaTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { createAudioContext } from "@/lib/media/audio";
|
||||
import {
|
||||
buildSourceWaveformSummary,
|
||||
type SourceWaveformSummary,
|
||||
} from "@/lib/media/waveform-summary";
|
||||
|
||||
interface GetSourceWaveformSummaryArgs {
|
||||
sourceKey: string;
|
||||
audioBuffer?: AudioBuffer;
|
||||
sourceFile?: File;
|
||||
audioUrl?: string;
|
||||
}
|
||||
|
||||
export class WaveformCache {
|
||||
private summaries = new Map<string, Promise<SourceWaveformSummary>>();
|
||||
|
||||
getSourceSummary({
|
||||
sourceKey,
|
||||
audioBuffer,
|
||||
sourceFile,
|
||||
audioUrl,
|
||||
}: GetSourceWaveformSummaryArgs): Promise<SourceWaveformSummary> {
|
||||
const existing = this.summaries.get(sourceKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = this.buildSummary({
|
||||
sourceKey,
|
||||
audioBuffer,
|
||||
sourceFile,
|
||||
audioUrl,
|
||||
}).catch((error) => {
|
||||
this.summaries.delete(sourceKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
this.summaries.set(sourceKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
clearSource({ sourceKey }: { sourceKey: string }): void {
|
||||
this.summaries.delete(sourceKey);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.summaries.clear();
|
||||
}
|
||||
|
||||
private async buildSummary({
|
||||
sourceKey,
|
||||
audioBuffer,
|
||||
sourceFile,
|
||||
audioUrl,
|
||||
}: GetSourceWaveformSummaryArgs): Promise<SourceWaveformSummary> {
|
||||
if (audioBuffer) {
|
||||
return buildSourceWaveformSummary({ sourceKey, buffer: audioBuffer });
|
||||
}
|
||||
|
||||
let arrayBuffer: ArrayBuffer | null = null;
|
||||
if (sourceFile) {
|
||||
arrayBuffer = await sourceFile.arrayBuffer();
|
||||
} else if (audioUrl) {
|
||||
const response = await fetch(audioUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch waveform source: ${response.status}`);
|
||||
}
|
||||
arrayBuffer = await response.arrayBuffer();
|
||||
}
|
||||
|
||||
if (!arrayBuffer) {
|
||||
throw new Error(`No waveform source available for ${sourceKey}`);
|
||||
}
|
||||
|
||||
const audioContext = createAudioContext();
|
||||
try {
|
||||
const buffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
|
||||
return buildSourceWaveformSummary({ sourceKey, buffer });
|
||||
} finally {
|
||||
void audioContext.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const waveformCache = new WaveformCache();
|
||||
|
|
@ -1,261 +1,251 @@
|
|||
import { create } from "zustand";
|
||||
import type { SoundEffect, SavedSound } from "@/lib/sounds/types";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { toast } from "sonner";
|
||||
import { EditorCore } from "@/core";
|
||||
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
interface SoundsStore {
|
||||
topSoundEffects: SoundEffect[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasLoaded: boolean;
|
||||
showCommercialOnly: boolean;
|
||||
toggleCommercialFilter: () => void;
|
||||
searchQuery: string;
|
||||
searchResults: SoundEffect[];
|
||||
isSearching: boolean;
|
||||
searchError: string | null;
|
||||
lastSearchQuery: string;
|
||||
scrollPosition: number;
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
totalCount: number;
|
||||
isLoadingMore: boolean;
|
||||
savedSounds: SavedSound[];
|
||||
isSavedSoundsLoaded: boolean;
|
||||
isLoadingSavedSounds: boolean;
|
||||
savedSoundsError: string | null;
|
||||
|
||||
addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise<boolean>;
|
||||
setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void;
|
||||
setLoading: ({ loading }: { loading: boolean }) => void;
|
||||
setError: ({ error }: { error: string | null }) => void;
|
||||
setHasLoaded: ({ loaded }: { loaded: boolean }) => void;
|
||||
setSearchQuery: ({ query }: { query: string }) => void;
|
||||
setSearchResults: ({ results }: { results: SoundEffect[] }) => void;
|
||||
setSearching: ({ searching }: { searching: boolean }) => void;
|
||||
setSearchError: ({ error }: { error: string | null }) => void;
|
||||
setLastSearchQuery: ({ query }: { query: string }) => void;
|
||||
setScrollPosition: ({ position }: { position: number }) => void;
|
||||
setCurrentPage: ({ page }: { page: number }) => void;
|
||||
setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void;
|
||||
setTotalCount: ({ count }: { count: number }) => void;
|
||||
setLoadingMore: ({ loading }: { loading: boolean }) => void;
|
||||
appendSearchResults: ({ results }: { results: SoundEffect[] }) => void;
|
||||
appendTopSounds: ({ results }: { results: SoundEffect[] }) => void;
|
||||
resetPagination: () => void;
|
||||
loadSavedSounds: () => Promise<void>;
|
||||
saveSoundEffect: ({
|
||||
soundEffect,
|
||||
}: {
|
||||
soundEffect: SoundEffect;
|
||||
}) => Promise<void>;
|
||||
removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>;
|
||||
isSoundSaved: ({ soundId }: { soundId: number }) => boolean;
|
||||
toggleSavedSound: ({
|
||||
soundEffect,
|
||||
}: {
|
||||
soundEffect: SoundEffect;
|
||||
}) => Promise<void>;
|
||||
clearSavedSounds: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||
topSoundEffects: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
showCommercialOnly: true,
|
||||
|
||||
toggleCommercialFilter: () => {
|
||||
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
|
||||
},
|
||||
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
searchError: null,
|
||||
lastSearchQuery: "",
|
||||
scrollPosition: 0,
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
savedSounds: [],
|
||||
isSavedSoundsLoaded: false,
|
||||
isLoadingSavedSounds: false,
|
||||
savedSoundsError: null,
|
||||
|
||||
setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }),
|
||||
setLoading: ({ loading }) => set({ isLoading: loading }),
|
||||
setError: ({ error }) => set({ error }),
|
||||
setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }),
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
setSearchResults: ({ results }) =>
|
||||
set({ searchResults: results, currentPage: 1 }),
|
||||
setSearching: ({ searching }) => set({ isSearching: searching }),
|
||||
setSearchError: ({ error }) => set({ searchError: error }),
|
||||
setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }),
|
||||
setScrollPosition: ({ position }) => set({ scrollPosition: position }),
|
||||
setCurrentPage: ({ page }) => set({ currentPage: page }),
|
||||
setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }),
|
||||
setTotalCount: ({ count }) => set({ totalCount: count }),
|
||||
setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }),
|
||||
|
||||
appendSearchResults: ({ results }) =>
|
||||
set((state) => ({
|
||||
searchResults: [...state.searchResults, ...results],
|
||||
})),
|
||||
|
||||
appendTopSounds: ({ results }) =>
|
||||
set((state) => ({
|
||||
topSoundEffects: [...state.topSoundEffects, ...results],
|
||||
})),
|
||||
|
||||
resetPagination: () =>
|
||||
set({
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
}),
|
||||
|
||||
loadSavedSounds: async () => {
|
||||
if (get().isSavedSoundsLoaded) return;
|
||||
|
||||
try {
|
||||
set({ isLoadingSavedSounds: true, savedSoundsError: null });
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({
|
||||
savedSounds: savedSoundsData.sounds,
|
||||
isSavedSoundsLoaded: true,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to load saved sounds";
|
||||
set({
|
||||
savedSoundsError: errorMessage,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
console.error("Failed to load saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
saveSoundEffect: async ({ soundEffect }) => {
|
||||
try {
|
||||
await storageService.saveSoundEffect({ soundEffect });
|
||||
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({ savedSounds: savedSoundsData.sounds });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to save sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to save sound");
|
||||
console.error("Failed to save sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
removeSavedSound: async ({ soundId }) => {
|
||||
try {
|
||||
await storageService.removeSavedSound({ soundId });
|
||||
|
||||
set((state) => ({
|
||||
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to remove sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to remove sound");
|
||||
console.error("Failed to remove sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
isSoundSaved: ({ soundId }) => {
|
||||
const { savedSounds } = get();
|
||||
return savedSounds.some((sound) => sound.id === soundId);
|
||||
},
|
||||
|
||||
toggleSavedSound: async ({ soundEffect }) => {
|
||||
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
|
||||
|
||||
if (isSoundSaved({ soundId: soundEffect.id })) {
|
||||
await removeSavedSound({ soundId: soundEffect.id });
|
||||
} else {
|
||||
await saveSoundEffect({ soundEffect });
|
||||
}
|
||||
},
|
||||
|
||||
clearSavedSounds: async () => {
|
||||
try {
|
||||
await storageService.clearSavedSounds();
|
||||
set({
|
||||
savedSounds: [],
|
||||
savedSoundsError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to clear saved sounds";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to clear saved sounds");
|
||||
console.error("Failed to clear saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
addSoundToTimeline: async ({ sound }) => {
|
||||
const audioUrl = sound.previewUrl;
|
||||
if (!audioUrl) {
|
||||
toast.error("Sound file not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const editor = EditorCore.getInstance();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
const response = await fetch(audioUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to download audio: ${response.statusText}`);
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioContext = new AudioContext();
|
||||
const buffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
const audioTrack = tracks.audio[0];
|
||||
let trackId: string;
|
||||
|
||||
if (audioTrack) {
|
||||
trackId = audioTrack.id;
|
||||
} else {
|
||||
trackId = editor.timeline.addTrack({ type: "audio" });
|
||||
}
|
||||
|
||||
const element = buildLibraryAudioElement({
|
||||
sourceUrl: audioUrl,
|
||||
name: sound.name,
|
||||
duration: sound.duration,
|
||||
startTime: currentTime,
|
||||
buffer,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to add sound to timeline:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to add sound to timeline",
|
||||
{ id: `sound-${sound.id}` },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
import { create } from "zustand";
|
||||
import type { SoundEffect, SavedSound } from "@/lib/sounds/types";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { toast } from "sonner";
|
||||
import { EditorCore } from "@/core";
|
||||
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
interface SoundsStore {
|
||||
topSoundEffects: SoundEffect[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasLoaded: boolean;
|
||||
showCommercialOnly: boolean;
|
||||
toggleCommercialFilter: () => void;
|
||||
searchQuery: string;
|
||||
searchResults: SoundEffect[];
|
||||
isSearching: boolean;
|
||||
searchError: string | null;
|
||||
lastSearchQuery: string;
|
||||
scrollPosition: number;
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
totalCount: number;
|
||||
isLoadingMore: boolean;
|
||||
savedSounds: SavedSound[];
|
||||
isSavedSoundsLoaded: boolean;
|
||||
isLoadingSavedSounds: boolean;
|
||||
savedSoundsError: string | null;
|
||||
|
||||
addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise<boolean>;
|
||||
setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void;
|
||||
setLoading: ({ loading }: { loading: boolean }) => void;
|
||||
setError: ({ error }: { error: string | null }) => void;
|
||||
setHasLoaded: ({ loaded }: { loaded: boolean }) => void;
|
||||
setSearchQuery: ({ query }: { query: string }) => void;
|
||||
setSearchResults: ({ results }: { results: SoundEffect[] }) => void;
|
||||
setSearching: ({ searching }: { searching: boolean }) => void;
|
||||
setSearchError: ({ error }: { error: string | null }) => void;
|
||||
setLastSearchQuery: ({ query }: { query: string }) => void;
|
||||
setScrollPosition: ({ position }: { position: number }) => void;
|
||||
setCurrentPage: ({ page }: { page: number }) => void;
|
||||
setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void;
|
||||
setTotalCount: ({ count }: { count: number }) => void;
|
||||
setLoadingMore: ({ loading }: { loading: boolean }) => void;
|
||||
appendSearchResults: ({ results }: { results: SoundEffect[] }) => void;
|
||||
appendTopSounds: ({ results }: { results: SoundEffect[] }) => void;
|
||||
resetPagination: () => void;
|
||||
loadSavedSounds: () => Promise<void>;
|
||||
saveSoundEffect: ({
|
||||
soundEffect,
|
||||
}: {
|
||||
soundEffect: SoundEffect;
|
||||
}) => Promise<void>;
|
||||
removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>;
|
||||
isSoundSaved: ({ soundId }: { soundId: number }) => boolean;
|
||||
toggleSavedSound: ({
|
||||
soundEffect,
|
||||
}: {
|
||||
soundEffect: SoundEffect;
|
||||
}) => Promise<void>;
|
||||
clearSavedSounds: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||
topSoundEffects: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
showCommercialOnly: true,
|
||||
|
||||
toggleCommercialFilter: () => {
|
||||
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
|
||||
},
|
||||
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
searchError: null,
|
||||
lastSearchQuery: "",
|
||||
scrollPosition: 0,
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
savedSounds: [],
|
||||
isSavedSoundsLoaded: false,
|
||||
isLoadingSavedSounds: false,
|
||||
savedSoundsError: null,
|
||||
|
||||
setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }),
|
||||
setLoading: ({ loading }) => set({ isLoading: loading }),
|
||||
setError: ({ error }) => set({ error }),
|
||||
setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }),
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
setSearchResults: ({ results }) =>
|
||||
set({ searchResults: results, currentPage: 1 }),
|
||||
setSearching: ({ searching }) => set({ isSearching: searching }),
|
||||
setSearchError: ({ error }) => set({ searchError: error }),
|
||||
setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }),
|
||||
setScrollPosition: ({ position }) => set({ scrollPosition: position }),
|
||||
setCurrentPage: ({ page }) => set({ currentPage: page }),
|
||||
setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }),
|
||||
setTotalCount: ({ count }) => set({ totalCount: count }),
|
||||
setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }),
|
||||
|
||||
appendSearchResults: ({ results }) =>
|
||||
set((state) => ({
|
||||
searchResults: [...state.searchResults, ...results],
|
||||
})),
|
||||
|
||||
appendTopSounds: ({ results }) =>
|
||||
set((state) => ({
|
||||
topSoundEffects: [...state.topSoundEffects, ...results],
|
||||
})),
|
||||
|
||||
resetPagination: () =>
|
||||
set({
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
}),
|
||||
|
||||
loadSavedSounds: async () => {
|
||||
if (get().isSavedSoundsLoaded) return;
|
||||
|
||||
try {
|
||||
set({ isLoadingSavedSounds: true, savedSoundsError: null });
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({
|
||||
savedSounds: savedSoundsData.sounds,
|
||||
isSavedSoundsLoaded: true,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to load saved sounds";
|
||||
set({
|
||||
savedSoundsError: errorMessage,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
console.error("Failed to load saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
saveSoundEffect: async ({ soundEffect }) => {
|
||||
try {
|
||||
await storageService.saveSoundEffect({ soundEffect });
|
||||
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({ savedSounds: savedSoundsData.sounds });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to save sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to save sound");
|
||||
console.error("Failed to save sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
removeSavedSound: async ({ soundId }) => {
|
||||
try {
|
||||
await storageService.removeSavedSound({ soundId });
|
||||
|
||||
set((state) => ({
|
||||
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to remove sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to remove sound");
|
||||
console.error("Failed to remove sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
isSoundSaved: ({ soundId }) => {
|
||||
const { savedSounds } = get();
|
||||
return savedSounds.some((sound) => sound.id === soundId);
|
||||
},
|
||||
|
||||
toggleSavedSound: async ({ soundEffect }) => {
|
||||
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
|
||||
|
||||
if (isSoundSaved({ soundId: soundEffect.id })) {
|
||||
await removeSavedSound({ soundId: soundEffect.id });
|
||||
} else {
|
||||
await saveSoundEffect({ soundEffect });
|
||||
}
|
||||
},
|
||||
|
||||
clearSavedSounds: async () => {
|
||||
try {
|
||||
await storageService.clearSavedSounds();
|
||||
set({
|
||||
savedSounds: [],
|
||||
savedSoundsError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to clear saved sounds";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to clear saved sounds");
|
||||
console.error("Failed to clear saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
addSoundToTimeline: async ({ sound }) => {
|
||||
const audioUrl = sound.previewUrl;
|
||||
if (!audioUrl) {
|
||||
toast.error("Sound file not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const editor = EditorCore.getInstance();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
|
||||
const response = await fetch(audioUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to download audio: ${response.statusText}`);
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioContext = new AudioContext();
|
||||
const buffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
const element = buildLibraryAudioElement({
|
||||
sourceUrl: audioUrl,
|
||||
name: sound.name,
|
||||
duration: sound.duration,
|
||||
startTime: currentTime,
|
||||
buffer,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "auto", trackType: "audio" },
|
||||
element,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to add sound to timeline:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to add sound to timeline",
|
||||
{ id: `sound-${sound.id}` },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export function AudioProperties() {
|
||||
return <div className="space-y-4 p-5">Audio properties</div>;
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Effect } from "@/types/effects";
|
||||
import type { VisualElement } from "@/types/timeline";
|
||||
import { getEffect } from "@/lib/effects/registry";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePropertiesStore } from "@/stores/properties-store";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
SectionFields,
|
||||
} from "./section";
|
||||
import { EffectParamField } from "./effect-param-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowLeft01Icon,
|
||||
Delete02Icon,
|
||||
ViewIcon,
|
||||
ViewOffSlashIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/utils/ui";
|
||||
export function ClipEffectsProperties({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const closeClipEffects = usePropertiesStore(
|
||||
(state) => state.closeClipEffects,
|
||||
);
|
||||
const editor = useEditor();
|
||||
const effects = element.effects ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (effects.length === 0) closeClipEffects();
|
||||
}, [effects.length, closeClipEffects]);
|
||||
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
|
||||
const handleDragStart = ({ index }: { index: number }) => {
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
|
||||
event.preventDefault();
|
||||
if (index !== dropIndex) setDropIndex(index);
|
||||
};
|
||||
|
||||
const handleDrop = ({ toIndex }: { toIndex: number }) => {
|
||||
if (dragIndex !== null && dragIndex !== toIndex) {
|
||||
editor.timeline.reorderClipEffects({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
fromIndex: dragIndex,
|
||||
toIndex,
|
||||
});
|
||||
}
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDragIndex(null);
|
||||
setDropIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeClipEffects}
|
||||
aria-label="Back to properties"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} />
|
||||
</Button>
|
||||
<span className="text-sm font-medium">Effects</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 scrollbar-hidden">
|
||||
{effects.map((effect, index) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
|
||||
<div
|
||||
key={effect.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart({ index })}
|
||||
onDragOver={(event) => handleDragOver({ event, index })}
|
||||
onDrop={() => handleDrop({ toIndex: index })}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
"group",
|
||||
dragIndex === index && "opacity-40",
|
||||
dropIndex === index &&
|
||||
dragIndex !== null &&
|
||||
dragIndex !== index &&
|
||||
(index < dragIndex
|
||||
? "border-t-2 border-primary"
|
||||
: "border-b-2 border-primary"),
|
||||
)}
|
||||
>
|
||||
<ClipEffectSection
|
||||
effect={effect}
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClipEffectSection({
|
||||
effect,
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
effect: Effect;
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
|
||||
const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
|
||||
const updatedEffects = (element.effects ?? []).map((existing) =>
|
||||
existing.id !== effect.id
|
||||
? existing
|
||||
: { ...existing, params: { ...existing.params, [key]: value } },
|
||||
);
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { effects: updatedEffects },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commitParam = () => editor.timeline.commitPreview();
|
||||
|
||||
const toggleEffect = () =>
|
||||
editor.timeline.toggleClipEffect({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
effectId: effect.id,
|
||||
});
|
||||
|
||||
const removeEffect = () =>
|
||||
editor.timeline.removeClipEffect({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
effectId: effect.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
|
||||
<SectionHeader
|
||||
className="cursor-move"
|
||||
trailing={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={effect.enabled ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-label={`Toggle ${definition.name}`}
|
||||
onClick={toggleEffect}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove ${definition.name}`}
|
||||
onClick={removeEffect}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SectionTitle
|
||||
className={cn(!effect.enabled && "text-muted-foreground")}
|
||||
>
|
||||
{definition.name}
|
||||
</SectionTitle>
|
||||
</SectionHeader>
|
||||
{effect.enabled && (
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
{definition.params.map((param) => (
|
||||
<EffectParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={effect.params[param.key] ?? param.default}
|
||||
onPreview={previewParam({ key: param.key })}
|
||||
onCommit={commitParam}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { SectionField } from "./section";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { usePropertyDraft } from "./hooks/use-property-draft";
|
||||
|
||||
export function EffectParamField({
|
||||
param,
|
||||
value,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
param: EffectParamDefinition;
|
||||
value: number | string | boolean;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<SectionField label={param.label}>
|
||||
<EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
|
||||
</SectionField>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectParamInput({
|
||||
param,
|
||||
value,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
param: EffectParamDefinition;
|
||||
value: number | string | boolean;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
}) {
|
||||
if (param.type === "number") {
|
||||
return (
|
||||
<NumberParamField
|
||||
param={param}
|
||||
value={typeof value === "number" ? value : Number(value)}
|
||||
onPreview={onPreview}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) => {
|
||||
onPreview(checked);
|
||||
onCommit();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "select") {
|
||||
return (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(selected) => {
|
||||
onPreview(selected);
|
||||
onCommit();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{param.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return (
|
||||
<input
|
||||
type="color"
|
||||
className="h-8 w-full cursor-pointer rounded border"
|
||||
value={String(value)}
|
||||
onChange={(event) => onPreview(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function NumberParamField({
|
||||
param,
|
||||
value,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
param: NumberEffectParamDefinition;
|
||||
value: number;
|
||||
onPreview: (value: number) => void;
|
||||
onCommit: () => void;
|
||||
}) {
|
||||
const { min, max, step } = param;
|
||||
|
||||
const draft = usePropertyDraft({
|
||||
displayValue: String(value),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min, max });
|
||||
},
|
||||
onPreview,
|
||||
onCommit,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={([newValue]) => onPreview(newValue)}
|
||||
onValueCommit={onCommit}
|
||||
/>
|
||||
<NumberField
|
||||
className="w-16 shrink-0"
|
||||
value={draft.displayValue}
|
||||
onFocus={draft.onFocus}
|
||||
onChange={draft.onChange}
|
||||
onBlur={draft.onBlur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { EffectElement } from "@/types/timeline";
|
||||
import { getEffect } from "@/lib/effects/registry";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionFields,
|
||||
SectionTitle,
|
||||
} from "./section";
|
||||
import { EffectParamField } from "./effect-param-field";
|
||||
|
||||
export function EffectProperties({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const definition = getEffect({ effectType: element.effectType });
|
||||
|
||||
const previewParam =
|
||||
({ key }: { key: string }) =>
|
||||
(value: number | string | boolean) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { params: { ...element.params, [key]: value } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>{definition.name}</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
{definition.params.map((param) => (
|
||||
<EffectParamField
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={element.params[param.key] ?? param.default}
|
||||
onPreview={previewParam({ key: param.key })}
|
||||
onCommit={() => editor.timeline.commitPreview()}
|
||||
/>
|
||||
))}
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { KeyframeIcon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function KeyframeToggle({
|
||||
isActive,
|
||||
isDisabled = false,
|
||||
title,
|
||||
onToggle,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isDisabled?: boolean;
|
||||
title: string;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="text"
|
||||
aria-pressed={isActive}
|
||||
disabled={isDisabled}
|
||||
title={title}
|
||||
onClick={onToggle}
|
||||
className="[&>svg]:size-3.5 mb-0.5"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={KeyframeIcon}
|
||||
className={cn(isActive && "text-primary fill-primary")}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const sectionExpandedCache = new Map<string, boolean>();
|
||||
const mountedSectionKeys = new Set<string>();
|
||||
|
||||
interface SectionContext {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
collapsible: boolean;
|
||||
}
|
||||
|
||||
const SectionCtx = createContext<SectionContext | null>(null);
|
||||
|
||||
function useSectionContext() {
|
||||
return useContext(SectionCtx);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
children: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
sectionKey?: string;
|
||||
className?: string;
|
||||
showTopBorder?: boolean;
|
||||
showBottomBorder?: boolean;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
sectionKey,
|
||||
className,
|
||||
showTopBorder = true,
|
||||
showBottomBorder = true,
|
||||
}: SectionProps) {
|
||||
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
|
||||
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionKey) return;
|
||||
if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) {
|
||||
console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`);
|
||||
}
|
||||
mountedSectionKeys.add(sectionKey);
|
||||
return () => { mountedSectionKeys.delete(sectionKey); };
|
||||
}, [sectionKey]);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !isOpen;
|
||||
setIsOpen(next);
|
||||
if (sectionKey) sectionExpandedCache.set(sectionKey, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
showTopBorder && "border-t",
|
||||
showBottomBorder && "last:border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SectionCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
leading?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
children,
|
||||
trailing,
|
||||
leading,
|
||||
actions,
|
||||
onClick,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
const ctx = useSectionContext();
|
||||
const isCollapsible = ctx?.collapsible ?? false;
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
const isInteractive = isCollapsible || !!onClick;
|
||||
const handleClick = isCollapsible ? ctx?.toggle : onClick;
|
||||
|
||||
const chevronIcon = isCollapsible ? (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDownIcon}
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200 ease-out",
|
||||
isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const headerContent = (
|
||||
<>
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{(trailing || chevronIcon) && (
|
||||
<div className="flex items-center">
|
||||
{trailing}
|
||||
{chevronIcon && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={isOpen ? "Collapse section" : "Expand section"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleClick?.();
|
||||
}}
|
||||
>
|
||||
{chevronIcon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{actions}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!isInteractive) {
|
||||
return (
|
||||
<div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
|
||||
{headerContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex h-11 w-full cursor-pointer items-center gap-2 px-3.5",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }}
|
||||
>
|
||||
{headerContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionTitle({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const ctx = useSectionContext();
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer text-sm font-medium",
|
||||
isOpen ? "text-foreground" : "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isOpen ? "text-foreground" : "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionFields({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionField({
|
||||
label,
|
||||
beforeLabel,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
beforeLabel?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
<div className="flex h-4 items-center gap-1.5">
|
||||
{beforeLabel}
|
||||
<Label>{label}</Label>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useSectionContext();
|
||||
const isCollapsible = ctx?.collapsible ?? false;
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
|
||||
if (isCollapsible) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-100 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className={cn("p-4 pt-0", className)}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn("p-4 pt-0", className)}>{children}</div>;
|
||||
}
|
||||
|
|
@ -1,732 +0,0 @@
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "./section";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TEXT_BACKGROUND,
|
||||
DEFAULT_TEXT_ELEMENT,
|
||||
MAX_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
} from "@/constants/text-constants";
|
||||
import { usePropertyDraft } from "./hooks/use-property-draft";
|
||||
import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property";
|
||||
import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "./hooks/use-element-playhead";
|
||||
import { TransformSection, BlendingSection } from "./sections";
|
||||
import { KeyframeToggle } from "./keyframe-toggle";
|
||||
import { isPropertyAtDefault } from "./sections/transform";
|
||||
import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
TextFontIcon,
|
||||
ViewIcon,
|
||||
ViewOffSlashIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<ContentSection element={element} trackId={trackId} />
|
||||
<TransformSection element={element} trackId={trackId} />
|
||||
<BlendingSection element={element} trackId={trackId} />
|
||||
<TypographySection element={element} trackId={trackId} />
|
||||
<SpacingSection element={element} trackId={trackId} />
|
||||
<BackgroundSection element={element} trackId={trackId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const content = usePropertyDraft({
|
||||
displayValue: element.content,
|
||||
parse: (input) => input,
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { content: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:content" showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Content</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
value={content.displayValue}
|
||||
className="min-h-20"
|
||||
onFocus={content.onFocus}
|
||||
onChange={content.onChange}
|
||||
onBlur={content.onBlur}
|
||||
/>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function TypographySection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedTextColor = resolveColorAtTime({
|
||||
baseColor: element.color,
|
||||
animations: element.animations,
|
||||
propertyPath: "color",
|
||||
localTime,
|
||||
});
|
||||
|
||||
const textColor = useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "color",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor: resolvedTextColor,
|
||||
buildBaseUpdates: ({ value }) => ({ color: value }),
|
||||
});
|
||||
|
||||
const fontSize = usePropertyDraft({
|
||||
displayValue: element.fontSize.toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { fontSize: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:typography">
|
||||
<SectionHeader>
|
||||
<SectionTitle>Typography</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField label="Font">
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Size">
|
||||
<NumberField
|
||||
value={fontSize.displayValue}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
onFocus={fontSize.onFocus}
|
||||
onChange={fontSize.onChange}
|
||||
onBlur={fontSize.onBlur}
|
||||
onScrub={fontSize.scrubTo}
|
||||
onScrubEnd={fontSize.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
|
||||
icon={<HugeiconsIcon icon={TextFontIcon} />}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField
|
||||
label="Color"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={textColor.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle text color keyframe"
|
||||
onToggle={textColor.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: resolvedTextColor.replace("#", ""),
|
||||
})}
|
||||
onChange={(color) => textColor.onChange({ color: `#${color}` })}
|
||||
onChangeEnd={textColor.onChangeEnd}
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SpacingSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const letterSpacing = usePropertyDraft({
|
||||
displayValue: Math.round(
|
||||
element.letterSpacing ?? DEFAULT_LETTER_SPACING,
|
||||
).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.round(parsed);
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { letterSpacing: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
const lineHeight = usePropertyDraft({
|
||||
displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed)
|
||||
? null
|
||||
: Math.max(0.1, Math.round(parsed * 10) / 10);
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { lineHeight: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Spacing</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField label="Letter spacing" className="w-1/2">
|
||||
<NumberField
|
||||
value={letterSpacing.displayValue}
|
||||
onFocus={letterSpacing.onFocus}
|
||||
onChange={letterSpacing.onChange}
|
||||
onBlur={letterSpacing.onBlur}
|
||||
onScrub={letterSpacing.scrubTo}
|
||||
onScrubEnd={letterSpacing.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { letterSpacing: DEFAULT_LETTER_SPACING },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
(element.letterSpacing ?? DEFAULT_LETTER_SPACING) ===
|
||||
DEFAULT_LETTER_SPACING
|
||||
}
|
||||
icon={<OcTextWidthIcon size={14} />}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Line height" className="w-1/2">
|
||||
<NumberField
|
||||
value={lineHeight.displayValue}
|
||||
onFocus={lineHeight.onFocus}
|
||||
onChange={lineHeight.onChange}
|
||||
onBlur={lineHeight.onBlur}
|
||||
onScrub={lineHeight.scrubTo}
|
||||
onScrubEnd={lineHeight.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { lineHeight: DEFAULT_LINE_HEIGHT },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
(element.lineHeight ?? DEFAULT_LINE_HEIGHT) ===
|
||||
DEFAULT_LINE_HEIGHT
|
||||
}
|
||||
icon={<OcTextHeightIcon size={14} />}
|
||||
/>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedBgColor = resolveColorAtTime({
|
||||
baseColor: element.background.color,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.color",
|
||||
localTime,
|
||||
});
|
||||
|
||||
const bgColor = useKeyframedColorProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.color",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedColor: resolvedBgColor,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...element.background, color: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const bg = element.background;
|
||||
|
||||
const resolvedPaddingX = resolveNumberAtTime({
|
||||
baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
});
|
||||
const resolvedPaddingY = resolveNumberAtTime({
|
||||
baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingY",
|
||||
localTime,
|
||||
});
|
||||
const resolvedOffsetX = resolveNumberAtTime({
|
||||
baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetX",
|
||||
localTime,
|
||||
});
|
||||
const resolvedOffsetY = resolveNumberAtTime({
|
||||
baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetY",
|
||||
localTime,
|
||||
});
|
||||
const resolvedCornerRadius = resolveNumberAtTime({
|
||||
baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.cornerRadius",
|
||||
localTime,
|
||||
});
|
||||
|
||||
const paddingX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedPaddingX).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
|
||||
},
|
||||
valueAtPlayhead: resolvedPaddingX,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, paddingX: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const paddingY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingY",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedPaddingY).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
|
||||
},
|
||||
valueAtPlayhead: resolvedPaddingY,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, paddingY: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const offsetX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetX",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOffsetX).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.round(parsed);
|
||||
},
|
||||
valueAtPlayhead: resolvedOffsetX,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, offsetX: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const offsetY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetY",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOffsetY).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : Math.round(parsed);
|
||||
},
|
||||
valueAtPlayhead: resolvedOffsetY,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, offsetY: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const cornerRadius = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.cornerRadius",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedCornerRadius).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX });
|
||||
},
|
||||
valueAtPlayhead: resolvedCornerRadius,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
background: { ...bg, cornerRadius: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const toggleBackgroundEnabled = () => {
|
||||
const enabled = !element.background.enabled;
|
||||
const color =
|
||||
enabled && element.background.color === "transparent"
|
||||
? lastSelectedColor.current
|
||||
: element.background.color;
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
background: {
|
||||
...element.background,
|
||||
enabled,
|
||||
color,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
collapsible
|
||||
defaultOpen={element.background.enabled}
|
||||
sectionKey="text:background"
|
||||
>
|
||||
<SectionHeader
|
||||
trailing={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleBackgroundEnabled();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SectionTitle>Background</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent
|
||||
className={cn(
|
||||
!element.background.enabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Color"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={bgColor.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle background color keyframe"
|
||||
onToggle={bgColor.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ColorPicker
|
||||
value={
|
||||
!element.background.enabled ||
|
||||
element.background.color === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: resolvedBgColor.replace("#", "")
|
||||
}
|
||||
onChange={(color) => {
|
||||
const hexColor = `#${color}`;
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = hexColor;
|
||||
}
|
||||
bgColor.onChange({ color: hexColor });
|
||||
}}
|
||||
onChangeEnd={bgColor.onChangeEnd}
|
||||
/>
|
||||
</SectionField>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField
|
||||
label="Width"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={paddingX.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle background width keyframe"
|
||||
onToggle={paddingX.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="W"
|
||||
value={paddingX.displayValue}
|
||||
min={0}
|
||||
onFocus={paddingX.onFocus}
|
||||
onChange={paddingX.onChange}
|
||||
onBlur={paddingX.onBlur}
|
||||
onScrub={paddingX.scrubTo}
|
||||
onScrubEnd={paddingX.commitScrub}
|
||||
onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedPaddingX,
|
||||
staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField
|
||||
label="Height"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={paddingY.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle background height keyframe"
|
||||
onToggle={paddingY.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="H"
|
||||
value={paddingY.displayValue}
|
||||
min={0}
|
||||
onFocus={paddingY.onFocus}
|
||||
onChange={paddingY.onChange}
|
||||
onBlur={paddingY.onBlur}
|
||||
onScrub={paddingY.scrubTo}
|
||||
onScrubEnd={paddingY.commitScrub}
|
||||
onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedPaddingY,
|
||||
staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField
|
||||
label="X-offset"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={offsetX.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle x-offset keyframe"
|
||||
onToggle={offsetX.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="X"
|
||||
value={offsetX.displayValue}
|
||||
onFocus={offsetX.onFocus}
|
||||
onChange={offsetX.onChange}
|
||||
onBlur={offsetX.onBlur}
|
||||
onScrub={offsetX.scrubTo}
|
||||
onScrubEnd={offsetX.commitScrub}
|
||||
onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOffsetX,
|
||||
staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField
|
||||
label="Y-offset"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={offsetY.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle y-offset keyframe"
|
||||
onToggle={offsetY.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="Y"
|
||||
value={offsetY.displayValue}
|
||||
onFocus={offsetY.onFocus}
|
||||
onChange={offsetY.onChange}
|
||||
onBlur={offsetY.onBlur}
|
||||
onScrub={offsetY.scrubTo}
|
||||
onScrubEnd={offsetY.commitScrub}
|
||||
onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOffsetY,
|
||||
staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
</div>
|
||||
<SectionField
|
||||
label="Corner radius"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={cornerRadius.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle corner radius keyframe"
|
||||
onToggle={cornerRadius.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon="R"
|
||||
value={cornerRadius.displayValue}
|
||||
min={CORNER_RADIUS_MIN}
|
||||
max={CORNER_RADIUS_MAX}
|
||||
onFocus={cornerRadius.onFocus}
|
||||
onChange={cornerRadius.onChange}
|
||||
onBlur={cornerRadius.onBlur}
|
||||
onScrub={cornerRadius.scrubTo}
|
||||
onScrubEnd={cornerRadius.commitScrub}
|
||||
onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedCornerRadius,
|
||||
staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
|
||||
defaultValue: CORNER_RADIUS_MIN,
|
||||
})}
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type {
|
||||
ImageElement,
|
||||
StickerElement,
|
||||
VideoElement,
|
||||
} from "@/types/timeline";
|
||||
import { BlendingSection, TransformSection } from "./sections";
|
||||
|
||||
export function VideoProperties({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: VideoElement | ImageElement | StickerElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<TransformSection
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
showTopBorder={false}
|
||||
/>
|
||||
<BlendingSection element={element} trackId={trackId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import {
|
||||
DEFAULT_BLEND_MODE,
|
||||
DEFAULT_OPACITY,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { OcCheckerboardIcon } from "@opencut/ui/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/types/rendering";
|
||||
import type { ElementType } from "@/types/timeline";
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "../keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/lib/animation";
|
||||
import { isPropertyAtDefault } from "./transform";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
[{ value: "normal", label: "Normal" }],
|
||||
[
|
||||
{ value: "darken", label: "Darken" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "color-burn", label: "Color Burn" },
|
||||
],
|
||||
[
|
||||
{ value: "lighten", label: "Lighten" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||
{ value: "color-dodge", label: "Color Dodge" },
|
||||
],
|
||||
[
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
{ value: "soft-light", label: "Soft Light" },
|
||||
{ value: "hard-light", label: "Hard Light" },
|
||||
],
|
||||
[
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "exclusion", label: "Exclusion" },
|
||||
],
|
||||
[
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "saturation", label: "Saturation" },
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "luminosity", label: "Luminosity" },
|
||||
],
|
||||
];
|
||||
|
||||
export function BlendingSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: BlendingElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
|
||||
const didSelectRef = useRef(false);
|
||||
const committedBlendModeRef = useRef(blendMode);
|
||||
if (!editor.timeline.isPreviewActive()) {
|
||||
committedBlendModeRef.current = blendMode;
|
||||
}
|
||||
|
||||
const previewBlendMode = ({ value }: { value: BlendMode }) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
||||
],
|
||||
});
|
||||
|
||||
const commitBlendMode = (value: string) => {
|
||||
if (editor.timeline.isPreviewActive()) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { blendMode: value as BlendMode },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
didSelectRef.current = true;
|
||||
};
|
||||
|
||||
const handleBlendModeOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
if (!didSelectRef.current) editor.timeline.discardPreview();
|
||||
didSelectRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedOpacity = resolveOpacityAtTime({
|
||||
baseOpacity: element.opacity,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const opacity = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "opacity",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOpacity * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
||||
},
|
||||
valueAtPlayhead: resolvedOpacity,
|
||||
buildBaseUpdates: ({ value }) => ({ opacity: value }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.type}:blending`}>
|
||||
<SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField
|
||||
label="Opacity"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={opacity.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle opacity keyframe"
|
||||
onToggle={opacity.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
className="w-full"
|
||||
icon={
|
||||
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
|
||||
}
|
||||
value={opacity.displayValue}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={opacity.onFocus}
|
||||
onChange={opacity.onChange}
|
||||
onBlur={opacity.onBlur}
|
||||
onScrub={opacity.scrubTo}
|
||||
onScrubEnd={opacity.commitScrub}
|
||||
onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOpacity,
|
||||
staticValue: element.opacity,
|
||||
defaultValue: DEFAULT_OPACITY,
|
||||
})}
|
||||
dragSensitivity="slow"
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Blend mode" className="w-1/2">
|
||||
<Select
|
||||
value={committedBlendModeRef.current}
|
||||
onOpenChange={handleBlendModeOpenChange}
|
||||
onValueChange={commitBlendMode}
|
||||
>
|
||||
<SelectTrigger
|
||||
icon={<HugeiconsIcon icon={RainDropIcon} />}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select blend mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36">
|
||||
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
|
||||
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
|
||||
{group.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode({ value: option.value as BlendMode })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
|
||||
<SelectSeparator />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./transform";
|
||||
export * from "./blending";
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { VisualElement } from "@/types/timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "../section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowExpandIcon,
|
||||
Link05Icon,
|
||||
RotateClockwiseIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useState } from "react";
|
||||
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
|
||||
import { KeyframeToggle } from "../keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
|
||||
export function parseNumericInput({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
export function isPropertyAtDefault({
|
||||
hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
staticValue,
|
||||
defaultValue,
|
||||
}: {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number;
|
||||
staticValue: number;
|
||||
defaultValue: number;
|
||||
}): boolean {
|
||||
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
|
||||
return isNearlyEqual({
|
||||
leftValue: resolvedValue,
|
||||
rightValue: defaultValue,
|
||||
});
|
||||
}
|
||||
|
||||
return staticValue === defaultValue;
|
||||
}
|
||||
|
||||
export function TransformSection({
|
||||
element,
|
||||
trackId,
|
||||
showTopBorder = true,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
showTopBorder?: boolean;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [isScaleLocked, setIsScaleLocked] = useState(false);
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const isPlayheadWithinElementRange =
|
||||
playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
|
||||
playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
|
||||
|
||||
const positionX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.position.x",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.position.x).toString(),
|
||||
parse: (input) => parseNumericInput({ input }),
|
||||
valueAtPlayhead: resolvedTransform.position.x,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
x: value,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const positionY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.position.y",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.position.y).toString(),
|
||||
parse: (input) => parseNumericInput({ input }),
|
||||
valueAtPlayhead: resolvedTransform.position.y,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
y: value,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const scale = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scale",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.scale * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseNumericInput({ input });
|
||||
if (parsed === null) return null;
|
||||
return Math.max(parsed, 1) / 100;
|
||||
},
|
||||
valueAtPlayhead: resolvedTransform.scale,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
scale: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const scaleFieldProps = {
|
||||
className: "flex-1",
|
||||
value: scale.displayValue,
|
||||
onFocus: scale.onFocus,
|
||||
onChange: scale.onChange,
|
||||
onBlur: scale.onBlur,
|
||||
dragSensitivity: "slow" as const,
|
||||
onScrub: scale.scrubTo,
|
||||
onScrubEnd: scale.commitScrub,
|
||||
onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
|
||||
isDefault: isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.scale,
|
||||
staticValue: element.transform.scale,
|
||||
defaultValue: DEFAULT_TRANSFORM.scale,
|
||||
}),
|
||||
};
|
||||
|
||||
const rotation = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.rotate).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseNumericInput({ input });
|
||||
if (parsed === null) return null;
|
||||
return clamp({ value: parsed, min: -360, max: 360 });
|
||||
},
|
||||
valueAtPlayhead: resolvedTransform.rotate,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
rotate: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const hasPositionKeyframe =
|
||||
positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
|
||||
|
||||
const togglePositionKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
|
||||
const keyframesToRemove: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
keyframeId: string;
|
||||
}> = [];
|
||||
if (positionX.keyframeIdAtTime) {
|
||||
keyframesToRemove.push({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.position.x" as const,
|
||||
keyframeId: positionX.keyframeIdAtTime,
|
||||
});
|
||||
}
|
||||
if (positionY.keyframeIdAtTime) {
|
||||
keyframesToRemove.push({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.position.y" as const,
|
||||
keyframeId: positionY.keyframeIdAtTime,
|
||||
});
|
||||
}
|
||||
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: keyframesToRemove,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.position.x",
|
||||
time: localTime,
|
||||
value: resolvedTransform.position.x,
|
||||
},
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.position.y",
|
||||
time: localTime,
|
||||
value: resolvedTransform.position.y,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
collapsible
|
||||
sectionKey={`${element.type}:transform`}
|
||||
showTopBorder={showTopBorder}
|
||||
>
|
||||
<SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Scale"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={scale.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle scale keyframe"
|
||||
onToggle={scale.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isScaleLocked ? (
|
||||
<>
|
||||
<NumberField icon="W" {...scaleFieldProps} />
|
||||
<NumberField icon="H" {...scaleFieldProps} />
|
||||
</>
|
||||
) : (
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldProps}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-pressed={isScaleLocked}
|
||||
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
|
||||
>
|
||||
<HugeiconsIcon icon={Link05Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
</SectionField>
|
||||
<SectionField
|
||||
label="Position"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={hasPositionKeyframe}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle position keyframe"
|
||||
onToggle={togglePositionKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon="X"
|
||||
className="flex-1"
|
||||
value={positionX.displayValue}
|
||||
onFocus={positionX.onFocus}
|
||||
onChange={positionX.onChange}
|
||||
onBlur={positionX.onBlur}
|
||||
onScrub={positionX.scrubTo}
|
||||
onScrubEnd={positionX.commitScrub}
|
||||
onReset={() =>
|
||||
positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.position.x,
|
||||
staticValue: element.transform.position.x,
|
||||
defaultValue: DEFAULT_TRANSFORM.position.x,
|
||||
})}
|
||||
/>
|
||||
<NumberField
|
||||
icon="Y"
|
||||
className="flex-1"
|
||||
value={positionY.displayValue}
|
||||
onFocus={positionY.onFocus}
|
||||
onChange={positionY.onChange}
|
||||
onBlur={positionY.onBlur}
|
||||
onScrub={positionY.scrubTo}
|
||||
onScrubEnd={positionY.commitScrub}
|
||||
onReset={() =>
|
||||
positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.position.y,
|
||||
staticValue: element.transform.position.y,
|
||||
defaultValue: DEFAULT_TRANSFORM.position.y,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
|
||||
<SectionField
|
||||
label="Rotation"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={rotation.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle rotation keyframe"
|
||||
onToggle={rotation.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
||||
className="flex-none"
|
||||
value={rotation.displayValue}
|
||||
onFocus={rotation.onFocus}
|
||||
onChange={rotation.onChange}
|
||||
onBlur={rotation.onBlur}
|
||||
dragSensitivity="slow"
|
||||
onScrub={rotation.scrubTo}
|
||||
onScrubEnd={rotation.commitScrub}
|
||||
onReset={() =>
|
||||
rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.rotate,
|
||||
staticValue: element.transform.rotate,
|
||||
defaultValue: DEFAULT_TRANSFORM.rotate,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
headerRef?: React.RefObject<HTMLElement | null>;
|
||||
onSelectionComplete: ({
|
||||
elements,
|
||||
isAdditive,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
isAdditive: boolean;
|
||||
}) => void;
|
||||
isEnabled?: boolean;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
isAdditive: boolean;
|
||||
}
|
||||
|
||||
interface SelectionRectangle {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function getNormalizedRectangle({
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
return {
|
||||
left: Math.min(startPos.x, endPos.x),
|
||||
top: Math.min(startPos.y, endPos.y),
|
||||
right: Math.max(startPos.x, endPos.x),
|
||||
bottom: Math.max(startPos.y, endPos.y),
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
|
||||
const adjustedStart = {
|
||||
x: startPos.x - containerRect.left + scrollLeft,
|
||||
y: startPos.y - scrollRect.top + scrollTop,
|
||||
};
|
||||
const adjustedEnd = {
|
||||
x: endPos.x - containerRect.left + scrollLeft,
|
||||
y: endPos.y - scrollRect.top + scrollTop,
|
||||
};
|
||||
|
||||
return getNormalizedRectangle({
|
||||
startPos: adjustedStart,
|
||||
endPos: adjustedEnd,
|
||||
});
|
||||
}
|
||||
|
||||
function isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
}: {
|
||||
elementRectangle: SelectionRectangle;
|
||||
selectionRectangle: SelectionRectangle;
|
||||
}): boolean {
|
||||
return !(
|
||||
elementRectangle.right < selectionRectangle.left ||
|
||||
elementRectangle.left > selectionRectangle.right ||
|
||||
elementRectangle.bottom < selectionRectangle.top ||
|
||||
elementRectangle.top > selectionRectangle.bottom
|
||||
);
|
||||
}
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
headerRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
}: UseSelectionBoxProps) {
|
||||
const editor = useEditor();
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null,
|
||||
);
|
||||
const justFinishedSelectingRef = useRef(false);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
({ clientX, clientY, shiftKey, ctrlKey, metaKey }: React.MouseEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: clientX, y: clientY },
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: false,
|
||||
isAdditive: shiftKey || ctrlKey || metaKey,
|
||||
});
|
||||
},
|
||||
[isEnabled],
|
||||
);
|
||||
|
||||
const selectElementsInBox = useCallback(
|
||||
({
|
||||
startPos,
|
||||
endPos,
|
||||
isAdditive,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
isAdditive: boolean;
|
||||
}) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const container = containerRef.current;
|
||||
const selectionRectangle = getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer: tracksScrollRef.current,
|
||||
startPos,
|
||||
endPos,
|
||||
});
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const timelineHeaderHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
for (const [trackIndex, track] of tracks.entries()) {
|
||||
const trackTop = getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
});
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const elementTop =
|
||||
timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
|
||||
const elementBottom = elementTop + trackHeight;
|
||||
|
||||
for (const element of track.elements) {
|
||||
const elementLeft = element.startTime * pixelsPerSecond;
|
||||
const elementRight = elementLeft + element.duration * pixelsPerSecond;
|
||||
|
||||
const elementRectangle = {
|
||||
left: elementLeft,
|
||||
top: elementTop,
|
||||
right: elementRight,
|
||||
bottom: elementBottom,
|
||||
};
|
||||
|
||||
const intersects = isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
});
|
||||
|
||||
if (intersects) {
|
||||
selectedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
onSelectionComplete({ elements: selectedElements, isAdditive });
|
||||
},
|
||||
[
|
||||
containerRef,
|
||||
headerRef,
|
||||
onSelectionComplete,
|
||||
editor,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
||||
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
|
||||
const shouldActivate = deltaX > 5 || deltaY > 5;
|
||||
|
||||
const newSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(newSelectionBox);
|
||||
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox({
|
||||
startPos: newSelectionBox.startPos,
|
||||
endPos: newSelectionBox.currentPos,
|
||||
isAdditive: newSelectionBox.isAdditive,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (selectionBox?.isActive) {
|
||||
justFinishedSelectingRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
justFinishedSelectingRef.current = false;
|
||||
});
|
||||
}
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, selectElementsInBox]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const previousBodyUserSelect = document.body.style.userSelect;
|
||||
const container = containerRef.current;
|
||||
const previousContainerUserSelect = container?.style.userSelect ?? "";
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
if (container) container.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = previousBodyUserSelect;
|
||||
if (container) container.style.userSelect = previousContainerUserSelect;
|
||||
};
|
||||
}, [selectionBox, containerRef]);
|
||||
|
||||
const shouldIgnoreClick = useCallback(() => {
|
||||
return justFinishedSelectingRef.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
shouldIgnoreClick,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SelectionBoxState {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
isAdditive: boolean;
|
||||
initialSelectedIds: string[];
|
||||
initialAnchorId: string | null;
|
||||
}
|
||||
|
||||
function isIntersecting({
|
||||
selectionRect,
|
||||
itemRect,
|
||||
}: {
|
||||
selectionRect: DOMRect;
|
||||
itemRect: DOMRect;
|
||||
}) {
|
||||
return !(
|
||||
itemRect.right < selectionRect.left ||
|
||||
itemRect.left > selectionRect.right ||
|
||||
itemRect.bottom < selectionRect.top ||
|
||||
itemRect.top > selectionRect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
function createSelectionRect({
|
||||
startPos,
|
||||
currentPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
}) {
|
||||
return new DOMRect(
|
||||
Math.min(startPos.x, currentPos.x),
|
||||
Math.min(startPos.y, currentPos.y),
|
||||
Math.abs(currentPos.x - startPos.x),
|
||||
Math.abs(currentPos.y - startPos.y),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAssetsSelectionBox({
|
||||
containerRef,
|
||||
getSelectableElements,
|
||||
selectedIds,
|
||||
selectionAnchorId,
|
||||
onSelectionChange,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
getSelectableElements: () => Map<string, HTMLElement>;
|
||||
selectedIds: string[];
|
||||
selectionAnchorId: string | null;
|
||||
onSelectionChange: ({
|
||||
intersectedIds,
|
||||
initialSelectedIds,
|
||||
initialAnchorId,
|
||||
isAdditive,
|
||||
}: {
|
||||
intersectedIds: string[];
|
||||
initialSelectedIds: string[];
|
||||
initialAnchorId: string | null;
|
||||
isAdditive: boolean;
|
||||
}) => void;
|
||||
}) {
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null,
|
||||
);
|
||||
const justFinishedSelectingRef = useRef(false);
|
||||
const isAssetItemTarget = useCallback((target: EventTarget | null) => {
|
||||
return target instanceof Element && target.closest("[data-asset-item='true']");
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAssetItemTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: event.clientX, y: event.clientY },
|
||||
currentPos: { x: event.clientX, y: event.clientY },
|
||||
isActive: false,
|
||||
isAdditive: event.ctrlKey || event.metaKey,
|
||||
initialSelectedIds: selectedIds,
|
||||
initialAnchorId: selectionAnchorId,
|
||||
});
|
||||
},
|
||||
[isAssetItemTarget, selectedIds, selectionAnchorId],
|
||||
);
|
||||
|
||||
const updateSelection = useCallback(
|
||||
({
|
||||
startPos,
|
||||
currentPos,
|
||||
isAdditive,
|
||||
initialSelectedIds,
|
||||
initialAnchorId,
|
||||
}: SelectionBoxState) => {
|
||||
const selectionRect = createSelectionRect({ startPos, currentPos });
|
||||
const intersectedIds = [...getSelectableElements().entries()]
|
||||
.filter(([, element]) =>
|
||||
isIntersecting({
|
||||
selectionRect,
|
||||
itemRect: element.getBoundingClientRect(),
|
||||
}),
|
||||
)
|
||||
.map(([id]) => id);
|
||||
|
||||
onSelectionChange({
|
||||
intersectedIds,
|
||||
initialSelectedIds,
|
||||
initialAnchorId,
|
||||
isAdditive,
|
||||
});
|
||||
},
|
||||
[getSelectableElements, onSelectionChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
||||
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
|
||||
const shouldActivate = deltaX > 5 || deltaY > 5;
|
||||
const nextSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: clientX, y: clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(nextSelectionBox);
|
||||
|
||||
if (!nextSelectionBox.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelection(nextSelectionBox);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (selectionBox.isActive) {
|
||||
justFinishedSelectingRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
justFinishedSelectingRef.current = false;
|
||||
});
|
||||
}
|
||||
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, updateSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousBodyUserSelect = document.body.style.userSelect;
|
||||
const previousContainerUserSelect = containerRef.current?.style.userSelect ?? "";
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.userSelect = "none";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = previousBodyUserSelect;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.userSelect = previousContainerUserSelect;
|
||||
}
|
||||
};
|
||||
}, [containerRef, selectionBox]);
|
||||
|
||||
const shouldIgnoreClick = useCallback(() => {
|
||||
return justFinishedSelectingRef.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
isSelecting: selectionBox?.isActive ?? false,
|
||||
handleMouseDown,
|
||||
shouldIgnoreClick,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
|
||||
function mergeUniqueIds({ ids }: { ids: string[] }) {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function getRangeIds({
|
||||
orderedIds,
|
||||
anchorId,
|
||||
targetId,
|
||||
}: {
|
||||
orderedIds: string[];
|
||||
anchorId: string;
|
||||
targetId: string;
|
||||
}) {
|
||||
const anchorIndex = orderedIds.indexOf(anchorId);
|
||||
const targetIndex = orderedIds.indexOf(targetId);
|
||||
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
return [targetId];
|
||||
}
|
||||
|
||||
const rangeStart = Math.min(anchorIndex, targetIndex);
|
||||
const rangeEnd = Math.max(anchorIndex, targetIndex);
|
||||
return orderedIds.slice(rangeStart, rangeEnd + 1);
|
||||
}
|
||||
|
||||
export function useAssetsSelection({
|
||||
orderedIds,
|
||||
}: {
|
||||
orderedIds: string[];
|
||||
}) {
|
||||
const {
|
||||
selectedMediaIds,
|
||||
selectionAnchorId,
|
||||
setMediaSelection,
|
||||
clearMediaSelection,
|
||||
} = useAssetsPanelStore();
|
||||
const selectedIdSet = useMemo(() => {
|
||||
return new Set(selectedMediaIds);
|
||||
}, [selectedMediaIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const validIds = new Set(orderedIds);
|
||||
const nextSelectedIds = selectedMediaIds.filter((id) => validIds.has(id));
|
||||
const nextAnchorId =
|
||||
selectionAnchorId && validIds.has(selectionAnchorId)
|
||||
? selectionAnchorId
|
||||
: nextSelectedIds[nextSelectedIds.length - 1] ?? null;
|
||||
const isSelectionUnchanged =
|
||||
nextSelectedIds.length === selectedMediaIds.length &&
|
||||
nextAnchorId === selectionAnchorId;
|
||||
|
||||
if (isSelectionUnchanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMediaSelection({
|
||||
ids: nextSelectedIds,
|
||||
anchorId: nextAnchorId,
|
||||
});
|
||||
}, [orderedIds, selectedMediaIds, selectionAnchorId, setMediaSelection]);
|
||||
|
||||
const replaceSelection = useCallback(
|
||||
(ids: string[], anchorId?: string | null) => {
|
||||
setMediaSelection({
|
||||
ids: mergeUniqueIds({ ids }),
|
||||
anchorId,
|
||||
});
|
||||
},
|
||||
[setMediaSelection],
|
||||
);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(id: string) => {
|
||||
return selectedIdSet.has(id);
|
||||
},
|
||||
[selectedIdSet],
|
||||
);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(id: string) => {
|
||||
if (selectedIdSet.has(id)) {
|
||||
const nextSelectedIds = selectedMediaIds.filter(
|
||||
(selectedId) => selectedId !== id,
|
||||
);
|
||||
const nextAnchorId =
|
||||
selectionAnchorId === id
|
||||
? nextSelectedIds[nextSelectedIds.length - 1] ?? null
|
||||
: selectionAnchorId;
|
||||
replaceSelection(nextSelectedIds, nextAnchorId);
|
||||
return;
|
||||
}
|
||||
|
||||
replaceSelection([...selectedMediaIds, id], id);
|
||||
},
|
||||
[
|
||||
selectedIdSet,
|
||||
selectedMediaIds,
|
||||
selectionAnchorId,
|
||||
replaceSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const selectRange = useCallback(
|
||||
({
|
||||
targetId,
|
||||
isAdditive,
|
||||
}: {
|
||||
targetId: string;
|
||||
isAdditive: boolean;
|
||||
}) => {
|
||||
const anchorId =
|
||||
selectionAnchorId ??
|
||||
selectedMediaIds[selectedMediaIds.length - 1] ??
|
||||
targetId;
|
||||
const rangeIds = getRangeIds({
|
||||
orderedIds,
|
||||
anchorId,
|
||||
targetId,
|
||||
});
|
||||
const nextSelectedIds = isAdditive
|
||||
? mergeUniqueIds({
|
||||
ids: [...selectedMediaIds, ...rangeIds],
|
||||
})
|
||||
: rangeIds;
|
||||
|
||||
replaceSelection(nextSelectedIds, anchorId);
|
||||
},
|
||||
[orderedIds, replaceSelection, selectedMediaIds, selectionAnchorId],
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
({
|
||||
event,
|
||||
id,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
id: string;
|
||||
}) => {
|
||||
const isToggleSelection = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (event.shiftKey) {
|
||||
selectRange({
|
||||
targetId: id,
|
||||
isAdditive: isToggleSelection,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isToggleSelection) {
|
||||
toggleSelection(id);
|
||||
return;
|
||||
}
|
||||
|
||||
replaceSelection([id], id);
|
||||
},
|
||||
[replaceSelection, selectRange, toggleSelection],
|
||||
);
|
||||
|
||||
const handleItemContextMenu = useCallback(
|
||||
(id: string) => {
|
||||
if (selectedIdSet.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
replaceSelection([id], id);
|
||||
},
|
||||
[selectedIdSet, replaceSelection],
|
||||
);
|
||||
|
||||
const applyBoxSelection = useCallback(
|
||||
({
|
||||
intersectedIds,
|
||||
initialSelectedIds,
|
||||
initialAnchorId,
|
||||
isAdditive,
|
||||
}: {
|
||||
intersectedIds: string[];
|
||||
initialSelectedIds: string[];
|
||||
initialAnchorId: string | null;
|
||||
isAdditive: boolean;
|
||||
}) => {
|
||||
const nextSelectedIds = isAdditive
|
||||
? mergeUniqueIds({
|
||||
ids: [...initialSelectedIds, ...intersectedIds],
|
||||
})
|
||||
: intersectedIds;
|
||||
const nextAnchorId = isAdditive
|
||||
? initialAnchorId ?? intersectedIds[intersectedIds.length - 1] ?? null
|
||||
: intersectedIds[intersectedIds.length - 1] ?? null;
|
||||
|
||||
replaceSelection(nextSelectedIds, nextAnchorId);
|
||||
},
|
||||
[replaceSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedMediaIds,
|
||||
selectionAnchorId,
|
||||
isSelected,
|
||||
replaceSelection,
|
||||
clearSelection: clearMediaSelection,
|
||||
handleItemClick,
|
||||
handleItemContextMenu,
|
||||
applyBoxSelection,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
|
||||
export function useElementRegistry() {
|
||||
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
|
||||
const registerElement = useCallback(
|
||||
(id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
elementRefs.current.set(id, element);
|
||||
return;
|
||||
}
|
||||
|
||||
elementRefs.current.delete(id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getElement = useCallback((id: string) => {
|
||||
return elementRefs.current.get(id) ?? null;
|
||||
}, []);
|
||||
|
||||
const getElements = useCallback(() => {
|
||||
return elementRefs.current;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
registerElement,
|
||||
getElement,
|
||||
getElements,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useRevealItem(
|
||||
highlightId: string | null,
|
||||
onClearHighlight: () => void,
|
||||
getElement: (id: string) => HTMLElement | null,
|
||||
highlightDuration = 1000,
|
||||
) {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId) return;
|
||||
|
||||
setHighlightedId(highlightId);
|
||||
|
||||
const target = getElement(highlightId);
|
||||
target?.scrollIntoView({ block: "center" });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setHighlightedId(null);
|
||||
onClearHighlight();
|
||||
}, highlightDuration);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [highlightId, onClearHighlight, highlightDuration, getElement]);
|
||||
|
||||
return { highlightedId };
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ struct LayerUniformBuffer {
|
|||
opacity: f32,
|
||||
flip_x: f32,
|
||||
flip_y: f32,
|
||||
_padding: [f32; 2], // WebGL requires uniform buffer sizes to be multiples of 16 bytes (40 → 48)
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
|
|
@ -549,6 +550,7 @@ impl Compositor {
|
|||
opacity: layer.opacity,
|
||||
flip_x: if layer.transform.flip_x { 1.0 } else { 0.0 },
|
||||
flip_y: if layer.transform.flip_y { 1.0 } else { 0.0 },
|
||||
_padding: [0.0; 2],
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue