Skip to content

feat: AI-friendly editor API + effects layer#9

Open
ziqiangai wants to merge 12 commits into
mainfrom
feat/ai-friendly-api
Open

feat: AI-friendly editor API + effects layer#9
ziqiangai wants to merge 12 commits into
mainfrom
feat/ai-friendly-api

Conversation

@ziqiangai

Copy link
Copy Markdown
Contributor

Summary

  • New AI-facing EditorApi on @aicut/core: structured EditResult<T> returns, explicit targets, atomic batch(), and an operation event bus. Old-shape mutators still ship; the new ones sit alongside.
  • New @aicut/effects package (unpublished): decoupled overlay that renders animated feedback per edit operation. Ships with default splitClip and moveClipTo stick-figure effects; every effect is swappable or disable-able.
  • New /#/api playground route in examples/react-demo — 9 cards, one per new API, showing structured return values live-editing a shared timeline.

Changes

@aicut/core

  • EditResult<T> + EditErrorReason union
  • batch(label, fn): coalesces N mutations into one undo entry (sync + async)
  • New mutators: splitClip, moveClipTo, trimClip, deleteClip, addClip — all EditResult-returning, all emit OperationEvents
  • New query helpers: findClipAt, getClipsInRange, getClipsOnTrack, timelineToSourceMs, sourceToTimelineMs
  • captureFrame: composite-or-raw canvas capture → Blob
  • probeMediaSource: URL → duration/dimensions via headless <video>
  • on(\"operation\", …): event stream with before/after project snapshots and batchId

@aicut/effects (new)

  • <AiCutEffects> overlay component + useOperationStream low-level hook
  • defaultSplitEffect (drop → strike → recoil, ~800ms) with cubic-bezier easing per phase; strike + flash line lands ~200ms after mount so the visual peak sits near the data commit instead of trailing it
  • defaultMoveEffect (spawn → lift → parabolic arc + walk cycle → drop → exit, ~1.45s); arc height scales with move distance
  • <StickFigure> character with 7 poses; the character prop lets you replace it (Lottie / Rive / whatever)

Demo

  • examples/react-demo/src/ApiPlayground.tsx mounted at /#/api in the router

Tests

  • 134/134 unit tests passing in @aicut/coreeditor.test.ts covers batch semantics, EditResult failure paths, and operation events; model-query.test.ts covers pure helpers.
  • Manual verification via /#/api playground.

Publish plan

  • @aicut/effects is a new package — first publish needs --access public.
  • @aicut/core / @aicut/react / @aicut/vue bumps go out via scripts/publish.sh after merge, per the existing OTP workflow.

Test plan

  • Load /#/api, exercise each card, verify EditResult JSON
  • Toggle Effects on/off — timeline animations render only when on
  • Split a clip mid-way — verify strike + flash line at cut point
  • Move a clip across tracks — verify parabolic arc + walk cycle
  • pnpm --filter @aicut/core test — 134/134 pass
  • pnpm --filter react-demo typecheck clean

ziqiangai added 12 commits July 2, 2026 15:37
Adds a second, deterministic-by-default surface on `EditorApi` for
callers without UI state (AI tool-loops, scripts, tests). Existing
positional methods are untouched — legacy code compiles.

Core (@aicut/core):

- New `EditResult<T>` discriminated union — every new mutator returns
  `{ ok: true, data }` or `{ ok: false, reason, hint? }`. Reasons are
  a typed union (`clip-not-found` | `time-outside-clip` | `overlap` |
  `invalid-time` | `invalid-range` | `source-not-found` |
  `source-load-failed` | `internal-error`), so AI callers can branch
  without guessing.

- `Editor.batch(label, fn)` — wraps `beginInteraction / endInteraction`
  with try/finally + async support. N mutations become one undo
  entry. `label` reserved for future labeled-history UI.

- `Editor.splitClip({ clipId, timeMs })` — split a specific clip, no
  more first-match-wins across other tracks. Fails cleanly outside
  the clip's [start, end).

- `Editor.moveClipTo({ clipId, toTrackId?, startMs?, onOverlap })` —
  explicit destination. `onOverlap: "error"` (default) refuses when
  taken; `"auto"` opts into today's smart-routing.

- `Editor.trimClip({ clipId, edge, timeMs })` — move one edge without
  the ripple-close side effects the UI-facing `trimLeft` applies.

- `Editor.deleteClip({ clipId })` — typed removal.

- `Editor.addClip({ sourceUrl | sourceId, trackId, startMs, inMs?,
  outMs?, onOverlap?, meta? })` — one-shot: probe the URL for
  duration + dimensions (via new `probeMediaSource` helper), register
  the source, insert the clip. Async. `meta` accepted but silently
  dropped in MVP (Clip.meta field lands in a future release; passing
  it now is forward-compatible).

- `Editor.captureFrame({ timeMs, source: 'composite' | 'raw', clipId?,
  format?, quality?, maxWidth? })` — grab a still frame as JPEG/PNG
  blob. Composite mode reads the compositor canvas after seek + rAF
  (requires CanvasCompositorEngine or WebCodecsEngine). Raw mode
  spawns a hidden `<video>` seeked to the source-local equivalent
  time — engine-independent. Purpose: feed AI vision models without
  requiring hosts to plumb the compositor themselves.

- Pure query helpers on Project (in `model.ts`, re-exported from
  core index):
  - `findClipAt(project, timeMs, trackIndex?)` — first matching clip.
  - `getClipsInRange(project, startMs, endMs, trackIndex?)` — all
    overlapping clips.
  - `getClipsOnTrack(project, trackIndex)` — clips on one track.
  - `timelineToSourceMs(clip, timelineMs)` / `sourceToTimelineMs(...)`
    — the coordinate math AI would otherwise re-derive per tool call.

- `probeMediaSource(url)` — the metadata probe `addClip` uses
  internally, exposed for hosts that want to pre-fetch before
  deciding whether to insert.

React (@aicut/react):

- Re-exports the model helpers + probe + `EditResult` / `EditErrorReason`
  types so hosts stay on one import.

Demo:

- New `#/api` route: **API playground** page. Card layout — one card
  per new method with form inputs bound to its params, Run button,
  pretty-printed EditResult log. Runs against a live editor with the
  compositor canvas engine (so captureFrame composite mode works).
  Purpose: dogfood the ergonomics before wiring AI on top. If a form
  feels awkward, a tool spec built from the same shape will feel
  worse.

- Router gains "API playground 🧪" tab.

Tests: 125/125 pass across 9 files. New coverage:
  editor.test.ts — batch (sync/async/throwing/coalescing) +
    splitClip/moveClipTo/trimClip/deleteClip/addClip variants.
  model-query.test.ts — findClipAt / getClipsInRange /
    getClipsOnTrack / timelineToSourceMs / sourceToTimelineMs.

Not in this commit (post-MVP):
  - `validateProject` helper (#169)
  - `fillGap` composite (#171)
  - `findGapsOnTrack` helper (#172)
  - AiCopilot LLM demo (deferred until MVP APIs prove ergonomic in
    the playground)
Core: OperationEvent type + 'operation' event on the bus. Every
AI-facing mutator (splitClip / moveClipTo / trimClip / deleteClip /
addClip) fires exactly one event with kind + args + result +
before/after project snapshots + batchId when inside batch().
Nested batch() calls reuse the outermost id. 9 new unit tests
(134/134 pass).

@aicut/effects (new package, WIP): package skeleton + types +
geometry helpers. AiCutEffects component + character SVG + default
splitEffect / moveEffect handlers not landed yet — pausing here to
hotfix a useEditorState infinite-loop on main.
Ships the effects overlay on top of the operation event bus. Every
`splitClip` / `moveClipTo` on the timeline gets a stick figure
animation that reads as "the little worker did that", verifying the
architecture works end-to-end before the character art gets replaced
with real designs.

@aicut/effects (new package):

- `<AiCutEffects>` — the overlay component. Mount once inside
  `<EditorProvider>`. Subscribes to `editor.on("operation", ...)`,
  builds an `EffectContext` (timeline / preview rects + coordinate
  helpers) at emit time, dispatches to a registered handler for that
  op kind, renders whatever JSX the handler returns into a fixed-
  position pointer-events:none overlay. Holds one active effect per
  kind — a second op of the same kind replaces the first via React
  key change. Different kinds run concurrently.

  Fully opt-in:
    - `enabled={false}` disables the whole layer (no subscription,
      no render).
    - `effects={{ splitClip: myFn }}` swaps a single handler.
    - `effects={{ splitClip: false }}` disables one kind without a
      replacement.
    - `zIndex` prop controls stacking.

- `useOperationStream(handler)` — low-level hook for hosts who want
  Canvas / non-React overlays / telemetry loggers without going
  through the JSX handler shape.

- `<StickFigure pose facing scale color />` — minimum-viable SVG
  character with 7 poses (idle / walking / cutting / lifting /
  carrying / dropping / waving). Intentionally crude so it reads as
  "intent placeholder" — hosts will swap in Lottie / Rive / hand-
  drawn art. Pose type is a widened string union so custom
  character components can implement any subset.

- `defaultSplitEffect` — worker walks in from the right, parks at
  the cut point, sawing pose + flash line for a beat, walks off the
  left. Total ~1.7s.

- `defaultMoveEffect` — worker enters near the source clip, lifts a
  ghost rectangle overhead, walks along the timeline to the
  destination (source rect read from `op.beforeProject`, dest from
  `op.afterProject`), drops the ghost onto the new position, exits.
  Total ~2.1s.

- `buildEffectContext(editor)` — geometry helper. Reads DOM live:
  timeline root rect, preview host rect, `timelineToScreenX(ms)` +
  `clipToScreenRect(clipId)` that account for header width + scroll
  offset + CSS-var track height. Exported so custom effects can reuse
  it.

- Trim / delete / add ship without a default (`false` in the built-
  in map) — first pass keeps the shipped surface honest with 2 real
  animations, more follow when the shape is proven.

Demo:

- `#/api` playground now mounts `<AiCutEffects>` outside the shell
  (so its fixed overlay stacks correctly) and gains a
  `🎭 Effects: on/off` chip in the header for A/B toggling.

- Fire splitClip → stick figure walks in, saws over the cut column,
  walks off. Fire moveClipTo → figure lifts a ghost clip, carries
  it to the destination row, drops. Toggle chip disables both.

Everything is decoupled:
  - `@aicut/core` never learns about characters or SVG — it just
    emits `operation` events with data.
  - `@aicut/react` doesn't depend on `@aicut/effects`. A host that
    doesn't import `@aicut/effects` gets zero animation code.
  - Effect handlers are pure functions of `(op, ctx, onComplete)`
    → JSX — hosts swap any subset without touching the library.
  - Character components take a `pose` prop only — swap
    `<StickFigure>` for anything with the same prop shape.

Not in this pass:
  - trim / delete / add effects (schema in place, just no defaults)
  - audio / sound effects
  - character art polish (this is a "prove the pipes work" figure)
  - AiCopilot demo (planned as follow-up now that operation events
    are the natural feedback surface for it)
splitClip: rebuilt as drop → strike → recoil (~800ms). Strike + yellow
flash fires ~200ms after mount so the visual peak lands close to the
data commit instead of trailing 600ms behind. Cubic-bezier easing per
phase for anticipation / snap / follow-through.

moveClipTo: rebuilt as spawn → lift → carry → drop → exit (~1.45s).
Carry phase follows a parabolic arc scaled by horizontal distance (short
moves stay flat, long moves arc high). Walk-cycle pose swap every 140ms
plus a small vertical bob reads as steps instead of a slide.

Both stay within the pure overlay layer — no core / timeline coupling.
Playwright coverage for @aicut/effects wired into the /#/api
playground. Asserts:
  - overlay mounts at idle (parent listener container present)
  - splitClip Run → [data-effect-kind="splitClip"] node appears + unmounts
  - moveClipTo Run → [data-effect-kind="moveClipTo"] node appears + unmounts
  - Effects toggle off → overlay detaches; further ops render nothing

Timing assertions are generous (500ms mount, 1.5s split unmount, 2.5s
move unmount) to accommodate RAF-scheduled state transitions without
being flaky. Animation quality itself is not asserted here — that's a
visual review, not a spec.
The previous StickFigure-based defaults felt stiff for two structural
reasons: (1) pose swaps between rendered SVG frames read as stop-motion
because there's no interpolation between poses, and (2) the causal
timing was inverted — the timeline data mutated at t=0 but the visual
"cut" landed 200-600ms later, so the split appeared before the action.

This rewrite:
  - Drops StickFigure entirely.
  - Adds a <Bear> character using two AI-generated WebP sticker assets
    (chop pose = arms raised, carry pose = arms outstretched). Assets
    are inlined as base64 data URLs from bears.ts so consumers get a
    zero-config import.
  - Every effect uses ONE bear image throughout — motion comes from
    CSS transforms + keyframes, never pose swapping. GPU-only path,
    stays 60fps.
  - defaultSplitEffect: at t=0 a bright light beam appears at the cut
    point (causal anchor — the beam IS the split), while the bear pops
    in above with arms cocked, swings down through the beam ~250ms in,
    and floats away. ~700ms total.
  - defaultMoveEffect: source-position ring pulse signals grab, ghost
    clip lifts into the bear's outstretched arms, both slide along a
    flat ease-in-out curve to the destination with intrinsic wobble,
    destination ring pulse signals drop, then fade. ~900ms total.

Design references (raw candidate JPEGs from the seedream generation
run) live under packages/effects/design/bears/ for future re-picks —
not shipped to npm.
…the real clip

Two changes, both from user feedback while iterating on the bear-based
choreography:

1. Slower durations for the testing phase so the choreography reads
   clearly frame by frame:
     splitClip:  700ms → 1400ms
     moveClipTo: 900ms → 1800ms
   Halve these back once the shape is locked in.

2. moveClipTo ghost redesigned so it looks like the real timeline clip
   being lifted, not a dashed drag-preview box. Previously it was a
   thin (55%-height) purple bar with a dashed border, which reads as
   "here's a placeholder while the bear walks" — but the user's mental
   model is "the bear lifts the actual video strip up and carries it".
   Now:
     • Ghost matches source-clip footprint exactly (full width AND
       height).
     • Solid purple gradient fill styled like real clip bars, with a
       small `clipId` label inside so it's recognisable as *this*
       specific clip.
     • Squash-on-arrival lands with a slight vertical squeeze then
       relaxes — the "put it down" cue.
     • New fading outline stays briefly at the source position (25%
       opacity by 25% through the effect, gone by 40%) to soften the
       fact that the underlying timeline data has already teleported.
       Bridges the causal gap between data mutation and visual arrival.
Fundamental rewrite of the AI-op feedback pattern. The previous overlay
approach was structurally capped: the DOM overlay could only paint a
ghost of the real clip, never the clip itself, so moves read as
"data teleported, mascot chased it" and no amount of tweaking made
that feel professional. This lands the animation INSIDE the canvas
renderer where the actual clip bars live.

Two low-level Timeline APIs on `@aicut/core`:

  animateClip(clipId, {
    fromStartMs, fromTrackIndex,
    toStartMs,   toTrackIndex,
    durationMs?,
  })
    Registers a per-clip render-time position tween. While active,
    `buildDrawState` interpolates (start, trackIndex) with an ease-out
    cubic and `drawTrackRow` picks up the overrides — the clip's data
    model is unchanged, only its painted position moves. TrackIndex is
    a float during transit so the clip physically slides between rows.
    The raf loop stays alive while any tween is in flight; finished
    entries auto-clear inside buildDrawState.

  flashCut(trackIndex, atMs, durationMs = 220)
    Registers a bright vertical flash on top of the clip body at
    (trackIndex, atMs) — the canonical "a cut just happened here" cue.
    Sharp 12% attack, long decay with a soft outward glow, alpha
    pre-computed per frame so drawing is stateless.

`DrawState` gained two matching read-only fields (clipPositionOverrides,
flashes) so the pure `draw*` functions stay stateless. A new
`drawFlashes` pass runs inside the tracks clip region so flashes
inherit the same scroll transform as the clips they mark.

@aicut/react `<Timeline>` primitive now subscribes to `editor.on
("operation", …)` and translates events into those two APIs:

  moveClipTo → animateClip from beforeProject rect → afterProject rect
  splitClip  → flashCut at (cut track, cut time)

Durations are temporarily generous (moveClipTo 560ms, splitClip 420ms)
for the current testing phase — halve back to ~280ms / ~180ms once the
shape is locked in.
…fault

Now that <Timeline> handles the primary AI-op feedback in-canvas via
animateClip / flashCut, running the bear overlay on top would be
double-drawing the same event. Change the default:

  - <AiCutEffects> DEFAULT_EFFECTS all `false`. Hosts get no overlay
    handlers out of the box; the bear is opt-in via an explicit
    `effects={{ splitClip: defaultSplitEffect, moveClipTo: defaultMoveEffect }}`
    prop.
  - defaultSplitEffect / defaultMoveEffect stay exported so hosts who
    want the playful vibe can wire them without pulling from internals.
  - API playground chip renames to "🧸 Bear overlay: on/off" and starts
    off — canvas animation is what visitors see first, matching the
    real product surface. Chip flips the effects prop between the
    opt-in map and undefined.
  - e2e spec rewritten for the new semantics: the overlay is empty by
    default; enabling the chip is what makes the bear effects mount.
… relax

Split now shows the clip being cleaved, not just a flash line landing.
The two new halves briefly pull apart along the cut edge in pixel
space, then gently close back into contact with the flash decaying
alongside. Reads as a physical cut rather than a lighting cue.

Core additions:

  DrawState.clipPositionOverrides value gained a `startPxOffset?: number`
  field — a pixel-space nudge applied to a clip's rendered startX after
  pxPerSec conversion. Effects author in screen pixels regardless of
  the current zoom, which is exactly what a "constant-width gap"
  wants.

  Timeline.animateSplit(leftId, rightId, cutAtMs, trackIndex, opts?)
  registers a physical-cleave animation for both halves at once. The
  gap follows an asymmetric envelope: fast open (0 → 35% ease-out) to
  a peak, then long relax (35% → 100% ease-out cubic) back to zero.
  Internally also fires flashCut over ~70% of the same window, so the
  cut line is bright while the gap is open and fades before the
  halves fully close.

  drawClipAt gained a `startPxOffset` parameter (defaults to 0) that
  drawTrackRow now threads through from the override map.

React primitive:

  splitClip operations now dispatch to animateSplit using the two
  newClipIds from result.data (falling back to flashCut only when
  those aren't present — defensive, since core always returns them
  on ok=true).

Testing-phase parameters: durationMs=520, peakGapPx=14 for slow visual
review. Ship at durationMs ~260 / peakGapPx ~7.
Bumped the testing-phase durations so the choreography reads clearly
during walkthroughs. Ship targets stay the same (~260-280ms).

  moveClipTo animateClip:  560ms → 820ms
  splitClip  animateSplit: 520ms → 780ms, peakGapPx 14 → 16
…media

Domain-neutral primitive: a clip can carry a `placeholder` field with
optional label / badge / progress. When set, the canvas paints a
distinct "not solid content" look (diagonal white stripes scrolling
at 32 px/sec over a deep-purple gradient, dashed border, centered
label with animated dots, top-right badge, optional bottom progress
bar) instead of thumbnails.

Motivation: AI-generated video is going to be a common source, so
you often want to reserve a slot on the timeline while the model is
still generating, then swap in real media on completion. Core stays
out of the AI semantics — it just knows "no playable media yet, show
the placeholder look". The host owns the meaning (AI generation, an
upload in flight, a queued render task, etc.) and drives label /
badge / progress via updatePlaceholder.

New Editor API:
  - addPlaceholderClip({ trackId, startMs, durationMs, label?, badge?,
    progress?, onOverlap? }) → EditResult<{ clipId, sourceId }>
      Reserves a slot and creates a synthetic MediaSource with
      duration=slot so downstream time-slot math stays uniform.
  - updatePlaceholder(clipId, { label?, badge?, progress? })
      Merges into the existing placeholder object; explicit undefined
      clears a field (e.g. progress removed → bar disappears).
  - replaceClipSource({ clipId, sourceUrl })
      Async — probes the URL, creates a real MediaSource, points the
      clip at it, clears placeholder. Preserves start + track index.

New error reason `not-media`: `splitClip` now rejects a placeholder
clip with this reason (nothing meaningful to split). Move / trim /
delete all work as normal since they're time-slot ops.

New OperationEvent kinds: `addPlaceholderClip`, `updatePlaceholder`,
`replaceClipSource` — subscribe via editor.on("operation", …) to
react to the placeholder lifecycle.

Canvas render (packages/core/src/timeline/draw.ts):
  - drawTrackRow branches to drawPlaceholderClipAt when clip.placeholder
    is present.
  - Stripe phase advances from performance.now(); Timeline.scheduleRender's
    heartbeat now keeps the raf loop alive while any placeholder is on
    screen, so the pattern crawls smoothly even without other animation.
  - fitLabel utility truncates the center text with an ellipsis via
    binary search so the label always fits the clip width.

New playground card `addPlaceholderClip` in examples/react-demo:
adds a placeholder, ticks progress every 220ms (simulated polling),
click "complete" to replaceClipSource with sample.mp4 or "cancel" to
deleteClip. Demonstrates the whole AI-generation lifecycle end-to-end.

Also fix a gitignore gap: scripts/.publish-creds.* was ignored on
main (added there in the CodeArtifact rename) but not on this branch.
Adding the rule so local publish tokens stay out of commits here too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant