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:
1 parent
3696669a1e
commit
98f894fa40
5 files changed
+108
-23
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user