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/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%);
|
||||
--primary: hsl(205, 84%, 47%);
|
||||
--primary-foreground: hsl(0 0% 91%);
|
||||
--secondary: hsl(216, 13%, 92%);
|
||||
--secondary: hsl(216, 13%, 95%);
|
||||
--secondary-foreground: hsl(0 0% 2%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(216, 13%, 92%);
|
||||
--accent: hsl(216, 13%, 95%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--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,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
FolderPlus,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
@@ -372,6 +373,15 @@ export function TimelineElement({
|
||||
|
||||
<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
|
||||
onClick={handleElementDeleteContext}
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
@@ -122,7 +122,7 @@ export function TimelinePlayhead({
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-40"
|
||||
className="absolute pointer-events-auto z-100"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
|
||||
@@ -53,7 +53,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={inputType}
|
||||
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]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
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