fixed choppy video on iPhone - mp4 encoding

This commit is contained in:
2026-02-08 18:04:34 +00:00
parent 8896aced3e
commit ac95ee9060
22 changed files with 539 additions and 60 deletions

104
server/convert-webm.js Normal file
View File

@@ -0,0 +1,104 @@
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import { spawn } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const mediaDir = path.join(__dirname, '..', 'public', 'media');
const transcodeToMp4 = (inputPath, outputPath) => new Promise((resolve, reject) => {
const args = [
'-y',
'-i', inputPath,
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
'-c:v', 'libx264',
'-profile:v', 'baseline',
'-level', '3.0',
'-pix_fmt', 'yuv420p',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
outputPath
];
const ffmpeg = spawn('ffmpeg', args, { stdio: 'ignore' });
ffmpeg.on('error', reject);
ffmpeg.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`ffmpeg exited with code ${code}`));
}
});
});
const toMediaPath = (filename) => `/media/${filename}`;
async function run() {
const db = await open({
filename: path.join(__dirname, 'signsync.db'),
driver: sqlite3.Database
});
const addColumnIfMissing = async (columnDef) => {
try {
await db.exec(`ALTER TABLE recordings ADD COLUMN ${columnDef}`);
} catch (error) {
if (!String(error.message).toLowerCase().includes('duplicate column')) {
console.error('Error adding column:', error);
}
}
};
await addColumnIfMissing('recorded_video_processing INTEGER DEFAULT 0');
await addColumnIfMissing('recorded_video_processing_2 INTEGER DEFAULT 0');
const rows = await db.all('SELECT id, recorded_video_path, recorded_video_path_2 FROM recordings');
for (const row of rows) {
const updates = {};
for (const key of ['recorded_video_path', 'recorded_video_path_2']) {
const currentPath = row[key];
if (!currentPath || !currentPath.endsWith('.webm')) continue;
const inputPath = path.join(mediaDir, path.basename(currentPath));
if (!fs.existsSync(inputPath)) {
console.warn(`Missing file for ${key} on row ${row.id}: ${inputPath}`);
continue;
}
const outputPath = inputPath.replace(/\.webm$/i, '.mp4');
if (!fs.existsSync(outputPath)) {
console.log(`Transcoding ${inputPath} -> ${outputPath}`);
await transcodeToMp4(inputPath, outputPath);
}
updates[key] = toMediaPath(path.basename(outputPath));
fs.unlinkSync(inputPath);
}
if (Object.keys(updates).length > 0) {
await db.run(
`UPDATE recordings
SET recorded_video_path = COALESCE(?, recorded_video_path),
recorded_video_path_2 = COALESCE(?, recorded_video_path_2),
recorded_video_processing = 0,
recorded_video_processing_2 = 0
WHERE id = ?`,
[updates.recorded_video_path || null, updates.recorded_video_path_2 || null, row.id]
);
}
}
await db.close();
console.log('Conversion complete.');
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -7,6 +7,7 @@ import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { spawn } from 'child_process';
// Generate a unique link ID
const generateUniqueId = () => {
@@ -17,7 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = 3001;
const PORT = process.env.PORT ? Number(process.env.PORT) : 3005;
// Middleware
app.use(cors());
@@ -30,13 +31,22 @@ if (!fs.existsSync(mediaDir)) {
}
// Configure multer for video uploads
const getExtensionForMime = (mimetype) => {
if (!mimetype) return '.webm';
if (mimetype.includes('mp4')) return '.mp4';
if (mimetype.includes('webm')) return '.webm';
if (mimetype.includes('quicktime')) return '.mov';
return '.webm';
};
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, mediaDir);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const safeName = `recording_${timestamp}.webm`;
const ext = getExtensionForMime(file.mimetype);
const safeName = `recording_${timestamp}${ext}`;
cb(null, safeName);
}
});
@@ -56,6 +66,61 @@ const upload = multer({
// Initialize SQLite database
let db;
const transcodeToMp4 = (inputPath, outputPath) => new Promise((resolve, reject) => {
const args = [
'-y',
'-i', inputPath,
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
'-c:v', 'libx264',
'-profile:v', 'baseline',
'-level', '3.0',
'-pix_fmt', 'yuv420p',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
outputPath
];
const ffmpeg = spawn('ffmpeg', args, { stdio: 'ignore' });
ffmpeg.on('error', reject);
ffmpeg.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`ffmpeg exited with code ${code}`));
}
});
});
const isMp4Upload = (file) => {
if (!file) return false;
if (file.mimetype && file.mimetype.includes('mp4')) return true;
return path.extname(file.filename).toLowerCase() === '.mp4';
};
const transcodeQueue = [];
let isTranscoding = false;
const processTranscodeQueue = async () => {
if (isTranscoding) return;
const job = transcodeQueue.shift();
if (!job) return;
isTranscoding = true;
try {
await job();
} catch (error) {
console.error('Transcode job failed:', error);
} finally {
isTranscoding = false;
processTranscodeQueue();
}
};
const enqueueTranscode = (job) => {
transcodeQueue.push(job);
processTranscodeQueue();
};
async function initializeDatabase() {
db = await open({
filename: path.join(__dirname, 'signsync.db'),
@@ -69,14 +134,32 @@ async function initializeDatabase() {
name TEXT NOT NULL,
email TEXT NOT NULL,
recorded_video_path TEXT NOT NULL,
recorded_video_processing INTEGER DEFAULT 0,
youtube_video_url TEXT NOT NULL,
name_2 TEXT,
email_2 TEXT,
recorded_video_path_2 TEXT,
recorded_video_processing_2 INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
const addColumnIfMissing = async (columnDef) => {
try {
await db.exec(`ALTER TABLE recordings ADD COLUMN ${columnDef}`);
} catch (error) {
if (!String(error.message).toLowerCase().includes('duplicate column')) {
console.error('Error adding column:', error);
}
}
};
await addColumnIfMissing('recorded_video_processing INTEGER DEFAULT 0');
await addColumnIfMissing('recorded_video_processing_2 INTEGER DEFAULT 0');
await db.run('UPDATE recordings SET recorded_video_processing = 0 WHERE recorded_video_processing IS NULL');
await db.run('UPDATE recordings SET recorded_video_processing_2 = 0 WHERE recorded_video_processing_2 IS NULL');
console.log('Database initialized');
}
@@ -95,11 +178,13 @@ app.post('/api/recordings', upload.single('video'), async (req, res) => {
const recordedVideoPath = `/media/${req.file.filename}`;
const uniqueLink = generateUniqueId();
const needsTranscode = !isMp4Upload(req.file);
const recordedVideoProcessing = needsTranscode ? 1 : 0;
const result = await db.run(
`INSERT INTO recordings (unique_link, name, email, recorded_video_path, youtube_video_url)
VALUES (?, ?, ?, ?, ?)`,
[uniqueLink, name, email, recordedVideoPath, youtubeVideoUrl || '']
`INSERT INTO recordings (unique_link, name, email, recorded_video_path, recorded_video_processing, youtube_video_url)
VALUES (?, ?, ?, ?, ?, ?)`,
[uniqueLink, name, email, recordedVideoPath, recordedVideoProcessing, youtubeVideoUrl || '']
);
res.json({
@@ -107,8 +192,30 @@ app.post('/api/recordings', upload.single('video'), async (req, res) => {
id: result.lastID,
uniqueLink,
recordedVideoPath,
recordedVideoProcessing,
message: 'Recording saved successfully'
});
if (needsTranscode) {
const inputPath = req.file.path;
const outputPath = inputPath.replace(/\.[^.]+$/, '.mp4');
enqueueTranscode(async () => {
try {
await transcodeToMp4(inputPath, outputPath);
await db.run(
`UPDATE recordings SET recorded_video_path = ?, recorded_video_processing = 0 WHERE unique_link = ?`,
[`/media/${path.basename(outputPath)}`, uniqueLink]
);
fs.unlink(inputPath, () => {});
} catch (transcodeError) {
console.error('Transcode failed, keeping original file:', transcodeError);
await db.run(
`UPDATE recordings SET recorded_video_processing = 0 WHERE unique_link = ?`,
[uniqueLink]
);
}
});
}
} catch (error) {
console.error('Error saving recording:', error);
res.status(500).json({ error: 'Failed to save recording' });
@@ -193,17 +300,43 @@ app.post('/api/share/:uniqueLink/second-video', upload.single('video'), async (r
}
const recordedVideoPath2 = `/media/${req.file.filename}`;
const needsTranscode = !isMp4Upload(req.file);
const recordedVideoProcessing2 = needsTranscode ? 1 : 0;
await db.run(
`UPDATE recordings SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ? WHERE unique_link = ?`,
[name, email, recordedVideoPath2, uniqueLink]
`UPDATE recordings
SET name_2 = ?, email_2 = ?, recorded_video_path_2 = ?, recorded_video_processing_2 = ?
WHERE unique_link = ?`,
[name, email, recordedVideoPath2, recordedVideoProcessing2, uniqueLink]
);
res.json({
success: true,
recordedVideoPath2,
recordedVideoProcessing2,
message: 'Second recording saved successfully'
});
if (needsTranscode) {
const inputPath = req.file.path;
const outputPath = inputPath.replace(/\.[^.]+$/, '.mp4');
enqueueTranscode(async () => {
try {
await transcodeToMp4(inputPath, outputPath);
await db.run(
`UPDATE recordings SET recorded_video_path_2 = ?, recorded_video_processing_2 = 0 WHERE unique_link = ?`,
[`/media/${path.basename(outputPath)}`, uniqueLink]
);
fs.unlink(inputPath, () => {});
} catch (transcodeError) {
console.error('Transcode failed, keeping original file:', transcodeError);
await db.run(
`UPDATE recordings SET recorded_video_processing_2 = 0 WHERE unique_link = ?`,
[uniqueLink]
);
}
});
}
} catch (error) {
console.error('Error saving second recording:', error);
res.status(500).json({ error: 'Failed to save second recording' });

Binary file not shown.