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:
1 parent
51c6bca2c8
commit
c2709ff158
6 files changed
+86
-5
No files matched your search
@@ -319,7 +319,10 @@ function transcodeToMp4(input, output, opts) {
|
|||||||
// Rescale/crop needed (e.g. vertical reels) or non-H264 fallback source.
|
// Rescale/crop needed (e.g. vertical reels) or non-H264 fallback source.
|
||||||
// Post-processing, not realtime: spend encode time on quality. -r 60
|
// Post-processing, not realtime: spend encode time on quality. -r 60
|
||||||
// turns the recorder's variable frame timing into constant fps.
|
// 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',
|
args.push('-vf', vf, '-r', '60',
|
||||||
'-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p',
|
'-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p',
|
||||||
'-c:a', 'aac', '-b:a', '256k');
|
'-c:a', 'aac', '-b:a', '256k');
|
||||||
|
|||||||
@@ -427,6 +427,11 @@
|
|||||||
<option value="1080x1440">3:4 verticale (1080×1440)</option>
|
<option value="1080x1440">3:4 verticale (1080×1440)</option>
|
||||||
<option value="2560x1080">21:9 cinema (2560×1080)</option>
|
<option value="2560x1080">21:9 cinema (2560×1080)</option>
|
||||||
</select>
|
</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>
|
<label>Durata (Reel: si ferma da solo)</label>
|
||||||
<select id="rec-dur">
|
<select id="rec-dur">
|
||||||
<option value="0">∞ manuale</option>
|
<option value="0">∞ manuale</option>
|
||||||
|
|||||||
+34
-2
@@ -1866,14 +1866,45 @@ function fmtTime(s) {
|
|||||||
function recTick() { $('#rec-time').textContent = fmtTime(Math.floor((Date.now() - recStartedAt) / 1000)); }
|
function recTick() { $('#rec-time').textContent = fmtTime(Math.floor((Date.now() - recStartedAt) / 1000)); }
|
||||||
function stopRecTimer() { if (recTimer) { clearInterval(recTimer); recTimer = null; } }
|
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', () => {
|
$('#btn-rec').addEventListener('click', () => {
|
||||||
if (!recOn) {
|
if (!recOn) {
|
||||||
// Pass format + optional max duration so the output can auto-stop (Reel mode).
|
// Pass format + optional max duration so the output can auto-stop (Reel mode).
|
||||||
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
|
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 {
|
} else {
|
||||||
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
|
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();
|
stopRecTimer();
|
||||||
$('#rec-label').textContent = 'Conversione MP4…';
|
$('#rec-label').textContent = 'Conversione MP4…';
|
||||||
$('#btn-rec').disabled = true;
|
$('#btn-rec').disabled = true;
|
||||||
@@ -2171,6 +2202,7 @@ djv.onReport((m) => {
|
|||||||
if (ledCfg.on) ledSend();
|
if (ledCfg.on) ledSend();
|
||||||
glbSend();
|
glbSend();
|
||||||
mapSendAll();
|
mapSendAll();
|
||||||
|
recGuideSend();
|
||||||
const sel = $('#device-select');
|
const sel = $('#device-select');
|
||||||
const cur = sel.value;
|
const cur = sel.value;
|
||||||
sel.innerHTML = '<option value="">— Input live (mic/line/BlackHole) —</option>';
|
sel.innerHTML = '<option value="">— Input live (mic/line/BlackHole) —</option>';
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div><!-- /#stage -->
|
</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="shaders.js"></script>
|
||||||
<script src="effects.js"></script>
|
<script src="effects.js"></script>
|
||||||
<script src="fluid.js"></script>
|
<script src="fluid.js"></script>
|
||||||
|
|||||||
+26
-2
@@ -219,7 +219,7 @@ async function startRecording(opts) {
|
|||||||
// Reel mode: auto-stop after maxMs using the format chosen at start.
|
// Reel mode: auto-stop after maxMs using the format chosen at start.
|
||||||
if (opts && opts.maxMs > 0) {
|
if (opts && opts.maxMs > 0) {
|
||||||
clearTimeout(recAutoStop);
|
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 });
|
djv.report({ type: 'recState', recording: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -231,10 +231,33 @@ async function startRecording(opts) {
|
|||||||
function stopRecording(opts) {
|
function stopRecording(opts) {
|
||||||
clearTimeout(recAutoStop); recAutoStop = null;
|
clearTimeout(recAutoStop); recAutoStop = null;
|
||||||
if (!recording || !recorder) return;
|
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();
|
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
|
// 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
|
// arrives as a data: URL (same-origin) so the canvas isn't tainted and the
|
||||||
// pixels can be uploaded to a WebGL texture.
|
// pixels can be uploaded to a WebGL texture.
|
||||||
@@ -411,6 +434,7 @@ djv.onControl(async (m) => {
|
|||||||
}
|
}
|
||||||
case 'recStart': startRecording(m); break;
|
case 'recStart': startRecording(m); break;
|
||||||
case 'recStop': stopRecording(m); break;
|
case 'recStop': stopRecording(m); break;
|
||||||
|
case 'recGuide': recGuideCfg = m; updateRecGuide(); break;
|
||||||
case 'gain': audio.gain = m.value; break;
|
case 'gain': audio.gain = m.value; break;
|
||||||
case 'speed': speed = m.value; audio.scrollRate = m.value; break;
|
case 'speed': speed = m.value; audio.scrollRate = m.value; break;
|
||||||
case 'bandGain': audio[m.band + 'Gain'] = m.value; break;
|
case 'bandGain': audio[m.band + 'Gain'] = m.value; break;
|
||||||
|
|||||||
@@ -944,6 +944,18 @@ kbd {
|
|||||||
padding: 0 3px;
|
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) */
|
/* Multi-output rows (tab Schermo) */
|
||||||
.aux-row {
|
.aux-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in new issue
Block a user