fix(export, native, ui): resolve critical data integrity and resource management bugs in PR #122

- Implement comprehensive solo/mute export pipeline in audioEncoder.ts
- Fix duplicate caption IDs by adding chunk-specific indices in handlers.ts
- Prioritize native monitor selection by bounds in monitor_utils.cpp
- Fix alignment for blur annotations extending beyond canvas boundaries
- Resolve timeline selection desync and sidebar visibility for blur annotations
- Fix memory leaks in audio waveform generation with AudioContext cleanup
This commit is contained in:
Mahdy Arief committed 2026-03-28 06:09:50 +07:00
1 parent cfeabee6d6
commit b04e4b2b62
9 files changed
+82 -54

No files matched your search

+7 -4
View File
@@ -1607,7 +1607,9 @@ async function generateAutoCaptionsFromVideo(
let jsonEnabled = true
const updateChunkProgress = (progress: number) => {
if (totalDurationMs > 0) {
const totalProgress = (offsetMs / totalDurationMs * 100) + (progress / (totalDurationMs / CHUNK_SIZE_MS));
const totalRangeMs = totalDurationMs > 0 ? totalDurationMs : 1;
const rangeOffsetMs = offsetMs - startTimeMs;
const totalProgress = (rangeOffsetMs / totalRangeMs * 100) + (progress / (totalRangeMs / CHUNK_SIZE_MS));
safeSend(webContents, 'auto-caption-progress', { progress: Math.min(99, totalProgress) })
} else {
safeSend(webContents, 'auto-caption-progress', { progress })
@@ -1634,15 +1636,16 @@ async function generateAutoCaptionsFromVideo(
// Adjust timings and deduplicate
const adjustedCues = cues
.map(cue => ({
.map((cue, idx) => ({
...cue,
id: `caption-${offsetMs}-${idx}`,
startMs: cue.startMs + offsetMs,
endMs: cue.endMs + offsetMs
}))
// Only keep cues that START within this chunk's main window (prevent overlap duplicates)
// Except for the very last chunk where we take everything
.filter(cue => {
const isLastChunk = totalDurationMs > 0 && (offsetMs + CHUNK_SIZE_MS >= totalDurationMs);
const isLastChunk = offsetMs + CHUNK_SIZE_MS >= endTimeMs;
if (isLastChunk) return true;
return cue.startMs < offsetMs + CHUNK_SIZE_MS;
});
@@ -1659,7 +1662,7 @@ async function generateAutoCaptionsFromVideo(
break;
}
if (totalDurationMs > 0 && offsetMs + CHUNK_SIZE_MS >= totalDurationMs) {
if (offsetMs + CHUNK_SIZE_MS >= endTimeMs) {
break;
}
@@ -46,32 +46,7 @@ HMONITOR findMonitorByDisplayId(int64_t displayId) {
}
}
// Try matching lower 32-bits (HMONITOR handles on Windows are often 32-bit values zero-extended or sign-extended to 64-bit)
for (const auto& m : monitors) {
if ((static_cast<int64_t>(reinterpret_cast<intptr_t>(m.handle)) & 0xFFFFFFFF) == (displayId & 0xFFFFFFFF)) {
std::cerr << "WARNING: Found monitor via 32-bit partial match. Target: " << displayId
<< ", Found: " << static_cast<int64_t>(reinterpret_cast<intptr_t>(m.handle)) << std::endl;
return m.handle;
}
}
// If we only have one monitor and we couldn't match by ID, just use the only one we have.
// This is a safe fallback for the most common case (single monitor setups) where Electron
// and native Windows might report different IDs for the same display.
if (monitors.size() == 1) {
std::cerr << "WARNING: Found only one monitor, using it as fallback for displayId " << displayId
<< " (Handle: " << reinterpret_cast<intptr_t>(monitors[0].handle) << ")" << std::endl;
return monitors[0].handle;
}
// Debug: Print available monitors if not found
std::cerr << "ERROR: Monitor matching failed for displayId " << displayId << ". Enumerated " << monitors.size() << " monitors:" << std::endl;
for (const auto& m : monitors) {
std::cerr << " - Handle: " << reinterpret_cast<intptr_t>(m.handle)
<< " (device: " << wstringToString(m.deviceName)
<< ", bounds: " << m.x << "," << m.y << " " << m.width << "x" << m.height << ")" << std::endl;
}
// No exact ID match found. Return nullptr to allow main.cpp to try coordinate-based matching.
return nullptr;
}
@@ -34,9 +34,13 @@ export function AudioSettingsPanel({
const [waveform, setWaveform] = useState<number[] | null>(null);
useEffect(() => {
let active = true;
if (audio.audioPath) {
generateWaveform(audio.audioPath, 120).then(setWaveform);
generateWaveform(audio.audioPath, 120).then(result => {
if (active) setWaveform(result);
});
}
return () => { active = false; };
}, [audio.audioPath]);
const clipDurationMs = audio.endMs - audio.startMs;
+2 -1
View File
@@ -3838,6 +3838,7 @@ export default function VideoEditor() {
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onSeek={(time) => videoPlaybackRef.current?.seek(time)}
autoCaptions={autoCaptions}
onAutoCaptionsChange={setAutoCaptions}
@@ -3878,7 +3879,7 @@ export default function VideoEditor() {
onAudioFadeOutMsChange={handleAudioFadeOutMsChange}
onAudioDelete={handleAudioDelete}
selectedCaptionId={selectedCaptionId}
onSelectCaption={setSelectedCaptionId}
onSelectCaption={handleSelectCaption}
timeSelection={timeSelection}
isMasterSelected={isMasterSelected}
masterAudioVolume={masterAudioVolume}
@@ -341,7 +341,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" ? region.type : "text",
type:
region.type === "image" || region.type === "figure" || region.type === "blur"
? region.type
: "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
@@ -384,10 +387,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
figureData: region.figureData
? {
...DEFAULT_FIGURE_DATA,
...region.figureData,
}
...DEFAULT_FIGURE_DATA,
...region.figureData,
}
: undefined,
blurIntensity: isFiniteNumber(region.blurIntensity) ? region.blurIntensity : undefined,
};
})
: [];
@@ -408,7 +412,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
startMs,
endMs,
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 2) : 1,
muted: typeof region.muted === "boolean" ? region.muted : false,
soloed: typeof region.soloed === "boolean" ? region.soloed : false,
fadeInMs: isFiniteNumber(region.fadeInMs) ? clamp(region.fadeInMs, 0, 10000) : 0,
+9 -9
View File
@@ -307,25 +307,25 @@ function renderBlur(
// Determine bounds and ensure they are within canvas to avoid ImageData errors
const srcX = Math.max(0, x);
const srcY = Math.max(0, y);
const srcW = Math.min(width, ctx.canvas.width - srcX);
const srcH = Math.min(height, ctx.canvas.height - srcY);
const srcWidth = Math.min(x + width, ctx.canvas.width) - srcX;
const srcHeight = Math.min(y + height, ctx.canvas.height) - srcY;
if (srcW <= 0 || srcH <= 0) {
if (srcWidth <= 0 || srcHeight <= 0) {
ctx.restore();
return;
}
// Capture the current canvas region
const imageData = ctx.getImageData(srcX, srcY, srcW, srcH);
// Capture the current canvas region precisely for this intersection
const imageData = ctx.getImageData(srcX, srcY, srcWidth, srcHeight);
let offscreen: HTMLCanvasElement | OffscreenCanvas;
if (typeof document !== 'undefined') {
offscreen = document.createElement('canvas');
} else {
offscreen = new OffscreenCanvas(srcW, srcH);
offscreen = new OffscreenCanvas(srcWidth, srcHeight);
}
offscreen.width = srcW;
offscreen.height = srcH;
offscreen.width = srcWidth;
offscreen.height = srcHeight;
const offCtx = offscreen.getContext('2d') as CanvasRenderingContext2D;
offCtx.putImageData(imageData, 0, 0);
@@ -340,7 +340,7 @@ function renderBlur(
}
ctx.clip();
// Apply the blur filter and draw the captured region back
// Apply the blur filter and draw the captured region back at its source position
ctx.filter = `blur(${intensity}px)`;
ctx.drawImage(offscreen as any, srcX, srcY);
+37 -7
View File
@@ -28,6 +28,7 @@ export class AudioProcessor {
masterAudioVolume = 1,
audioTrackVolume = 1,
masterAudioMuted = false,
masterAudioSoloed = false,
): Promise<void> {
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : []
const sortedSpeedRegions = speedRegions
@@ -39,8 +40,15 @@ export class AudioProcessor {
? [...audioRegions].sort((a, b) => a.startMs - b.startMs)
: []
// When audio regions or speed edits are present, use AudioContext mixing path.
if (sortedSpeedRegions.length > 0 || sortedAudioRegions.length > 0) {
// When audio regions, speed edits, or global volume/mute settings are present, use AudioContext mixing path.
if (
sortedSpeedRegions.length > 0 ||
sortedAudioRegions.length > 0 ||
masterAudioVolume !== 1 ||
audioTrackVolume !== 1 ||
masterAudioMuted ||
masterAudioSoloed
) {
const renderedAudioBlob = await this.renderMixedTimelineAudio(
videoUrl,
sortedTrims,
@@ -49,6 +57,7 @@ export class AudioProcessor {
masterAudioVolume,
audioTrackVolume,
masterAudioMuted,
masterAudioSoloed,
)
if (!this.cancelled) {
await this.muxRenderedAudioBlob(renderedAudioBlob, muxer)
@@ -288,6 +297,7 @@ export class AudioProcessor {
masterAudioVolume: number,
audioTrackVolume: number,
masterAudioMuted: boolean,
masterAudioSoloed: boolean,
): Promise<Blob> {
const mediaSource = await resolveMediaElementSource(videoUrl)
const media = document.createElement('audio')
@@ -311,9 +321,11 @@ export class AudioProcessor {
const audioContext = new AudioContext()
const destinationNode = audioContext.createMediaStreamDestination()
// Connect original video audio
// Connect original video audio with its own gain node
const sourceNode = audioContext.createMediaElementSource(media)
sourceNode.connect(destinationNode)
const masterGainNode = audioContext.createGain()
sourceNode.connect(masterGainNode)
masterGainNode.connect(destinationNode)
// Prepare external audio region elements
const audioRegionElements: {
@@ -412,6 +424,18 @@ export class AudioProcessor {
}
}
// Check for Solo - if any track is soloed, everything else is muted
const anyTrackSoloed = masterAudioSoloed || audioRegionElements.some(e => e.region.soloed);
// Update Master Video Audio Track Volume
let masterTargetVolume = masterAudioMuted ? 0 : audioTrackVolume;
if (anyTrackSoloed && !masterAudioSoloed) {
masterTargetVolume = 0;
}
// Factor in the overall Master output volume
masterTargetVolume *= masterAudioVolume;
masterGainNode.gain.setTargetAtTime(masterTargetVolume, audioContext.currentTime, 0.015);
// Sync external audio regions with the video timeline position
for (const entry of audioRegionElements) {
const { media: audioEl, region, gainNode } = entry
@@ -429,9 +453,15 @@ export class AudioProcessor {
}
fadeMultiplier = Math.max(0, Math.min(1, fadeMultiplier));
// Apply total volume including global settings
const totalVolume = masterAudioMuted ? 0 : region.volume * audioTrackVolume * masterAudioVolume * fadeMultiplier;
gainNode.gain.setTargetAtTime(totalVolume, audioContext.currentTime, 0.015);
// Respect Solo and Mute
let regionTargetVolume = (region.muted || masterAudioMuted) ? 0 : region.volume;
if (anyTrackSoloed && !region.soloed) {
regionTargetVolume = 0;
}
// Also factor in global master volume
regionTargetVolume *= masterAudioVolume * fadeMultiplier;
gainNode.gain.setTargetAtTime(regionTargetVolume, audioContext.currentTime, 0.015);
if (audioEl.paused) {
audioEl.currentTime = audioOffset
+5
View File
@@ -59,6 +59,10 @@ interface VideoExporterConfig extends ExportConfig {
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
masterAudioVolume: number;
masterAudioMuted: boolean;
masterAudioSoloed: boolean;
audioTrackVolume: number;
}
export class VideoExporter {
@@ -210,6 +214,7 @@ export class VideoExporter {
this.config.masterAudioVolume,
this.config.audioTrackVolume,
this.config.masterAudioMuted,
this.config.masterAudioSoloed,
),
"audio processing",
);
+7 -1
View File
@@ -13,12 +13,12 @@ export async function generateWaveform(audioPath: string, samples = 200): Promis
return waveformCache.get(cacheKey)!;
}
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
try {
const response = await fetch(toFileUrl(audioPath));
const arrayBuffer = await response.arrayBuffer();
// Use an offline audio context to decode the data
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const channelData = audioBuffer.getChannelData(0); // Use the first channel
@@ -40,5 +40,11 @@ export async function generateWaveform(audioPath: string, samples = 200): Promis
} catch (error) {
console.error('Failed to generate waveform:', error);
return new Array(samples).fill(0);
} finally {
try {
await audioContext.close();
} catch (e) {
// ignore close errors
}
}
}