Skip to content

Commit a9595ee

Browse files
authored
v0.7.3 — 3-column preview + canvas-units pan/scale + multi-track export (#6)
* feat: picture-in-picture (multi-track preview compositing) Both built-in engines now composite every video track's currently- active clip when `pictureInPicture.enabled` flips on. Off by default so today's single-clip behaviour is unchanged. Same opt-in pattern as keyframes / clipEdgeNav / aspect. Track order = z-order: tracks[0] paints on top, matching the timeline's visual top-to-bottom convention (CapCut / Premiere). Audio policy: only the top track's source stays unmuted; lower tracks mute to avoid stacked playback. Engines - `HtmlVideoEngine`: replaced `currentClipId: string | null` with `activeByTrack: Map<trackId, clipId>`. Wrappers stay visible per track via z-index, transforms apply per active clip. Same-source multi-track is a known limitation — one decoder per source means one currentTime; the primary track wins. Upload the file twice for separate `MediaSource.id`s to play distinct positions. - `CanvasCompositorEngine`: `paint()` walks tracks in reverse so the top track paints last (= on top). Output / content rects for the overlay still reflect the primary clip — keyframe handles on PiP overlays are out of scope for v1. - `PlaybackEngine`: new optional `setPictureInPictureEnabled?(enabled)` hook. Engines that don't implement it are documented as PiP-incapable; the toggle becomes a no-op for them. Editor + wrappers - `EditorOptions.pictureInPicture?: { enabled?: boolean }` (default false) - `Editor.isPictureInPictureEnabled` / `setPictureInPictureEnabled` - `pictureInPictureEnabledChange` event - React: reactive `pictureInPicture` prop - Vue: reactive `pictureInPicture` prop Demo - New "Picture-in-picture" sidebar section + checkbox. - Fixed a latent demo bug where the `ready`-event auto-seed kept appending clips even when the host had already set up clips for that source (e.g. via a scripted multi-track project). Now the auto-seed skips sources that already have a clip somewhere. Tests - 5 new PiP tests on `CanvasCompositorEngine` (default off, idempotent toggle, instance method exposed, survives setProject swap, no-throw on empty project). All 83 unit tests pass. * demo: PiP-aware upload affordance The upload zone now tells users which track their next file will land on. Track 0 = main video (no badge needed); track > 0 = PiP overlay, shown via a purple pill with a picture-in-picture glyph: "Next → Track 2 (PiP overlay)". Reactive on every project mutation, so removing a clip rolls the badge back to "Next → Track 1". Routing fix: uploads route to the FIRST EMPTY video track (falls back to track 0 when everything is full). Before, every upload landed on track 0, stacking serially — so you couldn't actually set up PiP without manually dragging. When a PiP-overlay upload happens, the new clip is pre-seeded with a scale=0.35 + offset so the overlay shrinks to a corner immediately instead of fully covering the underlying track. User keeps full control via keyframes after that. * fix(pip): z-order flip + lower-track fallback + per-clip overlay rect Three issues with v1 PiP from the previous commits: 1. Z-order was backwards. Track 0 was painting on top — but uploads route to the first empty track, so the second upload (the PiP overlay) ends up on track 1 / 2 / etc. and was getting silently hidden behind the track-0 main video. Flipped to Premiere / AE convention: higher track index = higher z. PiP overlay now sits on top of the main video as expected. 2. Dragging the only track-0 clip out of the playhead range blanked the preview even when a lower-track clip was still active. Root cause: `computeActiveClips` bailed after the first video track in PiP-off mode, and `primaryActiveClip` bailed after the first video track regardless of whether it had content. Both now walk through every video track and use the first one with an active clip — restoring the natural single-clip behaviour while keeping PiP composition correct. 3. Keyframe handles / dashed border always tracked the primary clip even when the user had a PiP overlay selected. `PlaybackEngine`'s `getFrameRect` / `getOutputFrameRect` now accept an optional `clipId`; both built-in engines honor it (CanvasCompositorEngine caches per-clip content rects during paint, HtmlVideoEngine computes from the clip's transform on demand). Editor passes the selected clip when PiP is on so the overlay latches onto the actual selection. * feat(pip): built-in toolbar toggle + centered upload pre-seed Two follow-ups on the v1 PiP work: 1. PiP toolbar button. Opt in via `pictureInPicture.toolbarToggle: true` (default false to keep today's chrome unchanged). When on, a PiP icon button appears next to the keyframe affordance with an outlined / filled state and Enable / Disable tooltips. Same gating pattern as keyframes / clipEdgeNav. i18n added (English + zh-CN). 2. Demo upload pre-seed was using `panX: 260, panY: 130` in absolute CSS px — those offsets pushed the PiP overlay OUTSIDE the canvas on typical preview dims, so the user couldn't see it (only discoverable by enabling keyframes to see the dashed canvas border). Pre-seed now uses only `scale: 0.4` with no offset, so the overlay always lands centered inside the canvas regardless of preview size. The user drags it where they want from there via the overlay handles. Demo's sidebar PiP checkbox replaced by the toolbar button — the help text just reports the current state (mirrored via the `pictureInPictureEnabledChange` event) and tells users how to drop files onto track 1 / 2. * feat(pip): toolbar "+ overlay" action; enable stays a host concern CapCut-style mental model: the toolbar PiP button is a quick "+ add a PiP overlay video" action, not a toggle. The enable flag stays purely a host-controlled feature gate (sidebar checkbox, settings menu, whatever). Library changes: - `pictureInPicture.toolbarToggle` removed. Replaced with `pictureInPicture.toolbarAdd: boolean` (opt-in, default false). When true, the toolbar shows a "+ PiP" icon button. - New `requestPictureInPictureAdd` event. The library doesn't run an upload itself — that's a host concern (different upload pipelines, auth, validation, etc). React wrapper exposes `onPictureInPictureAddRequested?`, Vue emits `pictureInPictureAddRequested`. - i18n collapsed from pipEnable / pipDisable / pipToggle to a single `pipAdd` string. Demo changes: - Main sidebar UploadPanel reverted to "append to track 0" — no routing magic, no "Next → Track N" badge. That was a leaky abstraction. - New `triggerPipUpload` handler opens a hidden `<input type=file>` when the toolbar button fires `requestPictureInPictureAdd`. - PiP upload pipeline mirrors the sidebar upload (server upload when VITE_UPLOAD_ENDPOINT set, else local blob URL), then drops the clip on the first available non-track-0 video track at the playhead with `scale: 0.4` so it lands centered inside the canvas. - Sidebar PiP "Enable" checkbox back — it's the feature gate, the toolbar action just adds an overlay clip. * fix(pip): gate toolbar "+ overlay" button on enabled flag Bug reported: the toolbar PiP button was visible + clickable even when the host's `pictureInPicture.enabled` was false. Users would add a PiP overlay clip, see nothing change, and reasonably conclude the feature was broken. `EditorUI` now ANDs `toolbarAdd && enabled` when computing the toolbar state, so: enabled=false, toolbarAdd=true → button hidden enabled=true, toolbarAdd=true → button visible enabled=true, toolbarAdd=false → button hidden Mirrors CapCut's single-concept "PiP feature on/off" semantics — when off, every PiP affordance disappears, not just the engine compositing. * fix(pip): dashed selection border wraps the selected clip's content Selecting a PiP overlay showed only four corner dots floating in the middle of the canvas with no visible border connecting them — because `frameBody` was always pinned to the output canvas, not the selected clip. For a PiP at scale 0.4 the canvas border ran around the entire preview while the handles sat in the middle 40%. Pin `frameBody` to the SELECTED clip's content rect instead. For a normal full-frame clip content == canvas so the visual is unchanged; for a PiP overlay the border now wraps the clip the user actually selected. Also suppress the red letterbox-warning state when content is intentionally smaller than canvas (5% gap threshold). A deliberately-shrunk PiP overlay shouldn't ring red forever just because it doesn't fill the canvas — that was the whole point. * fix(pip): bring back the canvas-extent guide alongside selection frame Previous commit pinned the dashed border to the selected clip's content rect — which fixed PiP selection visibility but erased the "this is the editable canvas" outer guide. Both rects serve different jobs: - Canvas guide: faint dashed outline of the OUTPUT canvas. Visual reference for where the clip can be moved to without leaving the output area. Visibility tied to `previewFrame.enabled`, same as before. - Selection frame: bright dashed border around the SELECTED clip's content rect. Doubles as the drag-to-pan target + corner-handle anchor when keyframes mode is on. Visibility tied to whether there's a selection. For a normal full-frame clip the two rects coincide so the visual matches today's behaviour exactly. For a PiP overlay you now see both the outer canvas guide AND a tight inner border around the clip, plus the four corner handles right on the inner border — that's the affordance that was missing. Canvas guide is purely visual (pointer-events: none) so clicks pass through to whatever lives below. * fix(engine): canvas locks to track-0 first clip when aspect is "Original" CapCut posture: when the user picks "Original" for canvas aspect, the canvas stays anchored to the FIRST clip dropped onto the project. Subsequent clips on the same or different tracks letterbox inside that fixed canvas. The canvas never resizes mid-playback. Both engines were tracking the currently-active primary clip instead — so when track 0's first clip ended and the playhead crossed into track 0's second clip with a different intrinsic aspect, the canvas visibly jumped. Fix: new `canvasReferenceDims()` helper picks the first clip (by start time) on the first video track and uses its source video's intrinsic dims. Falls back to the active primary clip only when the reference hasn't decoded yet, so the empty-state window stays covered. * demo: all features on by default + canvas engine; fix(pip): clip-center scale Two changes: 1. Demo defaults flipped to all-on so visitors immediately see the editor in its full configuration without hunting through the sidebar: - Playback engine: HTML5 → Canvas compositor - Keyframes: on - Clip-edge nav: on - PiP: on The original "off" defaults made sense when each was a beta surface; now they're shipped features. 2. Corner-handle scale was using the OUTPUT CANVAS center as its distance reference. For a PiP overlay positioned away from canvas center, that made the drag feel mushy near the canvas center (distances small → ratios big → scale jumps) and hyper-responsive near the edges. Anchoring to the SELECTED clip's own center makes the resize feel consistent regardless of where the PiP sits on the canvas — same uniform-expand-from- center behavior, but with predictable drag distance. * fix(pip): CapCut-style opposite-corner anchored resize Corner drag was uniform-from-center scaling — the dragged corner grew toward the cursor but the OPPOSITE corner moved away with it. What users actually want (CapCut / Premiere / every NLE): the opposite corner stays pinned and the dragged corner follows the cursor. New scale drag math: - Record the opposite corner in viewport coords at drag start. - Each pointermove maps cursor offset into (width, height) of a new bounding rect anchored at the opposite corner. - Aspect is locked (we only have one `scale` value), so the new scale is `max(sx, sy)` — the clip grows along the dominant axis the user is pulling. - panX / panY get auto-adjusted so the anchored corner stays put while the clip's center shifts halfway toward the dragged side. Verified by Playwright: dragging TL up-left by 50px moves TL by -49.8, -49.8 (tracks cursor) while BR drifts only 0.2px (pinned). * fix(pip): edge handles + projection scale = jitter-free anchored resize Three changes that go together: 1. Eight handles instead of four. Added top / right / bottom / left midpoint handles. CapCut shows all eight; previously we only had the four corners. 2. Edge handles only consume their perpendicular axis. Top edge ignores horizontal cursor motion, left edge ignores vertical, etc. Implemented via axisX / axisY masks on the drag state. The non-touched axis anchors at the clip CENTER so the edge expands symmetrically — same as Figma / Sketch / CapCut. 3. Scale math switched from `max(sx, sy)` to a diagonal projection of the cursor offset onto the natural (baseW, baseH) direction. `max(sx, sy)` flipped axes whenever the cursor wandered slightly off-diagonal, which made the scale jump by ~1% between frames and the anchored corner jitter by a pixel. Projection-based scale moves smoothly under noisy cursor paths. Also dropped the `Math.round` on panX / panY during scale drags — integer-quantising those let the anchored corner wobble as scale grew continuously. Float pan offsets, anchored to within float- rounding noise. Verified by Playwright: noisy TL drag → BR drift (0.00, 0.00). Right-edge drag with vertical cursor noise → L drift 0.00, R follows horizontal cursor 39.98 / 40. * fix(pip): no-jump on edge handle press Edge handles are 18×6 (or 6×18) wide hit zones, but their LOGICAL anchor (the actual edge midpoint we want to track) is a single point at the handle's geometric center. If the user pressed slightly off-center inside that hit zone — easy to do on a 6px-wide right-edge handle — the very first frame applied a 1-2px scale delta as if the cursor had been exactly on the edge, snapping the clip a pixel or two. Record the offset between `pointerdown` cursor position and the handle's logical anchor point, subtract it from every subsequent cursor read. Drag math now treats the cursor as if it had pressed exactly on the edge, regardless of where inside the handle the user actually clicked. Verified with Playwright: pressing on the LEFT side of the 6px right-edge handle, the clip's dims don't change at all between BEFORE and JUST-AFTER-DOWN. Movement only starts when the cursor actually moves. * fix(pip): canvas-compositor.getFrameRect computes on demand When a drag wrote new scale + panX in the same tick, the overlay's dashed border briefly snapped a pixel or two because the canvas engine's `frameRectsByClip` cache was filled inside paint() — which ran in its OWN rAF tick. The overlay queried the rect BEFORE that tick's paint, so it read the previous frame's cached value. Now `getFrameRect()` recomputes from the live project state using the same math as paint(). Visible canvas pixels and overlay handles agree to float precision. Cache stays as a backstop for the rare case where the project changed but no video has decoded yet. HtmlVideoEngine already computed on demand so no change needed. * fix(pip): edge drag stops shoving the clip sideways `newCenter` was unconditionally adding ±newW/2 (or ±newH/2) to the anchor, even for edge handles where the perpendicular axis had been masked out of the projection. For a top-edge drag (axisX=0), the anchor sits at the clip's horizontal center but the formula still added `dirX * newW/2` — pushing the clip right by half its width every frame. Visible symptom: drag the top edge upward, clip instantly translates right by its full width. Gate the half-dimension offset on the axis mask so it only applies to axes the handle actually touches. Corners are unchanged (both mask bits = 1). Edges now keep their non-touched axis center pinned at the anchor. Verified by Playwright: Top edge drag UP 60px → top moves -60, bottom drift 0, clip expands symmetrically L/R (±30). Left edge drag LEFT 40px → right drift 0, top/bottom expand symmetrically (±20). * feat(pip): snap on resize + click-to-select on preview Two CapCut-style affordances on the keyframe overlay: 1. Snap during corner / edge drag, gated on the existing `editor.getSnap()` toggle. Scale snaps to 1.0 (full canvas), 0.5, 0.25, and 2.0 stops when the user's projected drag distance passes within SNAP_PX of any target. The same threshold the timeline uses, so the feel matches. 2. Click anywhere on the preview area auto-selects the topmost clip whose painted content rect contains the click point. Gated on keyframes mode — without keyframes, selection still happens via the timeline only. Walks tracks top-down so a PiP overlay wins the hit-test over the main background, which is what users expect ("click on the PiP, it's selected"). Editor API: new `getClipFrameRect(clipId)` so the overlay can query any active clip's rect without going through the selection-aware default path. * fix(pip): clicking PiP after main re-selects it After selecting the main (full-canvas) clip its selection frame body stretched across the whole canvas and intercepted every subsequent click — including ones meant to land on the PiP overlay underneath the cursor. The user saw "click PiP, becomes selected; click main, becomes selected; click PiP again, nothing happens". Move the host's hit-test handler to the CAPTURE phase so it runs before the frame body's bubble-phase pointerdown. When the hit-tested top clip differs from the current selection, swap the selection and `stopPropagation()` so the frame body doesn't start a translate drag on the previously-selected clip. When the click lands on the already-selected clip, fall through to the existing translate-drag behaviour. Also ignore clicks on the resize handles (they have their own scale-drag handler) instead of the previous "anything inside the overlay root" check, which was rejecting clicks on the frame body even when they ought to re-route. Verified by Playwright: PiP → main → PiP → main, all four selections come back correctly. * feat: two-mode toolbar layout (single / wrap) EditorOptions.toolbar.layout = "single" (default) | "wrap": - "single": CSS grid `1fr auto 1fr` locks the center cluster (time / play / duration / fullscreen) to the toolbar's geometric center. Long edit-action groups on the left no longer push the play button off-center the way the previous flex layout did. - "wrap": CSS grid with template-areas splits to two rows. Row 1 = edit cluster (left + extras-left). Row 2 = playback (centered) + viewport / extras-right (end-aligned). Use this when the host opts into the full button set and the chrome doesn't fit comfortably on one row. Reactive: `editor.setToolbarLayout(...)`, React `toolbar={{ layout }}` prop, Vue `toolbar` prop. New `toolbarLayoutChange` event for hosts that want to mirror the state. Demo: new "Toolbar layout" radio group in the sidebar between "Header" and "Toolbar slots". Switching at runtime swaps the grid template with no remount. * Revert "feat: two-mode toolbar layout (single / wrap)" This reverts commit 36b9ad5. * feat: 3-column preview card + frame-aware ruler + upload endpoint UI: - New `previewLayout` option (default: "centered"). 3-column main row with `panelLeft` / `panelRight` slots flanking the preview, CapCut- desktop style. Falls back to "fullWidth" for embeds. - Preview rendered as a width-capped, rounded card with the playback controls (time / play / duration / fullscreen) docked as a 40px footer instead of overlaying the canvas guide. - Keyframe panel moves into `panelLeft` so the numeric editor lives next to the viewport rather than on top of it. - `aicut-panel-*` slots have no separator border — host content reads as islands floating on the editor background. Timeline ruler: - New `EditorOptions.rulerMinTickPx` (default 80) + matching `editor.{get,set}RulerMinTickPx`. Standalone Timeline mirrors via `TimelineOptions.rulerMinTickPx`. - Seconds remain the primary unit at every zoom; once a frame is visually distinct (≥ 10px) sub-ticks switch to frame-aligned with small "Nf" mid-labels every 5 frames. Matches CapCut behavior. - `SCALE_MAX` raised from 400 → 2400 px/sec so the slider can reach one-frame-per-major-tick at 30 fps. Editor + toolbar now import the bounds from one canonical location. - Fix: integer modulo in the major-tick check — float math used to hide labels like 0.6s / 1.2s at high zoom. Backend: - TS backend adds `POST /upload` (multipart) → uploads land under `uploads/<uuid><.ext>` and the JSON response carries the absolute URL the editor wires into the source. - `GET /files/:id` serves both exports and uploads; whitelisted extensions only. - ffmpeg subprocess strips inherited HTTP/HTTPS/ALL proxy env vars and appends loopback hosts to NO_PROXY so a user's system proxy (Clash, mitmproxy, work VPN) can no longer intercept `http://localhost:<vite>` source fetches with a 503. Demo: - Export popover collapses to "尺寸 (auto from editor aspect) + 帧率 + 确定" — no more aspect/resolution dropdowns. Output dims derive from the toolbar 比例 chip. - Sidebar gains a ruler-density slider exposing rulerMinTickPx live. - `.env.example` companion `.env.local` (gitignored) wires the demo to the local backend by default. Other: - `formatRulerLabel` now takes a single sec arg; frame formatting is driven by the picker output. - Preview card aspect-ratio derives from `editor.getAspect()` via the `--aicut-preview-aspect` CSS var; `setProject` re-applies it. * feat(backend): multi-track timeline compositor + auto canvas dims renderProject: rewrite as a single-pass filter_complex that composites ALL video tracks of the project, not just the first one. - One `-i` per unique source (decode reuse). - Each clip: `trim(in,out)` → `setpts +clip.start/TB` → fit-to-canvas letterbox → `scale=w='trunc(iw*<scaleExpr>/2)*2':h='trunc(...)':eval=frame`. - Black `color=` canvas of the chosen output dims spans the full timeline so gaps stay black instead of trailing the last frame. - Bottom-to-top overlay with `enable='between(t,start,end)'` so each clip only paints during its window. Last layer tagged `[vout]`. - Track z-order follows `project.tracks` index — `tracks[last]` paints on top, matching the editor's PiP convention. - Pan / scale keyframe expressions feed the overlay x/y math and the per-frame scale filter via the new `tVar` option on `compileKeyframeExpression`. Caller passes `tVar: "(t-${clip.start/1000})"` so keyframe times stay clip-local even though the overlay sees global timeline `t`. - Audio: only the top video track contributes (editor PiP policy). Clips run through `atrim,asetpts,adelay` and `amix` together. Sources without an audio stream are skipped — probed up-front by `ffprobe -select_streams a`, since ffmpeg's `?` modifier on stream specs doesn't fully tolerate missing streams in filter_complex. Auto canvas dims: when client omits `output.width/height`, backend ffprobes the first clip on the bottom track and uses its intrinsic dimensions as the canvas — mirrors the editor's `canvasReferenceDims` so panX/panY (CSS pixels of the editor canvas) land at the same absolute positions in the export. Otherwise the editor's 1:1 preview canvas would author against 960×960 but ffmpeg defaulted to 1920×1080, and everything that fit the preview shifted in the export. Server `/files/:id`: implement HTTP Range (206 Partial Content). QuickTime `.mov` screen recordings put the `moov` atom at the end of the file; without range support ffmpeg's HTTP client can't seek to read it and aborts with "moov atom not found" before any frame decodes. Supports `bytes=A-B`, `bytes=A-`, and suffix `bytes=-N`. ffmpeg.ts: extract `noProxyEnv()` so both ffmpeg and ffprobe spawns share the proxy-strip env — earlier the ffprobe path skipped it, causing dimension probes to silently fail (returning the 1920×1080 fallback even when sources were on `http://localhost:<vite>`). Existing single-clip + per-segment + concat-demuxer path is gone — the timeline compositor handles single-track projects as a 1-clip graph cleanly. Tests: 15/15 pass (13 unit + 2 integration). compileKeyframeExpression back-compat is preserved by defaulting `tVar` to `"t"`. * feat: project.output as the authoritative canvas; pan/scale in canvas px Move the editor to a single, project-owned coordinate system instead of mixing preview CSS pixels with output pixels. The same pan / scale values now mean the same thing in the preview, in the canvas guide, and in the backend export — `preview == export`, pixel-for-pixel. Schema (types.ts, model.ts): - New `Project.output: { width, height, fps? }`. This is the canvas every spatial value lives in: keyframe panX/panY, clip-level static panX/panY, the canvas-guide rect, and the export resolution. - `Project.fps` is now deprecated in favor of `output.fps` but stays honored for legacy projects. - `normalizeProject` fills `output` from `aspect` (via the new `DEFAULT_OUTPUT_DIMS` 1080p-tier table) when missing, so existing projects round-trip cleanly. - New `defaultOutputForAspect(aspect)` helper, re-exported from `@aicut/core` + the React / Vue wrappers. Editor (editor.ts): - New `getOutput() / setOutput(...)` API. `setAspect("9:16")` now mirrors the picked aspect into `project.output` so all consumers see the new canvas atomically (no more "aspect chip says 9:16 but export wrote 1:1" drift). - `getOutput()` resolution chain: project.output → aspect-derived → engine canvasReferenceDims → 1920×1080 fallback. Engines (canvas-compositor.ts, html-video.ts): - Public `getCanvasReferenceDims()` added to the engine interface. Reads `project.output` first; falls back to the bottom-track anchor clip for legacy projects. - Compositor pan math switched from `panX * dpr` (CSS-px) to `panX * canvasScale` (canvas-px). HTML5 engine mirrors via `outRect.w / aw` ratio. `getFrameRect` updated to match. Keyframe overlay (keyframe-overlay.ts): - Drag handler converts the CSS-px cursor delta into canvas px via `canvas.width / outRect.w` before mutating the keyframe, so the stored values are unit-correct. - Snap thresholds + edge-alignment stops translated into canvas pixels; 8-px snap distance stays visually 8 px on screen regardless of preview size. Backend (render.ts): - Output dim resolution: explicit request → `project.output` → ffprobe → 1920×1080. The probe step is now strictly a legacy fallback for projects without `output`. - Output fps: `opts.fps → project.output.fps → project.fps → 30`. Demo (App.tsx): - Drops the local `DEFAULT_EXPORT_DIMS` table — reads from `editor.getOutput()` instead. The export popover and the backend call both rely on the single source of truth. - "Export" no longer attaches width/height to the request body when an aspect is set; dims ride along inside `project.output` so the backend computes its canvas without a separate mapping. Wrappers: - Re-export `ProjectOutput` + `DEFAULT_OUTPUT_DIMS` + `defaultOutputForAspect` from `@aicut/react` and `@aicut/vue`. Behavior: - Picking 9:16 in the toolbar IMMEDIATELY writes a 1080×1920 canvas; the preview re-letterboxes on next paint and any subsequent PiP upload keeps that canvas (also relies on the prior `normalizeProject` fix that preserves aspect/output across `setProject`). - Per-axis CSS↔canvas conversions in the overlay drag mean dragging the PiP 10 visible pixels nudges panX by exactly the right number of canvas units, not the historical "10 × preview-DPR" mush. Tests: 83 core tests pass (no regressions). 15 backend tests pass. React + Vue typecheck clean. Demo typecheck clean. Breaking note: keyframe panX/panY values stored under v0.7.x are interpreted as preview-CSS-pixels and will read as canvas-pixels under this commit. Old PiP positions show "less offset" until the user retunes; the demo's sample project regenerates each fresh load. * release: v0.7.3 — kf-drag polish + screenshots refreshed Bug fixes since v0.7.2: Compositor (canvas-compositor.ts): - `paint()` and `computeFrameRect()` were resolving the canvas dims from `parseAspect(project.aspect)`, which returns the bare ratio numbers (e.g. `[9, 16]`) instead of pixel dims. With the new canvas-pixel pan model, `canvasScale = min(cw/9, ch/16)` resolved to ~90× and any pan multiplied by it flung the PiP entirely off screen — the "PiP disappears the moment you drag it once you've picked 9:16" report. Both call sites now go through `canvasReferenceDims()`, which already prefers `project.output` (real px) and falls back to the bottom-track anchor. Keyframe overlay (keyframe-overlay.ts): - Corner-handle anchored resize (scale drag) was writing pan offsets in CSS pixels while the rest of the pipeline now stores canvas pixels — the anchored corner appeared to "jump" the moment the user grabbed a handle. Convert `(newCenter - canvasCenter)` through `cssToCanvasX/Y` before persisting, same ratio the translate-drag uses. Timeline (draw.ts, hit.ts): - Keyframe-hit radius bumped from 8 → 12 px so a near-miss click no longer falls through to clip-drag. - Keyframe diamonds now paint on `dim` clips too (at reduced opacity) — previously a dim source-clip skipped its kf diamonds entirely, so during a clip-drag the original position looked empty and the user perceived "the kf disappeared when I started dragging". Editor (editor.ts): - `moveKeyframe` now moves every kf in the same moment (±16 ms) as a unit instead of just the grabbed one. The toolbar "+kf" pins three props (panX / panY / scale) at the playhead together; the old per- kf move would fracture the moment so two props stayed at the original time and the visual diamond looked broken / "gone". Theme (theme.css): - Pull the fullscreen button out of the centered preview-controls flex group (absolute-positioned to the right) so the time / play / duration trio stays geometrically centered, matching the editor's preview card design. Screenshots: - `scripts/screenshots.sh` regenerated. The export-progress spec was updated for the new header-button + popover-confirm flow and now clamps its clip rect to the viewport so the sidebar's status block no longer falls off the screenshot canvas. Versions: - @aicut/core, @aicut/react, @aicut/vue all bumped 0.7.1 → 0.7.3 (skipping 0.7.2 — that tag is reserved for the main-branch in-progress release). * docs(readme): refresh for v0.7.3 - New "Preview layout" section covering `previewLayout: "centered" | "fullWidth"` and the matching `panelLeft` / `panelRight` slots. - New "Project canvas" section explaining `Project.output` as the single coordinate system every spatial value lives in. Cross-link from the Keyframe section so the canvas-pixel unit semantics are discoverable. - Custom slots table expanded from 4 → 6 entries (panel slots). Drop the stale "Aspect ratio" hint on `toolbarLeft` — aspect is the built-in 比例 chip now. - Keyframe section: note that timeline diamond drag moves the whole moment (all props pinned at the same time travel together). - Export backend wire-contract block: document the `/upload` endpoint + HTTP Range on `/files/:id` (required for QuickTime .mov), the output-dim resolution priority (`opts → project.output → ffprobe`), and the TS backend's single-pass multi-track compositor. Note that the Go backend isn't at parity yet. - Roadmap: tick off the v0.7.3 deliverables (multi-track export, project.output, 3-column layout, frame ruler, upload + range); surface "Go backend parity" as the next backend item.
1 parent e145cce commit a9595ee

43 files changed

Lines changed: 3532 additions & 790 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,18 @@ Switching at runtime is a regular prop change — the toolbar re-titles and the
189189

190190
## 🧩 Custom slots
191191

192-
Four host-fillable slots on the editor — empty by default, no chrome cost. The same pattern is the standalone `<Timeline>`'s `toolbarLeft`/`toolbarRight` props.
192+
Six host-fillable slots on the editor — empty by default, no chrome cost. The same pattern is the standalone `<Timeline>`'s `toolbarLeft`/`toolbarRight` props.
193193

194194
| Slot | Where | Typical use |
195195
| :--- | :--- | :--- |
196196
| `headerLeft` | Top header, left | Project name, file menu, breadcrumbs |
197197
| `headerRight` | Top header, right | Share / Export / profile / settings |
198-
| `toolbarLeft` | Toolbar, left bookend | Aspect ratio, size, branding |
198+
| `toolbarLeft` | Toolbar, left bookend | Project name, status pill, branding |
199199
| `toolbarRight` | Toolbar, right bookend | Custom action icons |
200+
| `panelLeft` | Main row, left of preview (centered layout) | Source library, media bin, generators |
201+
| `panelRight` | Main row, right of preview (centered layout) | Inspector, properties, AI tools |
202+
203+
The two `panel*` slots only render when `previewLayout="centered"` (the default — see [Preview layout](#-preview-layout-3-column--full-width) below). The aspect chip is now built into the toolbar, so `toolbarLeft` is free for whatever else.
200204

201205
When both `headerLeft` and `headerRight` are empty the header bar collapses entirely — the default editor layout is byte-for-byte identical to before the slots existed.
202206

@@ -213,25 +217,61 @@ When both `headerLeft` and `headerRight` are empty the header bar collapses enti
213217
</>
214218
}
215219
// Toolbar bookends — independent slots.
216-
toolbarLeft={
217-
<select value={aspect} onChange={(e) => setAspect(e.target.value)}>
218-
<option value="16:9">16:9</option>
219-
<option value="9:16">9:16</option>
220-
<option value="1:1">1:1</option>
221-
</select>
222-
}
223-
toolbarRight={
224-
<button onClick={() => apiRef.current?.requestExport()}>Export</button>
225-
}
220+
toolbarLeft={<ProjectNameInput value={name} onChange={setName} />}
221+
toolbarRight={<ExportStatusPill status={exportStatus} />}
222+
// CapCut-desktop-style side rails — only render in centered layout.
223+
panelLeft={<MediaBin sources={sources} onPickClip={addClip} />}
224+
panelRight={<InspectorPanel clip={selectedClip} />}
226225
/>
227226
```
228227

229228
---
230229

230+
## 🖼 Preview layout (3-column / full-width)
231+
232+
The editor ships two top-level layouts, picked via `previewLayout`:
233+
234+
| Layout | Behaviour | When to use |
235+
| :--- | :--- | :--- |
236+
| `"centered"` (default) | Preview lives in a width-capped card in the middle third of the row, with `panelLeft` / `panelRight` slots flanking it. | CapCut-desktop posture — host owns a media library / inspector. |
237+
| `"fullWidth"` | Preview spans the entire row, no side columns. | Embeds where the host doesn't need side panels. |
238+
239+
Playback controls (time · play · duration · fullscreen) sit as a footer on the preview card in both layouts, not on top of the canvas — keyframe handles and the canvas guide stay unobstructed.
240+
241+
```tsx
242+
<VideoEditor
243+
previewLayout="centered" // or "fullWidth"
244+
panelLeft={<MediaBin />}
245+
panelRight={<Inspector />}
246+
/>
247+
```
248+
249+
The layout switch is reactive — flip the prop and the chrome rearranges without remounting the editor.
250+
251+
---
252+
253+
## 🖍 Project canvas
254+
255+
Every spatial value in a project — keyframe `panX`/`panY`, the canvas guide rect, the export resolution — lives in **one coordinate system**, the project canvas:
256+
257+
```ts
258+
project.output = { width: 1920, height: 1080, fps: 30 };
259+
```
260+
261+
The preview is just a scaled view of this canvas. The export writes exactly these dimensions. Pan/scale are in canvas pixels, not preview pixels, so a project authored on a 720×720 tab and rendered on a 4K screen lands every transform identically.
262+
263+
- `editor.setAspect("9:16")` updates `Project.output` to the 1080p tier for that ratio (1080×1920). Hosts can override with `editor.setOutput({ width, height, fps })`.
264+
- The backend export reads `Project.output` first; explicit `output` in the request body overrides.
265+
- Legacy projects without `output`: `normalizeProject` derives one from `aspect` or the first clip's intrinsic source dims (CapCut "Original" behaviour), so old JSON round-trips without manual migration.
266+
267+
---
268+
231269
## 🎞 Keyframe animation
232270

233271
CapCut-style **per-property keyframes** for `panX`, `panY`, and `scale` — pin a value at any moment, the engine animates between them. Authored in the editor, previewed live by any playback engine, **compiled to ffmpeg expressions on export** so the rendered mp4 matches the preview frame-for-frame.
234272

273+
`panX` / `panY` are stored in **canvas pixels** of `Project.output` (see [Project canvas](#-project-canvas)). One pan unit = one pixel in the export, regardless of preview size — so the same project rendered on a small embed and a full-screen tab lands every keyframe in the same place.
274+
235275
```ts
236276
clip.keyframes = [
237277
{ id: "k1", prop: "scale", time: 0, value: 1 },
@@ -244,7 +284,7 @@ clip.keyframes = [
244284
| :--- | :--- |
245285
| **Per-property model** | `panX` / `panY` / `scale` animate independently. Pre-easing tuple-keyframes auto-migrate via `normalizeProject`. |
246286
| **Easing curves** | `linear` / `easeIn` / `easeOut` / `easeInOut` (cubic). Stored on the leaving kf — matches AE / Premiere / CapCut convention. Omitted = linear (back-compat). |
247-
| **Editor UI** | Toolbar diamond toggle, draggable preview overlay (translate body / scale corners / pinch wheel), floating numeric panel with easing dropdown, **timeline diamond markers** with drag-to-retime + snap. |
287+
| **Editor UI** | Toolbar diamond toggle, draggable preview overlay (translate body / scale corners / pinch wheel), floating numeric panel with easing dropdown, **timeline diamond markers** with drag-to-retime (drags the whole moment — every prop pinned at the same time travels together) + snap. |
248288
| **Backend export** | `compileKeyframeExpression` emits a `gte(t,A)*lt(t,B)*…` sum compiled into `scale=...:eval=frame` + `overlay=…:eval=frame` filters. Both `@aicut/backend-ts` and `@aicut/backend-go` support it identically. |
249289
| **PiP semantics** | Output frame is fixed; pan/scale moves the *content* inside it (`overflow: hidden` in the HTML engine, `ctx.clip()` in canvas, ffmpeg `overlay` onto a fixed-size `color` background on the backend). |
250290
| **Lossless splits** | `splitClipAt` mid-segment **inserts interpolated boundary keyframes** so cutting and not moving the halves plays back identically to the un-cut clip. |
@@ -291,10 +331,17 @@ POST /export Content-Type: application/json
291331
data: {"phase":"concat","overall":0.99,"totalClips":3}
292332
data: {"phase":"done","fileUrl":"/files/<uuid>.mp4","id":"<uuid>"}
293333
294-
GET /files/<uuid>.mp4 → video/mp4
334+
POST /upload (multipart, field "file") → { url, id, name }
335+
GET /files/<uuid>.<ext> → video/* (with HTTP Range support)
295336
```
296337

297-
`out_time_us` from ffmpeg's `-progress` stream is aggregated across the per-clip encode passes, so the overall fraction is honest end-to-end. Aborting the client connection (or AbortController on the fetch) kills the in-flight ffmpeg.
338+
Output dimensions resolve in priority order: explicit `output` in the request body → `Project.output` → ffprobe of the bottom track's first clip → 1920×1080. With `Project.output` set (default behaviour from the editor), the request body's `output` is purely an override.
339+
340+
The TS backend runs the entire timeline through a **single `filter_complex` pass** — bottom track painted onto a black `color=` canvas first, each higher track overlaid with `enable='between(t,start,end)'` so PiP windows respect their lifetimes. Keyframe `panX`/`panY`/`scale` compile to per-frame ffmpeg expressions; the compositor renders at 2× internal resolution and downsamples with lanczos so sub-pixel motion stays smooth. Audio: only the top track contributes (matching the editor's PiP policy), mixed via `amix`.
341+
342+
`out_time_us` from ffmpeg's `-progress` stream feeds the overall fraction. Aborting the client connection (or AbortController on the fetch) kills the in-flight ffmpeg.
343+
344+
The Go backend still uses the per-clip + concat strategy and ignores cross-track compositing — slated for parity in a follow-up release.
298345

299346
<div align="center">
300347

@@ -497,6 +544,12 @@ The script is idempotent — already-published versions are skipped, so a re-run
497544
- [x] Density knobs — `timelineHeight` (reactive), `trackHeight`, `rulerHeight` for compact viewports
498545
- [x] Per-clip keyframe animation (X / Y / Scale) + easing curves (linear / easeIn / easeOut / easeInOut)
499546
- [x] Backend ffmpeg compilation of keyframes — animated `scale` + `overlay` filter graph with per-frame `t`-expressions, both TS + Go
547+
- [x] Multi-track timeline export (PiP-aware compositor in TS backend — single `filter_complex` pass with per-track overlay + `enable` windows)
548+
- [x] Source-of-truth project canvas (`Project.output`) — pan/scale in canvas pixels, preview/export pixel-for-pixel
549+
- [x] 3-column preview layout + `panelLeft`/`panelRight` host slots (CapCut-desktop posture)
550+
- [x] Frame-aware timeline ruler — seconds first, frame sub-ticks at high zoom (`rulerMinTickPx` knob)
551+
- [x] Backend upload endpoint + HTTP Range support (QuickTime `.mov` with trailing moov streams correctly)
552+
- [ ] Go backend parity with TS multi-track compositor
500553
- [ ] Speed adjustment (timeline already reserves the slot)
501554
- [ ] Audio track rendering + waveform thumbnails
502555
- [ ] WebCodecs engine: multi-track compositing + transitions

‎backends/ts/package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
},
1414
"dependencies": {
1515
"@aicut/core": "workspace:*",
16-
"fastify": "^5.2.0",
17-
"@fastify/cors": "^10.0.1"
16+
"@fastify/cors": "^10.0.1",
17+
"@fastify/multipart": "^10.0.0",
18+
"fastify": "^5.2.0"
1819
},
1920
"devDependencies": {
2021
"@types/node": "^22.10.2",

‎backends/ts/src/ffmpeg.ts‎

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,119 @@ export async function resolveFfmpeg(): Promise<string> {
2222
return "ffmpeg";
2323
}
2424

25+
/** Resolve ffprobe alongside ffmpeg, with the same precedence. */
26+
export async function resolveFfprobe(): Promise<string> {
27+
const envBin = process.env["AICUT_FFPROBE"];
28+
if (envBin && (await fileExists(envBin))) return envBin;
29+
30+
const bundled = path.resolve(__dirname, "..", "ffmpeg-bin", "ffprobe");
31+
if (await fileExists(bundled)) return bundled;
32+
33+
return "ffprobe";
34+
}
35+
36+
/** Same proxy-strip env used by ffmpeg subprocesses — see runFfmpeg
37+
* for the why. Exported so ffprobe spawns can share it. */
38+
function noProxyEnv(): NodeJS.ProcessEnv {
39+
const env: NodeJS.ProcessEnv = { ...process.env };
40+
delete env.HTTP_PROXY;
41+
delete env.http_proxy;
42+
delete env.HTTPS_PROXY;
43+
delete env.https_proxy;
44+
delete env.ALL_PROXY;
45+
delete env.all_proxy;
46+
env.NO_PROXY = "localhost,127.0.0.1,::1";
47+
env.no_proxy = "localhost,127.0.0.1,::1";
48+
return env;
49+
}
50+
51+
/**
52+
* Probe a source's intrinsic video dimensions. Used to derive the
53+
* export canvas size when the client doesn't pass one, matching the
54+
* frontend's `canvasReferenceDims` (= first video clip on the bottom
55+
* track). Returns null on any failure — caller decides the fallback.
56+
*/
57+
export async function probeVideoDimensions(
58+
ffprobeBin: string,
59+
url: string,
60+
): Promise<{ width: number; height: number } | null> {
61+
return new Promise((resolve) => {
62+
const proc = spawn(
63+
ffprobeBin,
64+
[
65+
"-v",
66+
"error",
67+
"-select_streams",
68+
"v:0",
69+
"-show_entries",
70+
"stream=width,height",
71+
"-of",
72+
"csv=p=0",
73+
url,
74+
],
75+
{ stdio: ["ignore", "pipe", "pipe"], env: noProxyEnv() },
76+
);
77+
let stdout = "";
78+
proc.stdout.on("data", (b: Buffer) => {
79+
stdout += b.toString();
80+
});
81+
proc.on("error", () => resolve(null));
82+
proc.on("close", () => {
83+
const trimmed = stdout.trim();
84+
const m = /^(\d+),(\d+)$/.exec(trimmed);
85+
if (!m) return resolve(null);
86+
const width = Number(m[1]);
87+
const height = Number(m[2]);
88+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
89+
return resolve(null);
90+
}
91+
resolve({ width, height });
92+
});
93+
});
94+
}
95+
96+
/**
97+
* Check whether a source has at least one audio stream. The render
98+
* graph hits a hard "no streams" error when we wire `[N:a]` against
99+
* a video-only source (and ffmpeg's `?` modifier only helps if the
100+
* sub-graph is independently dropped — chained filters trip the same
101+
* error). Probe up front so we know which clips contribute audio.
102+
*
103+
* Failures (network error, missing binary) treat the source as
104+
* audio-less rather than aborting the whole render — at worst the
105+
* output is silent, which is recoverable.
106+
*/
107+
export async function probeHasAudio(
108+
ffprobeBin: string,
109+
url: string,
110+
): Promise<boolean> {
111+
return new Promise((resolve) => {
112+
const proc = spawn(
113+
ffprobeBin,
114+
[
115+
"-v",
116+
"error",
117+
"-select_streams",
118+
"a",
119+
"-show_entries",
120+
"stream=index",
121+
"-of",
122+
"csv=p=0",
123+
url,
124+
],
125+
{ stdio: ["ignore", "pipe", "pipe"], env: noProxyEnv() },
126+
);
127+
let stdout = "";
128+
proc.stdout.on("data", (b: Buffer) => {
129+
stdout += b.toString();
130+
});
131+
proc.on("error", () => resolve(false));
132+
proc.on("close", () => {
133+
resolve(stdout.trim().length > 0);
134+
});
135+
});
136+
}
137+
25138
async function fileExists(p: string): Promise<boolean> {
26139
try {
27140
await access(p, constants.X_OK);
@@ -56,7 +169,12 @@ export function runFfmpeg(
56169
// We want stdout when progress is being parsed; without an
57170
// onStdoutLine consumer, draining it costs nothing meaningful but
58171
// keeps the contract uniform.
59-
const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
172+
// Strip any inherited HTTP/HTTPS proxy env vars from the ffmpeg
173+
// subprocess — see noProxyEnv for the why.
174+
const proc = spawn(bin, args, {
175+
stdio: ["ignore", "pipe", "pipe"],
176+
env: noProxyEnv(),
177+
});
60178
let stderr = "";
61179
let stdoutBuf = "";
62180
proc.stdout.on("data", (b: Buffer) => {

‎backends/ts/src/keyframe-expression.ts‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,42 @@ import type { EasingKind, Keyframe, KeyframeProp } from "@aicut/core";
22

33
/**
44
* Compile a per-property keyframe array into an ffmpeg filter
5-
* expression in `t` (output stream timestamp, seconds). The result is
6-
* suitable to drop into `scale=w='...':h='...':eval=frame` or
7-
* `overlay=x='...':eval=frame` — both filters expose `t` as the
8-
* current output timestamp.
5+
* expression. The result is suitable to drop into
6+
* `scale=w='...':h='...':eval=frame` or `overlay=x='...':eval=frame`
7+
* — both filters expose `t` as the current OUTPUT timestamp.
98
*
109
* Math: each segment between kf[i] and kf[i+1] contributes
1110
*
12-
* gte(t, A) * lt(t, B) * ( a.value + easedT * (b.value - a.value) )
11+
* gte(T, A) * lt(T, B) * ( a.value + easedT * (b.value - a.value) )
1312
*
14-
* where easedT applies the LEAVING keyframe's outgoing curve to the
15-
* normalized progress `(t - A)/(B - A)`. Before-first holds the
13+
* where `T` is the time-variable expression (default `t`) and
14+
* easedT applies the LEAVING keyframe's outgoing curve to the
15+
* normalized progress `(T - A)/(B - A)`. Before-first holds the
1616
* first value; after-last holds the last value. Boundaries use a
17-
* half-open `[A, B)` mask so the same t never gets counted twice.
17+
* half-open `[A, B)` mask so the same T never gets counted twice.
1818
*
1919
* For props with NO keyframes the function returns the static
2020
* fallback as a literal so the caller can drop the result straight
2121
* into the filter without conditionals.
2222
*
23-
* `t` here matches the segment-local timeline because the encoder
24-
* runs each clip in its own ffmpeg call with `-ss` + `-t` cropping
25-
* the input to clip-local time. So a keyframe at clip-local 1000ms
26-
* compiles to `gte(t, 1.000000)` directly.
23+
* `tVar` lets the caller substitute a CLIP-LOCAL expression when the
24+
* filter sees global timeline `t` — e.g. when compositing multiple
25+
* tracks in a single filter_complex pass, callers pass
26+
* `tVar: "(t-${clip.start/1000})"` so keyframe times stay anchored
27+
* to clip start.
2728
*/
29+
export interface CompileOpts {
30+
/** Expression that evaluates to clip-local seconds. Defaults to `"t"`. */
31+
tVar?: string;
32+
}
33+
2834
export function compileKeyframeExpression(
2935
keyframes: Keyframe[] | undefined,
3036
prop: KeyframeProp,
3137
fallback: number,
38+
opts: CompileOpts = {},
3239
): string {
40+
const T = opts.tVar ?? "t";
3341
if (!keyframes || keyframes.length === 0) return formatNumber(fallback);
3442
const propKfs = keyframes
3543
.filter((k) => k.prop === prop)
@@ -42,7 +50,7 @@ export function compileKeyframeExpression(
4250
const last = propKfs[propKfs.length - 1]!;
4351

4452
// Before first kf — held at first.value.
45-
parts.push(`lt(t,${formatTime(first.time)})*${formatNumber(first.value)}`);
53+
parts.push(`lt(${T},${formatTime(first.time)})*${formatNumber(first.value)}`);
4654

4755
// Segments.
4856
for (let i = 0; i < propKfs.length - 1; i += 1) {
@@ -52,18 +60,18 @@ export function compileKeyframeExpression(
5260
const bSec = formatTime(b.time);
5361
const dur = (b.time - a.time) / 1000;
5462
if (dur <= 0) continue; // zero-length segment — skip to avoid div/0
55-
const rawT = `(t-${aSec})/${formatNumber(dur)}`;
63+
const rawT = `(${T}-${aSec})/${formatNumber(dur)}`;
5664
const easedT = easingExpression(rawT, a.easing ?? "linear");
5765
const delta = b.value - a.value;
5866
const lerpedExpr =
5967
delta === 0
6068
? formatNumber(a.value) // value held — constant segment
6169
: `(${formatNumber(a.value)}+(${easedT})*(${formatNumber(delta)}))`;
62-
parts.push(`gte(t,${aSec})*lt(t,${bSec})*${lerpedExpr}`);
70+
parts.push(`gte(${T},${aSec})*lt(${T},${bSec})*${lerpedExpr}`);
6371
}
6472

6573
// After last kf — held at last.value.
66-
parts.push(`gte(t,${formatTime(last.time)})*${formatNumber(last.value)}`);
74+
parts.push(`gte(${T},${formatTime(last.time)})*${formatNumber(last.value)}`);
6775

6876
return parts.join("+");
6977
}

0 commit comments

Comments
 (0)