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.
|
||||
// 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');
|
||||
|
||||
@@ -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
@@ -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>';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user