9:16 recording: positionable crop strip + on-screen guide frame

New controls in the Registra tab: a 'Posizione taglio' slider choosing WHICH
strip of the screen survives the cover-crop when the format differs from the
display (ffmpeg crop x/y driven by cropX 0..1), and a guide checkbox. The
guide is a dashed frame + dimmed surroundings drawn on the output window
(outside #stage, last in DOM, overlay only — composeFrame never captures
it), live-updated from slider/format and hidden when no crop applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-09-08 17:57:25 +02:00
1 parent 51c6bca2c8
commit c2709ff158
6 files changed
+86 -5

No files matched your search

+4 -1
View File
@@ -319,7 +319,10 @@ function transcodeToMp4(input, output, opts) {
// Rescale/crop needed (e.g. vertical reels) or non-H264 fallback source.
// Post-processing, not realtime: spend encode time on quality. -r 60
// turns the recorder's variable frame timing into constant fps.
const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`;
// cropX 0..1 picks WHICH strip survives the cover-crop (0.5 = centre).
const p = Math.max(0, Math.min(1, (opts && opts.cropX != null) ? opts.cropX : 0.5));
const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,` +
`crop=${w}:${h}:(iw-ow)*${p.toFixed(3)}:(ih-oh)*${p.toFixed(3)}`;
args.push('-vf', vf, '-r', '60',
'-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '256k');
+5
View File
@@ -427,6 +427,11 @@
<option value="1080x1440">3:4 verticale (1080×1440)</option>
<option value="2560x1080">21:9 cinema (2560×1080)</option>
</select>
<label>Posizione taglio <span id="rec-crop-val">centro</span></label>
<input type="range" id="rec-crop" min="0" max="100" step="1" value="50"
title="Quale fascia dello schermo entra nel video quando il formato è diverso dallo schermo (es. 9:16)" />
<label class="chk"><input type="checkbox" id="rec-guide-chk" checked />
Mostra l'area registrata sul monitor (riquadro guida, non viene registrato)</label>
<label>Durata (Reel: si ferma da solo)</label>
<select id="rec-dur">
<option value="0">∞ manuale</option>
+34 -2
View File
@@ -1866,14 +1866,45 @@ function fmtTime(s) {
function recTick() { $('#rec-time').textContent = fmtTime(Math.floor((Date.now() - recStartedAt) / 1000)); }
function stopRecTimer() { if (recTimer) { clearInterval(recTimer); recTimer = null; } }
// Crop position + on-screen guide for formats that differ from the screen
// (e.g. 9:16 reels): the slider picks WHICH strip of the screen is recorded,
// the guide draws that area on the output (overlay only — never recorded).
let recCropCfg = { pos: 50, guide: true };
try { recCropCfg = Object.assign(recCropCfg, JSON.parse(localStorage.getItem('reccrop') || '{}')); } catch (e) {}
function recGuideSend() {
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
send({ type: 'recGuide', on: recCropCfg.guide, w, h, pos: recCropCfg.pos / 100 });
}
function recCropLabel() {
const p = recCropCfg.pos;
$('#rec-crop-val').textContent = p === 50 ? 'centro' : (p < 50 ? '◀ ' + (50 - p) * 2 + '%' : (p - 50) * 2 + '% ▶');
}
(function recCropInit() {
$('#rec-crop').value = recCropCfg.pos;
$('#rec-guide-chk').checked = recCropCfg.guide;
recCropLabel();
$('#rec-crop').addEventListener('input', (e) => {
recCropCfg.pos = parseInt(e.target.value, 10);
localStorage.setItem('reccrop', JSON.stringify(recCropCfg));
recCropLabel(); recGuideSend();
});
$('#rec-guide-chk').addEventListener('change', (e) => {
recCropCfg.guide = e.target.checked;
localStorage.setItem('reccrop', JSON.stringify(recCropCfg));
recGuideSend();
});
$('#rec-aspect').addEventListener('change', recGuideSend);
})();
$('#btn-rec').addEventListener('click', () => {
if (!recOn) {
// Pass format + optional max duration so the output can auto-stop (Reel mode).
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
send({ type: 'recStart', w, h, maxMs: parseInt($('#rec-dur').value, 10) || 0 });
send({ type: 'recStart', w, h, maxMs: parseInt($('#rec-dur').value, 10) || 0, cropX: recCropCfg.pos / 100 });
recGuideSend();
} else {
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
send({ type: 'recStop', w, h });
send({ type: 'recStop', w, h, cropX: recCropCfg.pos / 100 });
stopRecTimer();
$('#rec-label').textContent = 'Conversione MP4…';
$('#btn-rec').disabled = true;
@@ -2171,6 +2202,7 @@ djv.onReport((m) => {
if (ledCfg.on) ledSend();
glbSend();
mapSendAll();
recGuideSend();
const sel = $('#device-select');
const cur = sel.value;
sel.innerHTML = '<option value="">— Input live (mic/line/BlackHole) —</option>';
+5
View File
@@ -46,6 +46,11 @@
</div>
</div><!-- /#stage -->
<!-- Recording crop guide: overlay only, composeFrame never draws it, so it
is visible on the monitor but absent from the recorded file. Last in
DOM order = paints above everything without needing a z-index. -->
<div id="rec-guide"></div>
<script src="shaders.js"></script>
<script src="effects.js"></script>
<script src="fluid.js"></script>
+26 -2
View File
@@ -219,7 +219,7 @@ async function startRecording(opts) {
// 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);
recAutoStop = setTimeout(() => stopRecording({ w: opts.w, h: opts.h, cropX: opts.cropX }), opts.maxMs);
}
djv.report({ type: 'recState', recording: true });
} catch (e) {
@@ -231,10 +231,33 @@ async function startRecording(opts) {
function stopRecording(opts) {
clearTimeout(recAutoStop); recAutoStop = null;
if (!recording || !recorder) return;
pendingRecOpts = { w: opts && opts.w, h: opts && opts.h };
pendingRecOpts = { w: opts && opts.w, h: opts && opts.h, cropX: opts && opts.cropX };
recorder.stop();
}
// ---- crop guide: shows the recorded area on the monitor (overlay only) ----
const recGuideEl = document.getElementById('rec-guide');
let recGuideCfg = null; // { on, w, h, pos }
function updateRecGuide() {
const c = recGuideCfg;
const W = window.innerWidth, H = window.innerHeight;
const a = c && c.w && c.h ? c.w / c.h : 0;
// No guide when off or when the format already matches the screen (no crop).
if (!c || !c.on || !a || Math.abs(a - W / H) < 0.01) {
recGuideEl.classList.remove('show');
return;
}
let gw, gh, x, y;
if (a < W / H) { gh = H; gw = H * a; x = (W - gw) * (c.pos ?? 0.5); y = 0; }
else { gw = W; gh = W / a; x = 0; y = (H - gh) * (c.pos ?? 0.5); }
recGuideEl.style.left = x + 'px';
recGuideEl.style.top = y + 'px';
recGuideEl.style.width = gw + 'px';
recGuideEl.style.height = gh + 'px';
recGuideEl.classList.add('show');
}
window.addEventListener('resize', updateRecGuide);
// Custom SVG/image source for the "SVG/Immagine" effect family. The image
// arrives as a data: URL (same-origin) so the canvas isn't tainted and the
// pixels can be uploaded to a WebGL texture.
@@ -411,6 +434,7 @@ djv.onControl(async (m) => {
}
case 'recStart': startRecording(m); break;
case 'recStop': stopRecording(m); break;
case 'recGuide': recGuideCfg = m; updateRecGuide(); break;
case 'gain': audio.gain = m.value; break;
case 'speed': speed = m.value; audio.scrollRate = m.value; break;
case 'bandGain': audio[m.band + 'Gain'] = m.value; break;
+12
View File
@@ -944,6 +944,18 @@ kbd {
padding: 0 3px;
}
/* Recording crop guide on the output (overlay only, never recorded) */
#rec-guide {
position: fixed;
display: none;
border: 2px dashed rgba(130, 185, 255, 0.85);
box-sizing: border-box;
/* dim everything outside the recorded area */
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
#rec-guide.show { display: block; }
/* Multi-output rows (tab Schermo) */
.aux-row {
display: flex;