diff --git a/README.md b/README.md index 130be41..62db57b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ same time. - Press Alt+t to add an in/out transition based on the playhead position over the selected clip. The last used transition is remembered as the default. Drag the overlay triangle to adjust duration; Delete clears the focused transition. - Real decoded audio waveforms on clips - Canvas aspect presets (16:9, 9:16 reels, 4:5, 1:1) + safe-area guides +- **Program Monitor zoom** — mouse-wheel over the preview zooms the composition + toward the cursor (fit → up to **2 screen pixels per canvas pixel**). Magnified + view uses **native scrollbars** so overflow stays reachable; middle-click or + Alt+drag pans. The **Fit** button (shown while zoomed) resets to the + fit-to-stage baseline - Preview playback speed — shuttle the monitor through 1×/1.5×/2×/4× with **J**/**K**/**L** (from a stop J/L start playback; while playing L steps faster and J slower, K toggles play/pause and resets to 1×); affects the diff --git a/app.js b/app.js index af0868a..15c551c 100644 --- a/app.js +++ b/app.js @@ -153,6 +153,7 @@ const state = { connected: false, exporting: false, rendering: false, // fast (server/ffmpeg) export in progress guides: false, // safe-area overlay on the monitor + viewZoom: 1, // program-monitor display zoom (1 = fit stage) ffmpeg: false, // server reports ffmpeg available dirtyTimeline: true, gesture: false, workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT @@ -221,9 +222,10 @@ const els = { exportOverlay: $("exportOverlay"), exportProgress: $("exportProgress"), exportTitle: $("exportTitle"), exportNote: $("exportNote"), projectName: $("projectName"), monitorRes: $("monitorRes"), - aspectSel: $("aspectSel"), btnGuides: $("btnGuides"), safeOverlay: $("safeOverlay"), - btnSpeed: $("btnSpeed"), - monitorStage: $("monitorStage"), + aspectSel: $("aspectSel"), btnGuides: $("btnGuides"), btnZoom100: $("btnZoom100"), + safeOverlay: $("safeOverlay"), btnSpeed: $("btnSpeed"), + monitorStage: $("monitorStage"), monitorScroll: $("monitorScroll"), + monitorZoomInner: $("monitorZoomInner"), exportSetup: $("exportSetup"), engineFast: $("engineFast"), engineRealtime: $("engineRealtime"), }; const ctx2d = els.preview.getContext("2d"); @@ -3307,6 +3309,7 @@ function pickClipAt(pt, W, H) { let canvasDrag = null, canvasDidMove = false; els.preview.style.touchAction = "none"; els.preview.addEventListener("pointerdown", (e) => { + if (e.altKey || e.button === 1) return; // leave to monitor pan const W = els.preview.width, H = els.preview.height, pt = canvasPt(e); const cur = getClip(state.selId); canvasDrag = null; @@ -4387,15 +4390,188 @@ els.btnGuides.addEventListener("click", () => { state.guides = !state.guides; els.btnGuides.classList.toggle("on", state.guides); els.safeOverlay.classList.toggle("hidden", !state.guides); + if (state.guides) updateSafeOverlay(); }); -/* Keep the guide overlay glued to the (letterboxed, CSS-scaled) canvas */ +/* ── Program-monitor view zoom (wheel) + fit reset ── + Zoom enlarges the canvas layout size inside a scrollport (native scrollbars), + not a CSS transform — overflow stays reachable. Pointer mapping via + getBoundingClientRect still tracks the visible canvas. + Max zoom = VIEW_PIXEL_MAX screen CSS pixels per canvas pixel. */ +const VIEW_ZOOM_MIN = 1; +const VIEW_PIXEL_MAX = 2; +let monitorFitCache = null; // {w,h} fit size captured at zoom start (stable while zoomed) +let monitorViewPad = { x: 0, y: 0 }; // content padding so any canvas point can sit under the cursor +function measureMonitorFit() { + const sw = els.monitorStage.clientWidth, sh = els.monitorStage.clientHeight; + const pw = project.width || els.preview.width || 1; + const ph = project.height || els.preview.height || 1; + const s = Math.min(sw / pw, sh / ph); + return { w: pw * s, h: ph * s }; +} +function monitorFitSize() { + if (monitorFitCache) return monitorFitCache; + const w = els.preview.offsetWidth, h = els.preview.offsetHeight; + if (w > 0 && h > 0 && state.viewZoom <= 1.001) return { w, h }; + return measureMonitorFit(); +} +function maxViewZoom() { + const { w } = monitorFitSize(); + const pxW = project.width || els.preview.width; + if (!w || !pxW) return VIEW_ZOOM_MIN; + return Math.max(VIEW_ZOOM_MIN, VIEW_PIXEL_MAX * pxW / w); +} +function applyMonitorView() { + const z = state.viewZoom; + const zoomed = z > 1.001; + const scroll = els.monitorScroll; + const inner = els.monitorZoomInner; + if (!zoomed) { + state.viewZoom = 1; + monitorFitCache = null; + monitorViewPad = { x: 0, y: 0 }; + els.preview.style.width = ""; + els.preview.style.height = ""; + if (inner) inner.style.padding = ""; + scroll.scrollLeft = 0; + scroll.scrollTop = 0; + } else { + if (!monitorFitCache) monitorFitCache = monitorFitSize(); + const fit = monitorFitCache; + els.preview.style.width = (fit.w * z) + "px"; + els.preview.style.height = (fit.h * z) + "px"; + // Pad by the stage size so scrollLeft can be "negative" relative to the canvas + // (needed when zooming from a centered fit letterbox without jumping). + if (!monitorViewPad.x && !monitorViewPad.y) { + monitorViewPad = { + x: Math.ceil(els.monitorStage.clientWidth || scroll.clientWidth || 0), + y: Math.ceil(els.monitorStage.clientHeight || scroll.clientHeight || 0), + }; + } + if (inner) inner.style.padding = `${monitorViewPad.y}px ${monitorViewPad.x}px`; + } + els.btnZoom100.classList.toggle("hidden", !zoomed); + scroll.classList.toggle("is-zoomed", zoomed); + updateSafeOverlay(); +} +let monitorViewRaf = 0; +let monitorViewAfter = null; +/** Coalesce repeated zoom/pan updates into one paint (updateSafeOverlay ≤ once/frame). */ +function scheduleMonitorView(after) { + if (after) monitorViewAfter = after; + if (monitorViewRaf) return; + monitorViewRaf = requestAnimationFrame(() => { + monitorViewRaf = 0; + const fn = monitorViewAfter; + monitorViewAfter = null; + applyMonitorView(); + if (fn) fn(); + }); +} +function resetMonitorView() { + state.viewZoom = 1; + monitorFitCache = null; + monitorViewPad = { x: 0, y: 0 }; + monitorViewAfter = null; + if (monitorViewRaf) { + cancelAnimationFrame(monitorViewRaf); + monitorViewRaf = 0; + } + applyMonitorView(); // immediate — don't wait a frame to clear zoom +} +els.btnZoom100.addEventListener("click", resetMonitorView); +els.monitorScroll.addEventListener("wheel", (e) => { + e.preventDefault(); + const scroll = els.monitorScroll; + const rect = scroll.getBoundingClientRect(); + const ox = e.clientX - rect.left; + const oy = e.clientY - rect.top; + const oldZ = state.viewZoom; + if (oldZ <= 1.001) { + monitorFitCache = { + w: els.preview.offsetWidth || measureMonitorFit().w, + h: els.preview.offsetHeight || measureMonitorFit().h, + }; + } + const fit = monitorFitSize(); + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + const next = clamp(+(oldZ * factor).toFixed(3), VIEW_ZOOM_MIN, maxViewZoom()); + if (Math.abs(next - oldZ) < 1e-4) { + if (next <= VIEW_ZOOM_MIN) resetMonitorView(); + return; + } + // Fraction of the canvas under the cursor (works for centered fit and scrolled zoom). + const cv = els.preview.getBoundingClientRect(); + const relX = cv.width > 0 ? (e.clientX - cv.left) / cv.width : 0.5; + const relY = cv.height > 0 ? (e.clientY - cv.top) / cv.height : 0.5; + state.viewZoom = next; + if (next <= VIEW_ZOOM_MIN) { + resetMonitorView(); + return; + } + scheduleMonitorView(() => { + void scroll.scrollWidth; // ensure padding/size are laid out before assigning scroll + const pad = monitorViewPad; + scroll.scrollLeft = pad.x + relX * fit.w * next - ox; + scroll.scrollTop = pad.y + relY * fit.h * next - oy; + }); +}, { passive: false }); +/* Pan while zoomed: middle mouse, or Alt+drag — drives native scroll position. */ +let viewPanDrag = null; +els.monitorScroll.addEventListener("pointerdown", (e) => { + if (state.viewZoom <= 1.001) return; + if (e.button === 1 || (e.button === 0 && e.altKey)) { + const scroll = els.monitorScroll; + viewPanDrag = { x: e.clientX, y: e.clientY, sl: scroll.scrollLeft, st: scroll.scrollTop }; + scroll.classList.add("is-panning"); + scroll.setPointerCapture(e.pointerId); + e.preventDefault(); + } +}); +els.monitorScroll.addEventListener("pointermove", (e) => { + if (!viewPanDrag) return; + const scroll = els.monitorScroll; + scroll.scrollLeft = viewPanDrag.sl - (e.clientX - viewPanDrag.x); + scroll.scrollTop = viewPanDrag.st - (e.clientY - viewPanDrag.y); +}); +function endViewPan(e) { + if (!viewPanDrag) return; + viewPanDrag = null; + els.monitorScroll.classList.remove("is-panning"); + try { els.monitorScroll.releasePointerCapture(e.pointerId); } catch { } +} +els.monitorScroll.addEventListener("pointerup", endViewPan); +els.monitorScroll.addEventListener("pointercancel", endViewPan); +els.monitorScroll.addEventListener("auxclick", (e) => { if (e.button === 1) e.preventDefault(); }); +els.monitorScroll.addEventListener("scroll", () => { + if (state.guides) updateSafeOverlay(); + scheduleMonitorView(); +}); +if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(() => { + if (state.viewZoom <= 1.001) { + monitorFitCache = null; + if (state.guides) updateSafeOverlay(); + return; + } + const scroll = els.monitorScroll; + const relX = scroll.scrollWidth > 0 ? scroll.scrollLeft / scroll.scrollWidth : 0; + const relY = scroll.scrollHeight > 0 ? scroll.scrollTop / scroll.scrollHeight : 0; + monitorFitCache = measureMonitorFit(); + if (state.viewZoom > maxViewZoom()) state.viewZoom = maxViewZoom(); + applyMonitorView(); + scroll.scrollLeft = relX * scroll.scrollWidth; + scroll.scrollTop = relY * scroll.scrollHeight; + }).observe(els.monitorStage); +} +/* Keep the guide overlay glued to the canvas inside .monitor-zoom-inner */ function updateSafeOverlay() { if (!state.guides) return; - const stage = els.monitorStage.getBoundingClientRect(); - const cv = els.preview.getBoundingClientRect(); + const cv = els.preview; const o = els.safeOverlay.style; - o.left = (cv.left - stage.left) + "px"; o.top = (cv.top - stage.top) + "px"; - o.width = cv.width + "px"; o.height = cv.height + "px"; + o.left = cv.offsetLeft + "px"; + o.top = cv.offsetTop + "px"; + o.width = cv.offsetWidth + "px"; + o.height = cv.offsetHeight + "px"; els.safeOverlay.classList.toggle("vertical", project.height > project.width); } diff --git a/index.html b/index.html index 48763d3..db0cf2f 100644 --- a/index.html +++ b/index.html @@ -62,7 +62,11 @@
-
Program Monitor +
+ + Program Monitor + + @@ -70,12 +74,16 @@
- - diff --git a/style.css b/style.css index 51c6e8e..cfba42c 100644 --- a/style.css +++ b/style.css @@ -365,6 +365,19 @@ body.track-size-s .clip.selected { border-bottom: 1px solid var(--border); } +.panel-head-title { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.panel-head-title .btn { + text-transform: none; + letter-spacing: normal; + font-weight: 600; +} + .panel-head-actions { display: flex; gap: 6px; @@ -544,8 +557,7 @@ body.track-size-s .clip.selected { .monitor-stage { flex: 1; display: flex; - align-items: center; - justify-content: center; + flex-direction: column; background: #000; min-height: 0; margin: 8px; @@ -554,6 +566,45 @@ body.track-size-s .clip.selected { position: relative; } +.monitor-scroll { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.monitor-scroll.is-zoomed { + display: block; + overflow: auto; + cursor: grab; +} + +.monitor-scroll.is-zoomed.is-panning { + cursor: grabbing; +} + +.monitor-zoom-inner { + position: relative; + line-height: 0; + flex-shrink: 0; +} + +/* Fit mode: fill the scrollport so #preview max-% resolves against the stage */ +.monitor-scroll:not(.is-zoomed) .monitor-zoom-inner { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.monitor-scroll.is-zoomed .monitor-zoom-inner { + display: inline-block; +} + #preview { max-width: 100%; max-height: 100%; @@ -561,10 +612,15 @@ body.track-size-s .clip.selected { background: #000; } +.monitor-scroll.is-zoomed #preview { + max-width: none; + max-height: none; +} + /* ── Per-track VU (RMS / LUFS / Peak) ── */ .vu-meter { position: absolute; - right: 2px; + right: 8px; bottom: 10px; display: flex; flex-direction: column; @@ -580,7 +636,7 @@ body.track-size-s .clip.selected { display: block; width: 100%; margin: 0; - padding: 2px 0 2px; + padding: 2px 0 2px 14px; border: 0; background: transparent; color: #c8c8d0; @@ -594,7 +650,7 @@ body.track-size-s .clip.selected { .vu-channels { display: flex; align-items: flex-start; - gap: 4px; + gap: 2px; } .vu-scale { position: relative;