Reels: record for Instagram from the phone

Timed recording (15/30/60/90s, auto-stop in the output) with 9:16
1080x1920 as the default format; countdown on the record button. New
/api/recordings (list) + /recordings static + delete endpoint; the Out tab
lists recorded reels with a download link (save to the phone, share to
Instagram) and delete. recStop returns name/url and recSaved carries them
so a just-recorded reel is one tap away. Transcode lightened for the Pi
(ultrafast, 3 threads, nice -10): no hardware H.264 encoder on the Pi 5,
and a full-core encode can brown the box out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-17 15:49:19 +02:00
1 parent 3696669a1e
commit 98f894fa40
5 files changed
+108 -23

No files matched your search

+26 -4
View File
@@ -37,6 +37,7 @@ app.use((req, res, next) => { if (!req.path.startsWith('/media')) res.set('Cache
app.use(express.static(path.join(ROOT, 'public'), { etag: false, lastModified: false }));
app.use('/media', express.static(MEDIA_DIR));
app.use('/svg', express.static(SVG_DIR));
app.use('/recordings', express.static(REC_DIR)); // reels: download the MP4s from the phone
// ---- Media library ---------------------------------------------------------
const AUDIO_RE = /\.(mp3|m4a|aac|wav|flac|ogg|opus|aif|aiff)$/i;
@@ -126,6 +127,22 @@ app.get('/api/peaks', (req, res) => {
});
});
// ---- Reels: list / delete the recorded MP4s --------------------------------
app.get('/api/recordings', (_req, res) => {
let files = [];
try { files = fs.readdirSync(REC_DIR).filter(f => /\.mp4$/i.test(f)); } catch (e) {}
const items = files.map(f => {
const st = fs.statSync(path.join(REC_DIR, f));
return { name: f, url: '/recordings/' + encodeURIComponent(f), size: st.size, mtime: st.mtimeMs };
}).sort((a, b) => b.mtime - a.mtime);
res.json(items);
});
app.post('/api/recordings/delete', (req, res) => {
const f = path.join(REC_DIR, path.basename((req.body && req.body.name) || ''));
try { fs.unlinkSync(f); res.json({ ok: true }); }
catch (e) { res.status(404).json({ ok: false, error: e.message }); }
});
// ---- Recording: output streams WebM chunks, we mux/transcode to MP4 --------
let rec = null; // { ws stream, tmp }
function startRec() {
@@ -135,10 +152,14 @@ function startRec() {
}
function transcode(input, output, w, h) {
const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`;
const args = ['-y', '-i', input, '-vf', vf, '-c:v', 'libx264', '-preset', 'veryfast',
'-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', output];
// Pi 5 has no hardware H.264 encoder: keep the software encode LIGHT
// (ultrafast, 3 threads, low priority) so the kiosk/server stay responsive
// and the box doesn't brown-out under full-core load.
const args = ['-n', '10', ffmpegPath(), '-y', '-i', input, '-vf', vf,
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '22', '-threads', '3',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', output];
return new Promise((resolve, reject) => {
execFile(ffmpegPath(), args, { maxBuffer: 1 << 24 }, (err, _o, stderr) =>
execFile('nice', args, { maxBuffer: 1 << 24 }, (err, _o, stderr) =>
err ? reject(new Error(String(stderr || err.message).slice(-400))) : resolve());
});
}
@@ -153,7 +174,8 @@ app.post('/api/rec/stop', async (req, res) => {
const cur = rec; rec = null;
await new Promise(r => cur.stream.end(r));
const out = path.join(REC_DIR, 'DJV-' + Date.now() + '.mp4');
try { await transcode(cur.tmp, out, w, h); fs.unlink(cur.tmp, () => {}); res.json({ ok: true, path: out }); }
const name = path.basename(out);
try { await transcode(cur.tmp, out, w, h); fs.unlink(cur.tmp, () => {}); res.json({ ok: true, path: out, name, url: '/recordings/' + encodeURIComponent(name) }); }
catch (e) { res.json({ ok: false, error: e.message }); }
});