Derived clips: link MP4 versions, list them apart, video Enhance output

Groundwork for Enhance "Animate" (#208): a video made from a photo.

- Version linking reads the XMP link from MP4 / MOV too (top-level uuid box
  with the XMP UUID, mdat seeked over), at ingest and on Enhance approve.
- New kind `animated`, a derived clip (CLIP_KINDS): hidden behind the photo
  like a version, but not counted in photo_version_counts / version_count,
  never a display choice (400 clip_not_displayable), and listed as `clips`
  in GET /image/versions. Approve returns the clip entry.
- Templates whose imageapi:save is ImageApiSaveVideoVersion report
  output "video" in /enhance/templates (no enhance.toml change). Previews
  already relay ComfyUI's content type (video/mp4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-11-01 19:38:56 -05:00
1 parent 31c1b14d61
commit deb3d051ac
8 files changed
+308 -31

No files matched your search

+18 -8
View File
@@ -360,7 +360,12 @@ hash — design it alongside Branch D.
A **version** is a non-destructive restoration, upscale or edit of a photo that
an external process (the ComfyUI node in `comfyui-imageapi-versions`) writes next
to the original, as `<dir>/aiedits/<original name>.<kind>[.<n>].png`. ImageApi
to the original, as `<dir>/aiedits/<original name>.<kind>[.<n>].png`. A
**derived clip** (kind `animated`, `CLIP_KINDS`) is a video made from a photo
(Enhance "Animate", `<name>.animated[.<n>].mp4`): linked, hidden and lifecycled
exactly like a version, but never counted as one (`photo_version_counts`,
`version_count`), never a display choice (`PUT /image/versions/display` answers 400
`clip_not_displayable`), and listed apart, as `clips` in `GET /image/versions`. ImageApi
only detects it (library roots stay read-only) and shows original + versions as
**one photo**; a *global* choice (not per user) says which file is displayed.
Code: `src/database/version_dao.rs` (tables, DAO, SQL predicates),
@@ -376,8 +381,9 @@ for the `link_versions` binary).
global choice; no row = the original, and a display file with no live row reads as
the original.
- **Linking at ingest** (`watcher::process_new_files`, before any derived work):
(1) a PNG's XMP `xmpMM:DerivedFrom/stRef:documentID = blake3:<hex>` (iTXt chunk
`XML:com.adobe.xmp`, read by hand, no decode); (2) the file-name convention next
(1) the file's XMP `xmpMM:DerivedFrom/stRef:documentID = blake3:<hex>`: a PNG's
iTXt chunk `XML:com.adobe.xmp`, or an MP4 / MOV's top-level `uuid` box with the XMP
UUID (both read by hand, no decode; `mdat` is seeked over); (2) the file-name convention next
to an indexed original in the same library (same dir, or the parent of
`aiedits/`). Name links are retried every tick (`sweep_name_links`, paged by a
per-library resume cursor that wraps, so candidates that never resolve can't starve
@@ -455,7 +461,9 @@ auth and stays LAN-only, so ImageApi relays previews too.
`file`, `prompt_mode`, `prompt_join`, `output_megapixels`).
- **Templates** are API-format workflow exports, read-only (never edit them from
here). Nodes are found by `_meta.title`: `imageapi:source` (required, gets the
photo path), `imageapi:save` (required, forced to `require_approval = true`),
photo path), `imageapi:save` (required, forced to `require_approval = true`; a
`ImageApiSaveVideoVersion` there makes the template's `output` `video`, an MP4 clip,
else `image`),
`imageapi:prompt` (optional; fills the first of `text` / `prompt` / `value`),
`imageapi:reference` (optional). `prompt_mode = "append"`: the user's text is joined
after the template's own prompt with `prompt_join` (default one space); empty text
@@ -503,7 +511,7 @@ GET /enhance/templates
"templates": [{"name","label","description","accepts_prompt",
"prompt_mode": "append"|"replace", "prompt_required",
"accepts_reference","default","output_megapixels",
"warn_above_megapixels"}]}
"warn_above_megapixels", "output": "image"|"video"}]}
POST /enhance {"path","template","prompt"?,"reference_path"?,"library"?} -> job
GET /enhance/jobs/{id} -> job
GET /enhance/jobs?path=&library= -> {"jobs": [job, ...]} newest first, discarded hidden
@@ -511,8 +519,9 @@ GET /enhance/prompts?templates=a,b&limit=10 (templates optional = all; limit 1
-> {"prompts": [{"prompt","template","last_used","uses"}]} distinct trimmed prompts, newest
use first; template/last_used from the newest job; discarded + failed jobs count;
DB only (no library scope, answers even when ComfyUI is down or Enhance is off)
GET /enhance/jobs/{id}/preview/{n} -> the staged PNG (relayed from ComfyUI /view)
POST /enhance/jobs/{id}/approve {"n"} -> a /image/versions `versions[]` entry
GET /enhance/jobs/{id}/preview/{n} -> the staged PNG or MP4 (relayed from ComfyUI /view,
with its content type)
POST /enhance/jobs/{id}/approve {"n"} -> a /image/versions `versions[]` (or `clips[]`) entry
POST /enhance/jobs/{id}/discard -> job (hidden from the list; ComfyUI untouched)
job = {"id","status","queue_position"?,"error"?,"path","library_id","template",
@@ -737,7 +746,8 @@ POST /image/tags/bulk (many files, one transaction: { file_names, add: [names
GET /image/versions?path=...&library=... (path of the original or any version ->
{ original:{path,library_id,hash}, versions:[{path,
library_id,hash,kind,source,created_at,references,
width,height}], display_hash })
width,height}], clips:[same shape, kind animated],
display_hash })
POST /enhance ... (Enhance on demand; see "Enhance on demand")
PUT /image/versions/display (body { path, display_hash|null, library? }; global choice)
// /photos hides versions behind their original (include_versions=true lists them) and adds