diff --git a/CLAUDE.md b/CLAUDE.md index 4065cc2..a0b5f4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,10 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`, ```jsonc { "name": "My Edit", - "width": 1280, "height": 720, "fps": 30, // canvas/export settings + "width": 1280, "height": 720, "fps": 30, // composition canvas (clip coords use this space) + "exportFrame": { "x": 405, "y": 0, "w": 405, "h": 720 }, // optional delivery crop + // ^ omit = export the full canvas. Preview dims outside this rect; Fast export crops + // JPEGs to w×h. Clip x/y/scale stay relative to the composition center, not the frame. "background": "#000000", // canvas color behind all clips (optional) "revision": 7, // bump on every write! "markers": [ { "t": 2.5 }, { "t": 5.0, "label": "drop" } ], @@ -520,6 +523,12 @@ or simply `transitionOut: {type:"fade", duration:3}`. **Pulse / emphasis**: `keyframes: { scale:[{t:0,v:1},{t:0.3,v:1.12},{t:0.6,v:1}] }`. +**Reframe horizontal → vertical**: keep a wide composition canvas and crop for +delivery — e.g. `width:1920, height:1080` with +`exportFrame:{x:656,y:0,w:608,h:1080}` (9:16 fitted to canvas height). Position +footage with clip `x`/`y`/`scale`; the export frame can be dragged in the UI. +Omit `exportFrame` to export the full canvas. + **Vertical reel**: set project `width:1080, height:1920`; use the UI's safe-area guides (▦) to keep captions out of platform UI zones. diff --git a/README.md b/README.md index a31d19e..61e6922 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ same time. frame-step retargets the held slice; meters stay live. **Play** / **Pause** turns it off. - Canvas aspect presets (16:9, 9:16 reels, 4:5, 1:1) + safe-area guides +- **Export frame / reframing** — composition canvas can be larger than the delivery + crop (`exportFrame` in `project.json`). Preview dims the overscan; drag the + **Export frame** handle to reframe (e.g. 16:9 canvas → 9:16 export). Fast export + crops to the frame; Realtime export is disabled while a frame is set - **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 diff --git a/app.js b/app.js index b412674..eb99bf1 100644 --- a/app.js +++ b/app.js @@ -131,6 +131,14 @@ const ASPECT_PRESETS = [ { label: "4:5 · IG 1080×1350", w: 1080, h: 1350 }, { label: "1:1 · 1080×1080", w: 1080, h: 1080 }, ]; +/** Delivery-aspect presets fitted inside the composition canvas (export crop). */ +const EXPORT_FRAME_ASPECTS = [ + { label: "Full canvas", w: 0, h: 0 }, + { label: "9:16 Reel", w: 9, h: 16 }, + { label: "16:9", w: 16, h: 9 }, + { label: "4:5 IG", w: 4, h: 5 }, + { label: "1:1 Square", w: 1, h: 1 }, +]; const WAVE_PEAKS_PER_SEC = 50; const TRACK_IDS = new Set(TRACKS.map((t) => t.id)); // Audio lanes available for a video's per-channel linked audio (index = props.audioChannel). @@ -235,6 +243,7 @@ const project = { inPoint: null, // timeline work-area IN (seconds), or null outPoint: null, // timeline work-area OUT (seconds), or null disabledTracks: [], // track ids (V4…A3) hidden from preview/export when listed + exportFrame: null, // optional {x,y,w,h} delivery crop inside width×height canvas }; const state = { time: 0, playing: false, pps: 60, snap: true, @@ -245,6 +254,7 @@ const state = { connected: false, exporting: false, rendering: false, // fast (server/ffmpeg) export in progress guides: false, // safe-area overlay on the monitor + exportFrameView: true, // dimmed overscan + export frame overlay viewZoom: 1, // program-monitor display zoom (1 = fit stage) audioHold: false, // while paused, loop one frame of audio at the playhead ffmpeg: false, // server reports ffmpeg available @@ -320,7 +330,9 @@ const els = { exportOverlay: $("exportOverlay"), exportProgress: $("exportProgress"), exportTitle: $("exportTitle"), exportNote: $("exportNote"), projectName: $("projectName"), monitorRes: $("monitorRes"), - aspectSel: $("aspectSel"), btnGuides: $("btnGuides"), btnZoom100: $("btnZoom100"), + aspectSel: $("aspectSel"), btnGuides: $("btnGuides"), btnExportFrame: $("btnExportFrame"), + exportFrameSel: $("exportFrameSel"), exportFrameOverlay: $("exportFrameOverlay"), + btnZoom100: $("btnZoom100"), safeOverlay: $("safeOverlay"), btnSpeed: $("btnSpeed"), monitorStage: $("monitorStage"), monitorScroll: $("monitorScroll"), monitorZoomInner: $("monitorZoomInner"), kfGraphs: $("kfGraphs"), @@ -568,6 +580,61 @@ function startFolderRename(folderId) { if (e.key === "Escape") { e.preventDefault(); row.textContent = getFolder(folderId)?.name || "Folder"; row.blur(); } }); } +function normalizeExportFrame(raw, canvasW, canvasH) { + if (!raw || typeof raw !== "object") return null; + let w = Math.round(+raw.w), h = Math.round(+raw.h); + if (!w || !h || w < 8 || h < 8) return null; + let x = Math.round(+raw.x || 0), y = Math.round(+raw.y || 0); + w = Math.min(w, canvasW); h = Math.min(h, canvasH); + x = clamp(x, 0, Math.max(0, canvasW - w)); + y = clamp(y, 0, Math.max(0, canvasH - h)); + if (w >= canvasW && h >= canvasH) return null; + return { x, y, w, h }; +} +function getExportFrame() { + return normalizeExportFrame(project.exportFrame, project.width, project.height); +} +/** Largest axis-aligned rect of aspect aw×ah that fits inside the canvas. */ +function fitExportFrameAspect(aw, ah, canvasW = project.width, canvasH = project.height) { + const target = aw / ah, canvas = canvasW / canvasH; + let w, h; + if (target > canvas) { w = canvasW; h = Math.round(canvasW / target); } + else { h = canvasH; w = Math.round(canvasH * target); } + w = clamp(w, 8, canvasW); h = clamp(h, 8, canvasH); + return { + x: Math.round((canvasW - w) / 2), + y: Math.round((canvasH - h) / 2), + w, h, + }; +} +function exportFrameAspectIndex(ef) { + if (!ef) return 0; + const r = ef.w / ef.h; + for (let i = 1; i < EXPORT_FRAME_ASPECTS.length; i++) { + const a = EXPORT_FRAME_ASPECTS[i]; + const ar = a.w / a.h; + if (Math.abs(r - ar) < 0.02) return i; + } + return -1; +} +function updateMonitorRes() { + const ef = getExportFrame(); + els.monitorRes.textContent = ef + ? `${project.width}×${project.height} canvas → ${ef.w}×${ef.h} export · ${project.fps}fps` + : `${project.width} × ${project.height} · ${project.fps}fps`; +} +function syncExportFrameSel() { + if (!els.exportFrameSel) return; + const ef = getExportFrame(); + const i = exportFrameAspectIndex(ef); + let html = EXPORT_FRAME_ASPECTS.map((a, j) => { + const sel = ef ? (i === j) : (j === 0); + return ``; + }).join(""); + if (ef && i < 0) + html += ``; + els.exportFrameSel.innerHTML = html; +} function applyProject(data) { const wa = normalizeWorkArea(data.inPoint, data.outPoint); const disabledTracks = normalizeDisabledTracks(data.disabledTracks); @@ -583,6 +650,7 @@ function applyProject(data) { inPoint: wa.inPoint, outPoint: wa.outPoint, disabledTracks, + exportFrame: normalizeExportFrame(data.exportFrame, data.width || 1280, data.height || 720), }); const folderIds = new Set(project.folders.map((f) => f.id)); for (const m of project.media) { @@ -612,8 +680,11 @@ function applyProject(data) { for (const el of runtime.clipEls.values()) { try { el.pause(); el.src = ""; } catch { } } runtime.clipEls.clear(); runtime.clipGain.clear(); els.preview.width = project.width; els.preview.height = project.height; - els.monitorRes.textContent = `${project.width} × ${project.height} · ${project.fps}fps`; + updateMonitorRes(); syncAspectSel(); + syncExportFrameSel(); + updateExportFrameOverlay(); + els.btnExportFrame?.classList.toggle("on", state.exportFrameView && !!getExportFrame()); pruneSelection(); // keep the selection across external reloads where possible state.dirtyTimeline = true; renderBin(); renderInspector(); @@ -640,8 +711,8 @@ function scheduleSave() { }, 400); } function projectJSON() { - const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks } = project; - return { + const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks, exportFrame } = project; + const out = { name, width, height, fps, background, revision, folders: (folders || []).map(({ id, name, parentId, open }) => ({ id, name, parentId: parentId || null, open: open !== false })), @@ -658,6 +729,9 @@ function projectJSON() { outPoint: outPoint == null ? null : outPoint, disabledTracks: normalizeDisabledTracks(disabledTracks), }; + const ef = getExportFrame(); + if (ef) out.exportFrame = ef; + return out; } function listenSSE() { const es = new EventSource("/api/events"); @@ -5205,13 +5279,26 @@ function loop(ts) { function openExportSetup() { if (state.exporting) return; if (!project.clips.length) { alert("Timeline is empty — add some clips first."); return; } + const ef = getExportFrame(); const fastOk = state.connected && state.ffmpeg; els.engineFast.disabled = !fastOk; - els.engineFast.checked = fastOk; - els.engineRealtime.checked = !fastOk; + els.engineRealtime.disabled = false; + if (ef && fastOk) { + els.engineFast.checked = true; + els.engineRealtime.checked = false; + } else { + els.engineFast.checked = fastOk; + els.engineRealtime.checked = !fastOk; + } + if (ef) els.engineRealtime.disabled = true; $("engineFastNote").textContent = fastOk - ? "Frame-accurate ffmpeg encode. Keeps rendering if you switch tabs." + ? (ef ? "Exports the " + ef.w + "×" + ef.h + " delivery frame (cropped). Keeps rendering if you switch tabs." + : "Frame-accurate ffmpeg encode. Keeps rendering if you switch tabs.") : "Needs the server + ffmpeg on PATH."; + const rtNote = $("engineRealtime")?.closest(".engine-opt")?.querySelector(".dim"); + if (rtNote) rtNote.textContent = ef + ? "Unavailable while an export frame is set — use Fast export." + : "Plays the timeline once and records it. Keep the tab focused."; const warn = $("exportTrackWarn"); const disabled = TRACKS.filter((t) => !isTrackEnabled(t.id) && project.clips.some((c) => c.track === t.id) @@ -5236,6 +5323,17 @@ function startChosenExport() { /* ── Fast export ── */ let renderCancelled = false; +let exportCropCanvas = null; +function previewToExportBlob(quality = 0.95) { + const ef = getExportFrame(); + if (!ef) return new Promise((res) => els.preview.toBlob(res, "image/jpeg", quality)); + if (!exportCropCanvas) exportCropCanvas = document.createElement("canvas"); + exportCropCanvas.width = ef.w; + exportCropCanvas.height = ef.h; + exportCropCanvas.getContext("2d").drawImage( + els.preview, ef.x, ef.y, ef.w, ef.h, 0, 0, ef.w, ef.h); + return new Promise((res) => exportCropCanvas.toBlob(res, "image/jpeg", quality)); +} function seekVideosTo(t) { const waits = []; for (const c of project.clips) { @@ -5354,7 +5452,7 @@ async function fastExport() { await seekVideosTo(t); await prepareFrameAssets(t); // exact SVG frames + AI masks drawFrame(t); - const blob = await new Promise((res) => els.preview.toBlob(res, "image/jpeg", 0.95)); + const blob = await previewToExportBlob(0.95); const r = await fetch("/api/export/frame?id=" + sessId, { method: "POST", body: blob }); if (!r.ok) throw new Error((await r.json()).error || "frame upload failed"); const pct = ((f + 1) / frames) * 100; @@ -5581,11 +5679,48 @@ els.aspectSel.addEventListener("change", () => { if (!a) return; project.width = a.w; project.height = a.h; els.preview.width = a.w; els.preview.height = a.h; - els.monitorRes.textContent = `${a.w} × ${a.h} · ${project.fps}fps`; + if (project.exportFrame) + project.exportFrame = normalizeExportFrame(project.exportFrame, a.w, a.h); + updateMonitorRes(); syncAspectSel(); + syncExportFrameSel(); + updateExportFrameOverlay(); seekMediaWhilePaused(); scheduleSave(); }); +els.exportFrameSel?.addEventListener("change", () => { + const v = els.exportFrameSel.value; + if (v === "custom") return; + const preset = EXPORT_FRAME_ASPECTS[+v]; + if (!preset) return; + if (!preset.w) project.exportFrame = null; + else project.exportFrame = fitExportFrameAspect(preset.w, preset.h); + state.exportFrameView = !!getExportFrame(); + els.btnExportFrame?.classList.toggle("on", state.exportFrameView && !!getExportFrame()); + updateMonitorRes(); + syncExportFrameSel(); + updateExportFrameOverlay(); + scheduleSave(); +}); +els.btnExportFrame?.addEventListener("click", () => { + if (!getExportFrame()) { + const preset = EXPORT_FRAME_ASPECTS[1]; // 9:16 — common reframe default + project.exportFrame = fitExportFrameAspect(preset.w, preset.h); + state.exportFrameView = true; + syncExportFrameSel(); + updateMonitorRes(); + scheduleSave(); + } else if (state.exportFrameView) { + state.exportFrameView = false; + syncExportFrameSel(); + updateMonitorRes(); + scheduleSave(); + } else { + state.exportFrameView = true; + } + els.btnExportFrame.classList.toggle("on", state.exportFrameView && !!getExportFrame()); + updateExportFrameOverlay(); +}); els.btnGuides.addEventListener("click", () => { state.guides = !state.guides; els.btnGuides.classList.toggle("on", state.guides); @@ -5652,6 +5787,7 @@ function applyMonitorView() { els.btnZoom100.classList.toggle("hidden", !zoomed); scroll.classList.toggle("is-zoomed", zoomed); updateSafeOverlay(); + updateExportFrameOverlay(); } let monitorViewRaf = 0; let monitorViewAfter = null; @@ -5744,6 +5880,7 @@ els.monitorScroll.addEventListener("pointercancel", endViewPan); els.monitorScroll.addEventListener("auxclick", (e) => { if (e.button === 1) e.preventDefault(); }); els.monitorScroll.addEventListener("scroll", () => { if (state.guides) updateSafeOverlay(); + if (state.exportFrameView && getExportFrame()) updateExportFrameOverlay(); scheduleMonitorView(); }); if (typeof ResizeObserver !== "undefined") { @@ -5751,6 +5888,7 @@ if (typeof ResizeObserver !== "undefined") { if (state.viewZoom <= 1.001) { monitorFitCache = null; if (state.guides) updateSafeOverlay(); + if (state.exportFrameView && getExportFrame()) updateExportFrameOverlay(); return; } const scroll = els.monitorScroll; @@ -5774,6 +5912,112 @@ function updateSafeOverlay() { o.height = cv.offsetHeight + "px"; els.safeOverlay.classList.toggle("vertical", project.height > project.width); } +/* Dimmed overscan outside the delivery export frame (preview-only overlay). */ +const EF_EDGE = 10; +function layoutExportFrameOverlayPart(el, left, top, w, h) { + el.style.left = left + "px"; + el.style.top = top + "px"; + el.style.width = w + "px"; + el.style.height = h + "px"; +} +function updateExportFrameOverlay() { + const ov = els.exportFrameOverlay; + if (!ov) return; + const ef = getExportFrame(); + const show = state.exportFrameView && ef; + ov.classList.toggle("hidden", !show); + if (!show) return; + const cv = els.preview; + const root = ov.style; + root.left = cv.offsetLeft + "px"; + root.top = cv.offsetTop + "px"; + root.width = cv.offsetWidth + "px"; + root.height = cv.offsetHeight + "px"; + const sx = cv.offsetWidth / project.width; + const sy = cv.offsetHeight / project.height; + const hole = ov.querySelector(".ef-shade"); + const handle = ov.querySelector(".ef-handle"); + const edgeT = ov.querySelector(".ef-edge-t"); + const edgeB = ov.querySelector(".ef-edge-b"); + const edgeL = ov.querySelector(".ef-edge-l"); + const edgeR = ov.querySelector(".ef-edge-r"); + if (!hole) return; + const left = ef.x * sx, top = ef.y * sy, w = ef.w * sx, h = ef.h * sy; + layoutExportFrameOverlayPart(hole, left, top, w, h); + if (handle) layoutExportFrameOverlayPart(handle, left + 6, top + 6, Math.min(w - 12, 120), 22); + if (edgeT) layoutExportFrameOverlayPart(edgeT, left, top, w, EF_EDGE); + if (edgeB) layoutExportFrameOverlayPart(edgeB, left, top + h - EF_EDGE, w, EF_EDGE); + if (edgeL) layoutExportFrameOverlayPart(edgeL, left, top + EF_EDGE, EF_EDGE, Math.max(0, h - EF_EDGE * 2)); + if (edgeR) layoutExportFrameOverlayPart(edgeR, left + w - EF_EDGE, top + EF_EDGE, EF_EDGE, Math.max(0, h - EF_EDGE * 2)); +} +let exportFrameDrag = null; +function exportFrameDragTarget(e) { + return e.target.closest(".ef-handle, .ef-edge-t, .ef-edge-b, .ef-edge-l, .ef-edge-r"); +} +function exportFrameDragMove(e) { + if (!exportFrameDrag) return; + const cv = els.preview; + const sx = project.width / cv.offsetWidth; + const sy = project.height / cv.offsetHeight; + const ef = getExportFrame(); + if (!ef) return; + project.exportFrame = normalizeExportFrame({ + x: exportFrameDrag.ox + (e.clientX - exportFrameDrag.startX) * sx, + y: exportFrameDrag.oy + (e.clientY - exportFrameDrag.startY) * sy, + w: ef.w, h: ef.h, + }, project.width, project.height); + updateExportFrameOverlay(); +} +function exportFrameDragEnd(e) { + if (!exportFrameDrag) return; + exportFrameDrag = null; + els.exportFrameOverlay?.classList.remove("is-dragging"); + syncExportFrameSel(); + updateMonitorRes(); + scheduleSave(); + document.removeEventListener("pointermove", exportFrameDragMove); + document.removeEventListener("pointerup", exportFrameDragEnd, true); + document.removeEventListener("pointercancel", exportFrameDragEnd, true); + try { els.exportFrameOverlay?.releasePointerCapture(e.pointerId); } catch { } +} +const EF_NUDGE_PX = 1; +const EF_NUDGE_SHIFT_PX = 10; +function nudgeExportFrame(dx, dy) { + const ef = getExportFrame(); + if (!ef) return; + project.exportFrame = normalizeExportFrame({ + x: ef.x + dx, y: ef.y + dy, w: ef.w, h: ef.h, + }, project.width, project.height); + updateExportFrameOverlay(); + syncExportFrameSel(); + updateMonitorRes(); + scheduleSave(); +} +function exportFrameHandleKeydown(e) { + const k = e.key; + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(k)) return; + e.preventDefault(); + e.stopPropagation(); + const step = e.shiftKey ? EF_NUDGE_SHIFT_PX : EF_NUDGE_PX; + nudgeExportFrame( + k === "ArrowLeft" ? -step : k === "ArrowRight" ? step : 0, + k === "ArrowUp" ? -step : k === "ArrowDown" ? step : 0, + ); +} +els.exportFrameOverlay?.addEventListener("pointerdown", (e) => { + if (!exportFrameDragTarget(e)) return; + const ef = getExportFrame(); + if (!ef) return; + e.preventDefault(); + e.stopPropagation(); + els.exportFrameOverlay?.classList.add("is-dragging"); + exportFrameDrag = { startX: e.clientX, startY: e.clientY, ox: ef.x, oy: ef.y }; + try { els.exportFrameOverlay.setPointerCapture(e.pointerId); } catch { } + document.addEventListener("pointermove", exportFrameDragMove); + document.addEventListener("pointerup", exportFrameDragEnd, true); + document.addEventListener("pointercancel", exportFrameDragEnd, true); +}); +els.exportFrameOverlay?.querySelector(".ef-handle")?.addEventListener("keydown", exportFrameHandleKeydown); window.addEventListener("keydown", (e) => { const k = e.key; diff --git a/index.html b/index.html index bfbd1b1..5288563 100644 --- a/index.html +++ b/index.html @@ -74,6 +74,8 @@ + + 1280 × 720 · 30fps @@ -88,6 +90,15 @@
+