Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ same time.
- Press <kbd>Alt+t</kbd> 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; <kbd>Delete</kbd> 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
<kbd>Alt</kbd>+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 <kbd>J</kbd>/<kbd>L</kbd> start playback; while playing <kbd>L</kbd> steps faster
and <kbd>J</kbd> slower, <kbd>K</kbd> toggles play/pause and resets to 1×); affects the
Expand Down
192 changes: 184 additions & 8 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
}

Expand Down
22 changes: 15 additions & 7 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,28 @@

<!-- Program monitor -->
<section class="panel monitor">
<div class="panel-head"><span>Program Monitor</span>
<div class="panel-head">
<span class="panel-head-title">
<span>Program Monitor</span>
<button class="btn tiny hidden" id="btnZoom100" title="Reset monitor view to fit stage"><svg class="ico" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="square" d="M1.5 5V1.5H5M11 1.5h3.5V5M14.5 11v3.5H11M5 14.5H1.5V11"/><path fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" d="M3.2 3.2l2.6 2.6M12.8 3.2l-2.6 2.6M12.8 12.8l-2.6-2.6M3.2 12.8l2.6-2.6"/></svg> Fit</button>
</span>
<span class="panel-head-actions">
<select id="aspectSel" class="head-select" title="Canvas aspect preset"></select>
<button class="btn tiny toggle" id="btnGuides" title="Toggle safe-area guides">▦ Safe</button>
<span class="dim" id="monitorRes">1280 × 720 · 30fps</span>
</span>
</div>
<div class="monitor-stage" id="monitorStage">
<canvas id="preview" width="1280" height="720"></canvas>
<div id="safeOverlay" class="hidden">
<div class="sa-action"></div>
<div class="sa-title"></div>
<div class="sa-ui-bottom"></div>
<div class="sa-ui-right"></div>
<div class="monitor-scroll" id="monitorScroll">
<div class="monitor-zoom-inner" id="monitorZoomInner">
<canvas id="preview" width="1280" height="720"></canvas>
<div id="safeOverlay" class="hidden">
<div class="sa-action"></div>
<div class="sa-title"></div>
<div class="sa-ui-bottom"></div>
<div class="sa-ui-right"></div>
</div>
</div>
</div>
<div class="vu-meter" id="vuMeter" title="Click to switch RMS / LUFS / Peak"></div>
</div>
Expand Down
66 changes: 61 additions & 5 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -554,17 +566,61 @@ 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%;
display: block;
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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading