Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ Thumbs.db
.env
.env.local
*.env.*

/clypra_next_releases.md
/custom_gpu_rendering_plan.md
```
25 changes: 12 additions & 13 deletions src/components/editor/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, lazy, Suspense } from "react";
import { Film, Upload, Home, Settings } from "lucide-react";
import { Film, Upload, Home, Settings, Undo2, Redo2 } from "lucide-react";
import { Button } from "../ui/Button";
import { useProjectStore } from "@/store/projectStore";
import { useUIStore } from "@/store/uiStore";
Expand All @@ -17,7 +17,7 @@ interface TopBarProps {
export const TopBar: React.FC<TopBarProps> = ({ onRequestClose }) => {
const { project, closeProject } = useProjectStore();
const { toggleSettingsModal } = useUIStore();
const { state: historyState } = useHistoryStore();
const { state: historyState, undo, redo } = useHistoryStore();
const [showExportDialog, setShowExportDialog] = useState(false);

const { isFullscreen } = useTauriFullscreen();
Expand Down Expand Up @@ -48,17 +48,16 @@ export const TopBar: React.FC<TopBarProps> = ({ onRequestClose }) => {

{/* Right side - actions */}
<div className="flex items-center gap-1.5">
{/* Undo/Redo indicator */}
{(historyState.canUndo || historyState.canRedo) && (
<div className="hidden sm:flex items-center gap-1 text-[10px] text-text-muted mr-1">
<span title={`${historyState.position + 1} undo actions available`}>{historyState.position + 1} undo</span>
{historyState.canRedo && (
<>
<span>β€’</span>
<span title={`${historyState.size - historyState.position - 1} redo actions available`}>{historyState.size - historyState.position - 1} redo</span>
</>
)}
</div>
{/* Undo/Redo buttons with action-specific tooltips */}
{historyState.canUndo && (
<Button variant="ghost" size="icon-sm" onClick={undo} title={historyState.undoLabel ? `Undo ${historyState.undoLabel}` : "Undo"} style={{ WebkitAppRegion: "no-drag", cursor: "pointer" } as React.CSSProperties}>
<Undo2 className="w-3.5 h-3.5" />
</Button>
)}
{historyState.canRedo && (
<Button variant="ghost" size="icon-sm" onClick={redo} title={historyState.redoLabel ? `Redo ${historyState.redoLabel}` : "Redo"} style={{ WebkitAppRegion: "no-drag", cursor: "pointer" } as React.CSSProperties}>
<Redo2 className="w-3.5 h-3.5" />
</Button>
)}

<Button variant="ghost" size="icon-sm" onClick={toggleSettingsModal} title="Settings" style={{ WebkitAppRegion: "no-drag", cursor: "pointer" } as React.CSSProperties}>
Expand Down
255 changes: 255 additions & 0 deletions src/components/editor/timeline/AudioEnvelopeEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import React, { useState, useRef } from "react";
import { useTimelineStore } from "@/store/timelineStore";
import { useHistoryStore } from "@/store/historyStore";
import { TransformClipCommand } from "@/core/history/commands/TransformCommand";
import type { Clip } from "@/types";

interface AudioEnvelopeEditorProps {
clip: Clip;
clipWidthPx: number;
pixelsPerSecond: number;
}

export const AudioEnvelopeEditor: React.FC<AudioEnvelopeEditorProps> = ({
clip,
clipWidthPx,
pixelsPerSecond,
}) => {
const updateClip = useTimelineStore((s) => s.updateClip);
const { execute } = useHistoryStore();

const containerRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [activeDrag, setActiveDrag] = useState<"fadeIn" | "fadeOut" | "volume" | null>(null);
const [dragValue, setDragValue] = useState<number | null>(null);

// Drag tracking refs
const dragStartRef = useRef<{
startX: number;
startY: number;
initialVolume: number;
initialFadeIn: number;
initialFadeOut: number;
clipHeight: number;
} | null>(null);

const volume = clip.volume ?? 1.0;
const fadeIn = clip.fadeIn ?? 0;
const fadeOut = clip.fadeOut ?? 0;

// Calculate pixel positions for SVG paths
const fadeInPx = Math.max(0, Math.min(clipWidthPx, fadeIn * pixelsPerSecond));
const fadeOutPx = Math.max(0, Math.min(clipWidthPx, fadeOut * pixelsPerSecond));

// Height is 100% of container. Volume maps to Y position:
// Volume 1.0 => 10% (top padding)
// Volume 0.0 => 90% (bottom padding)
const volumePercent = volume * 100;
const volumeYPercent = 90 - volume * 80; // 0.0 -> 90%, 1.0 -> 10%

// Build SVG path for envelope visual overlay
const envelopePoints = `
0,100
${(fadeInPx / clipWidthPx) * 100},${volumeYPercent}
${((clipWidthPx - fadeOutPx) / clipWidthPx) * 100},${volumeYPercent}
100,100
`;

// Start drag handler
const handleDragStart = (
e: React.PointerEvent<HTMLDivElement>,
type: "fadeIn" | "fadeOut" | "volume"
) => {
e.stopPropagation();
e.preventDefault();

const container = containerRef.current;
if (!container) return;

const rect = container.getBoundingClientRect();
const clipHeight = rect.height || 40;

dragStartRef.current = {
startX: e.clientX,
startY: e.clientY,
initialVolume: volume,
initialFadeIn: fadeIn,
initialFadeOut: fadeOut,
clipHeight,
};

setActiveDrag(type);
setDragValue(type === "volume" ? volume : type === "fadeIn" ? fadeIn : fadeOut);
container.setPointerCapture(e.pointerId);
};

// Pointer move handler
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!activeDrag || !dragStartRef.current) return;

const start = dragStartRef.current;
const deltaX = e.clientX - start.startX;
const deltaY = e.clientY - start.startY;

if (activeDrag === "fadeIn") {
const deltaTime = deltaX / pixelsPerSecond;
const nextFadeIn = Math.max(0, Math.min(clip.duration - start.initialFadeOut, start.initialFadeIn + deltaTime));
updateClip(clip.id, { fadeIn: nextFadeIn });
setDragValue(nextFadeIn);
} else if (activeDrag === "fadeOut") {
const deltaTime = -deltaX / pixelsPerSecond;
const nextFadeOut = Math.max(0, Math.min(clip.duration - start.initialFadeIn, start.initialFadeOut + deltaTime));
updateClip(clip.id, { fadeOut: nextFadeOut });
setDragValue(nextFadeOut);
} else if (activeDrag === "volume") {
// Dragging UP decreases Y coordinate, so -deltaY increases volume
const deltaVol = -deltaY / (start.clipHeight * 0.8);
const nextVol = Math.max(0, Math.min(1.0, start.initialVolume + deltaVol));
updateClip(clip.id, { volume: nextVol });
setDragValue(nextVol);
}
};

// Pointer up handler
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!activeDrag || !dragStartRef.current) return;

const start = dragStartRef.current;
const finalVolume = clip.volume ?? 1.0;
const finalFadeIn = clip.fadeIn ?? 0;
const finalFadeOut = clip.fadeOut ?? 0;

containerRef.current?.releasePointerCapture(e.pointerId);
dragStartRef.current = null;
setActiveDrag(null);
setDragValue(null);

// Commit change to undo history if anything actually changed
if (
finalVolume !== start.initialVolume ||
finalFadeIn !== start.initialFadeIn ||
finalFadeOut !== start.initialFadeOut
) {
execute(
new TransformClipCommand(
clip.id,
{
volume: start.initialVolume,
fadeIn: start.initialFadeIn,
fadeOut: start.initialFadeOut,
},
{
volume: finalVolume,
fadeIn: finalFadeIn,
fadeOut: finalFadeOut,
}
)
);
}
};

// Double click resets volume to 100%
const handleVolumeDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (volume !== 1.0) {
execute(
new TransformClipCommand(
clip.id,
{ volume },
{ volume: 1.0 }
)
);
}
};

return (
<div
ref={containerRef}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className="absolute inset-0 z-20 pointer-events-none select-none overflow-hidden"
>
{/* Visual Envelope Shape (SVG) */}
<svg
className="w-full h-full absolute inset-0 opacity-40 hover:opacity-60 transition-opacity"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* Shaded area underneath volume envelope */}
<polygon
points={envelopePoints}
fill="rgba(16, 185, 129, 0.12)"
stroke="none"
/>
{/* Envelope boundary line */}
<polyline
points={`0,100 ${(fadeInPx / clipWidthPx) * 100},${volumeYPercent} ${((clipWidthPx - fadeOutPx) / clipWidthPx) * 100},${volumeYPercent} 100,100`}
fill="none"
stroke="rgba(16, 185, 129, 0.65)"
strokeWidth="1.5"
/>
</svg>

{/* Draggable fade-in handle (knob) */}
<div
className={`absolute w-3 h-3 bg-emerald-400 border border-white rounded-bl-lg cursor-ew-resize pointer-events-auto transition-opacity duration-150 flex items-center justify-center shadow-lg ${
isHovered || activeDrag === "fadeIn" ? "opacity-100 animate-fade-in" : "opacity-0"
}`}
style={{
left: `${fadeInPx}px`,
top: "0px",
transform: "translateX(-50%)",
}}
onPointerDown={(e) => handleDragStart(e, "fadeIn")}
title={`Fade In: ${fadeIn.toFixed(1)}s`}
/>

{/* Draggable fade-out handle (knob) */}
<div
className={`absolute w-3 h-3 bg-emerald-400 border border-white rounded-br-lg cursor-ew-resize pointer-events-auto transition-opacity duration-150 flex items-center justify-center shadow-lg ${
isHovered || activeDrag === "fadeOut" ? "opacity-100 animate-fade-in" : "opacity-0"
}`}
style={{
right: `${fadeOutPx}px`,
top: "0px",
transform: "translateX(50%)",
}}
onPointerDown={(e) => handleDragStart(e, "fadeOut")}
title={`Fade Out: ${fadeOut.toFixed(1)}s`}
/>

{/* Draggable volume bar line */}
<div
className={`absolute left-0 w-full h-[6px] -translate-y-1/2 cursor-ns-resize pointer-events-auto flex items-center transition-all ${
isHovered || activeDrag === "volume" ? "opacity-100" : "opacity-0"
}`}
style={{
top: `${volumeYPercent}%`,
}}
onPointerDown={(e) => handleDragStart(e, "volume")}
onDoubleClick={handleVolumeDoubleClick}
title="Double-click to reset volume"
>
<div className="w-full h-[1.5px] bg-emerald-400/90 shadow-[0_0_4px_rgba(52,211,153,0.5)] hover:bg-white" />
</div>

{/* Value drag Tooltip indicator */}
{activeDrag && dragValue !== null && (
<div
className="absolute left-1/2 bottom-1.5 -translate-x-1/2 bg-slate-950/85 text-[9px] font-bold text-white px-2 py-0.5 rounded border border-white/10 shadow-md flex items-center gap-1 backdrop-blur-sm z-30"
>
<span>
{activeDrag === "volume"
? `Volume: ${Math.round(dragValue * 100)}%`
: activeDrag === "fadeIn"
? `Fade In: ${dragValue.toFixed(1)}s`
: `Fade Out: ${dragValue.toFixed(1)}s`}
</span>
</div>
)}
</div>
);
};
9 changes: 9 additions & 0 deletions src/components/editor/timeline/Clip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getPlaybackClock, useTransportControls } from "@/hooks/usePlaybackClock
import type { Clip as ClipType, MediaAsset } from "@/types";
import { ClipFilmstrip } from "./ClipFilmstrip";
import { TimelineWaveform } from "./TimelineWaveform";
import { AudioEnvelopeEditor } from "./AudioEnvelopeEditor";
import { convertFileSrc } from "@tauri-apps/api/core";

const isExternalOrDataUrl = (value: string) => value.startsWith("data:") || value.startsWith("http") || value.startsWith("asset://");
Expand Down Expand Up @@ -736,6 +737,9 @@ const ClipInner: React.FC<ClipProps> = ({ clip, mediaAsset, pixelsPerSecond, sel
</div>
)}

{/* Audio Envelope Editor - overlay for clips with audio */}
{(isClipAudio || isClipVideo) && <AudioEnvelopeEditor clip={clip} clipWidthPx={width} pixelsPerSecond={pixelsPerSecond} />}

{/* Right trim handle */}
<div
data-testid={`clip-${clip.id}-resize-right`}
Expand Down Expand Up @@ -772,6 +776,11 @@ const arePropsEqual = (prevProps: ClipProps, nextProps: ClipProps) => {
return false;
}

// Check audio envelope properties
if (prevProps.clip.volume !== nextProps.clip.volume || prevProps.clip.fadeIn !== nextProps.clip.fadeIn || prevProps.clip.fadeOut !== nextProps.clip.fadeOut) {
return false;
}

// Check other props
if (prevProps.pixelsPerSecond !== nextProps.pixelsPerSecond || prevProps.selected !== nextProps.selected || prevProps.locked !== nextProps.locked || prevProps.isBeingShifted !== nextProps.isBeingShifted) {
return false;
Expand Down
Loading
Loading