mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41447bf9d4 | ||
|
|
f5c8a6cf0d | ||
|
|
2efd6b6f00 | ||
|
|
f800b17534 | ||
|
|
17b54d8b78 | ||
|
|
0bc39262f7 |
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Comment Guidelines
|
||||||
|
|
||||||
|
## Good Comments (Human-style)
|
||||||
|
- Explain WHY, not WHAT
|
||||||
|
- Document non-obvious behavior or edge cases
|
||||||
|
- Warn about performance implications or side effects
|
||||||
|
- Explain business logic that isn't clear from code
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```javascript
|
||||||
|
// transfer, not copy; sender buffer detaches
|
||||||
|
// satisfies: check shape; keep literals
|
||||||
|
// keep multibyte across chunks
|
||||||
|
// timingSafeEqual throws on length mismatch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bad Comments (AI-style)
|
||||||
|
- Don't explain what the code literally does
|
||||||
|
- Don't add changelog-style comments in code
|
||||||
|
- Don't comment every line or obvious operations
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
```javascript
|
||||||
|
// Prevent duplicate initialization
|
||||||
|
// Check if project is already loaded
|
||||||
|
// Mark as initializing to prevent race conditions
|
||||||
|
// (changed from blah to blah)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
Only add comments when there's genuinely non-obvious behavior, performance considerations, or business logic that needs context. Code should be self-documenting through naming and structure.
|
||||||
+7
-1
@@ -31,4 +31,10 @@ bun.lockb
|
|||||||
apps/transcription/__pycache__
|
apps/transcription/__pycache__
|
||||||
apps/transcription/env
|
apps/transcription/env
|
||||||
|
|
||||||
apps/bg-remover/env
|
apps/bg-remover/env
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
apps/core/target/
|
||||||
|
apps/core/pkg/
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
--popover-foreground: hsl(0 0% 2%);
|
--popover-foreground: hsl(0 0% 2%);
|
||||||
--primary: hsl(205, 84%, 47%);
|
--primary: hsl(205, 84%, 47%);
|
||||||
--primary-foreground: hsl(0 0% 91%);
|
--primary-foreground: hsl(0 0% 91%);
|
||||||
--secondary: hsl(216, 13%, 92%);
|
--secondary: hsl(216, 13%, 95%);
|
||||||
--secondary-foreground: hsl(0 0% 2%);
|
--secondary-foreground: hsl(0 0% 2%);
|
||||||
--muted: hsl(0 0% 85.1%);
|
--muted: hsl(0 0% 85.1%);
|
||||||
--muted-foreground: hsl(0 0% 50%);
|
--muted-foreground: hsl(0 0% 50%);
|
||||||
--accent: hsl(216, 13%, 92%);
|
--accent: hsl(216, 13%, 95%);
|
||||||
--accent-foreground: hsl(0 0% 2%);
|
--accent-foreground: hsl(0 0% 2%);
|
||||||
--destructive: hsl(0, 83%, 50%);
|
--destructive: hsl(0, 83%, 50%);
|
||||||
--destructive-foreground: hsl(0, 0%, 100%);
|
--destructive-foreground: hsl(0, 0%, 100%);
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||||
|
import { renderTimelineFrame } from "@/lib/timeline-renderer";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
import { useProjectStore, DEFAULT_FPS } from "@/stores/project-store";
|
||||||
|
import { useFrameCache } from "@/hooks/use-frame-cache";
|
||||||
|
|
||||||
|
interface CanvasRendererProps {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasRenderer({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
className,
|
||||||
|
}: CanvasRendererProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const lastFrameRef = useRef(-1);
|
||||||
|
const renderingRef = useRef(false);
|
||||||
|
const pendingFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const { tracks } = useTimelineStore();
|
||||||
|
const { mediaFiles } = useMediaStore();
|
||||||
|
const { activeProject } = useProjectStore();
|
||||||
|
|
||||||
|
const { getCachedFrame, cacheFrame, preRenderNearbyFrames } = useFrameCache();
|
||||||
|
|
||||||
|
const fps = useMemo(
|
||||||
|
() => activeProject?.fps || DEFAULT_FPS,
|
||||||
|
[activeProject?.fps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFrame = useCallback(
|
||||||
|
async (time: number, frame: number) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || renderingRef.current) return;
|
||||||
|
|
||||||
|
renderingRef.current = true;
|
||||||
|
pendingFrameRef.current = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const cachedFrame = getCachedFrame(
|
||||||
|
time,
|
||||||
|
tracks,
|
||||||
|
mediaFiles,
|
||||||
|
activeProject
|
||||||
|
);
|
||||||
|
if (cachedFrame) {
|
||||||
|
ctx.putImageData(cachedFrame, 0, 0);
|
||||||
|
preRenderNearbyFrames(
|
||||||
|
time,
|
||||||
|
tracks,
|
||||||
|
mediaFiles,
|
||||||
|
activeProject,
|
||||||
|
async (preRenderTime: number) => {
|
||||||
|
const tempCanvas = document.createElement("canvas");
|
||||||
|
tempCanvas.width = width;
|
||||||
|
tempCanvas.height = height;
|
||||||
|
const tempCtx = tempCanvas.getContext("2d");
|
||||||
|
if (!tempCtx)
|
||||||
|
throw new Error("Failed to create temp canvas context");
|
||||||
|
|
||||||
|
await renderTimelineFrame({
|
||||||
|
ctx: tempCtx,
|
||||||
|
time: preRenderTime,
|
||||||
|
canvasWidth: width,
|
||||||
|
canvasHeight: height,
|
||||||
|
tracks,
|
||||||
|
mediaFiles,
|
||||||
|
backgroundColor:
|
||||||
|
activeProject?.backgroundType === "blur"
|
||||||
|
? undefined
|
||||||
|
: activeProject?.backgroundColor || "#000000",
|
||||||
|
backgroundType: activeProject?.backgroundType,
|
||||||
|
blurIntensity: activeProject?.blurIntensity,
|
||||||
|
projectCanvasSize: activeProject?.canvasSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tempCtx.getImageData(0, 0, width, height);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await renderTimelineFrame({
|
||||||
|
ctx,
|
||||||
|
time,
|
||||||
|
canvasWidth: width,
|
||||||
|
canvasHeight: height,
|
||||||
|
tracks,
|
||||||
|
mediaFiles,
|
||||||
|
backgroundColor:
|
||||||
|
activeProject?.backgroundType === "blur"
|
||||||
|
? undefined
|
||||||
|
: activeProject?.backgroundColor || "#000000",
|
||||||
|
backgroundType: activeProject?.backgroundType,
|
||||||
|
blurIntensity: activeProject?.blurIntensity,
|
||||||
|
projectCanvasSize: activeProject?.canvasSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, width, height);
|
||||||
|
cacheFrame(time, imageData, tracks, mediaFiles, activeProject);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Frame render failed:", error);
|
||||||
|
} finally {
|
||||||
|
renderingRef.current = false;
|
||||||
|
|
||||||
|
if (pendingFrameRef.current !== null) {
|
||||||
|
const pendingFrame = pendingFrameRef.current;
|
||||||
|
const pendingTime = pendingFrame / fps;
|
||||||
|
pendingFrameRef.current = null;
|
||||||
|
void renderFrame(pendingTime, pendingFrame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
tracks,
|
||||||
|
mediaFiles,
|
||||||
|
activeProject,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
getCachedFrame,
|
||||||
|
cacheFrame,
|
||||||
|
preRenderNearbyFrames,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const render = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const time = usePlaybackStore.getState().currentTime;
|
||||||
|
const frame = Math.floor(time * fps);
|
||||||
|
|
||||||
|
if (frame === lastFrameRef.current) return;
|
||||||
|
lastFrameRef.current = frame;
|
||||||
|
|
||||||
|
if (renderingRef.current) {
|
||||||
|
pendingFrameRef.current = frame;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void renderFrame(time, frame);
|
||||||
|
}, [renderFrame, fps]);
|
||||||
|
|
||||||
|
useRafLoop(render);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
|
FolderPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
@@ -372,6 +373,15 @@ export function TimelineElement({
|
|||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem onClick={() => {}}>
|
||||||
|
<FolderPlus className="h-4 w-4 mr-2" />
|
||||||
|
{isMultipleSelected && isCurrentElementSelected
|
||||||
|
? `Group ${selectedElements.length} elements into scene`
|
||||||
|
: `Group into scene`}
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={handleElementDeleteContext}
|
onClick={handleElementDeleteContext}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function TimelinePlayhead({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={playheadRef}
|
ref={playheadRef}
|
||||||
className="absolute pointer-events-auto z-40"
|
className="absolute pointer-events-auto z-100"
|
||||||
style={{
|
style={{
|
||||||
left: `${leftPosition}px`,
|
left: `${leftPosition}px`,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-accent/50 px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-accent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
paddingRight,
|
paddingRight,
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
import { MediaFile } from "@/types/media";
|
||||||
|
|
||||||
|
export function usePreviewAudio(mediaFiles: MediaFile[]) {
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const audioGainRef = useRef<GainNode | null>(null);
|
||||||
|
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
|
||||||
|
const playingSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
|
||||||
|
|
||||||
|
const { isPlaying, volume, muted } = usePlaybackStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stopAll = () => {
|
||||||
|
for (const src of playingSourcesRef.current) {
|
||||||
|
try {
|
||||||
|
src.stop();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
playingSourcesRef.current.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebAudioWindow = Window & {
|
||||||
|
AudioContext?: typeof AudioContext;
|
||||||
|
webkitAudioContext?: typeof AudioContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureAudioGraph = async () => {
|
||||||
|
if (!audioContextRef.current) {
|
||||||
|
const win = window as WebAudioWindow;
|
||||||
|
const Ctx = win.AudioContext ?? win.webkitAudioContext;
|
||||||
|
if (!Ctx) return;
|
||||||
|
audioContextRef.current = new Ctx();
|
||||||
|
}
|
||||||
|
if (!audioGainRef.current) {
|
||||||
|
audioGainRef.current = audioContextRef.current!.createGain();
|
||||||
|
audioGainRef.current.connect(audioContextRef.current!.destination);
|
||||||
|
}
|
||||||
|
if (audioContextRef.current!.state === "suspended") {
|
||||||
|
try {
|
||||||
|
await audioContextRef.current!.resume();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const gainValue = muted ? 0 : Math.max(0, Math.min(1, volume));
|
||||||
|
audioGainRef.current!.gain.setValueAtTime(
|
||||||
|
gainValue,
|
||||||
|
audioContextRef.current!.currentTime
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNow = async () => {
|
||||||
|
await ensureAudioGraph();
|
||||||
|
const audioCtx = audioContextRef.current!;
|
||||||
|
const gain = audioGainRef.current!;
|
||||||
|
|
||||||
|
const tracksSnapshot = useTimelineStore.getState().tracks;
|
||||||
|
const mediaList = mediaFiles;
|
||||||
|
const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const));
|
||||||
|
const playbackNow = usePlaybackStore.getState().currentTime;
|
||||||
|
|
||||||
|
const audible: Array<{
|
||||||
|
id: string;
|
||||||
|
elementStart: number;
|
||||||
|
trimStart: number;
|
||||||
|
trimEnd: number;
|
||||||
|
duration: number;
|
||||||
|
muted: boolean;
|
||||||
|
trackMuted: boolean;
|
||||||
|
}> = [];
|
||||||
|
const uniqueIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const track of tracksSnapshot) {
|
||||||
|
for (const element of track.elements) {
|
||||||
|
if (element.type !== "media") continue;
|
||||||
|
const media = idToMedia.get(element.mediaId);
|
||||||
|
if (!media || media.type !== "audio") continue;
|
||||||
|
const visibleDuration =
|
||||||
|
element.duration - element.trimStart - element.trimEnd;
|
||||||
|
if (visibleDuration <= 0) continue;
|
||||||
|
const localTime = playbackNow - element.startTime + element.trimStart;
|
||||||
|
if (localTime < 0 || localTime >= visibleDuration) continue;
|
||||||
|
|
||||||
|
const mediaElement = element;
|
||||||
|
audible.push({
|
||||||
|
id: media.id,
|
||||||
|
elementStart: element.startTime,
|
||||||
|
trimStart: element.trimStart,
|
||||||
|
trimEnd: element.trimEnd,
|
||||||
|
duration: element.duration,
|
||||||
|
muted: !!mediaElement.muted,
|
||||||
|
trackMuted: !!track.muted,
|
||||||
|
});
|
||||||
|
uniqueIds.add(media.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audible.length === 0) return;
|
||||||
|
|
||||||
|
const decodePromises: Array<Promise<void>> = [];
|
||||||
|
for (const id of uniqueIds) {
|
||||||
|
if (!audioBuffersRef.current.has(id)) {
|
||||||
|
const mediaItem = idToMedia.get(id);
|
||||||
|
if (!mediaItem) continue;
|
||||||
|
const p = (async () => {
|
||||||
|
const arr = await mediaItem.file.arrayBuffer();
|
||||||
|
const buf = await audioCtx.decodeAudioData(arr.slice(0));
|
||||||
|
audioBuffersRef.current.set(id, buf);
|
||||||
|
})();
|
||||||
|
decodePromises.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(decodePromises);
|
||||||
|
|
||||||
|
const startAt = audioCtx.currentTime + 0.02;
|
||||||
|
for (const entry of audible) {
|
||||||
|
if (entry.muted || entry.trackMuted) continue;
|
||||||
|
const buffer = audioBuffersRef.current.get(entry.id);
|
||||||
|
if (!buffer) continue;
|
||||||
|
const visibleDuration =
|
||||||
|
entry.duration - entry.trimStart - entry.trimEnd;
|
||||||
|
const localTime = Math.max(
|
||||||
|
0,
|
||||||
|
playbackNow - entry.elementStart + entry.trimStart
|
||||||
|
);
|
||||||
|
const playDuration = Math.max(0, visibleDuration - localTime);
|
||||||
|
if (playDuration <= 0) continue;
|
||||||
|
const src = audioCtx.createBufferSource();
|
||||||
|
src.buffer = buffer;
|
||||||
|
src.connect(gain);
|
||||||
|
try {
|
||||||
|
src.start(startAt, localTime, playDuration);
|
||||||
|
playingSourcesRef.current.add(src);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSeek = () => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
for (const src of playingSourcesRef.current) {
|
||||||
|
try {
|
||||||
|
src.stop();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
playingSourcesRef.current.clear();
|
||||||
|
void scheduleNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
void ensureAudioGraph();
|
||||||
|
|
||||||
|
for (const src of playingSourcesRef.current) {
|
||||||
|
try {
|
||||||
|
src.stop();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
playingSourcesRef.current.clear();
|
||||||
|
if (isPlaying) {
|
||||||
|
void scheduleNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("playback-seek", onSeek as EventListener);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("playback-seek", onSeek as EventListener);
|
||||||
|
for (const src of playingSourcesRef.current) {
|
||||||
|
try {
|
||||||
|
src.stop();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
playingSourcesRef.current.clear();
|
||||||
|
};
|
||||||
|
}, [isPlaying, volume, muted, mediaFiles]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useRafLoop(callback: () => void) {
|
||||||
|
const requestRef = useRef<number>(0);
|
||||||
|
const callbackRef = useRef(callback);
|
||||||
|
|
||||||
|
// Update callback ref when callback changes
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loop = () => {
|
||||||
|
callbackRef.current();
|
||||||
|
requestRef.current = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
requestRef.current = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(requestRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user