Commit a9595ee
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
File tree
- backends/ts
- src
- docs/screenshots
- e2e/tests
- examples/react-demo/src
- packages
- core
- src
- playback
- timeline
- ui
- styles
- react
- src
- vue
- src
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
189 | 189 | | |
190 | 190 | | |
191 | 191 | | |
192 | | - | |
| 192 | + | |
193 | 193 | | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
197 | 197 | | |
198 | | - | |
| 198 | + | |
199 | 199 | | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
200 | 204 | | |
201 | 205 | | |
202 | 206 | | |
| |||
213 | 217 | | |
214 | 218 | | |
215 | 219 | | |
216 | | - | |
217 | | - | |
218 | | - | |
219 | | - | |
220 | | - | |
221 | | - | |
222 | | - | |
223 | | - | |
224 | | - | |
225 | | - | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
226 | 225 | | |
227 | 226 | | |
228 | 227 | | |
229 | 228 | | |
230 | 229 | | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
231 | 269 | | |
232 | 270 | | |
233 | 271 | | |
234 | 272 | | |
| 273 | + | |
| 274 | + | |
235 | 275 | | |
236 | 276 | | |
237 | 277 | | |
| |||
244 | 284 | | |
245 | 285 | | |
246 | 286 | | |
247 | | - | |
| 287 | + | |
248 | 288 | | |
249 | 289 | | |
250 | 290 | | |
| |||
291 | 331 | | |
292 | 332 | | |
293 | 333 | | |
294 | | - | |
| 334 | + | |
| 335 | + | |
295 | 336 | | |
296 | 337 | | |
297 | | - | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
298 | 345 | | |
299 | 346 | | |
300 | 347 | | |
| |||
497 | 544 | | |
498 | 545 | | |
499 | 546 | | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
500 | 553 | | |
501 | 554 | | |
502 | 555 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | | - | |
17 | | - | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
18 | 19 | | |
19 | 20 | | |
20 | 21 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
25 | 138 | | |
26 | 139 | | |
27 | 140 | | |
| |||
56 | 169 | | |
57 | 170 | | |
58 | 171 | | |
59 | | - | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
60 | 178 | | |
61 | 179 | | |
62 | 180 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
6 | | - | |
7 | | - | |
8 | | - | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
9 | 8 | | |
10 | 9 | | |
11 | 10 | | |
12 | | - | |
| 11 | + | |
13 | 12 | | |
14 | | - | |
15 | | - | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
16 | 16 | | |
17 | | - | |
| 17 | + | |
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
24 | | - | |
25 | | - | |
26 | | - | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
27 | 28 | | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
28 | 34 | | |
29 | 35 | | |
30 | 36 | | |
31 | 37 | | |
| 38 | + | |
32 | 39 | | |
| 40 | + | |
33 | 41 | | |
34 | 42 | | |
35 | 43 | | |
| |||
42 | 50 | | |
43 | 51 | | |
44 | 52 | | |
45 | | - | |
| 53 | + | |
46 | 54 | | |
47 | 55 | | |
48 | 56 | | |
| |||
52 | 60 | | |
53 | 61 | | |
54 | 62 | | |
55 | | - | |
| 63 | + | |
56 | 64 | | |
57 | 65 | | |
58 | 66 | | |
59 | 67 | | |
60 | 68 | | |
61 | 69 | | |
62 | | - | |
| 70 | + | |
63 | 71 | | |
64 | 72 | | |
65 | 73 | | |
66 | | - | |
| 74 | + | |
67 | 75 | | |
68 | 76 | | |
69 | 77 | | |
| |||
0 commit comments