diff --git a/README.md b/README.md index 4ca11c8..6a5a189 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ - 🔧 **Forced Repair** — Fixes files with corrupted timestamps via an intermediate pipeline (`.w64`/`.mp4`). - 🔬 **Quick Scan + Deep Scan** — Checks container integrity and analyzes frame by frame looking for artifacts and errors. - 🔬 **Myopic Scan** — Deep Scan restricted to selected tracks. +- 🔊 **Silence Scan** — Analyze and identify extended periods of silence within audio tracks. - 🎛️ **Track Selection** — Choose which video, audio, and subtitle streams to keep in the final file. +- 🎶 **Smart Spectrum Sync** — Automatically aligns audio tracks from different sources, using advanced waveform cross-correlation. - ⏱️ **Sync Adjustment / End Cut** — Defines time offset and end cut to avoid lip-sync issues. - 🔀 **File Merging** — Merges tracks from two files into a single MKV, with automatic/manual sync and Strict Mode. - 🏷️ **Tag Editing** — Edits language (e.g., `por`, `eng`, `jpn`) and title for each track. @@ -101,6 +103,7 @@ After analyzing a file, an interactive menu is displayed with the following opti - ⏱️ **Adjust Sync / End Cut** — Defines time offset and end cut. - 🔍 **Deep Scan** — Frame-by-frame analysis of all tracks. - 🔬 **Myopic Scan** — Deep Scan only on selected tracks. +- 🔊 **Silence Scan** — Detects long periods of silence in audio tracks. - 🏷️ **Edit Tags** — Edits language and title of each track. ## ⚙️ Configuration diff --git a/README.pt.md b/README.pt.md index 93271a4..e54e3a5 100644 --- a/README.pt.md +++ b/README.pt.md @@ -28,8 +28,10 @@ - 🔄 **Conversão (Transcode)** — Converte para codecs Direct Play (H.264 8-bit / AAC, EAC3 ou FLAC) com regras de fallback configuráveis. - 🔧 **Reparo Forçado** — Corrige arquivos com timestamps corrompidos via pipeline intermediário (`.w64`/`.mp4`). - 🔬 **Quick Scan + Deep Scan** — Verifica integridade do container e analisa quadro a quadro em busca de artefatos e erros. -- 🔬 **Myopic Scan** — Deep Scan restrito às faixas selecionadas. +- 🔬 **Myopic Scan** — Deep Scan restrito apenas às faixas selecionadas. +- 🔊 **Silence Scan** — Analisar e identificar longos períodos de silêncio nas faixas de áudio. - 🎛️ **Seleção de Faixas** — Escolha quais streams de vídeo, áudio e legenda manter no arquivo final. +- 🎶 **Smart Spectrum Sync** — Alinha automaticamente faixas de áudio de origens diferentes, usando correlação matemática de ondas sonoras. - ⏱️ **Ajuste de Sincronia / Corte Final** — Define offset temporal e corte final para evitar problemas de lip-sync. - 🔀 **Mesclagem de Arquivos** — Une faixas de dois arquivos em um único MKV, com sync automático/manual e Modo Estrito. - 🏷️ **Edição de Tags** — Edita idioma (ex: `por`, `eng`, `jpn`) e título de cada faixa. @@ -101,7 +103,8 @@ Após a análise de um arquivo, um menu interativo é exibido com as seguintes o - ⏱️ **Ajustar Sincronia / Corte Final** — Define offset temporal e corte final. - 🔍 **Deep Scan** — Análise quadro a quadro de todas as faixas. - 🔬 **Myopic Scan** — Deep Scan apenas nas faixas selecionadas. -- 🏷️ **Editar Tags** — Edita idioma e título de cada faixa. +- 🔊 **Silence Scan** — Detecta longos períodos de silêncio nas faixas de áudio. +- 🏷️ **Editar Tags** — Edita o idioma e título de cada faixa. ## ⚙️ Configuração diff --git a/src/commands/check.ts b/src/commands/check.ts index 52481df..02b9617 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -13,7 +13,7 @@ import { buildCheckCommand } from '../utils/builder.ts'; import { ValidationError } from '../utils/errors.ts'; import { getMediaInfo, runQuickScan } from '../utils/ffprobe.ts'; import { t } from '../utils/i18n.ts'; -import { filterGarbageStreams } from '../utils/mediaUtils.ts'; +import { filterGarbageStreams, buildAudioMaps, buildAudioLabels, buildSelectedAudioMaps, buildSelectedAudioLabels } from '../utils/mediaUtils.ts'; import { editTagsMenu, handleExecutionMenu, onCancel, sanitizePath } from '../utils/ui.ts'; import { renderActionPlan, renderMatrix } from '../views/checkView.ts'; import { buildGroupedOptions } from '../views/streamOptions.ts'; @@ -35,8 +35,12 @@ interface CheckLoopContext { totalFrames: number; fullScanInputs: string[]; fullScanMaps: string[]; + fullAudioScanMaps: string[]; + fullAudioScanLabels: string[]; selectedScanInputs: string[]; selectedScanMaps: string[]; + selectedAudioScanMaps: string[]; + selectedAudioScanLabels: string[]; } export async function checkCommand(args: string[]) { @@ -69,8 +73,12 @@ export async function checkCommand(args: string[]) { ffmpegRepairCmd: context.ffmpegRepairCmd, fullScanInputs: context.fullScanInputs, fullScanMaps: context.fullScanMaps, + fullAudioScanMaps: context.fullAudioScanMaps, + fullAudioScanLabels: context.fullAudioScanLabels, selectedScanInputs: context.selectedScanInputs, selectedScanMaps: context.selectedScanMaps, + selectedAudioScanMaps: context.selectedAudioScanMaps, + selectedAudioScanLabels: context.selectedAudioScanLabels, outputPath, totalDuration: context.totalDuration, totalFrames: context.totalFrames, @@ -210,8 +218,12 @@ function buildLoopContext( totalFrames: diagnostic.metadata.totalFrames, fullScanInputs: [targetFile], fullScanMaps: ['0'], + fullAudioScanMaps: buildAudioMaps(probeData.streams, 0), + fullAudioScanLabels: buildAudioLabels(probeData.streams), selectedScanInputs: [targetFile], - selectedScanMaps: selectedStreams.map((stream) => `0:${stream.streamIndex}`) + selectedScanMaps: selectedStreams.map((stream) => `0:${stream.streamIndex}`), + selectedAudioScanMaps: buildSelectedAudioMaps(selectedStreams), + selectedAudioScanLabels: buildSelectedAudioLabels(selectedStreams) }; } diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 26e4cb6..70fda13 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { confirm, groupMultiselect, log, note, select, text } from '@clack/prompts'; +import { confirm, groupMultiselect, log, note, select, text, spinner } from '@clack/prompts'; import pc from 'picocolors'; import { getPreferredVideoSource, getPrimaryVideoStream } from '../services/analyzer.ts'; @@ -11,13 +11,18 @@ import type { FFprobeData, SelectedStream } from '../types/media'; import { buildMergeCommand } from '../utils/builder.ts'; import { ValidationError } from '../utils/errors.ts'; import { getMediaInfo } from '../utils/ffprobe.ts'; -import { calculateTotalFrames } from '../utils/formatters.ts'; +import { calculateTotalFrames, parseTimestampToSeconds, formatSecondsToTimestamp } from '../utils/formatters.ts'; import { t } from '../utils/i18n.ts'; import { editTagsMenu, handleExecutionMenu, onCancel, sanitizePath } from '../utils/ui.ts'; import { buildSyncOptions, renderComparison } from '../views/mergeView.ts'; import { buildGroupedOptions } from '../views/streamOptions.ts'; +import { calculateSpectrumDelay } from '../services/spectrumAnalyzer.ts'; +import { extractRawAudio } from '../utils/ffmpeg.ts'; +import { buildAudioMaps, buildAudioLabels, buildSelectedAudioMaps, buildSelectedAudioLabels } from '../utils/mediaUtils.ts'; const fallbackRules = fallbackRulesData as FallbackRules; +const SPECTRUM_SAMPLE_WINDOW_SEC = 10; +const SPECTRUM_SEARCH_WINDOW_SEC = 30; interface MergeSourcePaths { sourcePathA: string; @@ -54,6 +59,10 @@ interface MergeLoopContext { totalFrames: number; fullScanInputs: string[]; fullScanMaps: string[]; + fullAudioScanMaps: string[]; + fullAudioScanLabels: string[]; + selectedAudioScanMaps: string[]; + selectedAudioScanLabels: string[]; } export async function mergeCommand(_args: string[]) { @@ -72,7 +81,7 @@ export async function mergeCommand(_args: string[]) { if (Math.abs(media.durationDiffMs) > 1000) { note(pc.yellow(t('mergeDurationAlert')), t('durationAlertTitle')); - applySyncState(state, await promptSyncAdjustment(media.durationDiffMs, state)); + applySyncState(state, await promptSyncAdjustment(media, state)); } while (true) { @@ -85,6 +94,10 @@ export async function mergeCommand(_args: string[]) { ffmpegRepairCmd: context.ffmpegRepairCmd, fullScanInputs: context.fullScanInputs, fullScanMaps: context.fullScanMaps, + fullAudioScanMaps: context.fullAudioScanMaps, + fullAudioScanLabels: context.fullAudioScanLabels, + selectedAudioScanMaps: context.selectedAudioScanMaps, + selectedAudioScanLabels: context.selectedAudioScanLabels, outputPath: media.outputPath, totalDuration: context.totalDuration, totalFrames: context.totalFrames, @@ -110,7 +123,7 @@ export async function mergeCommand(_args: string[]) { } if (result.action === 'adjust_sync') { - applySyncState(state, await promptSyncAdjustment(media.durationDiffMs, state)); + applySyncState(state, await promptSyncAdjustment(media, state)); continue; } @@ -223,10 +236,10 @@ function buildStreamOptionSources(media: MergeMediaContext) { } async function promptSyncAdjustment( - durationDiffMs: number, + media: MergeMediaContext, currentState: MergeSyncState ): Promise { - const options = buildSyncOptions(durationDiffMs); + const options = buildSyncOptions(media.durationDiffMs); const chosenSyncAction = onCancel(await select({ message: t('mergeHowToSync'), @@ -236,8 +249,50 @@ async function promptSyncAdjustment( let nextDelayMs = currentState.currentDelayMs; let nextApplyShortest = currentState.applyShortest; - if (chosenSyncAction === 'auto') { - nextDelayMs = durationDiffMs; + if (chosenSyncAction === 'spectrum') { + log.info(pc.cyan(t('mergeSpectrumHint'))); + + const tsStr = onCancel(await text({ + message: t('mergeAskTimestamp'), + placeholder: '00:15:30', + validate(value) { + if (!value || !value.includes(':')) return t('validNumber'); + } + })); + + const durA = SPECTRUM_SAMPLE_WINDOW_SEC; + const durB = SPECTRUM_SEARCH_WINDOW_SEC; + + const tsSecs = parseTimestampToSeconds(tsStr); + const tsBSecs = Math.max(0, tsSecs - SPECTRUM_SAMPLE_WINDOW_SEC); // Starts before the timestamp + const startTsB = formatSecondsToTimestamp(tsBSecs); + + const s = spinner(); + s.start(t('mergeSpectrumExtracting')); + + try { + const bufferA = await extractRawAudio(media.sourcePathA, tsStr, durA); + const bufferB = await extractRawAudio(media.sourcePathB, startTsB, durB); + + s.message(t('mergeSpectrumCalculating')); + + const rawOffsetMs = calculateSpectrumDelay(bufferA, bufferB); + const diffSecs = tsSecs - tsBSecs; + + const realDelayMs = rawOffsetMs - (diffSecs * 1000); + nextDelayMs = Math.round(realDelayMs * -1); + + s.stop(pc.green(t('successOp'))); + log.success(pc.green(t('mergeSpectrumResult', nextDelayMs))); + + } catch (err) { + s.stop(pc.red(t('mergeSpectrumFailed'))); + if (err instanceof Error) { + log.error(err.message); + } + } + } else if (chosenSyncAction === 'auto') { + nextDelayMs = media.durationDiffMs; } else if (chosenSyncAction === 'manual') { const delayStr = onCancel(await text({ message: t('mergeAskDelay'), @@ -297,7 +352,11 @@ function buildLoopContext(media: MergeMediaContext, state: MergeSessionState): M totalDuration: media.totalDuration, totalFrames: media.totalFrames, fullScanInputs: [media.sourcePathA, media.sourcePathB], - fullScanMaps: buildFullScanMaps(media) + fullScanMaps: buildFullScanMaps(media), + fullAudioScanMaps: buildFullAudioScanMaps(media), + fullAudioScanLabels: buildFullAudioScanLabels(media), + selectedAudioScanMaps: buildSelectedAudioMaps(state.selectedStreams), + selectedAudioScanLabels: buildSelectedAudioLabels(state.selectedStreams) }; } @@ -308,6 +367,20 @@ function buildFullScanMaps(media: MergeMediaContext) { ]; } +function buildFullAudioScanMaps(media: MergeMediaContext) { + return [ + ...buildAudioMaps(media.infoA.streams, 0), + ...buildAudioMaps(media.infoB.streams, 1) + ]; +} + +function buildFullAudioScanLabels(media: MergeMediaContext) { + return [ + ...buildAudioLabels(media.infoA.streams), + ...buildAudioLabels(media.infoB.streams) + ]; +} + function buildSourceScanMaps(probeData: FFprobeData, fileIndex: number) { return probeData.streams .filter((stream) => stream.codec_type === 'video' || stream.codec_type === 'audio') diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 3d4b283..63b1fc8 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -17,6 +17,7 @@ export default { menuAdjustSync: '⏱️ Adjust Sync / Final Cut', menuDeepScanFull: '🔍 Deep Scan (All tracks from original file)', menuDeepScanSelected: '🔬 Myopic Scan (Selected tracks only)', + menuSilenceScan: '🔊 Silence Scan (Check for audio drops / Mute)', menuRunRepairScan: '🔧 Execute Forced Repair + 🔍 Deep Scan', menuRunRepairOnly: '🔧 Execute Forced Repair only', menuEditTags: '🏷️ Edit Track Tags (Language/Title)', @@ -79,7 +80,7 @@ export default { mergeNone: 'None', mergeSelectStreams: 'Select the streams you want to keep', mergeHowToSync: 'How do you want to adjust the sync for File B?', - mergeAutoSync: '⏭️ Auto-align ({0} to match File A)', + mergeAutoSync: '⏭️ Match Durations ({0} offset to match File A)', mergeManualSync: '✏️ Enter value manually', mergeNoSync: '❌ Do not adjust (0ms)', mergeAskDelay: 'Enter the delay for File B in milliseconds (e.g., 2000 to delay 2s, -500 to advance 0.5s):', @@ -88,6 +89,15 @@ export default { mergeModifyStreams: 'Modify the streams you want to keep:', mergeCmdSuggested: 'Suggested FFmpeg Command (Merge)', + // Command: Merge (Spectrum Sync) + mergeSpectrumSync: '🎶 Smart Spectrum Sync (Audio-based auto-align)', + mergeAskTimestamp: 'Enter the timestamp of File A (Reference) for analysis:', + mergeSpectrumHint: '💡 Tip: Choose a timestamp without dialogue, preferably with music or strong ambient sounds.', + mergeSpectrumResult: 'Sync complete! Calculated compensation: {0}ms', + mergeSpectrumExtracting: 'Extracting audio frequencies for comparison...', + mergeSpectrumCalculating: 'Calculating cross-correlation matrix...', + mergeSpectrumFailed: 'Failed to analyze audio spectrum.', + // Scanners (FFprobe / FFmpeg) scanQuickStart: 'Running integrity Quick Scan...', scanQuickPass: '✔ Quick Scan passed: Container structure is intact.', @@ -107,6 +117,15 @@ export default { convFail: '✖ Error during conversion (Code {0}).', convStartFail: '✖ Failed to start FFmpeg process: {0}', + // Silence Scan + scanSilenceStart: 'Searching for audio drops (Silence Scan)...', + scanSilencePass: '✔ Audio intact: No sudden drops detected.', + scanSilenceWarn: '⚠ Warning: Audio drops detected!', + scanSilenceDetail: 'Silence detected at {0} (duration: {1}s)', + scanSilenceFail: '✖ Failed to execute Silence Scan.', + scanSilenceProgress: 'Silence Scan in progress: {0}%', + scanSilenceItem: '(duration: {0}s)', + // Command: Lang langSelect: '🌐 Select your preferred language:', langChanged: '✔ Language changed successfully!', diff --git a/src/locales/pt-BR.ts b/src/locales/pt-BR.ts index c98a5ba..e089ff2 100644 --- a/src/locales/pt-BR.ts +++ b/src/locales/pt-BR.ts @@ -17,6 +17,7 @@ export default { menuAdjustSync: '⏱️ Ajustar Sincronia / Corte Final', menuDeepScanFull: '🔍 Deep Scan (Todas as faixas do arquivo original)', menuDeepScanSelected: '🔬 Myopic Scan (Apenas faixas selecionadas)', + menuSilenceScan: '🔊 Silence Scan (Verificar quedas de áudio / Mudo)', menuRunRepairScan: '🔧 Executar Reparo Forçado + 🔍 Deep Scan', menuRunRepairOnly: '🔧 Executar Reparo Forçado apenas', menuEditTags: '🏷️ Editar Tags das Faixas (Idioma/Título)', @@ -79,7 +80,7 @@ export default { mergeNone: 'Nenhuma', mergeSelectStreams: 'Selecione as faixas que deseja manter', mergeHowToSync: 'Como deseja ajustar a sincronia do Arquivo B?', - mergeAutoSync: '⏭️ Auto-alinhar ({0} para igualar ao Arquivo A)', + mergeAutoSync: '⏭️ Igualar Durações ({0} para igualar ao Arquivo A)', mergeManualSync: '✏️ Digitar valor manualmente', mergeNoSync: '❌ Não ajustar (0ms)', mergeAskDelay: 'Informe o atraso do Arquivo B em milissegundos (ex: 2000 para atrasar 2s, -500 para adiantar 0.5s):', @@ -88,6 +89,15 @@ export default { mergeModifyStreams: 'Modifique as faixas que deseja manter:', mergeCmdSuggested: 'Comando FFmpeg Sugerido (Merge)', + // Command: Merge (Spectrum Sync) + mergeSpectrumSync: '🎶 Smart Spectrum Sync (Auto-alinhamento por áudio)', + mergeAskTimestamp: 'Digite o momento do Arquivo A (Referência) para análise:', + mergeSpectrumHint: '💡 Dica: Escolha um momento sem falas/dublagem, de preferência com música ou sons ambientes marcantes.', + mergeSpectrumResult: 'Sincronia concluída! Compensação calculada: {0}ms', + mergeSpectrumExtracting: 'Extraindo frequências de áudio para comparação...', + mergeSpectrumCalculating: 'Calculando matriz de correlação cruzada...', + mergeSpectrumFailed: 'Falha ao analisar espectro de áudio.', + // Scanners (FFprobe / FFmpeg) scanQuickStart: 'Executando Quick Scan de integridade...', scanQuickPass: '✔ Quick Scan aprovado: Estrutura do container intacta.', @@ -107,6 +117,15 @@ export default { convFail: '✖ Erro durante a conversão (Código {0}).', convStartFail: '✖ Falha ao tentar iniciar o processo do FFmpeg: {0}', + // Silence Scan + scanSilenceStart: 'Procurando por quedas de áudio (Silence Scan)...', + scanSilencePass: '✔ Áudio íntegro: Nenhuma queda brusca detectada.', + scanSilenceWarn: '⚠ Atenção: Quedas de áudio detectadas!', + scanSilenceDetail: 'Silêncio detectado em {0} (duração: {1}s)', + scanSilenceFail: '✖ Falha ao executar o Silence Scan.', + scanSilenceProgress: 'Silence Scan em andamento: {0}%', + scanSilenceItem: '(duração: {0}s)', + // Comando: Lang langSelect: '🌐 Selecione o idioma de sua preferência / Select your preferred language:', langChanged: '✔ Idioma alterado com sucesso!', diff --git a/src/services/spectrumAnalyzer.test.ts b/src/services/spectrumAnalyzer.test.ts new file mode 100644 index 0000000..609d518 --- /dev/null +++ b/src/services/spectrumAnalyzer.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { calculateSpectrumDelay } from './spectrumAnalyzer.ts'; + +describe('services/spectrumAnalyzer.ts', () => { + test('Should return 0 for perfect alignment (identical arrays)', () => { + const audioA = new Float32Array([0.1, -0.2, 0.5, 0.3]); + const audioB = new Float32Array([0.1, -0.2, 0.5, 0.3]); + + expect(calculateSpectrumDelay(audioA, audioB)).toBe(0); + }); + + test('Should accurately find positive displacement', () => { + const audioA = new Float32Array([0.5, 0.8, -0.2]); + const audioB = new Float32Array([0, 0, 0.5, 0.8, -0.2, 0.1]); + + expect(calculateSpectrumDelay(audioA, audioB)).toBe(2); + }); + + test('Should align correctly even with inverted phase (phase shift)', () => { + const audioA = new Float32Array([0.5, 0.8, -0.2]); + const audioB = new Float32Array([0, 0, -0.5, -0.8, 0.2, 0]); + + expect(calculateSpectrumDelay(audioA, audioB)).toBe(2); + }); + + test('Should return 0 if snippet has absolute silence (zero variance)', () => { + const audioA = new Float32Array([0, 0, 0]); + const audioB = new Float32Array([0, 0, 0, 0, 0]); + + expect(calculateSpectrumDelay(audioA, audioB)).toBe(0); + }); + + test('Should throw error if search window is smaller than snippet', () => { + const audioA = new Float32Array([1, 2, 3]); + const audioB = new Float32Array([1, 2]); + + expect(() => calculateSpectrumDelay(audioA, audioB)) + .toThrow('Search window in File B must be larger than File A snippet.'); + }); +}); \ No newline at end of file diff --git a/src/services/spectrumAnalyzer.ts b/src/services/spectrumAnalyzer.ts new file mode 100644 index 0000000..a225492 --- /dev/null +++ b/src/services/spectrumAnalyzer.ts @@ -0,0 +1,60 @@ +/** + * Calculates the exact delay between two audio buffers using the Pearson Correlation Coefficient (PCC). + * Extracted arrays must be sampled at 1000Hz for a 1:1 millisecond mapping. + * Time Complexity: O(N * (M - N)) + */ +export const calculateSpectrumDelay = (audioA: Float32Array, audioB: Float32Array): number => { + const lenA = audioA.length; + const lenB = audioB.length; + const slideRange = lenB - lenA; + + if (slideRange < 0) { + throw new Error('Search window in File B must be larger than File A snippet.'); + } + + let sumA = 0; + for (let i = 0; i < lenA; i++) sumA += audioA[i]!; + const meanA = sumA / lenA; + + let varA = 0; + for (let i = 0; i < lenA; i++) { + const diff = audioA[i]! - meanA; + varA += diff * diff; + } + + if (varA === 0) return 0; + + let maxPCC = -Infinity; + let bestOffset = 0; + + let sumB = 0; + for (let i = 0; i < lenA; i++) sumB += audioB[i]!; + + for (let i = 0; i <= slideRange; i++) { + if (i > 0) { + sumB = sumB - audioB[i - 1]! + audioB[i + lenA - 1]!; + } + const meanB = sumB / lenA; + + let covariance = 0; + let varB = 0; + + for (let j = 0; j < lenA; j++) { + const valB_diff = audioB[i + j]! - meanB; + covariance += (audioA[j]! - meanA) * valB_diff; + varB += valB_diff * valB_diff; + } + + if (varB > 0) { + const pcc = covariance / Math.sqrt(varA * varB); + const absPCC = Math.abs(pcc); + + if (absPCC > maxPCC) { + maxPCC = absPCC; + bestOffset = i; + } + } + } + + return bestOffset; +}; \ No newline at end of file diff --git a/src/utils/ffmpeg.test.ts b/src/utils/ffmpeg.test.ts index cee3c90..f05f3b6 100644 --- a/src/utils/ffmpeg.test.ts +++ b/src/utils/ffmpeg.test.ts @@ -6,7 +6,9 @@ import { getDynamicVideoEncoder, getDynamicAudioEncoder, runDeepScan, - runConversion + runConversion, + extractRawAudio, + runSilenceScan, } from './ffmpeg.ts'; import { JellyError } from './errors.ts'; @@ -14,8 +16,9 @@ mock.module('@clack/prompts', () => ({ spinner: () => ({ start: mock(), stop: mock(), - message: mock() - }) + message: mock(), + }), + note: mock() })); describe('utils/ffmpeg.ts', () => { @@ -25,6 +28,7 @@ describe('utils/ffmpeg.ts', () => { spawnSpy.mockClear(); }); + // Utility Functions test('parseFfmpegTime should convert time strings correctly', () => { expect({ standard: parseFfmpegTime('01:30:15.50'), @@ -71,6 +75,7 @@ describe('utils/ffmpeg.ts', () => { }); }); + // Deep Scan test('runDeepScan should process streams and resolve correctly', async () => { const mockProcess = new EventEmitter() as any; mockProcess.stderr = new EventEmitter(); @@ -121,6 +126,35 @@ describe('utils/ffmpeg.ts', () => { }); }); + test('runDeepScan should flag errors if remaining buffer contains actual errors (not frame/size) even on exit 0', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runDeepScan(['in.mkv'], ['0:v'], 100); + + mockProcess.stderr.emit('data', Buffer.from('Some critical decoding error without newline')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(true); + }); + + test('runDeepScan should ignore remaining buffer if it starts with frame= or size=', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runDeepScan(['in.mkv'], ['0:v'], 100); + + mockProcess.stderr.emit('data', Buffer.from('size=2048kB time=00:02:00.00')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(false); + }); + + // Conversion test('runConversion should track progress and resolve on success', async () => { const mockProcess = new EventEmitter() as any; mockProcess.stderr = new EventEmitter(); @@ -135,6 +169,37 @@ describe('utils/ffmpeg.ts', () => { await expect(convPromise).resolves.toBeUndefined(); }); + test('runConversion should handle commands with "-y" and truncate long logs (>10 lines)', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const convPromise = runConversion('ffmpeg -y -i test.mkv', 100); + + for (let i = 1; i <= 12; i++) { + mockProcess.stderr.emit('data', Buffer.from(`Log line ${i}\n`)); + } + + mockProcess.emit('close', 0); + await expect(convPromise).resolves.toBeUndefined(); + }); + + test('runConversion should track progress using totalFrames if totalDurationSec is 0 or missing', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const convPromise = runConversion('ffmpeg -i test.mkv', 0, 100); + + mockProcess.stderr.emit('data', Buffer.from('frame= ')); + mockProcess.stderr.emit('data', Buffer.from('50 fps=30\n')); + mockProcess.stderr.emit('data', Buffer.from('\r\n\r\n \n')); + + mockProcess.emit('close', 0); + + await expect(convPromise).resolves.toBeUndefined(); + }); + test('runConversion should reject with JellyError on non-zero exit or error', async () => { const mockProcess1 = new EventEmitter() as any; mockProcess1.stderr = new EventEmitter(); @@ -164,4 +229,167 @@ describe('utils/ffmpeg.ts', () => { errCode: 'FFMPEG_START_FAILED' }); }); -}); + + // Audio Extraction + test('extractRawAudio should resolve with Float32Array on success', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const extractPromise = extractRawAudio('fake.mkv', '00:00:00', 2); + + const fakeData = Buffer.alloc(16); + fakeData.writeFloatLE(0.1, 0); + fakeData.writeFloatLE(0.2, 4); + fakeData.writeFloatLE(0.3, 8); + fakeData.writeFloatLE(0.4, 12); + + mockProcess.stdout.emit('data', fakeData); + mockProcess.emit('close', 0); + + const result = await extractPromise; + expect(result).toBeInstanceOf(Float32Array); + expect(result.length).toBe(4); + expect(result[0]).toBeCloseTo(0.1); + }); + + test('extractRawAudio should reject with JellyError on non-zero exit', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const extractPromise = extractRawAudio('fake.mkv', '00:00:00', 2); + + mockProcess.stderr.emit('data', Buffer.from('Extraction failed\n')); + mockProcess.emit('close', 1); + + let caught: any; + try { await extractPromise; } catch (e) { caught = e; } + + expect({ + isJelly: caught instanceof JellyError, + code: caught?.code, + message: caught?.message + }).toMatchObject({ + isJelly: true, + code: 'FFMPEG_EXTRACTION_FAILED', + message: expect.stringContaining('Extraction failed') + }); + }); + + test('extractRawAudio should throw JellyError if spawn fails', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const extractPromise = extractRawAudio('fake.mkv', '00:00:00', 2); + + mockProcess.emit('error', new Error('ENOENT')); + + let caught: any; + try { await extractPromise; } catch (e) { caught = e; } + + expect({ + isJelly: caught instanceof JellyError, + code: caught?.code + }).toMatchObject({ + isJelly: true, + code: 'FFMPEG_START_FAILED' + }); + }); + + // Silence Scan + test('runSilenceScan should resolve false when no silence is found', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + + mockProcess.stderr.emit('data', Buffer.from('size=256kB time=00:00:25.00 bitrate=100kbits/s\n')); + mockProcess.stderr.emit('data', Buffer.from('some random ffmpeg log without silence\n')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(false); + }); + + test('runSilenceScan should capture and group multiple silence durations via memory address', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0', '0:a:1'], 100, ['ENG', 'POR']); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xABC] silence_start: 10.5\n')); + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xDEF] silence_start: 20.0\n')); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xABC] silence_end: 15.5 | silence_duration: 5.0\n')); + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xDEF] silence_end: 22.0 | silence_duration: 2.0\n')); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xABC] silence_start: 30.0\n')); + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0xABC] silence_end: 33.0 | silence_duration: 3.0\n')); + + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(true); + }); + + test('runSilenceScan should handle silence_duration without a preceding silence_start safely', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0x999] silence_end: 10.0 | silence_duration: 5.0\n')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(false); + }); + + test('runSilenceScan should format warnings correctly even if trackLabels are missing', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0x111] silence_start: 1.0\n')); + mockProcess.stderr.emit('data', Buffer.from('[silencedetect @ 0x111] silence_duration: 2.0\n')); + mockProcess.emit('close', 0); + + const result = await scanPromise; + expect(result).toBe(true); + }); + + test('runSilenceScan should return true on failure (non-zero exit) without throwing', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + mockProcess.emit('close', 1); + + const result = await scanPromise; + expect(result).toBe(true); + }); + + test('runSilenceScan should return true and stop spinner on process error (e.g. spawn failed)', async () => { + const mockProcess = new EventEmitter() as any; + mockProcess.stderr = new EventEmitter(); + spawnSpy.mockReturnValue(mockProcess); + + const scanPromise = runSilenceScan(['in.mkv'], ['0:a:0'], 100); + + mockProcess.emit('error', new Error('spawn ENOENT')); + + const result = await scanPromise; + expect(result).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index 6de1c38..4479fed 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -1,9 +1,19 @@ import { spawn } from 'child_process'; -import { spinner } from '@clack/prompts'; +import { spinner, note } from '@clack/prompts'; import pc from 'picocolors'; import { t } from './i18n.ts'; import { JellyError } from './errors.ts'; import type { MediaStream } from '../types/media'; +import { formatSecondsToTimestamp } from './formatters.ts'; + +export const DEFAULT_SILENCE_NOISE_DB = '-50dB'; +export const DEFAULT_SILENCE_DURATION = 2; + +export interface SilenceScanResult { + trackIdx: number; + start: number; + duration: number; +} export function parseFfmpegTime(timeStr: string) { const parts = timeStr.split(':'); @@ -43,6 +53,47 @@ export function getDynamicAudioEncoder( return `-c:a:${outputIndex} ${targetCodec} -b:a:${outputIndex} ${targetBitrate}k`; } +function generateProgressBar(percent: number, length: number = 25): string { + const filled = Math.round((percent / 100) * length); + const empty = length - filled; + return '█'.repeat(filled) + '░'.repeat(empty); +} + +function createStderrParser( + totalDurationSec: number, + totalFrames: number, + onProgress: (percent: number) => void, + onLine: (line: string) => void +) { + const state = { buffer: '' }; + + const parse = (data: Buffer | string) => { + state.buffer += data.toString(); + + const timeMatch = state.buffer.match(/time=\s*(\d{2}:\d{2}:\d{2}[\.\d]*)/); + if (timeMatch?.[1] && totalDurationSec > 0) { + const currentTime = parseFfmpegTime(timeMatch[1]); + onProgress(Math.min(100, Math.round((currentTime / totalDurationSec) * 100))); + } else if (totalFrames > 0) { + const frameMatch = state.buffer.match(/frame=\s*(\d+)/); + if (frameMatch?.[1]) { + const currentFrame = Number.parseInt(frameMatch[1], 10); + onProgress(Math.min(100, Math.round((currentFrame / totalFrames) * 100))); + } + } + + const lines = state.buffer.split(/[\r\n]+/); + state.buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) onLine(trimmed); + } + }; + + return { parse, state }; +} + export async function runDeepScan(inputs: string[], maps: string[], totalDurationSec: number): Promise { console.log(''); const dsSpinner = spinner(); @@ -51,58 +102,36 @@ export async function runDeepScan(inputs: string[], maps: string[], totalDuratio let hasErrors = false; return new Promise((resolve, reject) => { - // Montagem dinâmica dos argumentos para ler apenas o que importa const ffmpegArgs = ['-v', 'warning', '-stats']; + inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); maps.forEach(m => { ffmpegArgs.push('-map', m); }); - - // O ESCUDO DEFINITIVO: Ignora qualquer legenda (-sn) e qualquer dado/fonte (-dn) ffmpegArgs.push('-sn', '-dn', '-f', 'null', '-'); const ff = spawn('ffmpeg', ffmpegArgs); let errorOutput = ''; - let stderrBuffer = ''; - - ff.stderr.on('data', (data) => { - stderrBuffer += data.toString(); - const timeMatch = stderrBuffer.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/); - const matchTime = timeMatch?.[1]; - if (matchTime && totalDurationSec > 0) { - const currentTime = parseFfmpegTime(matchTime); - let percent = Math.round((currentTime / totalDurationSec) * 100); - if (percent > 100) percent = 100; - - const barLength = 25; - const filled = Math.round((percent / 100) * barLength); - const empty = barLength - filled; - const bar = '█'.repeat(filled) + '░'.repeat(empty); + const onProgress = (percent: number) => { + dsSpinner.message(`${t('scanDeepProgress', percent)} [${pc.cyan(generateProgressBar(percent))}]`); + }; - dsSpinner.message(`${t('scanDeepProgress', percent)} [${pc.cyan(bar)}]`); + const onLine = (line: string) => { + if (!line.startsWith('frame=') && !line.startsWith('size=')) { + errorOutput += line + '\n'; } + }; - const lines = stderrBuffer.split(/[\r\n]+/); - stderrBuffer = lines.pop() || ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('frame=') && !trimmed.startsWith('size=')) { - errorOutput += trimmed + '\n'; - hasErrors = true; - } - } - }); + const { parse, state } = createStderrParser(totalDurationSec, 0, onProgress, onLine); + ff.stderr.on('data', parse); ff.on('close', (code) => { - if (stderrBuffer.trim()) { - const trimmed = stderrBuffer.trim(); - if (!trimmed.startsWith('frame=') && !trimmed.startsWith('size=')) { - errorOutput += trimmed + '\n'; - hasErrors = true; - } + const remainingBuffer = state.buffer.trim(); + if (remainingBuffer && !remainingBuffer.startsWith('frame=') && !remainingBuffer.startsWith('size=')) { + errorOutput += remainingBuffer + '\n'; } if (errorOutput.trim()) { + hasErrors = true; dsSpinner.stop(pc.yellow(t('scanDeepWarn'))); console.log(pc.dim(errorOutput.trim())); } else if (code === 0) { @@ -133,63 +162,164 @@ export async function runConversion(ffmpegCmd: string, totalDurationSec: number, let tailLog: string[] = []; let lastBar = '[░░░░░░░░░░░░░░░░░░░░░░░░░] 0%'; - let stderrBuffer = ''; - ff.stderr.on('data', (data) => { - stderrBuffer += data.toString(); - const lines = stderrBuffer.split(/[\r\n]+/); - stderrBuffer = lines.pop() || ''; + const onProgress = (percent: number) => { + lastBar = `[${pc.cyan(generateProgressBar(percent))}] ${percent}%`; + }; + + const onLine = (line: string) => { + tailLog.push(line); + if (tailLog.length > 10) tailLog.shift(); + convSpinner.message(`${t('convProgress')}\n${lastBar}\n\n${pc.dim(tailLog.join('\n'))}`); + }; + + const { parse } = createStderrParser(totalDurationSec, totalFrames, onProgress, onLine); + ff.stderr.on('data', parse); + + ff.on('close', (code) => { + if (code === 0) { + convSpinner.stop(pc.green(t('convPass'))); + resolve(); + } else { + convSpinner.stop(pc.red(t('convFail', code))); + reject(new JellyError(t('convFail', code ?? -1), 'FFMPEG_FAILED')); + } + }); + + ff.on('error', (err: Error) => { + convSpinner.stop(pc.red(t('convStartFail', err.message))); + reject(new JellyError(t('convStartFail', err.message), 'FFMPEG_START_FAILED')); + }); + }); +} + +export async function runSilenceScan( + inputs: string[], + maps: string[], + totalDurationSec: number, + trackLabels?: string[] +): Promise { + console.log(''); + const scanSpinner = spinner(); + scanSpinner.start(t('scanSilenceStart')); + + return new Promise((resolve) => { + const ffmpegArgs = ['-v', 'info', '-stats']; + inputs.forEach(inp => { ffmpegArgs.push('-i', inp); }); + maps.forEach(m => { ffmpegArgs.push('-map', m); }); + + ffmpegArgs.push('-vn', '-af', `silencedetect=noise=${DEFAULT_SILENCE_NOISE_DB}:d=${DEFAULT_SILENCE_DURATION}`, '-f', 'null', '-'); + + const ff = spawn('ffmpeg', ffmpegArgs); + + const results: SilenceScanResult[] = []; + let nextTrackIdx = 0; + const addressToTrackIdx = new Map(); + const currentStarts = new Map(); + + const onProgress = (percent: number) => { + scanSpinner.message(`${t('scanSilenceProgress', percent)} [${pc.cyan(generateProgressBar(percent))}]`); + }; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; + const onLine = (line: string) => { + const startMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_start:\s*([\d.]+)/); + if (startMatch) { + const addr = startMatch[1]!; + if (!addressToTrackIdx.has(addr)) { + addressToTrackIdx.set(addr, nextTrackIdx++); + } + currentStarts.set(addr, Number.parseFloat(startMatch[2]!)); + } - tailLog.push(trimmed); - if (tailLog.length > 10) { - tailLog.shift(); + const durationMatch = line.match(/silencedetect.*@\s+(0x[0-9a-fA-F]+)\].*?silence_duration:\s*([\d.]+)/); + if (durationMatch) { + const addr = durationMatch[1]!; + const cStart = currentStarts.get(addr); + const trackIdx = addressToTrackIdx.get(addr); + + if (cStart !== undefined && trackIdx !== undefined) { + results.push({ trackIdx, start: cStart, duration: Number.parseFloat(durationMatch[2]!) }); + currentStarts.delete(addr); } + } + }; - const timeMatch = trimmed.match(/time=\s*(\d{2}:\d{2}:\d{2}[\.\d]*)/); - const frameMatch = trimmed.match(/frame=\s*(\d+)/); + const { parse } = createStderrParser(totalDurationSec, 0, onProgress, onLine); + ff.stderr.on('data', parse); - let percent = -1; + ff.on('close', (code) => { + if (code !== 0) { + scanSpinner.stop(pc.red(t('scanSilenceFail'))); + return resolve(true); + } - const matchTime = timeMatch?.[1]; - const matchFrame = frameMatch?.[1]; + if (results.length > 0) { + scanSpinner.stop(pc.yellow(t('scanSilenceWarn'))); - if (matchTime && totalDurationSec > 0) { - const currentTime = parseFfmpegTime(matchTime); - percent = Math.round((currentTime / totalDurationSec) * 100); - } else if (matchFrame && totalFrames > 0) { - const currentFrame = Number.parseInt(matchFrame, 10); - percent = Math.round((currentFrame / totalFrames) * 100); + const grouped = new Map>(); + for (const r of results) { + if (!grouped.has(r.trackIdx)) grouped.set(r.trackIdx, []); + grouped.get(r.trackIdx)!.push(r); } - if (percent >= 0) { - if (percent > 100) percent = 100; - const barLength = 25; - const filled = Math.round((percent / 100) * barLength); - const empty = barLength - filled; - lastBar = `[${pc.cyan('█'.repeat(filled) + '░'.repeat(empty))}] ${percent}%`; + let msg = ''; + const sortedGroups = Array.from(grouped.entries()).sort((a, b) => a[0] - b[0]); + + for (const [tIdx, items] of sortedGroups) { + const langLabel = trackLabels && trackLabels[tIdx] ? ` [${trackLabels[tIdx]}]` : ''; + msg += `${msg ? '\n\n' : ''}${pc.bold(`🎧 Trilha Afetada #${tIdx + 1}${langLabel}`)}`; + + items.forEach(item => { + const durText = t('scanSilenceItem', item.duration.toFixed(2)); + msg += `\n • ${formatSecondsToTimestamp(item.start)} ${pc.dim(durText)}`; + }); } - convSpinner.message(`${t('convProgress')}\n${lastBar}\n\n${pc.dim(tailLog.join('\n'))}`); + note(msg); + resolve(true); + } else { + scanSpinner.stop(pc.green(t('scanSilencePass'))); + resolve(false); } }); + ff.on('error', () => { + scanSpinner.stop(pc.red(t('scanSilenceFail'))); + resolve(true); + }); + }); +} + +export async function extractRawAudio(filePath: string, startTs: string, durationSec: number): Promise { + return new Promise((resolve, reject) => { + const ff = spawn('ffmpeg', [ + '-v', 'error', + '-ss', startTs, + '-t', durationSec.toString(), + '-i', filePath, + '-ac', '1', + '-ar', '1000', + '-f', 'f32le', + 'pipe:1' + ]); + + const chunks: Buffer[] = []; + let errorLog = ''; + + ff.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)); + ff.stderr.on('data', (data: Buffer) => errorLog += data.toString()); + ff.on('close', (code) => { - if (code === 0) { - convSpinner.stop(pc.green(t('convPass'))); - resolve(); - } else { - convSpinner.stop(pc.red(t('convFail', code))); - reject(new JellyError(t('convFail', code ?? -1), 'FFMPEG_FAILED')); + if (code !== 0) { + reject(new JellyError(`${t('mergeSpectrumFailed')} ${errorLog.trim()}`, 'FFMPEG_EXTRACTION_FAILED')); + return; } + const fullBuffer = Buffer.concat(chunks); + resolve(new Float32Array(fullBuffer.buffer, fullBuffer.byteOffset, fullBuffer.byteLength / 4)); }); - ff.on('error', (err: Error) => { - convSpinner.stop(pc.red(t('convStartFail', err.message))); - reject(new JellyError(t('convStartFail', err.message), 'FFMPEG_START_FAILED')); + ff.on('error', (err) => { + reject(new JellyError(`${t('mergeSpectrumFailed')} ${err.message}`, 'FFMPEG_START_FAILED')); }); }); -} +} \ No newline at end of file diff --git a/src/utils/formatters.test.ts b/src/utils/formatters.test.ts index f15e044..3104db2 100644 --- a/src/utils/formatters.test.ts +++ b/src/utils/formatters.test.ts @@ -9,7 +9,9 @@ import { formatSize, padLabel, formatSubtitleCodec, - calculateTotalFrames + calculateTotalFrames, + parseTimestampToSeconds, + formatSecondsToTimestamp } from './formatters.ts'; import { t } from './i18n.ts'; import type { MediaStream } from '../types/media.d.ts'; @@ -140,4 +142,30 @@ describe('utils/formatters.ts', () => { missing: 0 }); }); + + test('parseTimestampToSeconds should correctly parse HH:MM:SS, MM:SS and SS', () => { + expect({ + full: parseTimestampToSeconds('00:15:30'), + partial: parseTimestampToSeconds('15:30'), + secondsOnly: parseTimestampToSeconds('45'), + invalid: parseTimestampToSeconds('invalid') + }).toMatchObject({ + full: 930, + partial: 930, + secondsOnly: 45, + invalid: 0 + }); + }); + + test('formatSecondsToTimestamp should format seconds to HH:MM:SS padding zeros', () => { + expect({ + full: formatSecondsToTimestamp(930), + short: formatSecondsToTimestamp(1), + hours: formatSecondsToTimestamp(3665) + }).toMatchObject({ + full: '00:15:30', + short: '00:00:01', + hours: '01:01:05' + }); + }); }); \ No newline at end of file diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index df9ec07..1385a24 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -89,3 +89,17 @@ export const calculateTotalFrames = ( } return 0; }; + +export const parseTimestampToSeconds = (ts: string): number => { + const parts = ts.split(':').map(Number); + if (parts.length === 3) return (parts[0]! * 3600) + (parts[1]! * 60) + parts[2]!; + if (parts.length === 2) return (parts[0]! * 60) + parts[1]!; + return Number(ts) || 0; +}; + +export const formatSecondsToTimestamp = (totalSeconds: number): string => { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = Math.floor(totalSeconds % 60); + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; +}; \ No newline at end of file diff --git a/src/utils/mediaUtils.test.ts b/src/utils/mediaUtils.test.ts index 9905b79..cbd6b91 100644 --- a/src/utils/mediaUtils.test.ts +++ b/src/utils/mediaUtils.test.ts @@ -4,7 +4,11 @@ import { isAttachedPic, isGarbageStream, hasEmbeddedGarbage, - filterGarbageStreams + filterGarbageStreams, + buildAudioMaps, + buildAudioLabels, + buildSelectedAudioMaps, + buildSelectedAudioLabels } from './mediaUtils.ts'; import type { MediaStream } from '../types/media.d.ts'; @@ -87,3 +91,28 @@ describe('utils/mediaUtils.ts', () => { expect(filtered).not.toContain(coverArtAttached); }); }); + +describe('Audio Metadata Extractors', () => { + const sampleStreams: any[] = [ + { index: 0, codec_type: 'video', tags: { language: 'eng' } }, + { index: 1, codec_type: 'audio', tags: { language: 'por' } }, + { index: 2, codec_type: 'audio' } // language: 'und' + ]; + + const sampleSelected: any[] = [ + { streamIndex: 0, type: 'video', language: 'eng', fileIndex: 0 }, + { streamIndex: 1, type: 'audio', language: 'por', fileIndex: 1 }, + { streamIndex: 2, type: 'audio' } // language: 'und', fileIndex: 0 + ]; + + test('buildAudioMaps & buildAudioLabels should extract audio properties from FFprobeData streams', () => { + expect(buildAudioMaps(sampleStreams, 0)).toEqual(['0:1', '0:2']); + expect(buildAudioMaps(sampleStreams, 1)).toEqual(['1:1', '1:2']); + expect(buildAudioLabels(sampleStreams)).toEqual(['POR', 'UND']); + }); + + test('buildSelectedAudioMaps & buildSelectedAudioLabels should extract audio properties from SelectedStream', () => { + expect(buildSelectedAudioMaps(sampleSelected)).toEqual(['1:1', '0:2']); + expect(buildSelectedAudioLabels(sampleSelected)).toEqual(['POR', 'UND']); + }); +}); \ No newline at end of file diff --git a/src/utils/mediaUtils.ts b/src/utils/mediaUtils.ts index 1813010..2052e71 100644 --- a/src/utils/mediaUtils.ts +++ b/src/utils/mediaUtils.ts @@ -1,4 +1,4 @@ -import type { MediaStream } from '../types/media'; +import type { MediaStream, SelectedStream } from '../types/media'; const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'pgs', 'dvd_subtitle', 'vobsub']); const ATTACHED_PIC_CODECS = new Set(['mjpeg', 'png', 'bmp']); @@ -23,3 +23,15 @@ export const hasEmbeddedGarbage = (streams: MediaStream[]): boolean => streams.s export const filterGarbageStreams = (streams: MediaStream[]): MediaStream[] => streams.filter((stream) => !isGarbageStream(stream)); + +export const buildAudioMaps = (streams: MediaStream[], fileIndex: number = 0) => + streams.filter(s => s.codec_type === 'audio').map(s => `${fileIndex}:${s.index}`); + +export const buildAudioLabels = (streams: MediaStream[]) => + streams.filter(s => s.codec_type === 'audio').map(s => (s.tags?.language || 'und').toUpperCase()); + +export const buildSelectedAudioMaps = (streams: SelectedStream[]) => + streams.filter(s => s.type === 'audio').map(s => `${s.fileIndex ?? 0}:${s.streamIndex}`); + +export const buildSelectedAudioLabels = (streams: SelectedStream[]) => + streams.filter(s => s.type === 'audio').map(s => (s.language || 'und').toUpperCase()); \ No newline at end of file diff --git a/src/utils/ui.test.ts b/src/utils/ui.test.ts index dbf59d6..e74b79c 100644 --- a/src/utils/ui.test.ts +++ b/src/utils/ui.test.ts @@ -139,7 +139,6 @@ describe('utils/ui.ts', () => { expect(result.action).toBe('done'); }); - // --- editTagsMenu --- test('editTagsMenu should initialize missing tags from ffprobe data and allow early exit', async () => { (clack.select as any).mockResolvedValueOnce(-1); @@ -194,4 +193,47 @@ describe('utils/ui.ts', () => { expect(result[0]).toMatchObject({ language: 'jpn', title: 'Original Mix' }); }); + + test('handleExecutionMenu should execute silence scan, update error state, and loop back', async () => { + (clack.select as any) + .mockResolvedValueOnce('silence_scan') + .mockResolvedValueOnce('exit'); + + const runSilenceSpy = spyOn(ffmpeg, 'runSilenceScan').mockResolvedValueOnce(true as any); + + const result = await handleExecutionMenu({ + ffmpegCmd: 'cmd', + fullScanInputs: ['full.mkv'], + fullScanMaps: ['0'], + fullAudioScanMaps: ['0:1'], + outputPath: 'out.mkv', + totalDuration: 100, + totalFrames: 2400 + }); + + expect(runSilenceSpy).toHaveBeenCalledWith(['full.mkv'], ['0:1'], 100, []); + expect(result.action).toBe('exit'); + expect(result.hasErrors).toBe(true); + }); + + test('handleExecutionMenu should bypass silence scan (no spawn) if no audio tracks are mapped', async () => { + (clack.select as any) + .mockResolvedValueOnce('silence_scan') + .mockResolvedValueOnce('exit'); + + const runSilenceSpy = spyOn(ffmpeg, 'runSilenceScan'); + + const result = await handleExecutionMenu({ + ffmpegCmd: 'cmd', + fullScanInputs: ['full.mkv'], + fullScanMaps: ['0'], + fullAudioScanMaps: [], + outputPath: 'out.mkv', + totalDuration: 100, + totalFrames: 2400 + }); + + expect(runSilenceSpy).not.toHaveBeenCalled(); + expect(result.action).toBe('exit'); + }); }); \ No newline at end of file diff --git a/src/utils/ui.ts b/src/utils/ui.ts index e1e5997..9a8f307 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -1,6 +1,6 @@ -import { isCancel, select, outro, text, confirm } from '@clack/prompts'; +import { isCancel, select, outro, text, confirm, spinner } from '@clack/prompts'; import pc from 'picocolors'; -import { runConversion, runDeepScan } from './ffmpeg.ts'; +import { runConversion, runDeepScan, runSilenceScan } from './ffmpeg.ts'; import { getRepairOutputPath } from '../services/repair.ts'; import { t } from './i18n.ts'; import { UserCancelError } from './errors.ts'; @@ -26,8 +26,12 @@ export async function handleExecutionMenu(options: { ffmpegRepairCmd?: string; fullScanInputs: string[]; fullScanMaps: string[]; + fullAudioScanMaps?: string[]; + fullAudioScanLabels?: string[]; selectedScanInputs?: string[]; selectedScanMaps?: string[]; + selectedAudioScanMaps?: string[]; + selectedAudioScanLabels?: string[]; outputPath: string; totalDuration: number; totalFrames: number; @@ -80,7 +84,8 @@ export async function handleExecutionMenu(options: { menuOptions.push({ label: t('menuDeepScanFull'), value: 'deep_scan_full' }); } } - + + menuOptions.push({ label: t('menuSilenceScan'), value: 'silence_scan' }); menuOptions.push({ label: t('exit'), value: 'exit' }); const action = onCancel(await select({ @@ -89,17 +94,35 @@ export async function handleExecutionMenu(options: { })); if (action === 'deep_scan_selected') { - fileHasErrors = await runDeepScan(options.selectedScanInputs!, options.selectedScanMaps!, options.totalDuration); + const dsErrors = await runDeepScan(options.selectedScanInputs!, options.selectedScanMaps!, options.totalDuration); + fileHasErrors = fileHasErrors || dsErrors; dsCompleted = true; continue; - } + } if (action === 'deep_scan_full') { - fileHasErrors = await runDeepScan(options.fullScanInputs, options.fullScanMaps, options.totalDuration); + const dsErrors = await runDeepScan(options.fullScanInputs, options.fullScanMaps, options.totalDuration); + fileHasErrors = fileHasErrors || dsErrors; dsCompleted = true; continue; } + if (action === 'silence_scan') { + const inputs = options.selectedScanInputs || options.fullScanInputs; + const audioMaps = options.selectedAudioScanMaps || options.fullAudioScanMaps || []; + const audioLabels = options.selectedAudioScanLabels || options.fullAudioScanLabels || []; + + if (audioMaps.length > 0) { + const silenceErrors = await runSilenceScan(inputs, audioMaps, options.totalDuration, audioLabels); + fileHasErrors = fileHasErrors || silenceErrors; + } else { + const scanSpinner = spinner(); + scanSpinner.start(t('scanSilenceStart')); + scanSpinner.stop(pc.green(t('scanSilencePass'))); + } + continue; + } + if (action === 'select_streams' || action === 'adjust_sync' || action === 'edit_tags') { return { action, deepScanCompleted: dsCompleted, hasErrors: fileHasErrors }; } @@ -118,7 +141,8 @@ export async function handleExecutionMenu(options: { await runConversion(cmdToRun, options.totalDuration, options.totalFrames); if (action === 'run_and_scan' || action === 'run_repair_and_scan') { - await runDeepScan([actualOutputPath], ['0'], options.totalDuration); + const dsErrors = await runDeepScan([actualOutputPath], ['0'], options.totalDuration); + fileHasErrors = fileHasErrors || dsErrors; } const successMsg = options.isMerge ? t('successMerge') : t('successOp'); diff --git a/src/views/mergeView.ts b/src/views/mergeView.ts index 0b1a030..1a7f2bf 100644 --- a/src/views/mergeView.ts +++ b/src/views/mergeView.ts @@ -6,12 +6,14 @@ import type { FFprobeData } from '../types/media'; export type SyncMenuOption = { label: string; - value: 'auto' | 'manual' | 'none'; + value: 'auto' | 'manual' | 'none' | 'spectrum'; }; export function buildSyncOptions(exactDiffMs: number): SyncMenuOption[] { const options: SyncMenuOption[] = []; + options.push({ label: t('mergeSpectrumSync'), value: 'spectrum' }); + if (Math.abs(exactDiffMs) > 1000) { options.push({ label: t('mergeAutoSync', exactDiffMs > 0 ? t('delayBehind', Math.abs(exactDiffMs)) : t('delayAhead', Math.abs(exactDiffMs))), @@ -24,7 +26,6 @@ export function buildSyncOptions(exactDiffMs: number): SyncMenuOption[] { return options; } - const buildFileSummary = (info: FFprobeData) => { const duration = info.format?.duration ? formatDuration(Number.parseFloat(info.format.duration)) : 'N/A'; const size = info.format?.size ? formatSize(Number.parseInt(info.format.size, 10)) : 'N/A'; diff --git a/tests/e2e/check.test.ts b/tests/e2e/check.test.ts index fcecfa3..73c12ef 100644 --- a/tests/e2e/check.test.ts +++ b/tests/e2e/check.test.ts @@ -44,7 +44,7 @@ describe('E2E: JellyCC Check Menu', () => { await cli.waitForText('What do you want to do?'); }; - // Entrance + // Entrance & Edge Cases test('Should show warn if provided file path does not exist', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', 'invalid-file.mkv'], process.cwd()); @@ -57,9 +57,7 @@ describe('E2E: JellyCC Check Menu', () => { test('Should prompt for video path if executed without arguments', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'check'], process.cwd()); - await cli.waitForText('What is the video file path?'); - expect(cli.getOutput()).toContain('What is the video file path?'); }); @@ -73,7 +71,7 @@ describe('E2E: JellyCC Check Menu', () => { expect(finalOutput).toContain('Corrupted media. Aborting analysis to prevent server crashes.'); }); - // Analyzer + // Analyzer & Action Plans test('Should show "Perfect" message and exit immediately if file perfectly matches rules', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('perfect.mkv')], process.cwd()); @@ -111,7 +109,6 @@ describe('E2E: JellyCC Check Menu', () => { expect(exitCode).toBe(0); expect(finalOutput).toContain('The file only requires cleanup (Remux). You discarded 1 stream(s).'); expect(finalOutput).toContain('Suggested Cleanup Command'); - expect(finalOutput).toContain('Operation finished. 🚀'); }); test('Should show Transcode action plan if video codec is incompatible', async () => { @@ -130,18 +127,9 @@ describe('E2E: JellyCC Check Menu', () => { expect(exitCode).toBe(0); expect(finalOutput).toContain('Suggested FFmpeg Command (Transcode + Cleanup)'); expect(finalOutput).toContain('hevc_10bit'); - expect(finalOutput).toContain('Operation finished. 🚀'); - }); - - // // Edge Cases - test('Should prompt to remove embedded garbage if cover art or PGS subtitles are detected', async () => { - cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('with_garbage.mkv')], process.cwd()); - - await cli.waitForText('Embedded garbage detected'); - - expect(cli.getOutput()).toContain('Embedded garbage detected'); }); + // Interactive Menus test('Should open stream selection menu, drop a track, and update action plan to Remux', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('perfect.mkv')], process.cwd()); @@ -164,12 +152,8 @@ describe('E2E: JellyCC Check Menu', () => { cli.write(Keys.Enter); const exitCode = await cli.waitForExit(); - const finalOutput = cli.getOutput(); - expect(exitCode).toBe(0); - expect(finalOutput).toContain('The file only requires cleanup (Remux). You discarded 1 stream(s).'); - expect(finalOutput).toContain('Suggested Cleanup Command'); - expect(finalOutput).toContain('Operation finished. 🚀'); + expect(cli.getOutput()).toContain('The file only requires cleanup (Remux). You discarded 1 stream(s).'); }); test('Should open tags editor, change language, and update the suggested command', async () => { @@ -178,8 +162,7 @@ describe('E2E: JellyCC Check Menu', () => { await acceptInitialTagEditing(); await openExecutionMenu(); - cli.write(Keys.Down); - cli.write(Keys.Down); + cli.press(Keys.Down, 2); cli.write(Keys.Enter); await cli.waitForText('Select the track you want to edit:'); @@ -187,7 +170,6 @@ describe('E2E: JellyCC Check Menu', () => { cli.write(Keys.Enter); await cli.waitForText('Language (3-letter code. Ex: eng, jpn, und):'); - cli.press(Keys.Backspace, 3); cli.write('jpn'); cli.write(Keys.Enter); @@ -206,29 +188,8 @@ describe('E2E: JellyCC Check Menu', () => { cli.write(Keys.Enter); const exitCode = await cli.waitForExit(); - const finalOutput = cli.getOutput(); - - expect(exitCode).toBe(0); - expect(finalOutput).toContain('language="jpn"'); - expect(finalOutput).toContain('The file only requires cleanup (Remux). You discarded 0 stream(s).'); - expect(finalOutput).toContain('Operation finished. 🚀'); - }); - - test('Should exit gracefully and print the generated command when selecting Exit', async () => { - cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('needs_transcode.mkv')], process.cwd()); - - await acceptInitialTagEditing(); - await openExecutionMenu(); - - cli.write(Keys.Up); - cli.write(Keys.Enter); - - const exitCode = await cli.waitForExit(); - const finalOutput = cli.getOutput(); - expect(exitCode).toBe(0); - expect(finalOutput).toContain('Clean command generated:'); - expect(finalOutput).toContain('Operation finished. 🚀'); + expect(cli.getOutput()).toContain('language="jpn"'); }); // Deep Scan @@ -239,7 +200,6 @@ describe('E2E: JellyCC Check Menu', () => { await openExecutionMenu(); const currentOutput = cli.getOutput(); - expect(currentOutput).not.toContain('Deep Scan (All tracks'); expect(currentOutput).not.toContain('Myopic Scan'); @@ -266,4 +226,42 @@ describe('E2E: JellyCC Check Menu', () => { expect(exitCode).toBe(0); }); -}); + + // Silence Scan + test('Should bypass Silence Scan execution if media has no audio tracks', async () => { + cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('no_audio.mkv')], process.cwd()); + + await openExecutionMenu(); + + cli.press(Keys.Down, 5); + cli.write(Keys.Enter); + + await cli.waitForText('Audio intact: No sudden drops detected.'); + await openExecutionMenu(); + + cli.write(Keys.Up); + cli.write(Keys.Enter); + + const exitCode = await cli.waitForExit(); + expect(exitCode).toBe(0); + }); + + test('Should execute Silence Scan successfully on a clean video and return to menu', async () => { + cli = new CliTester(['bun', 'run', 'src/index.ts', 'check', fixturePath('silence_clean.mkv')], process.cwd()); + + await acceptInitialTagEditing(); + await openExecutionMenu(); + + cli.press(Keys.Down, 5); + cli.write(Keys.Enter); + + await cli.waitForText('Audio intact: No sudden drops detected.'); + await openExecutionMenu(); + + cli.write(Keys.Up); + cli.write(Keys.Enter); + + const exitCode = await cli.waitForExit(); + expect(exitCode).toBe(0); + }); +}); \ No newline at end of file diff --git a/tests/e2e/merge.test.ts b/tests/e2e/merge.test.ts index db5963f..c5e39b2 100644 --- a/tests/e2e/merge.test.ts +++ b/tests/e2e/merge.test.ts @@ -150,8 +150,6 @@ describe('E2E: JellyCC Merge Menu', () => { cli.write(Keys.Space); cli.write(Keys.Enter); - // await finishTagPromptWithoutChanges(); - await cli.waitForText('Suggested FFmpeg Command (Merge)'); await openExecutionMenu(); exitFromExecutionMenu(); @@ -166,7 +164,7 @@ describe('E2E: JellyCC Merge Menu', () => { expect(finalOutput).toContain('Clean command generated:'); expect(finalOutput).toContain('Operation finished. 🚀'); }); - + test('Should allow manual sync adjustment and apply strict cut (-shortest)', async () => { cli = new CliTester(['bun', 'run', 'src/index.ts', 'merge'], process.cwd()); @@ -176,7 +174,7 @@ describe('E2E: JellyCC Merge Menu', () => { await cli.waitForText('Duration Alert'); await cli.waitForText('How do you want to adjust the sync for File B?'); - cli.press(Keys.Down, 1); + cli.press(Keys.Down, 2); cli.write(Keys.Enter); await cli.waitForText('Enter the delay for File B in milliseconds'); @@ -243,4 +241,42 @@ describe('E2E: JellyCC Merge Menu', () => { expect(finalOutput).toContain('Clean command generated:'); expect(finalOutput).toContain('Operation finished. 🚀'); }); -}); + + test('Should execute Spectrum Sync successfully and calculate compensation', async () => { + cli = new CliTester(['bun', 'run', 'src/index.ts', 'merge'], process.cwd()); + + await openMergePaths(longMergeFixture, longMergeFixture); + await acceptDefaultStreamSelection(); + + await cli.waitForText('Suggested FFmpeg Command (Merge)'); + await openExecutionMenu(); + cli.clearOutput(); + + cli.press(Keys.Down, 5); + cli.write(Keys.Enter); + + await cli.waitForText('How do you want to adjust the sync for File B?'); + + cli.write(Keys.Enter); + + await cli.waitForText('Enter the timestamp of File A (Reference) for analysis:'); + cli.write('00:00:00'); + cli.write(Keys.Enter); + + await cli.waitForText('Sync complete! Calculated compensation: 0ms'); + + await cli.waitForText('Do you want to use Strict Mode'); + cli.write(Keys.Enter); + + await cli.waitForText('Suggested FFmpeg Command (Merge)'); + await openExecutionMenu(); + exitFromExecutionMenu(); + + const exitCode = await cli.waitForExit(); + const finalOutput = cli.getOutput(); + + expect(exitCode).toBe(0); + expect(finalOutput).toContain('Sync complete! Calculated compensation: 0ms'); + expect(finalOutput).toContain('Clean command generated:'); + }); +}); \ No newline at end of file diff --git a/tests/helpers/setup-fixtures.ts b/tests/helpers/setup-fixtures.ts index afaf333..8f8cf1e 100644 --- a/tests/helpers/setup-fixtures.ts +++ b/tests/helpers/setup-fixtures.ts @@ -39,6 +39,21 @@ try { path.join(FIXTURES_DIR, 'needs_transcode.mkv') ]); + // No Audio + runFfmpeg('no_audio.mkv', [ + '-f', 'lavfi', '-i', 'testsrc=duration=2:size=640x360:rate=24', + '-c:v', 'libx264', + path.join(FIXTURES_DIR, 'no_audio.mkv') + ]); + + // Silence Clean (3 seconds of noise) + runFfmpeg('silence_clean.mkv', [ + '-f', 'lavfi', '-i', 'testsrc=duration=3:size=640x360:rate=24', + '-f', 'lavfi', '-i', 'sine=frequency=1000:duration=3', + '-c:v', 'libx264', '-c:a', 'aac', + path.join(FIXTURES_DIR, 'silence_clean.mkv') + ]); + // Garbage File (Video + Audio + Cover Art) const coverPath = path.join(FIXTURES_DIR, 'cover_temp.jpg'); runFfmpeg('cover_temp.jpg', [