# Widget spec — `picsart_asset_sheet`

> **DISABLED 2026-09-05 — not deleted.** The tool is no longer registered on the
> server (`asset-sheet.tool.ts`: the `@Tool` decorator is swapped for the no-op
> `DisabledTool`; the widget bundle, schema and resource stay in the tree). The
> film's asset flow is one gridded sheet per asset approved on
> `picsart_asset_review`; that approval is the lock, and the save tool no longer
> requires a `stressTest`. The spec below is kept as the record of the board.

Workflow board. The asset passport as one editable sheet: the descriptor with
line-level rewriting, references **in named slots**, and the consistency check
that decides whether it locks. Refit of `character-studio` (its turnaround
generation and Drive library carry over); extended to locations and props.

Rebuilt 2026-08-25 against an approved mock. The board it replaces had three
CTAs at one altitude — `Iterate`, `Lock vN`, `Reject` — where the loudest
button was the one almost always disabled (`Lock` needed a clean battery), the
one you actually used was the quietest and named after nothing a filmmaker
says, and the only always-live button was destructive with no confirm. Beneath
them a `w-20` strip asked whether a face was the same person, on 80px
centre-cropped thumbnails, with the reference nowhere on screen. See *What
changed and why* at the end.

## Tool input

```ts
{
  tag: string,                       // '@cal'
  assetType: 'character'|'location'|'prop',
  sheetVersion: string,              // 'v2'
  status: 'draft'|'review'|'locked',
  descriptor: Array<{ line: string, text: string }>,
                                     // keyed lines: 'face.eyes', 'costume.jacket', 'palette', …
  images: Array<{
    url: string,
    angle: string,                   // 'close 3/4', 'full body, neck-down'
    role?: 'face'|'body'             // characters
         | 'hero'|'reverse'          // locations
         | 'alone'|'scale'           // props
         | 'extra',
    previousUrl?: string,            // the render this one replaced
    variantOf?: string,
  }>,
  variants?: Array<{ tag: string, note: string, status: string }>,
  stressTest?: { passed: number, of: number,
                 failures: string[],
                 waived?: string,    // set when a caller already recorded a skip
                 shots?: Array<{ url: string, label: string, ok: boolean }> },
  voice?: {                           // characters only; locks with the passport
    mode?: 'preset'|'design',
    voiceId?: string, model?: string,  // 'preset': the id IS the identity
    description?: string,              // 'design': the words, and…
    sampleUrl?: string,                //   …the sample, which IS the identity
    nonSpeaking?: boolean,             // the recorded alternative to a voice
  },
  scenes: string[],
  imageModels?: Array<{ id: string, why?: string }>,     // from a live catalog read
  projectFolderUid?: string,          // film.json.drive.projectUid
}
```

**`projectFolderUid` names the film every sheet on the board belongs to.** It
rides back out on the feedback so the passport is filed in the right film:
`status` picks which part — `locked` promotes it into that project's `Library/`,
anything else files it under `Assets/` with the working record. Persistence is
not the point of that distinction — everything is already persisted — curation
is.

**There is no Save button, on purpose.** It was removed (2026-08-26): saving is
not something a user should have to press. Renders land in the film's folder as
they come back, approval files the passport, and the lock promotes it — every one
of those through `picsart_save_asset` from the assistant, the only writer of the
registry. The button was a second, hand-operated writer that saved the draft's
edits at whatever status the sheet happened to carry, without the assistant
knowing, so the registry could hold one descriptor while the lock recorded
another.

**`role` is the load-bearing new field.** It is what puts the identity anchor in
its own slot instead of a grid of equal peers — a close 3/4 owns the face, a
**neck-down** full body owns the costume, because a small face on a wide still
poisons identity (`../../skills/film-assets/references/ASSET-SHEETS.md`). Omit
it and the widget infers a role from the angle name, so old payloads still fill
their slots; name it and the sheet stops guessing.

**`imageModels` is never hardcoded.** Read the catalog and pass what is current
— the pipeline's model policy is checked, not remembered. Omit the field and the
re-render panel offers no model choice at all, which is the right default: the
same model tries again.

## Layout

- **Header**: tag, type, version, status; `in sc01, sc02, sc03 — every shot
  reuses this sheet`; then the state-variant row (base + each variant, `+ new
  state`).
- **Descriptor** — heading per type (*Who they are* / *What the place is* /
  *What the object is*), `tap a line to rewrite just that line`. Editing one
  line opens it inline with `Save` / `Discard` and records a surgical
  `from → to`. **Whole-descriptor rewrite is deliberately not offered** — that
  is also the one-variable rule: a five-line rewrite teaches nothing about which
  line fixed the drift.
- **References** — the two named slots first, the anchor wider, then *More
  angles*. Both slots share one height so their captions align; the anchor reads
  as primary through width and position. **An empty slot states its own rule**
  ("missing — in a hand or on a table, this is the image that anchors its size,
  because stated numbers get ignored") with `ask for it`. A render that replaced
  another carries a `was` chip.
- **Change panel** (per image) — two kinds, and the choice is the point:
  - **Fix one detail** — inpaints that region, keeps everything already right.
    The default. *No model choice is offered here*: swapping models to fix a
    collar re-rolls the identity too.
  - **Render it again** — a full pass. Only this kind may name a model, from
    `imageModels`, and picking one says so ("this round only swaps the model").
- **Consistency check** — the score, the per-type question, and the frames the
  asset was put through, each taking its whole grid cell. Tap one to flip its
  verdict. **Nothing is pinned beside a frame.** By the time this check runs the
  user has generated the asset, generated its angles and approved them — the
  references sit one section up on this same sheet — so a reference copy beside
  every frame repeats what they already know while halving the width of the only
  image under judgement. Size was the problem worth fixing, not proximity.
  Marking one broken asks **what broke**, and the answer is **recorded in place
  and nothing scrolls**: a small note says what it buys ("→ the `face` line gets
  tightened on the next pass"), and *blends with the other character* files the
  separate-and-composite rescue as a request. The cause travels in `breaks[]` and
  the next pass does the tightening — moving the user to the descriptor mid-triage
  only loses their place among the frames they were still judging. Below: harder
  conditions for the next round (`night exterior`, `back to camera`, …).
  - **Never run** → `Skip it — I will take the risk`, which requires a typed
    reason and reads back as `Skipped — <reason>`.
- **What goes back** — every queued change as a labelled row (`line`, `detail`,
  `re-render`, `new angle`, `breaks`, `also test`, `variant`) with a `×`. The
  payload is never a surprise.
- **Action bar** — **one primary, and it names what it will do**:

  | State | Footnote | Primary |
  |---|---|---|
  | a frame breaks | `1 frame still breaks — say what broke, or send the sheet back as it stands.` | `Send it back` / `Send N back` |
  | regenerable work queued | `Your edits go back together as one pass.` | `Send N back` |
  | clean check, nothing queued | `They hold up everywhere — lock @cal in.` | `Lock v2` |
  | check waived | `Check skipped — lock @cal on your word.` | `Lock v1` |
  | no check, no waiver | `Nothing to send yet — the consistency check has not run.` | *none* |
  | locked | `Locked at v2 — a change needs a new version.` | *none* |

  `Lock` is not a button you hunt for and find greyed out — it appears when
  locking is actually true. `Clear my changes` is the only other control, and
  only while the draft holds something.

**Verdict overrides do not block the lock.** Flipping the last red frame to
green is the user saying "this holds", and the recomputed score travels on the
lock payload either way — counting it as pending work would answer them with
"send your opinion back for another round" and hide the `Lock` they just earned.
Edits, renders, new angles, variants and harder tests *do* need a round trip.

**There is no `Reject`.** The sheet has two exits. Recasting an asset is a
conversation ("recast @cal"); deleting one is the library's job, where it has a
confirm that names what it removes. Neither belongs on a button sitting next to
`Lock` that fires without one.

## One board, the whole cast

`picsart_asset_sheet` takes **`sheets: [{…}, {…}]`** — every passport up for
review in one call. The board tabs them (each tab showing its own standing:
locked ✓, coming back ↩, or still undecided), holds a separate draft per asset,
and sends **one** message covering all of them. It refuses to send while any
sheet is undecided, so nothing is settled by silence.

A call per asset produced a card per asset, and the reviewer then had to answer
each one separately — the defect this shape exists to fix. One asset is the same
call with one entry, and its footer collapses back to the single primary action
it always had, because deciding and sending are then the same act.

The film's `projectFolderUid` is stated once for the board; every sheet on it
belongs to the same film.

## Feedback → `asset_sheet_feedback` **v3**

```ts
{
  type: 'asset_sheet_feedback',
  version: 3,
  total: number,                 // === sheets.length, always
  sheets: Array<{
    tag, assetType, sheetVersion,
    verdict: 'locked' | 'iterate',
    descriptorEdits: Array<{ line, from, to }>,
    renders: Array<{ angle, kind: 'point-edit'|'re-render', note, model? }>,
    newAngles: string[],
    newVariant?: { tag, note },
    stressTest?: { passed, of, failures, waived? },
    breaks?: Array<{ frame, cause }>,
    extraTests?: string[],
    generalComment?: string,
  }>,
}
```

Changes from v2: the per-asset body above is **unchanged** — it moved from the
top level into `sheets[]`, under a `total`. A one-asset board sends `sheets` of
length 1 rather than a different shape, so there is one contract to read, not
two. Each `iterate` sheet is its own independent set of regenerations; the batch
is a convenience for the reviewer, never a licence to blend several assets'
notes into one prompt.

Changes from v1:

- **`rejected` is retired** (see above).
- **`angleRequests: string[]` → `renders[]` + `newAngles[]`.** A detail fix now
  arrives typed, so the reader can honour the rule instead of guessing from a
  string: point edits are mask composites on the approved image, never a full
  pass and never on a different model. The instruction text says so explicitly.
- **`breaks[]`** carries why each frame broke — what turns a score into a named
  fix.
- **`stressTest.waived`** and **`extraTests[]`** are new. A `locked` verdict
  carrying `waived` tells the reader in plain words that the check was *waived,
  not passed*, and to record the reason on the registry row.

`stressTest` is **not** an echo of what you passed: `passed`/`of`/`failures` are
recomputed from the frames plus the user's flags (`stressVerdict` in
`stress.ts`), so a frame they marked broken comes back in `failures`. A partial
check keeps its denominator and its unshown verdicts — a declared 9/10 showing
two frames stays 9/10, it does not shrink to 1/2.

**The lock gate** (`lockGate` in `stress.ts`, unit-tested):
- live failures → never lockable, **waived or not**;
- no check at all → not lockable until a reason is recorded;
- otherwise → lockable.

## Build notes

From character-studio, keep: `TurnaroundModal`/`turnaround.ts`, Drive
persistence (folder-per-asset, folder uid = asset id, manifest in file
attributes — mind `[[project-drive-folder-listing-quirk]]`), voice preview.
Dropped in the rebuild: `AngleGrid` (→ `RefSlots`), `StressStrip`
(→ `ConsistencyCheck`; its `stressVerdict` moved to `stress.ts` unchanged).
`roles.ts` holds the slot definitions, the angle-name inference, and all
per-type copy — the sheet speaks about a person, a room or an object, never
about "the asset".

Server: `images[]` items and the new `imageModels` live in
`AssetSheetOutputSchema` (`server/src/mcp/shared/output-schemas.ts`). The item
schema needed `.passthrough()` — without it zod strips `role` and `previousUrl`
and the explicit path is dead code. `manifestToSheet` names the manifest
portrait's role so a library-rehydrated sheet fills the same slots as a fresh
one.

Previews: `asset-sheet` (character, one frame breaking) and
`asset-sheet-nocheck` (prop, no check run — the waiver path and the per-type
copy).

## Still open

- **Rights / consent and provenance.** The handbook calls a rights record the
  minimum for any *reusable* AI asset, and a consented likeness card is exactly
  this sheet with consent attached. The re-render panel already *takes* a model
  choice and keeps no record of which model made which image. Deferred
  deliberately: the complaint that drove this rebuild was overload.
- **Crowds** have no `assetType`, though the craft doc treats a crowd as a
  first-class asset with a collective descriptor and an exact count.
- **`WIDGET-FEEDBACK.md`** still documents v1. Its entry needs the v2 shape —
  left for after the in-flight `video_review` edits in that file land.
