Skip to content

Commit 193743d

Browse files
committed
feat: Implement media and document conversion capabilities using FFmpeg and Pandoc wrappers with progress tracking.
1 parent 049a587 commit 193743d

10 files changed

Lines changed: 2371 additions & 63 deletions

File tree

dist-electron/main.js

Lines changed: 926 additions & 31 deletions
Large diffs are not rendered by default.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import path from 'path'
2+
import fs from 'fs/promises'
3+
import { executeFFmpeg, probeMediaFile } from './utils/ffmpeg-wrapper'
4+
5+
export interface AudioConvertOptions {
6+
sourcePath: string
7+
outputDir: string
8+
targetFormat: string
9+
quality: number // 1-100
10+
overwriteBehavior: 'skip' | 'rename' | 'overwrite'
11+
onProgress?: (percent: number) => void
12+
}
13+
14+
export interface ConvertResult {
15+
success: boolean
16+
outputPath: string
17+
error?: string
18+
durationMs: number
19+
}
20+
21+
// Supported audio formats
22+
const AUDIO_FORMATS = ['mp3', 'wav', 'aac', 'ogg', 'flac', 'wma', 'm4a'] as const
23+
24+
/**
25+
* Check if a format is a supported audio format
26+
*/
27+
export function isAudioFormat(ext: string): boolean {
28+
const lower = ext.toLowerCase()
29+
return AUDIO_FORMATS.includes(lower as typeof AUDIO_FORMATS[number])
30+
}
31+
32+
/**
33+
* Generate a unique output file path, handling conflicts based on overwrite behavior.
34+
*/
35+
async function resolveOutputPath(
36+
outputDir: string,
37+
baseName: string,
38+
targetFormat: string,
39+
overwriteBehavior: 'skip' | 'rename' | 'overwrite'
40+
): Promise<{ path: string; skip: boolean }> {
41+
const outputPath = path.join(outputDir, `${baseName}.${targetFormat}`)
42+
43+
try {
44+
await fs.access(outputPath)
45+
// File exists
46+
if (overwriteBehavior === 'overwrite') {
47+
return { path: outputPath, skip: false }
48+
}
49+
if (overwriteBehavior === 'skip') {
50+
return { path: outputPath, skip: true }
51+
}
52+
// rename: find next available name
53+
let counter = 1
54+
let newPath: string
55+
do {
56+
newPath = path.join(outputDir, `${baseName} (${counter}).${targetFormat}`)
57+
counter++
58+
try {
59+
await fs.access(newPath)
60+
} catch {
61+
// File doesn't exist, use this path
62+
return { path: newPath, skip: false }
63+
}
64+
} while (counter < 10000)
65+
66+
return { path: newPath, skip: false }
67+
} catch {
68+
// File doesn't exist, use original path
69+
return { path: outputPath, skip: false }
70+
}
71+
}
72+
73+
/**
74+
* Get the base name of a file without its extension
75+
*/
76+
function getBaseName(filePath: string): string {
77+
const name = path.basename(filePath)
78+
const lastDot = name.lastIndexOf('.')
79+
if (lastDot === -1) return name
80+
return name.substring(0, lastDot)
81+
}
82+
83+
/**
84+
* Convert an audio file to the target format using FFmpeg.
85+
*/
86+
export async function convertAudio(options: AudioConvertOptions): Promise<ConvertResult> {
87+
const startTime = Date.now()
88+
const { sourcePath, outputDir, targetFormat, quality, overwriteBehavior, onProgress } = options
89+
90+
try {
91+
// Validate source file exists
92+
await fs.access(sourcePath)
93+
94+
// Validate that source is an audio file
95+
const mediaInfo = await probeMediaFile(sourcePath)
96+
if (!mediaInfo) {
97+
return {
98+
success: false,
99+
outputPath: '',
100+
error: 'Failed to probe audio file. The file may be corrupted or in an unsupported format.',
101+
durationMs: Date.now() - startTime,
102+
}
103+
}
104+
105+
// Check if the file has an audio stream
106+
if (!mediaInfo.audioCodec) {
107+
return {
108+
success: false,
109+
outputPath: '',
110+
error: 'Source file does not contain an audio stream.',
111+
durationMs: Date.now() - startTime,
112+
}
113+
}
114+
115+
// Ensure output directory exists
116+
await fs.mkdir(outputDir, { recursive: true })
117+
118+
// Resolve output path
119+
const baseName = getBaseName(sourcePath)
120+
const { path: outputPath, skip } = await resolveOutputPath(
121+
outputDir,
122+
baseName,
123+
targetFormat,
124+
overwriteBehavior
125+
)
126+
127+
if (skip) {
128+
return {
129+
success: true,
130+
outputPath,
131+
durationMs: Date.now() - startTime,
132+
}
133+
}
134+
135+
// Execute FFmpeg conversion
136+
const result = await executeFFmpeg({
137+
inputPath: sourcePath,
138+
outputPath,
139+
format: targetFormat,
140+
quality,
141+
onProgress,
142+
})
143+
144+
if (!result.success) {
145+
return {
146+
success: false,
147+
outputPath: '',
148+
error: result.error || 'FFmpeg conversion failed',
149+
durationMs: Date.now() - startTime,
150+
}
151+
}
152+
153+
return {
154+
success: true,
155+
outputPath,
156+
durationMs: Date.now() - startTime,
157+
}
158+
} catch (err) {
159+
const errorMessage = err instanceof Error ? err.message : String(err)
160+
console.error(`[audio-converter] Failed to convert ${sourcePath}:`, errorMessage)
161+
162+
return {
163+
success: false,
164+
outputPath: '',
165+
error: errorMessage,
166+
durationMs: Date.now() - startTime,
167+
}
168+
}
169+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import path from 'path'
2+
import fs from 'fs/promises'
3+
import { executePandoc, canPandocConvert } from './utils/pandoc-wrapper'
4+
5+
export interface DocumentConvertOptions {
6+
sourcePath: string
7+
outputDir: string
8+
targetFormat: string
9+
quality: number // 1-100 (not used for documents, but kept for interface consistency)
10+
overwriteBehavior: 'skip' | 'rename' | 'overwrite'
11+
}
12+
13+
export interface ConvertResult {
14+
success: boolean
15+
outputPath: string
16+
error?: string
17+
durationMs: number
18+
}
19+
20+
// Supported document formats (note: PDF as input is NOT supported by Pandoc)
21+
const DOCUMENT_FORMATS = ['pdf', 'epub', 'docx', 'txt', 'rtf', 'odt', 'md', 'html'] as const
22+
23+
/**
24+
* Check if a format is a supported document format
25+
*/
26+
export function isDocumentFormat(ext: string): boolean {
27+
const lower = ext.toLowerCase()
28+
return DOCUMENT_FORMATS.includes(lower as typeof DOCUMENT_FORMATS[number])
29+
}
30+
31+
/**
32+
* Generate a unique output file path, handling conflicts based on overwrite behavior.
33+
*/
34+
async function resolveOutputPath(
35+
outputDir: string,
36+
baseName: string,
37+
targetFormat: string,
38+
overwriteBehavior: 'skip' | 'rename' | 'overwrite'
39+
): Promise<{ path: string; skip: boolean }> {
40+
const outputPath = path.join(outputDir, `${baseName}.${targetFormat}`)
41+
42+
try {
43+
await fs.access(outputPath)
44+
// File exists
45+
if (overwriteBehavior === 'overwrite') {
46+
return { path: outputPath, skip: false }
47+
}
48+
if (overwriteBehavior === 'skip') {
49+
return { path: outputPath, skip: true }
50+
}
51+
// rename: find next available name
52+
let counter = 1
53+
let newPath: string
54+
do {
55+
newPath = path.join(outputDir, `${baseName} (${counter}).${targetFormat}`)
56+
counter++
57+
try {
58+
await fs.access(newPath)
59+
} catch {
60+
// File doesn't exist, use this path
61+
return { path: newPath, skip: false }
62+
}
63+
} while (counter < 10000)
64+
65+
return { path: newPath, skip: false }
66+
} catch {
67+
// File doesn't exist, use original path
68+
return { path: outputPath, skip: false }
69+
}
70+
}
71+
72+
/**
73+
* Get the base name of a file without its extension
74+
*/
75+
function getBaseName(filePath: string): string {
76+
const name = path.basename(filePath)
77+
const lastDot = name.lastIndexOf('.')
78+
if (lastDot === -1) return name
79+
return name.substring(0, lastDot)
80+
}
81+
82+
/**
83+
* Get file extension without the dot
84+
*/
85+
function getExtension(filePath: string): string {
86+
const ext = path.extname(filePath)
87+
return ext ? ext.substring(1).toLowerCase() : ''
88+
}
89+
90+
/**
91+
* Convert a document file to the target format using Pandoc.
92+
*/
93+
export async function convertDocument(options: DocumentConvertOptions): Promise<ConvertResult> {
94+
const startTime = Date.now()
95+
const { sourcePath, outputDir, targetFormat, overwriteBehavior } = options
96+
97+
try {
98+
// Validate source file exists
99+
await fs.access(sourcePath)
100+
101+
// Get source format
102+
const sourceExt = getExtension(sourcePath)
103+
if (!sourceExt) {
104+
return {
105+
success: false,
106+
outputPath: '',
107+
error: 'Could not determine source file format.',
108+
durationMs: Date.now() - startTime,
109+
}
110+
}
111+
112+
// Check if Pandoc can handle this conversion
113+
if (!canPandocConvert(sourceExt, targetFormat)) {
114+
// Special error message for PDF input
115+
if (sourceExt === 'pdf') {
116+
return {
117+
success: false,
118+
outputPath: '',
119+
error: 'PDF as input is not supported by Pandoc. To convert from PDF, you would need additional tools like pdftotext.',
120+
durationMs: Date.now() - startTime,
121+
}
122+
}
123+
124+
return {
125+
success: false,
126+
outputPath: '',
127+
error: `Pandoc does not support conversion from ${sourceExt} to ${targetFormat}.`,
128+
durationMs: Date.now() - startTime,
129+
}
130+
}
131+
132+
// Ensure output directory exists
133+
await fs.mkdir(outputDir, { recursive: true })
134+
135+
// Resolve output path
136+
const baseName = getBaseName(sourcePath)
137+
const { path: outputPath, skip } = await resolveOutputPath(
138+
outputDir,
139+
baseName,
140+
targetFormat,
141+
overwriteBehavior
142+
)
143+
144+
if (skip) {
145+
return {
146+
success: true,
147+
outputPath,
148+
durationMs: Date.now() - startTime,
149+
}
150+
}
151+
152+
// Execute Pandoc conversion
153+
const result = await executePandoc({
154+
inputPath: sourcePath,
155+
outputPath,
156+
targetFormat,
157+
})
158+
159+
if (!result.success) {
160+
return {
161+
success: false,
162+
outputPath: '',
163+
error: result.error || 'Pandoc conversion failed',
164+
durationMs: Date.now() - startTime,
165+
}
166+
}
167+
168+
return {
169+
success: true,
170+
outputPath,
171+
durationMs: Date.now() - startTime,
172+
}
173+
} catch (err) {
174+
const errorMessage = err instanceof Error ? err.message : String(err)
175+
console.error(`[document-converter] Failed to convert ${sourcePath}:`, errorMessage)
176+
177+
return {
178+
success: false,
179+
outputPath: '',
180+
error: errorMessage,
181+
durationMs: Date.now() - startTime,
182+
}
183+
}
184+
}

electron/converters/index.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
1-
export { convertImage, isImageFormat, generateThumbnail } from './image-converter'
2-
export type { ImageConvertOptions, ConvertResult } from './image-converter'
1+
// Image converter
2+
export { convertImage, isImageFormat, generateThumbnail as generateImageThumbnail } from './image-converter'
3+
export type { ImageConvertOptions } from './image-converter'
4+
5+
// Video converter
6+
export { convertVideo, isVideoFormat, generateThumbnail as generateVideoThumbnail } from './video-converter'
7+
export type { VideoConvertOptions } from './video-converter'
8+
9+
// Audio converter
10+
export { convertAudio, isAudioFormat } from './audio-converter'
11+
export type { AudioConvertOptions } from './audio-converter'
12+
13+
// Document converter
14+
export { convertDocument, isDocumentFormat } from './document-converter'
15+
export type { DocumentConvertOptions } from './document-converter'
16+
17+
// Shared types
18+
export type { ConvertResult } from './image-converter'
319

420
// Category detection using centralized format configuration
521
import { FORMAT_MAP, type FileCategory } from '../config/formats'

0 commit comments

Comments
 (0)