Phase 6 — Hero and iterations¶
Status: Implemented (P6.1–P6.3)
ROADMAP tasks: P6.1, P6.2, P6.3
ADRs: docs/adr/0012-idempotency-and-resumable-runs.md, docs/adr/0011-content-addressed-assets.md, docs/adr/0010-provider-plugin-architecture.md, docs/adr/0018-shared-models-live-in-core.md
Scope¶
Generate a hero thumbnail for one video as N iterations, show them, pick one, export, and refine through child iterate runs that use the picked image as reference.
- P6.1
core/services/hero.py(HeroService),cli/thumb.pygenerate. - P6.2
thumb pick|show|export. - P6.3
core/services/iterate.py(IterateService),thumb iterate.
Non-goals¶
- Playlist batches and resume — Phase 7. Hero runs are small (default N=4) and are not resumable; a failed hero run is re-issued.
- Provider-side variations/seeds beyond what
capabilities.supports_seedallows.
Interfaces¶
Collaborators arrive as Protocols declared beside the service, as FetchService does (core imports no other package); the names in the signatures are the adapters the CLI passes in. core cannot name ORM rows, so persistence is one HeroStore Protocol, satisfied by storage.runs.RunRepository, which also writes the raw and final assets; the service returns counts and the CLI reads the stored rows back to render them (as fetch does), so thumb generate and runs show print the same view. registry is providers.registry; renderer is templates.loader.PromptRenderer; finalize is imaging.finalize.render_final with output bound from [output].
class HeroService:
def __init__(self, store: HeroStore, registry: ProviderRegistry, renderer: TemplateRenderer, finalize: Finalize, *, logs_dir: Path) -> None: ...
async def generate(self, spec: RunSpec, *, progress: ProgressSink) -> RunResult
# RunSpec(video_id, template_ref, provider_key, n, concurrency, seed, vars, out_dir, provider_params,
# kind, parent_run_id, reference, prompt_append) # the last four are set by IterateService
# RunResult(run_id, status, iteration_ids, completed, failed, compliance_failed, reference_ignored)
# .exit_code -> ExitCode, .error() -> the ThumbforgeError the CLI raises after printing
class IterateService:
def __init__(self, store: IterateStore, hero: HeroService) -> None: ...
async def iterate(self, ref: str, *, n: int, concurrency: int, prompt_append: str | None, vars: Mapping[str, str], from_picked: bool, progress: ProgressSink) -> RunResult
# `ref` is a run id or an iteration id; `IterateStore.resolve_source` (a `RunRepository` method) turns it into
# IterateSource(parent_run_id, video_id, template_ref, provider_key, provider_params, reference: AssetInfo).
# The child is built as a `RunSpec` with `kind`, `parent_run_id`, `reference` and `prompt_append` set, and run by `HeroService.generate`.
# storage.runs.RunRepository, over ORM rows, so `core` does not name them
def pick(run_id: str, target: int | str) -> Iteration # ordinal or iteration id
def resolve_target(ref: str) -> Run | Iteration # a run id or an iteration id
def export(target: Run | Iteration, to: Path, *, raw: bool) -> list[Path]
Idempotency key (PLAN.md §6, verbatim): sha256(template.spec_hash + provider_profile.id + video.youtube_id + str(part_number) + rendered_prompt + str(seed) + reference_asset.sha256)[:32], stored on iteration.idempotency_key UNIQUE. For hero runs part_number is "" and the ordinal is folded into seed (or str(ordinal) when the provider has no seed) so N iterations get N keys. Iteration k of --seed S uses seed S + k - 1 (S defaults to 0) when the provider supports seeds. A hero run is neither deduplicated nor resumable, so its key also folds in the run id as a final term: without it, re-issuing a failed run (the intended recovery) would collide on iteration.idempotency_key UNIQUE.
Commands (PLAN.md §5.2):
| Command | Key flags | Output | Exit |
|---|---|---|---|
thumbforge thumb generate <video> |
--template NAME[@VERSION], --provider KEY, --n 4, --concurrency 1, --seed N, --var key=value (repeatable), --out DIR |
creates hero run + N iterations; preview grid; prints run id |
0, 3, 4, 5, 6 |
thumbforge thumb iterate <run\|iteration> |
--n 4, --prompt-append TEXT, --var …, --from-picked |
child run (kind='iterate', parent_run_id), same template/provider; reference = picked or given iteration's raw asset |
0, 2, 3, 4, 6 |
thumbforge thumb pick <run> <ordinal\|iteration-id> |
marks picked; un-picks siblings | 0, 2, 3 | |
thumbforge thumb show <run> |
--columns 2 |
preview grid with ordinals, picked marker, compliance status | 0, 3 |
thumbforge thumb export <run\|iteration> |
--to DIR, --raw |
copies the final (or raw) asset of every completed iteration of a run, or of one iteration, into DIR | 0, 2, 3 |
thumbforge runs show <run> (built minimally in P6.1; P7.4 added runs list|resume|cancel|delete, P8.4 runs cost) prints the run header and the iteration table of a stored run, or the same as --json with each iteration's full idempotency key, assets and compliance report; exit 3 for an unknown run. The header shows the run's lineage: Parent run and Reference (the first 12 characters of the reference asset's hash) on a child, Child runs on a parent; --json carries run.parent_run_id, run.child_run_ids (oldest first) and run.reference_asset (an asset object, or null).
Behaviour¶
thumb generate Vresolves the video (Phase 2 repos; exit3if unknown), template ([general] default_templateunless--template), provider ([general] default_providerunless--provider); creates or reuses aprovider_profilesnapshot (name = "<key>@<version>:<params sha>"); renders the prompt once; createsrun(kind='hero', status='running', video_id=V)and Npendingiterations with distinct keys; bindsrun_idin structlog contextvars.- Iterations run under
asyncio.Semaphore(min(--concurrency, capabilities.max_concurrency)), which guards the provider call only (finalizing and storing overlap with the next generation), with the Phase 3 retry policy; each success is stored as therawasset, passed throughfinalize(Phase 5render_final), and the returned bytes are stored as thefinalasset withcompliantandcompliance_report_jsonset from the report — stored even when non-compliant, so the user can inspect it →raw_asset_id,final_asset_id,compliant. Provider stdout/stderr land in<state_dir>/logs/runs/<run_id>/<idempotency_key>.{out,err}(the provider names them by the request key, not the iteration id). - Run status at the end: all completed →
completed, exit0; some failed →failed, exit6and the message fromPLAN.md§5.3; all failed →failed, exit4. A compliance failure on an otherwise successful iteration marks that iterationfailedwitherror_textfrom the report. When every failure is a compliance failure, exit5replaces both6and4. --out DIRadditionally copies the final asset of every completed iteration toDIR/<youtube_id>-<ordinal>.<ext>; a non-compliant final stays inspectable in the store but is not copied.thumb pick R 2setspicked = 1on ordinal 2 and0on siblings in one statement, so a run never has two picks; the argument is an ordinal or the id of one of that run's iterations. Picking again moves the pick; picking the picked iteration changes nothing. An unknown run, ordinal or iteration id exits3. An iteration that is notcompletedexits2with a hint to pick a completed one: afailediteration (compliance failures included) has no usable final.--jsonprints{run_id, picked: {id, ordinal}}.thumb iterate R --n 4 --prompt-append "warmer colours"createsrun(kind='iterate', parent_run_id=R, video_id=R.video_id, reference_asset_id=<picked or given iteration>.raw_asset_id), re-renders the prompt with the append andvars, and passes the reference path inGenerationRequest.reference_images. The argument is a run id or an iteration id: a run id refines that run's picked iteration, an iteration id refines that iteration and overrides any pick, and the child's parent is the run of that iteration. Without a picked iteration and without an explicit iteration id, exit2with hintthumb pick; an iteration that is notcompletedexits2; an unknown id exits3.--from-pickedwalks to the newest picked iteration in the parent chain: the run's own pick if it has one, otherwise its parent's, and so on. The child runs the parent's template version and provider with the parent's stored provider settings,[batch] concurrencybounds its parallelism,--ndefaults to 4, and its seeds count up from 0 as a hero's do. The prompt is the template rendered with--varalone (nothing is inherited from the parent's variables, so a template that needs one needs it passed again; a missing one exits2before any run exists), then--prompt-appendafter a blank line. The reference is part of each iteration's idempotency key. The output is the run viewgenerateprints, plus thePick one with:line.thumb exportcopies (never moves) assets;--rawselectsraw_asset_id.--tois always a directory, created when missing, and the files are named<youtube_id>-<ordinal>.<ext>likegenerate --out. A run id exports everycompletediteration; an iteration id exports that one. Export does not need a pick. A target with nocompletediteration exits2with a hint (runs show); an unknown id exits3; a destination that cannot be written exits1(AssetError).--jsonprints{run_id, iteration_id, raw, files}.thumb show Rprints the run header and iteration table thatgenerateprints (the picked iteration is starred in the table), then a grid with one tile per iteration that has a final, in ordinal order, captioned#<ordinal>,★when picked, then✔or✘for compliance; a non-compliant final is drawn so it can be inspected. A console that cannot draw colour gets the numbered path table instead, led by the same captions.--jsonprints the documentruns showprints, each iteration carryingpicked.
Acceptance criteria¶
thumbforge thumb generate dQw4w9WgXcQ --provider fake --n 4prints the run header, a 4-row iteration table withcompletedand✔, the preview grid, andPick one with: thumbforge thumb pick <run> <ordinal>; exit0;runs show <run>lists 4 iterations with distinctidempotency_key.- Same command with
--var fail=[[FAIL_PERMANENT]]on a template that interpolates{{ vars.fail }}into the prompt yields 4failedrows and exit4. - With a FakeProvider
delay_ms=500and--concurrency 4, wall time for--n 4is below 1.5 s; with--concurrency 1above 2 s. thumbforge thumb pick <run> 2thenthumb show <run>marks ordinal 2 with★;thumb pick <run> 9exits3.thumbforge thumb iterate <run> --n 2creates a run withkind = iterate,parent_run_id = <run>,reference_asset_id = <picked>.raw_asset_id; FakeProvider output shows the reference thumbnail in a corner (pixel check at(0,0)region).thumbforge thumb export <run> --to out/writesout/dQw4w9WgXcQ-1.jpg…-4.jpg, byte for byte the stored finals;--rawwrites.pngfor FakeProvider.thumbforge --json thumb generate …prints one JSON object withrun.id,iterations[],exit_code.
Test plan¶
- Unit:
HeroServicewith FakeProvider on in-memory DB andtmp_pathdata dir — key uniqueness, status roll-up matrix (all ok / partial / all failed / compliance-only failure), semaphore concurrency timing,--outcopies;IterateServicereference resolution (picked, explicit id,--from-pickedchain, none → exit2);pick/exportviaCliRunner. - Contract: none new; relies on the Phase 3 contract suite.
- Integration (
-m integration): onethumb generate --provider antigravity --n 1after S1–S4 close. - Golden: none (Phase 5 covers overlay pixels).
Open spikes¶
- S4 reference images — closed: Antigravity takes references as prose only (
capabilities.supports_reference_image = False).thumb iterateon such a provider logs a warning that the reference was not used and proceeds prompt-only; the child run still recordsreference_asset_idso the lineage is complete. - S10 preview rendering for
thumb show(shared with Phase 5).