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

+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;