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

+2
View File
@@ -142,3 +142,5 @@ textarea { width: 100%; min-height: 90px; resize: vertical; }
}
#tabs button small { font-size: 9px; }
#tabs button.active { opacity: 1; color: var(--accent); }
.item a.dl { flex: 0 0 auto; padding: 7px 12px; border: 1px solid var(--line); border-radius: 9px; background: rgba(76,175,80,.15); border-color: #4caf50; text-decoration: none; color: #d9ffd9; }
#rec-status a { color: #8cf5a0; }
+19 -8
View File
@@ -126,15 +126,26 @@
<!-- OUTPUT / REC -->
<section class="panel hidden" data-tab="out">
<button id="fs" class="wide">🖥 Output a tutto schermo</button>
<label class="lbl">Formato registrazione</label>
<select id="rec-fmt">
<option value="1920x1080">16:9 (1920×1080)</option>
<option value="1080x1920">9:16 (1080×1920)</option>
<option value="1080x1080">1:1 (1080×1080)</option>
<option value="1440x1080">4:3 (1440×1080)</option>
</select>
<button id="rec" class="wide">🔴 Avvia registrazione</button>
<label class="lbl">Registra Reel / video</label>
<div class="row">
<select id="rec-fmt">
<option value="1080x1920" selected>📱 Reel 9:16 (1080×1920)</option>
<option value="1080x1080">1:1 (1080×1080)</option>
<option value="1920x1080">16:9 (1920×1080)</option>
<option value="1440x1080">4:3 (1440×1080)</option>
</select>
<select id="rec-dur" title="Si ferma da solo alla durata scelta">
<option value="15000">⏱ 15s</option>
<option value="30000" selected>⏱ 30s</option>
<option value="60000">⏱ 60s</option>
<option value="90000">⏱ 90s</option>
<option value="0">∞ manuale</option>
</select>
</div>
<button id="rec" class="wide">🔴 Registra Reel</button>
<div id="rec-status" class="hint"></div>
<label class="lbl">Reel registrati <button id="rec-refresh" class="mini">↻</button></label>
<div id="rec-list" class="list"></div>
<label class="lbl">Dispositivo</label>
<button id="pw-reboot" class="wide danger">🔄 Riavvia Raspberry</button>
<button id="pw-shutdown" class="wide danger">⏻ Spegni Raspberry</button>
+51 -7
View File
@@ -604,18 +604,57 @@ $('#audio-src').addEventListener('change', (e) => {
$('#audio-src-refresh').addEventListener('click', () => send({ type: 'refreshDevices' }));
send({ type: 'refreshDevices' }); // ask the output for the current input device list
// ---- Output / Rec ----------------------------------------------------------
let recOn = false;
// ---- Output / Rec (Reels) --------------------------------------------------
let recOn = false, recDurMs = 0, recT0 = 0, recCd = null;
$('#fs').addEventListener('click', () => send({ type: 'outputFullscreen' }));
$('#rec').addEventListener('click', () => {
if (!recOn) { send({ type: 'recStart' }); }
else { const [w, h] = $('#rec-fmt').value.split('x').map(Number); send({ type: 'recStop', w, h }); }
const [w, h] = $('#rec-fmt').value.split('x').map(Number);
if (!recOn) {
recDurMs = parseInt($('#rec-dur').value, 10) || 0;
send({ type: 'recStart', w, h, maxMs: recDurMs }); // output auto-stops at maxMs
} else send({ type: 'recStop', w, h });
});
function updateRec() {
$('#rec').textContent = recOn ? '⏹ Ferma registrazione' : '🔴 Avvia registrazione';
clearInterval(recCd); recCd = null;
$('#rec').classList.toggle('recording', recOn);
if (!recOn) { $('#rec').textContent = '🔴 Registra Reel'; return; }
recT0 = Date.now();
const tick = () => {
const el = (Date.now() - recT0) / 1000;
$('#rec').textContent = recDurMs > 0
? '⏺ REC — stop tra ' + Math.max(0, Math.ceil(recDurMs / 1000 - el)) + 's (tocca per fermare)'
: '⏹ Ferma registrazione (' + fmt(el) + ')';
};
tick(); recCd = setInterval(tick, 500);
}
// Recorded reels list: download to the phone (then share to Instagram) or delete.
function fmtSize(b) { return b > 1e6 ? (b / 1e6).toFixed(1) + ' MB' : Math.round(b / 1e3) + ' KB'; }
function loadRecs() {
fetch('/api/recordings').then((r) => r.json()).then((items) => {
const el = $('#rec-list'); el.innerHTML = '';
if (!items.length) { el.innerHTML = '<div class="empty">Nessun reel ancora. Registra col pulsante qui sopra.</div>'; return; }
items.forEach((it) => {
const d = document.createElement('div'); d.className = 'item';
const date = new Date(it.mtime);
d.innerHTML = '<span>🎬 ' + date.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit' }) + ' ' +
date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) + ' · ' + fmtSize(it.size) + '</span>';
const dl = document.createElement('a'); dl.textContent = '📥'; dl.title = 'Scarica sul telefono';
dl.href = it.url; dl.download = it.name; dl.className = 'dl';
const del = document.createElement('button'); del.textContent = '✕';
del.addEventListener('click', (ev) => {
ev.stopPropagation();
fetch('/api/recordings/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: it.name }) })
.then(() => loadRecs());
});
d.appendChild(dl); d.appendChild(del);
el.appendChild(d);
});
}).catch(() => {});
}
$('#rec-refresh').addEventListener('click', loadRecs);
loadRecs();
// Power: reboot / shutdown the Pi. Two-tap confirm to avoid accidental taps.
function armPower(btnId, action) {
const btn = $('#' + btnId); if (!btn) return;
@@ -661,8 +700,13 @@ djv.onReport((m) => {
break;
case 'autoVj': setAutoVj(m); break;
case 'devices': inputDevices = m.list || []; renderAudioSrc(); break;
case 'recState': recOn = m.recording; updateRec(); break;
case 'recSaved': $('#rec-status').textContent = '✅ Salvato: ' + m.path; break;
case 'recState': recOn = m.recording; updateRec(); if (!recOn) loadRecs(); break;
case 'recSaved':
$('#rec-status').innerHTML = m.url
? '✅ Reel pronto — <a href="' + m.url + '" download="' + (m.name || 'reel.mp4') + '">📥 scarica sul telefono</a> e condividilo su Instagram'
: '✅ Salvato: ' + m.path;
loadRecs();
break;
case 'recError': $('#rec-status').textContent = '⚠️ ' + m.message; break;
}
});
+10 -4
View File
@@ -65,7 +65,7 @@ function restartSlideshow() {
// ---------------------------------------------------------------- recording
// Records the output canvas (video) + the audio tap into a WebM stream that
// main transcodes to MP4 at the chosen aspect ratio.
let recorder = null, recording = false, pendingRecOpts = null;
let recorder = null, recording = false, pendingRecOpts = null, recAutoStop = null;
let recCanvas = null, recCtx = null, composeRAF = null;
function pickMime() {
@@ -159,7 +159,7 @@ function stopCompose() {
if (composeRAF) { cancelAnimationFrame(composeRAF); composeRAF = null; }
}
async function startRecording() {
async function startRecording(opts) {
if (recording) return;
try {
await djv.recStart();
@@ -185,11 +185,16 @@ async function startRecording() {
const res = await djv.recStop(pendingRecOpts || {});
recording = false;
djv.report({ type: 'recState', recording: false });
if (res && res.ok) djv.report({ type: 'recSaved', path: res.path });
if (res && res.ok) djv.report({ type: 'recSaved', path: res.path, name: res.name, url: res.url });
else djv.report({ type: 'recError', message: (res && res.error) || 'errore sconosciuto' });
};
recorder.start(2000); // emit a chunk every 2s
recording = true;
// Reel mode: auto-stop after maxMs using the format chosen at start.
if (opts && opts.maxMs > 0) {
clearTimeout(recAutoStop);
recAutoStop = setTimeout(() => stopRecording({ w: opts.w, h: opts.h }), opts.maxMs);
}
djv.report({ type: 'recState', recording: true });
} catch (e) {
stopCompose();
@@ -198,6 +203,7 @@ async function startRecording() {
}
function stopRecording(opts) {
clearTimeout(recAutoStop); recAutoStop = null;
if (!recording || !recorder) return;
pendingRecOpts = { w: opts && opts.w, h: opts && opts.h };
recorder.stop();
@@ -327,7 +333,7 @@ djv.onControl(async (m) => {
djv.report({ type: 'autoVj', on: autoVj });
break;
case 'svg': loadCustomTexture(m.dataUrl); break;
case 'recStart': startRecording(); break;
case 'recStart': startRecording(m); break;
case 'recStop': stopRecording(m); break;
case 'gain': audio.gain = m.value; break;
case 'speed': speed = m.value; audio.scrollRate = m.value; break;
+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 }); }
});