Merge feat(export): allow re-saving exported video on dialog cancel (PR #181)

This commit is contained in:
Siddharth
2026-03-21 17:06:36 -07:00
3 changed files with 173 additions and 7 deletions
@@ -130,6 +130,8 @@ interface SettingsPanelProps {
onSaveProject?: () => void;
onLoadProject?: () => void;
onExport?: () => void;
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
onSaveUnsavedExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
onAnnotationContentChange?: (id: string, content: string) => void;
@@ -198,6 +200,8 @@ export function SettingsPanel({
onSaveProject,
onLoadProject,
onExport,
unsavedExport,
onSaveUnsavedExport,
selectedAnnotationId,
annotationRegions = [],
onAnnotationContentChange,
@@ -1150,6 +1154,17 @@ export function SettingsPanel({
</Button>
</div>
{unsavedExport && (
<Button
type="button"
size="lg"
onClick={onSaveUnsavedExport}
className="w-full mb-2 py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-500/20 hover:bg-indigo-500/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<Download className="w-4 h-4" />
Choose Save Location
</Button>
)}
<Button
data-testid={getTestId("export-button")}
type="button"
@@ -105,6 +105,11 @@ export default function VideoEditor() {
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
const [unsavedExport, setUnsavedExport] = useState<{
arrayBuffer: ArrayBuffer;
fileName: string;
format: string;
} | null>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
@@ -966,6 +971,27 @@ export default function VideoEditor() {
[handleShowExportedFile],
);
const handleSaveUnsavedExport = useCallback(async () => {
if (!unsavedExport) return;
try {
const saveResult = await window.electronAPI.saveExportedVideo(
unsavedExport.arrayBuffer,
unsavedExport.fileName,
);
if (saveResult.canceled) {
toast.info("Export canceled");
} else if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
} else {
toast.error(saveResult.message || "Failed to save export");
}
} catch (error) {
console.error("Error saving unsaved export:", error);
toast.error("Failed to save exported video");
}
}, [unsavedExport, handleExportSaved]);
const handleExport = useCallback(
async (settings: ExportSettings) => {
if (!videoPath) {
@@ -1045,8 +1071,10 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
toast.info("Export canceled");
} else if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("GIF", saveResult.path);
} else {
setExportError(saveResult.message || "Failed to save GIF");
@@ -1173,8 +1201,10 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
toast.info("Export canceled");
} else if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("Video", saveResult.path);
} else {
setExportError(saveResult.message || "Failed to save video");
@@ -1528,6 +1558,8 @@ export default function VideoEditor() {
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
/>
</Panel>
</PanelGroup>
@@ -169,6 +169,33 @@ function createInitialRange(totalMs: number): Range {
return { start: 0, end: FALLBACK_RANGE_MS };
}
function clampVisibleRange(candidate: Range, totalMs: number): Range {
if (totalMs <= 0) {
return candidate;
}
const span = Math.max(candidate.end - candidate.start, 1);
if (span >= totalMs) {
return { start: 0, end: totalMs };
}
const start = Math.max(0, Math.min(candidate.start, totalMs - span));
return { start, end: start + span };
}
function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number {
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
return delta * 16;
}
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return delta * pageSizePx;
}
return delta;
}
function formatTimeLabel(milliseconds: number, intervalMs: number) {
const totalSeconds = milliseconds / 1000;
const hours = Math.floor(totalSeconds / 3600);
@@ -204,18 +231,21 @@ function PlaybackCursor({
currentTimeMs,
videoDurationMs,
onSeek,
onRangeChange,
timelineRef,
keyframes = [],
}: {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
onRangeChange?: (updater: (previous: Range) => Range) => void;
timelineRef: React.RefObject<HTMLDivElement>;
keyframes?: { id: string; time: number }[];
}) {
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);
useEffect(() => {
if (!isDragging) return;
@@ -225,6 +255,7 @@ function PlaybackCursor({
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const contentWidth = Math.max(rect.width - sidebarWidth, 1);
// Allow dragging outside to 0 or max, but clamp the value
const relativeMs = pixelsToValue(clickX);
@@ -243,11 +274,51 @@ function PlaybackCursor({
absoluteMs = nearbyKeyframe.time;
}
setDragPreviewTimeMs(absoluteMs);
const visibleMs = range.end - range.start;
if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) {
const msPerPixel = visibleMs / contentWidth;
const overflowLeftPx = Math.max(0, -clickX);
const overflowRightPx = Math.max(0, clickX - contentWidth);
if (overflowLeftPx > 0 && range.start > 0) {
const shiftMs = overflowLeftPx * msPerPixel;
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start - shiftMs,
end: previous.end - shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
} else if (overflowRightPx > 0 && range.end < videoDurationMs) {
const shiftMs = overflowRightPx * msPerPixel;
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start + shiftMs,
end: previous.end + shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
}
}
onSeek(absoluteMs / 1000);
};
const handleMouseUp = () => {
setIsDragging(false);
setDragPreviewTimeMs(null);
document.body.style.cursor = "";
};
@@ -263,6 +334,7 @@ function PlaybackCursor({
}, [
isDragging,
onSeek,
onRangeChange,
timelineRef,
sidebarWidth,
range.start,
@@ -272,11 +344,14 @@ function PlaybackCursor({
keyframes,
]);
if (videoDurationMs <= 0 || currentTimeMs < 0) {
const displayTimeMs =
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;
if (videoDurationMs <= 0 || displayTimeMs < 0) {
return null;
}
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
const clampedTime = Math.min(displayTimeMs, videoDurationMs);
if (clampedTime < range.start || clampedTime > range.end) {
return null;
@@ -299,6 +374,7 @@ function PlaybackCursor({
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent timeline click
setDragPreviewTimeMs(currentTimeMs);
setIsDragging(true);
}}
>
@@ -444,6 +520,7 @@ function Timeline({
videoDurationMs,
currentTimeMs,
onSeek,
onRangeChange,
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
@@ -458,6 +535,7 @@ function Timeline({
videoDurationMs: number;
currentTimeMs: number;
onSeek?: (time: number) => void;
onRangeChange?: (updater: (previous: Range) => Range) => void;
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
@@ -514,6 +592,46 @@ function Timeline({
],
);
const handleTimelineWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
return;
}
const visibleMs = range.end - range.start;
if (visibleMs <= 0 || videoDurationMs <= visibleMs) {
return;
}
const dominantDelta =
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (dominantDelta === 0) {
return;
}
event.preventDefault();
const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1);
const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx);
const shiftMs = pixelsToValue(normalizedDeltaPx);
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start + shiftMs,
end: previous.end + shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
},
[onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue],
);
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
@@ -525,6 +643,7 @@ function Timeline({
style={style}
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
onClick={handleTimelineClick}
onWheel={handleTimelineWheel}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
@@ -532,6 +651,7 @@ function Timeline({
currentTimeMs={currentTimeMs}
videoDurationMs={videoDurationMs}
onSeek={onSeek}
onRangeChange={onRangeChange}
timelineRef={localTimelineRef}
keyframes={keyframes}
/>
@@ -657,17 +777,15 @@ export default function TimelineEditor({
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
pan: "Scroll",
zoom: "Ctrl + Scroll",
});
const timelineContainerRef = useRef<HTMLDivElement>(null);
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
useEffect(() => {
formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
formatShortcut(["mod", "Scroll"]).then((zoom) => {
setScrollLabels({ pan, zoom });
});
formatShortcut(["mod", "Scroll"]).then((zoom) => {
setScrollLabels({ pan: "Scroll", zoom });
});
}, []);
@@ -1351,6 +1469,7 @@ export default function TimelineEditor({
videoDurationMs={totalMs}
currentTimeMs={currentTimeMs}
onSeek={onSeek}
onRangeChange={setRange}
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}