Add video/audio to MP3 conversion feature

Implement drag-and-drop interface for converting video and audio files to MP3 format using FFmpeg. Users can now upload files (MP4, M4A, AVI, MKV, MOV, WAV, FLAC, OGG) and convert them with customizable bitrate and quality settings.

- Add conversion service with FFmpeg integration
- Add /convert-to-mp3 and /supported-formats API endpoints
- Add new "Video to MP3" tab with drag-and-drop UI
- Support multiple file uploads with batch conversion
- Add bitrate (128k-320k) and VBR quality options

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trouve Alexis 2025-11-29 22:56:58 +08:00
parent a974846efc
commit 23bb4cd2d9
5 changed files with 452 additions and 0 deletions

View File

@ -162,6 +162,159 @@ document.getElementById('download-form').addEventListener('submit', async (e) =>
};
});
// ==================== CONVERT TAB (Video to MP3) ====================
let convertSelectedFiles = [];
const convertDropZone = document.getElementById('convert-drop-zone');
const convertFileInput = document.getElementById('convert-file-input');
const convertSelectedFilesDiv = document.getElementById('convert-selected-files');
const convertFilesList = document.getElementById('convert-files-list');
const convertBtn = document.getElementById('convert-btn');
const convertClearFilesBtn = document.getElementById('convert-clear-files');
function updateConvertFilesList() {
if (convertSelectedFiles.length === 0) {
convertSelectedFilesDiv.style.display = 'none';
convertBtn.disabled = true;
return;
}
convertSelectedFilesDiv.style.display = 'block';
convertBtn.disabled = false;
convertFilesList.innerHTML = convertSelectedFiles.map((file, index) => `
<li>
<span class="file-name">${file.name}</span>
<span class="file-size">${formatSize(file.size)}</span>
<button type="button" class="remove-file" data-index="${index}">×</button>
</li>
`).join('');
// Add remove handlers
convertFilesList.querySelectorAll('.remove-file').forEach(btn => {
btn.addEventListener('click', () => {
convertSelectedFiles.splice(parseInt(btn.dataset.index), 1);
updateConvertFilesList();
});
});
}
function addConvertFiles(files) {
const videoAudioFiles = Array.from(files).filter(f =>
f.type.startsWith('video/') || f.type.startsWith('audio/') ||
f.name.match(/\.(mp4|avi|mkv|mov|m4a|wav|flac|ogg|webm|wmv|flv)$/i)
);
convertSelectedFiles = [...convertSelectedFiles, ...videoAudioFiles];
updateConvertFilesList();
}
// Drag & Drop events for convert
convertDropZone.addEventListener('click', () => convertFileInput.click());
convertDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
convertDropZone.classList.add('drag-over');
});
convertDropZone.addEventListener('dragleave', () => {
convertDropZone.classList.remove('drag-over');
});
convertDropZone.addEventListener('drop', (e) => {
e.preventDefault();
convertDropZone.classList.remove('drag-over');
addConvertFiles(e.dataTransfer.files);
});
convertFileInput.addEventListener('change', () => {
addConvertFiles(convertFileInput.files);
convertFileInput.value = '';
});
convertClearFilesBtn.addEventListener('click', () => {
convertSelectedFiles = [];
updateConvertFilesList();
});
// Convert form submit
document.getElementById('convert-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (convertSelectedFiles.length === 0) return;
const button = convertBtn;
const bitrate = document.getElementById('convert-bitrate').value;
const quality = document.getElementById('convert-quality').value;
const convertProgress = document.getElementById('convert-progress');
const convertProgressFill = document.getElementById('convert-progress-fill');
const convertProgressTitle = document.getElementById('convert-progress-title');
const convertProgressPercent = document.getElementById('convert-progress-percent');
const convertProgressInfo = document.getElementById('convert-progress-info');
const convertProgressCurrent = document.getElementById('convert-progress-current');
setLoading(button, true);
convertProgress.style.display = 'block';
convertProgressFill.style.width = '0%';
convertProgressTitle.textContent = 'Converting to MP3...';
convertProgressPercent.textContent = '0%';
convertProgressInfo.textContent = `0/${convertSelectedFiles.length} files`;
document.getElementById('convert-result').classList.remove('show');
const formData = new FormData();
convertSelectedFiles.forEach(file => formData.append('files', file));
formData.append('bitrate', bitrate);
formData.append('quality', quality);
try {
const response = await fetch(`${API_URL}/convert-to-mp3`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Conversion failed');
convertProgressFill.style.width = '100%';
convertProgressPercent.textContent = '100%';
convertProgressTitle.textContent = 'Conversion Complete!';
convertProgressInfo.textContent = `${data.successCount}/${data.totalFiles} files`;
showResult('convert-result', true, `
<h3>Conversion Complete!</h3>
<p>${data.successCount}/${data.totalFiles} files converted to MP3</p>
<div class="file-results">
${data.results.map(r => r.success ? `
<div class="file-result success">
<div class="file-info">
<strong>${r.fileName}</strong>
<span class="file-size">${r.size}</span>
</div>
<a href="${r.outputUrl}" download class="btn btn-small btn-primary">Download MP3</a>
</div>
` : `
<div class="file-result error">
<strong>${r.fileName}</strong>
<span class="error-msg">${r.error}</span>
</div>
`).join('')}
</div>
`);
// Clear selected files after successful conversion
convertSelectedFiles = [];
updateConvertFilesList();
} catch (error) {
showResult('convert-result', false, `<h3>Error</h3><p>${error.message}</p>`);
} finally {
setLoading(button, false);
setTimeout(() => {
convertProgress.style.display = 'none';
}, 1000);
}
});
// ==================== TRANSCRIBE TAB (Drag & Drop) ====================
let selectedFiles = [];

View File

@ -16,6 +16,7 @@
<!-- Tabs -->
<nav class="tabs">
<button class="tab active" data-tab="download">Download</button>
<button class="tab" data-tab="convert">Video to MP3</button>
<button class="tab" data-tab="transcribe">Transcribe</button>
<button class="tab" data-tab="process">Download + Transcribe</button>
<button class="tab" data-tab="translate">Translate</button>
@ -53,6 +54,71 @@
<div id="download-result" class="result"></div>
</section>
<!-- Convert Tab -->
<section id="convert" class="tab-content">
<h2>Convert Video/Audio to MP3</h2>
<div id="convert-drop-zone" class="drop-zone">
<div class="drop-zone-content">
<div class="drop-zone-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p class="drop-zone-text">Drag & drop video/audio files here</p>
<p class="drop-zone-hint">or click to select files</p>
<p class="drop-zone-formats">Supported: MP4, M4A, AVI, MKV, MOV, WAV, FLAC, OGG</p>
<input type="file" id="convert-file-input" multiple accept="video/*,audio/*,.mp4,.avi,.mkv,.mov,.m4a,.wav,.flac,.ogg" style="display: none;">
</div>
</div>
<div id="convert-selected-files" class="selected-files" style="display: none;">
<h3>Selected Files</h3>
<ul id="convert-files-list"></ul>
<button type="button" id="convert-clear-files" class="btn btn-small btn-secondary">Clear</button>
</div>
<form id="convert-form">
<div class="form-row">
<div class="form-group">
<label for="convert-bitrate">Audio Bitrate</label>
<select id="convert-bitrate">
<option value="128k">128 kbps</option>
<option value="192k" selected>192 kbps (Recommended)</option>
<option value="256k">256 kbps</option>
<option value="320k">320 kbps (High Quality)</option>
</select>
</div>
<div class="form-group">
<label for="convert-quality">VBR Quality</label>
<select id="convert-quality">
<option value="0">0 - Best Quality</option>
<option value="2" selected>2 - High Quality</option>
<option value="4">4 - Standard</option>
<option value="6">6 - Good</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary" id="convert-btn" disabled>
<span class="btn-text">Convert to MP3</span>
<span class="btn-loading">Converting...</span>
</button>
</form>
<div id="convert-progress" class="progress-container" style="display: none;">
<div class="progress-header">
<span id="convert-progress-title">Converting...</span>
<span id="convert-progress-info"></span>
</div>
<div class="progress-bar">
<div id="convert-progress-fill" class="progress-fill"></div>
</div>
<div class="progress-details">
<span id="convert-progress-percent">0%</span>
<span id="convert-progress-current"></span>
</div>
</div>
<div id="convert-result" class="result"></div>
</section>
<!-- Transcribe Tab -->
<section id="transcribe" class="tab-content">
<h2>Transcribe Audio File</h2>

View File

@ -519,6 +519,13 @@ select option {
font-size: 0.9rem;
}
.drop-zone-formats {
color: #64748b;
font-size: 0.75rem;
margin-top: 0.5rem;
font-style: italic;
}
/* Selected Files List */
.selected-files {
background: rgba(255, 255, 255, 0.05);

View File

@ -9,6 +9,7 @@ import { download, getInfo } from './services/youtube.js';
import { transcribeFile, transcribeAndSave, transcribeMultiple } from './services/transcription.js';
import { translateText, translateFile, translateMultiple, getLanguages } from './services/translation.js';
import { summarizeText, summarizeFile, getSummaryStyles } from './services/summarize.js';
import { convertToMP3, convertMultipleToMP3, getSupportedFormats } from './services/conversion.js';
dotenv.config();
@ -57,6 +58,22 @@ const uploadText = multer({
}
});
// Upload handler for video/audio files (for conversion)
const uploadVideo = multer({
storage,
fileFilter: (req, file, cb) => {
const videoTypes = ['video/mp4', 'video/avi', 'video/x-msvideo', 'video/quicktime', 'video/x-matroska', 'video/webm'];
const audioTypes = ['audio/m4a', 'audio/x-m4a', 'audio/wav', 'audio/flac', 'audio/ogg', 'audio/aac'];
const videoExtensions = /\.(mp4|avi|mkv|mov|wmv|flv|webm|m4v|m4a|wav|flac|ogg|aac)$/i;
if (videoTypes.includes(file.mimetype) || audioTypes.includes(file.mimetype) || file.originalname.match(videoExtensions)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only video/audio files are allowed.'));
}
}
});
app.use(cors());
app.use(express.json());
@ -387,6 +404,70 @@ app.post('/upload-transcribe', upload.array('files', 50), async (req, res) => {
}
});
/**
* POST /convert-to-mp3
* Upload video/audio files and convert them to MP3
*/
app.post('/convert-to-mp3', uploadVideo.array('files', 50), async (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const { bitrate = '192k', quality = '2' } = req.body;
const results = [];
console.log(`Converting ${req.files.length} files to MP3`);
for (let i = 0; i < req.files.length; i++) {
const file = req.files[i];
console.log(`[${i + 1}/${req.files.length}] Converting: ${file.originalname}`);
try {
const result = await convertToMP3(file.path, {
outputDir: OUTPUT_DIR,
bitrate,
quality,
});
results.push({
success: true,
fileName: file.originalname,
inputPath: file.path,
outputPath: result.outputPath,
outputUrl: `/files/${path.basename(result.outputPath)}`,
size: result.sizeHuman,
});
} catch (error) {
console.error(`Failed to convert ${file.originalname}: ${error.message}`);
results.push({
success: false,
fileName: file.originalname,
error: error.message,
});
}
}
res.json({
success: true,
totalFiles: req.files.length,
successCount: results.filter(r => r.success).length,
failCount: results.filter(r => !r.success).length,
results,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /supported-formats
* Get supported video/audio formats for conversion
*/
app.get('/supported-formats', (req, res) => {
res.json({ formats: getSupportedFormats() });
});
/**
* GET /process-stream
* Download and transcribe with SSE progress updates

145
src/services/conversion.js Normal file
View File

@ -0,0 +1,145 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs';
const execPromise = promisify(exec);
/**
* Convert a video/audio file to MP3 using FFmpeg
* @param {string} inputPath - Path to input file
* @param {object} options - Conversion options
* @param {string} options.outputDir - Output directory (default: same as input)
* @param {string} options.bitrate - Audio bitrate (default: 192k)
* @param {string} options.quality - Audio quality 0-9 (default: 2, where 0 is best)
* @returns {Promise<object>} Conversion result with output path
*/
export async function convertToMP3(inputPath, options = {}) {
const {
outputDir = path.dirname(inputPath),
bitrate = '192k',
quality = '2',
} = options;
// Ensure input file exists
if (!fs.existsSync(inputPath)) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Generate output path
const inputFilename = path.basename(inputPath, path.extname(inputPath));
const outputPath = path.join(outputDir, `${inputFilename}.mp3`);
// Check if output already exists
if (fs.existsSync(outputPath)) {
// Add timestamp to make it unique
const timestamp = Date.now();
const uniqueOutputPath = path.join(outputDir, `${inputFilename}_${timestamp}.mp3`);
return convertToMP3Internal(inputPath, uniqueOutputPath, bitrate, quality);
}
return convertToMP3Internal(inputPath, outputPath, bitrate, quality);
}
/**
* Internal conversion function
*/
async function convertToMP3Internal(inputPath, outputPath, bitrate, quality) {
try {
// FFmpeg command to convert to MP3
// -i: input file
// -vn: no video (audio only)
// -ar 44100: audio sample rate 44.1kHz
// -ac 2: stereo
// -b:a: audio bitrate
// -q:a: audio quality (VBR)
const command = `ffmpeg -i "${inputPath}" -vn -ar 44100 -ac 2 -b:a ${bitrate} -q:a ${quality} "${outputPath}"`;
console.log(`Converting: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`);
const { stdout, stderr } = await execPromise(command);
// Verify output file was created
if (!fs.existsSync(outputPath)) {
throw new Error('Conversion failed: output file not created');
}
const stats = fs.statSync(outputPath);
return {
success: true,
inputPath,
outputPath,
filename: path.basename(outputPath),
size: stats.size,
sizeHuman: formatBytes(stats.size),
};
} catch (error) {
console.error(`Conversion error: ${error.message}`);
throw new Error(`FFmpeg conversion failed: ${error.message}`);
}
}
/**
* Convert multiple files to MP3
* @param {string[]} inputPaths - Array of input file paths
* @param {object} options - Conversion options
* @returns {Promise<object>} Batch conversion results
*/
export async function convertMultipleToMP3(inputPaths, options = {}) {
const results = [];
let successCount = 0;
let failCount = 0;
for (let i = 0; i < inputPaths.length; i++) {
const inputPath = inputPaths[i];
console.log(`[${i + 1}/${inputPaths.length}] Converting: ${path.basename(inputPath)}`);
try {
const result = await convertToMP3(inputPath, options);
results.push({ ...result, index: i });
successCount++;
} catch (error) {
results.push({
success: false,
inputPath,
error: error.message,
index: i,
});
failCount++;
console.error(`Failed to convert ${path.basename(inputPath)}: ${error.message}`);
}
}
return {
totalFiles: inputPaths.length,
successCount,
failCount,
results,
};
}
/**
* Format bytes to human readable format
*/
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* Get supported input formats
*/
export function getSupportedFormats() {
return {
video: ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v'],
audio: ['.m4a', '.wav', '.flac', '.ogg', '.aac', '.wma', '.opus'],
};
}