Skip to content

Carry artist intent through generated assets - #252

Open
OmarB97 wants to merge 21 commits into
lightningpixel:mainfrom
OmarB97:fix/wire-persistent-artist-intent
Open

Carry artist intent through generated assets#252
OmarB97 wants to merge 21 commits into
lightningpixel:mainfrom
OmarB97:fix/wire-persistent-artist-intent

Conversation

@OmarB97

@OmarB97 OmarB97 commented Jul 28, 2026

Copy link
Copy Markdown

Summary

  • create a run-specific modly.intent.v1 record before the first Style/model stage
  • preserve source image, project, chosen Style label and prompt, Extra details, and Movement as distinct exact fields
  • carry the record after every successful image or mesh stage, failing loudly if context cannot travel
  • merge exporter metadata atomically instead of recreating the final sidecar
  • preserve tags and derived_from lineage while retaining the full intent history

This is stacked on #251 because it branches from the newest installed desktop build.

Verification

  • npm run build
  • npm run lint -- --quiet
  • npm test (23 Python tests and 94 Node tests)
  • npm run package -- --linux dir
  • installed the packaged build without restarting the operator's open Modly session
  • real desktop reference-image run: Style -> TRELLIS -> rig/animate -> optimizer -> GLB export
  • final sidecar retained both exact artist sentences, Style choice, project, geometry findings, operator override evidence, and every carried stage event
  • real uncertain run stopped after TRELLIS, persisted needs_clarification, and surfaced the exact question in the desktop UI without exporting or guessing
  • Library loaded the final rigged asset with tags, thumbnail, and two validated motion previews; catalog grew only by the four generated assets
  • compiled exporter probe preserved both the intent record and parent/root lineage

OmarB97 and others added 21 commits July 27, 2026 15:52
Rigged models (from the rigging extensions, or any GLB with clips) render
frozen in bind pose today: the viewer loads the glTF but never creates an
AnimationMixer, so the clips it just parsed never run. That reads as a
failed rig rather than a missing feature.

Create a mixer for models that carry clips, tick it from useFrame, and
dispose it with the model. When a file carries more than one clip, offer a
small picker so each motion can be reviewed without leaving the app.

Co-Authored-By: Claude Fable 5 <[email protected]>
Asset rows now render their pre-rendered <name>.thumb.png beside the
filename instead of showing bare text, falling back to the existing
text-only row when no thumbnail exists. The Library panel can also be
resized by dragging its edge, with the chosen width persisted across
restarts.
npm ci fails on main because the committed lockfile is stale against
package.json; npm install (what upstream CI itself falls back to) brings
it back in sync. Committing the result so ci is usable again on this
branch.

Co-Authored-By: Claude Fable 5 <[email protected]>
Ctrl/Cmd with +, -, or 0 now scales the whole window like a browser,
clamped to [-2, 4] and persisted to <userData>/ui-zoom.json so it survives
restarts. This extends the existing before-input-event handler (where the
macOS quit shortcut already lives) and calls webContents.setZoomLevel
directly, deliberately not CSS zoom: a CSS transform scales the rendered
pixels without telling the 3D viewport, so pointer math there (raycasting,
gizmo dragging) goes wrong as soon as the page is scaled.

Co-Authored-By: Claude Fable 5 <[email protected]>
The panel's resize handle was a 1px strip with no resting affordance —
easy to miss entirely. Widen it to 2px with a visible resting tint plus a
stronger hover/active highlight, mark it up as a proper separator
(role, aria-orientation, title) matching the Library panel's handle, raise
the width cap from 520 to 900 so wide parameter forms have room, and
persist the chosen width to localStorage (modly-panel-width) the same way
the Library panel already remembers its own width.

Co-Authored-By: Claude Fable 5 <[email protected]>
- Parameter labels used a truncating 5rem column that clipped anything
  longer than a couple of words; widen it to 8rem and let it wrap.
- The prompt textarea was a fixed 3-row, non-resizable box; make it 14
  rows by default and user-resizable (resize-y, 14rem min-height).
- Say plainly which Generate inputs are required vs optional: "Your
  sketch or photo -- required" and "Extra details -- optional".
- Add a text param type (a plain input, unlike the existing string type
  folder picker) so free-text fields like the exporter new name, project,
  and tags params render as usable text boxes instead of falling through
  to a numeric input.
- Render clickable tag-suggestion pills beneath any tags param: slugified
  words from that node typed name/project fields, plus a small fixed
  game-asset vocabulary, deduped and capped at 10. Clicking a pill toggles
  it in the comma-separated value; no network calls.

Co-Authored-By: Claude Fable 5 <[email protected]>
The clip picker was a raw select mounted via drei Html at the model
origin, recreating the whole AnimationMixer every time the clip changed
(a hard cut, not a blend) and easy to lose behind the scene. Replace it
with a proper bottom-left overlay component (MotionBar), matching the
existing ViewerToolbar/stats-overlay idiom:

- The mixer and active AnimationAction now live for as long as the model
  does; switching clips fades the old action out and the new one in over
  0.25s instead of recreating anything.
- GltfMeshModel reports its clip list up to Viewer3D (the same
  onStats/onObject callback pattern already used here), which owns the
  active index and renders MotionBar as a plain sibling of the Canvas.
- Zero clips renders nothing, exactly one clip shows its name read-only,
  more than one gets a labelled dropdown plus a Next button that cycles
  with a cross-fade. All playback loops.

Co-Authored-By: Claude Fable 5 <[email protected]>
The built-in exporter named every file export-<epoch-ms>.glb, so a folder
of work told you nothing. Add three optional params: model_name (used to
slug the file name, falling back to a dated model-YYYY-MM-DD-HHMM name),
project (files land under Exports/<project-slug>/ so a game groups
together instead of one long list), and tags. Output is always
<slug>-<4-char-unique>.<ext> so re-exports never collide.

Tags are the operator typed list plus what the file itself can tell:
rigged (has skins), animated plus clip-<name> per animation, textured,
and one of low-poly/mid-poly/high-poly by triangle count, plus the
project slug. Tags are written both as a <file>.tags.json sidecar and
embedded under the glTF asset extras (extras.modly), so they travel with
the file either way.

Ported from the already-working implementation at
/mnt/tank/modly/app/extracted/resources/builtin-extensions/mesh-exporter
on the operator machine.

Co-Authored-By: Claude Fable 5 <[email protected]>
The agent had no idea what was actually on screen beyond a mesh path and
triangle count -- it couldn't tell you which clip was playing, what the
model was named, or what game it belonged to. Extend both ends of that
pipeline:

- ChatPanel now mirrors the viewer's selected clip and full clip list
  (Viewer3D reports them into appStore alongside the existing
  meshStats/meshSelected pattern) into the /agent/chat context, so
  requests like "make the stroll slower" resolve without re-asking.
- agent.py resolves the current mesh's name/project/tags from its
  .tags.json sidecar, falling back to the GLB's own embedded
  extras.modly when no sidecar exists, and renders them into the system
  prompt in the same style as the existing scene-context lines.

Lineage: when mesh-exporter's input traces back to an existing workspace
asset, record derived_from in the sidecar -- the source's workspace-
relative path plus its name if it had one. The workflow runner threads a
sourceAssetPath through NodeOutput/ProcessInput from wherever a mesh
first resolves (the Load 3D Mesh node) down to whichever node eventually
exports, so multi-step chains still carry it. Re-deriving from an already
-derived asset keeps just the immediate parent plus the chain's root
(read from the parent's own derived_from, if any) instead of growing an
unbounded list. Surfaced in the chat context alongside name/project/tags.

Also fixes an open question: the rig-and-animate extension's "Describe
the movement" param sets multiline: true, a flag ParamField never
checked -- it fell into the plain "string" case and rendered as a single
-line input with a stray folder-browse button. ParamField now renders
any multiline-flagged param as a resizable textarea (matching the
existing "Extra details" box), independent of its base type.

Co-Authored-By: Claude Fable 5 <[email protected]>
Search now force-expands every section that contains a match instead of
requiring a manual Show click per group, and clearing the query falls
straight back to whatever was collapsed before (collapsedSectionKeys is
never mutated by the search itself).

The listing area now flexes to fill a resizable popup instead of being
capped at max-h-64 (roughly 3-4 rows) - a new bottom-edge drag handle
mirrors the existing width handle, with its own clamp/persist pair so
the height survives restarts the same way the width already does.

Also fixes the search input rendering as an oversized box with the
placeholder vertically centered in the middle of it - a known Chromium
input[type=search] quirk, fixed with appearance-none like the rest of
the app's inputs effectively already assume.
The panel width floor, default, and ceiling were all far too small
(260/320/560) for a panel that is now full-height - a thumbnail, a
readable name, its full workspace path, and its type badge could not
share a row without truncating. Raised to 380/640/960 and, since the
floor moved too, a panel already dragged down toward the old 280px-ish
minimum on a prior run now clamps back up to something usable on the
next launch instead of silently staying cramped.

Selecting a Library row and then separately clicking "Open selected
asset" is gone as a required two-step. A row click now selects and
opens in one motion via a shared openLibraryEntry() helper; the button
stays as a secondary affordance (useful to retry after an error) but
is no longer the only path. Non-openable entries still just select and
show their reason, same as before.

Hover previews: on hover, a row with a rendered preview manifest swaps
its static thumbnail for a looping WebP clip, with a small mini-select
control to cycle clips when more than one exists - lazily fetched and
cached per row, never fetched for rows with no manifest. This reuses
the existing workspace:library:thumbnail IPC channel rather than adding
a parallel one: an optional previewClip request field fetches a named
clip's WebP instead of the static thumbnail, and the default (no
previewClip) response now also carries the manifest's clip list when
one exists. The manifest's file field is trusted only as a filename
(basename-only) and re-resolved through the same
normalizeWorkspaceAssetPath guard every other workspace read uses, so
a malformed or stale manifest can never point outside the asset's own
directory. No preview manifests exist yet (that pipeline is separate,
in flight elsewhere) so today this is inert: previews stays empty and
every row behaves exactly as it did before this change.
Text typed into "Extra details" was assigned straight onto the node's params
and spread after them, so it overwrote the selected style's prompt entirely.
Choosing a Creature style and then asking to keep one tentacle short deleted
every word about being a photographic creature; the generator received a
cropped notebook drawing and faithfully rebuilt it as a flat card.

The flatness was blamed on decoder resolution and mesh budgets for a long
time. Neither was involved: the failed mesh measured 0.030 flatness with
29,745 faces, cascade resolutions reached only 0.068, and a properly styled
input at the same settings and seed reached 0.369.

mergeNodeText now combines the two, and keeps the fuller text when one already
contains the other — collapsing to the shorter note would throw the style away
again, which a test caught before this shipped.

Co-Authored-By: Claude Fable 5 <[email protected]>
Thin features like blades, wires and tentacles come out shredded at the normal
setting, and the detector for them was silently raising the setting on the
artist's behalf. It is not reliable enough for that: it fired at 0.99 on a
squid sketch and gave opposite verdicts on two crops of the same lamp.

It now offers the choice, says why, and states what it costs — about a minute
instead of about twenty-five seconds — and stays quiet when it is unsure. It
never blocks a run.

Co-Authored-By: Claude Fable 5 <[email protected]>
Answering the rigger's clarification question re-ran the workflow from the
top. The style step redraws rather than edits, so the artist answered a
question about a model they would never see again — and the rigger had already
worked out the answer before it stopped.

The machinery to resume from a single step was already there and unused.
Wording is now about the step at hand rather than branches, which is not
vocabulary a game designer should have to learn.

Co-Authored-By: Claude Fable 5 <[email protected]>
Ctrl/Cmd +/- and Settings' "Interface Scale" were separate systems for the
same thing, each with its own stored value and neither aware of the other.
The main process restored the saved zoom level when the window loaded, then
the renderer mounted and overwrote it with the scale preset — so the keyboard
setting was lost on every restart and had to be redone by hand each launch.

There is now a single saved factor. The main process still catches the chord,
because Chromium claims it before the page can see it, but it only forwards
the direction. Settings shows the real percentage rather than a preset switch
that would have to lie whenever the keyboard landed between presets.

Co-Authored-By: Claude Fable 5 <[email protected]>
Painting a texture in an external editor now updates the model in the
preview without a rebuild. Measured at about 63ms from save to visible
change, with nearest-pixel sampling preserved through every reload.

A malformed or wrong-sized save keeps the previous image and shows a
plain explanation rather than silently going stale, because a stale
texture that looks fine is the worst outcome here.

PSD was investigated and left out: Electron decodes only PNG and JPEG,
and partial PSD support risks confidently showing the wrong image.
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