Skip to content

feat(webv2): direct-input media widgets for ImageField and VideoField workflow fields - #67

Open
lstein wants to merge 3 commits into
mainfrom
fix/webv2-media-field-inputs
Open

feat(webv2): direct-input media widgets for ImageField and VideoField workflow fields#67
lstein wants to merge 3 commits into
mainfrom
fix/webv2-media-field-inputs

Conversation

@lstein

@lstein lstein commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Found while testing the MiniMax H3 port (#56#66) in the webv2 workflow editor, but this is core editor functionality affecting every media-consuming node (Wan video nodes included):

  1. VideoField inputs rendered as "Connection only" — and since the Video primitive's own field is a VideoField, there was no way to introduce a video into a graph at all. Every video node (Frame Range from Video, Concatenate Videos, Wan i2v/extend, MiniMax H3) was a dead end.
  2. ImageField had a minimal widget — filename text plus "Use gallery selection", with no thumbnail, no drop target, no upload.
  3. The frame-picker hint was dropped on the floorui_component=video-frame-index (used by Frame from Video's frame_index and Frame Range from Video's start_frame/end_frame) parsed to null, so those fields rendered as bare number inputs with no way to see which frame you were picking.

Both media field types now share one MediaInput widget:

  • Large preview matching the legacy editor's widget: full-width 128px area showing the item's frame thumbnail at contained size, with a dimensions badge (WxH, plus duration for videos) and a dashed drop-hint empty state. The whole preview is the drop target; a media icon stands in when a stale name 404s.
  • Use gallery selection, kind-matched — a selected image is never offered to a video field and vice versa.
  • Upload: hidden file picker → the existing gallery upload routes (/images/upload, /videos/upload) targeting the gallery's selected board, then the field adopts the uploaded item. Async work is account-scope guarded like the rest of the app.
  • Drag-and-drop: the input row is a dnd-kit drop target for a single dragged gallery item of the matching kind, with a DropZone overlay during eligible drags. Multi-item drags are rejected outright rather than silently keeping an arbitrary first item. Drop ids are instance-unique so the node editor and Linear UI panel can render the same field simultaneously.

VideoField joins the stateful field set with video_name value validation, which also makes it exposable in Linear UI forms exactly like ImageField.

And video-frame-index integer fields get the legacy editor's frame scrubber: the number input plus a live frame preview (a muted <video> seeked to frame / fps — the browser decodes the exact frame natively) and a slider, all writing the same field value. The widget reads its companion video field from the same node via a new optional nodeId prop on WorkflowFieldInput (both the node editor and the Linear UI panel pass it, so scrubbing works for exposed form fields too). Negative indices (-1 = last frame) are resolved for display only; the stored value is preserved verbatim and the backend stays authoritative.

New gallery surface (thin re-exports of existing internals): galleryVideoUrls, galleryTransfers.uploadVideo.

Review

Each commit got a fresh-context adversarial review; all confirmed findings are fixed in this diff.

Media widgets round — attacked cross-monitor drop interference (field vs board vs canvas handlers on the same DragEnd), drop-id collisions (editor + Linear panel, StrictMode), the upload lifecycle, image-widget regressions through the legacy selection fallbacks, and every consumer of the stateful/exposable/validation helpers:

  • Upload completion could land in the wrong project (HIGH): onChange dispatches into the active project, so an upload finishing after a project switch would churn the other project's graph, auto-flip its invoke route (setFieldValue is a high-confidence edit), and silently drop the adopted value — same class of bug the canvas import already pins against. Adoption is now gated on the widget still being mounted and the project id captured at upload start; otherwise an info toast points at the gallery. This also covers "node deleted mid-upload".
  • Upload never refreshed the gallery (MED): now calls invalidateGallery like every other upload path, so the uploaded file actually appears on the board it was sent to.
  • COLLECTION media fields (MED): list-typed inputs (e.g. Concatenate Videos' videos) reachable through legacy exposedFields migration would have rendered the single-value widget and written a bare object into a list; and the new video_name validation would have flipped persisted array values from valid to invalid. Both media widget cases now render connection-only for COLLECTION, and validation keeps the generic non-null check for COLLECTION values (this also fixes the same latent defect for ImageField arrays).
  • LOW: broken-thumbnail glyph for stale names (hidden via onError, name still shown); files picked through "All Files" with a mismatched MIME type are rejected client-side instead of round-tripping to a server 422; the unused video drag-data exports were dropped.

Frame scrubber round — attacked nodeId staleness across delete/paste/undo, the react-query cache entry shared with the media badge, seek-effect listener lifecycle, slider math edges, controlled-slider feedback loops, undo-history flooding while scrubbing, and react-flow drag/keyboard capture:

  • Connection-driven video fields previewed a stale video (MED): connecting an upstream video does not clear the field's stored value, so the scrubber kept previewing (and clamping against) the old video while invoke used the upstream one — with the video widget itself hidden, there was no cue. The scrubber now consults resolved edges (the same "connected" signal the editor and Linear panel use) and shows an explicit hint instead. The legacy editor's widget has this bug; the port fixes it.
  • LOW: single-frame videos gave the slider min === max, which zag renders as NaN% CSS — the preview now renders without a slider; a deleted video got a misleading "no probed frame rate" hint — it now says the video could not be loaded; empty-string video_name fired a guaranteed-404 lookup (/videos/i//…) — treated as unset, and the same latent flaw was fixed in the media widget's badge query.
  • Accepted as designed: preload="auto" fetches the video per scrubber instance (legacy parity — the backend serves Range requests); a value scrubbed on a long video then re-pointed at a shorter one displays clamped while the document keeps the raw value (the number input shows it, and the backend validates at invoke).

Deliberately unchanged: uploads while a date board is selected land in Uncategorized (the backend's own mapping) rather than refusing like the gallery grid does — with the invalidation fix the result is at least visible; happy to align it with the grid's refusal if preferred.

Testing

  • pnpm test, pnpm test:browser (491 incl. 13 new: media widget render/selection/badge/clear/upload/type-mismatch/COLLECTION, scrubber render + negative-index resolution, keyboard scrub writes the index, connection-driven suppression, single-frame no-slider, deleted-video hint, empty-name guard), pnpm lint (format, oxc, tsc, architecture check) — all green; vite build clean.
  • New unit tests for the drop resolver (single-item/kind-match/multi-drag rejection), VideoField value validation, and ui_component parsing passthrough.
  • Manual: drag a gallery video onto Frame Range from Video's video field; upload a video from the node; same for Image Primitive with drag/upload/selection; scrub start_frame/end_frame and confirm the previewed frames match the extracted range.

🤖 Generated with Claude Code

… workflow fields

VideoField was connection-only in the workflow editor - and since the Video
primitive's own field is a VideoField, there was no way to introduce a video
into a graph at all (every video-consuming node: Frame Range from Video,
Concatenate Videos, Wan/MiniMax video nodes). ImageField had a widget but no
thumbnail, drop target, or upload path.

Both now share a MediaInput widget:
- thumbnail + name of the current item;
- 'Use gallery selection' (kind-matched: a selected image is not offered to a
  video field);
- 'Upload' via a hidden file picker -> gallery upload (image/video routes) to
  the gallery's selected board, then adopts the uploaded item; account-scope
  guarded like the rest of the app's async effects;
- the whole row is a dnd-kit drop target for a single dragged gallery item of
  the matching kind (multi-item drags are rejected outright rather than
  keeping an arbitrary first item), with a DropZone overlay during eligible
  drags. Instance-unique drop ids keep the node editor and Linear UI panel
  from colliding when both render the same field.

VideoField joins the stateful field set and gains value validation
(video_name), making it exposable in Linear UI forms like ImageField. New
gallery surface: isGalleryVideoDragData, galleryVideoUrls,
galleryTransfers.uploadVideo (all thin re-exports of existing internals).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gets

Match the legacy editor's ImageField/VideoField widgets: a full-width 128px
preview showing the item's frame thumbnail at objectFit-contain size (the
whole area is the drop target), a dimensions badge (WxH, plus duration for
videos, resolved best-effort via the gallery item lookup), a dashed
drop-hint empty state, and a media icon fallback when the thumbnail 404s.
The selection/upload/clear buttons move to a compact row below the preview.
No changes to the drop/upload/adoption mechanics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the root label Aug 7, 2026
Port the legacy editor's VideoFrameIndexFieldInput to webv2: integer
fields marked ui_component=video-frame-index (frame_index on Frame from
Video; start_frame/end_frame on Frame Range from Video) render the
number input plus a live frame preview (a muted <video> seeked to
frame / fps) and a scrubber slider, all writing the same field value.

The widget reads its companion 'video' field from the same node via a
new optional nodeId prop on WorkflowFieldInput (both the node editor
and the Linear UI panel pass it), and resolves fps/duration/URL through
the same gallery item lookup the media widget's badge uses. Negative
indices (-1 = last frame) are resolved for display only; the field
value is preserved verbatim.

Degradations (adversarial-review findings): a connection-driven video
field suppresses the scrubber via resolved edges instead of previewing
the stale stored value (the legacy widget gets this wrong); single-frame
videos render the preview without a slider (min === max is NaN% CSS in
zag); deleted videos get a 'could not be loaded' hint instead of a
frame-rate story; empty-string video names never fire a lookup (also
fixed in the media widget's badge query).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants