Township: "Generate missing" keyframes button

New keyframes-missing render scope fills in only the keyframes that don't
exist yet for a whole match (clips + outcomes) — existing ones are kept and
nothing is re-rendered. Buttons added on the keyframes page and the match
detail action row; finishes immediately when none are missing.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 71fe0c19
...@@ -2834,7 +2834,10 @@ def launch_web_ui(default_args): ...@@ -2834,7 +2834,10 @@ def launch_web_ui(default_args):
# click "Re-render" afterwards to rebuild the clip from the new # click "Re-render" afterwards to rebuild the clip from the new
# keyframe. scope "keyframes" = whole match; "keyframe" = one clip # keyframe. scope "keyframes" = whole match; "keyframe" = one clip
# (idx) or one outcome (fighter+outcome). # (idx) or one outcome (fighter+outcome).
if scope in ("keyframes", "keyframe"): if scope in ("keyframes", "keyframes-missing", "keyframe"):
# "keyframes-missing" only fills in keyframes that don't exist yet
# (no delete); "keyframes"/"keyframe" delete then regenerate.
missing_only = (scope == "keyframes-missing")
image_model = getattr(default_args, "image_model", None) image_model = getattr(default_args, "image_model", None)
if not image_model: if not image_model:
try: try:
...@@ -2846,8 +2849,8 @@ def launch_web_ui(default_args): ...@@ -2846,8 +2849,8 @@ def launch_web_ui(default_args):
m = next((x for x in fight_plan m = next((x for x in fight_plan
if x.get("match_name") == match_name), None) if x.get("match_name") == match_name), None)
# Build the (filtered) fight/outcome plans + the list of stems to # Build the (filtered) fight/outcome plans + the list of stems to
# drop so _generate_keyframes (which keeps existing PNGs) remakes # (re)make. _generate_keyframes keeps existing PNGs, so deleting a
# exactly those. # stem first forces a remake; leaving it forces a fill-in.
fp, op, stems = [], [], [] fp, op, stems = [], [], []
if scope == "keyframe" and params.get("fighter") and params.get("outcome"): if scope == "keyframe" and params.get("fighter") and params.get("outcome"):
fr, oc = params.get("fighter"), params.get("outcome") fr, oc = params.get("fighter"), params.get("outcome")
...@@ -2872,10 +2875,10 @@ def launch_web_ui(default_args): ...@@ -2872,10 +2875,10 @@ def launch_web_ui(default_args):
return return
fp = [mm] fp = [mm]
stems = [_clip_stem_fight(match_name, c["idx"]) for c in mm["clips"]] stems = [_clip_stem_fight(match_name, c["idx"]) for c in mm["clips"]]
# "Regenerate all" (whole match) also covers this match's # Whole-match scopes also cover this match's OUTCOME keyframes —
# OUTCOME keyframes — not just the clips. Per-clip regen # not just the clips. Per-clip regen (scope == "keyframe" with
# (scope == "keyframe" with idx) stays clip-only. # idx) stays clip-only.
if scope == "keyframes": if scope in ("keyframes", "keyframes-missing"):
_mf = {mm.get("f1"), mm.get("f2")} - {None} _mf = {mm.get("f1"), mm.get("f2")} - {None}
for o in outcome_plan: for o in outcome_plan:
if o.get("match_name"): if o.get("match_name"):
...@@ -2886,14 +2889,22 @@ def launch_web_ui(default_args): ...@@ -2886,14 +2889,22 @@ def launch_web_ui(default_args):
op.append(o) op.append(o)
stems.append(_clip_stem_outcome( stems.append(_clip_stem_outcome(
o["fighter"], o["outcome"], o.get("match_name"))) o["fighter"], o["outcome"], o.get("match_name")))
_set_items([f"keyframe {s}" for s in stems]) # In missing-only mode, narrow progress items to the absent ones.
for i, s in enumerate(stems): work = ([s for s in stems if not (kdir / f"{s}.png").exists()]
if missing_only else stems)
if missing_only and not work:
_done("no missing keyframes — all present")
return
_set_items([f"keyframe {s}" for s in work])
for i, s in enumerate(work):
_item(i, "start") _item(i, "start")
try: if not missing_only:
(kdir / f"{s}.png").unlink() try:
except Exception: (kdir / f"{s}.png").unlink()
pass except Exception:
_prog(10, f"regenerating {len(stems)} keyframe(s)…") pass
_prog(10, ("filling in {n} missing keyframe(s)…" if missing_only
else "regenerating {n} keyframe(s)…").format(n=len(work)))
try: try:
_generate_keyframes( _generate_keyframes(
client, image_model, kdir, fp, op, client, image_model, kdir, fp, op,
...@@ -2906,10 +2917,11 @@ def launch_web_ui(default_args): ...@@ -2906,10 +2917,11 @@ def launch_web_ui(default_args):
_fail(f"keyframe regeneration failed: {e}") _fail(f"keyframe regeneration failed: {e}")
return return
# Mark each item done/failed by whether its PNG now exists. # Mark each item done/failed by whether its PNG now exists.
for i, s in enumerate(stems): for i, s in enumerate(work):
_item(i, "end", (kdir / f"{s}.png").exists()) _item(i, "end", (kdir / f"{s}.png").exists())
made = sum(1 for s in stems if (kdir / f"{s}.png").exists()) made = sum(1 for s in work if (kdir / f"{s}.png").exists())
_done(f"regenerated {made}/{len(stems)} keyframe(s) — " _done(f"{'generated' if missing_only else 'regenerated'} "
f"{made}/{len(work)} keyframe(s) — "
f"now click Re-render to rebuild the video(s)") f"now click Re-render to rebuild the video(s)")
return return
...@@ -4152,11 +4164,13 @@ async function reMatch(ev, scope, params){ ...@@ -4152,11 +4164,13 @@ async function reMatch(ev, scope, params){
'outcomes':'Re-render all output clips for this fighter (uses the video model)?', 'outcomes':'Re-render all output clips for this fighter (uses the video model)?',
'outcome':'Re-render this output clip?', 'outcome':'Re-render this output clip?',
'keyframes':'Regenerate ALL keyframe images for this match (uses the image model)? Existing keyframes are replaced; the clip videos are NOT re-rendered — click Re-render afterwards.', 'keyframes':'Regenerate ALL keyframe images for this match (uses the image model)? Existing keyframes are replaced; the clip videos are NOT re-rendered — click Re-render afterwards.',
'keyframes-missing':'Generate only the MISSING keyframe images for this match (uses the image model)? Existing keyframes are kept; nothing is re-rendered.',
'keyframe':'Regenerate this keyframe image (uses the image model)? The clip video is NOT re-rendered — click Re-render afterwards.'}; 'keyframe':'Regenerate this keyframe image (uses the image model)? The clip video is NOT re-rendered — click Re-render afterwards.'};
const kf=(scope==='keyframes'||scope==='keyframe'); const kf=(scope==='keyframes'||scope==='keyframe'||scope==='keyframes-missing');
const kfMiss=(scope==='keyframes-missing');
if(!(await uiConfirm(labels[scope]||'Proceed?', if(!(await uiConfirm(labels[scope]||'Proceed?',
{title:(kf?'Regenerate keyframes':'Regenerate'), {title:(kfMiss?'Generate missing keyframes':(kf?'Regenerate keyframes':'Regenerate')),
okText:(scope==='reassemble'?'Reassemble':(kf?'Regenerate':'Re-render')), okText:(scope==='reassemble'?'Reassemble':(kfMiss?'Generate missing':(kf?'Regenerate':'Re-render'))),
danger:(scope!=='reassemble'&&!kf)})))return; danger:(scope!=='reassemble'&&!kf)})))return;
const stEl=_findStatus(ev); const stEl=_findStatus(ev);
const setSt=(c,t)=>{ if(stEl){ stEl.style.color=c; stEl.textContent=t; } }; const setSt=(c,t)=>{ if(stEl){ stEl.style.color=c; stEl.textContent=t; } };
...@@ -4510,6 +4524,8 @@ document.addEventListener('DOMContentLoaded', resumeMatchJobs); ...@@ -4510,6 +4524,8 @@ document.addEventListener('DOMContentLoaded', resumeMatchJobs);
f' <a class="btn btn-secondary" style="font-size:.82rem;padding:.35rem .9rem;text-decoration:none" ' f' <a class="btn btn-secondary" style="font-size:.82rem;padding:.35rem .9rem;text-decoration:none" '
f'href="/match/keyframes?name={_esc(name)}">🖼 Keyframes ▸</a>' f'href="/match/keyframes?name={_esc(name)}">🖼 Keyframes ▸</a>'
f' <button class="btn btn-secondary" style="font-size:.82rem;padding:.35rem .9rem" ' f' <button class="btn btn-secondary" style="font-size:.82rem;padding:.35rem .9rem" '
f'onclick="reMatch(event,\'keyframes-missing\',{{match:\'{_esc(name)}\'}})">➕ Missing keyframes</button>'
f' <button class="btn btn-secondary" style="font-size:.82rem;padding:.35rem .9rem" '
f'onclick="reMatch(event,\'keyframes\',{{match:\'{_esc(name)}\'}})">🖼 Regenerate keyframes</button>' f'onclick="reMatch(event,\'keyframes\',{{match:\'{_esc(name)}\'}})">🖼 Regenerate keyframes</button>'
f' <button class="btn btn-danger" style="font-size:.82rem;padding:.35rem .9rem" ' f' <button class="btn btn-danger" style="font-size:.82rem;padding:.35rem .9rem" '
f'onclick="delVid(event,\'keyframes\',{{match:\'{_esc(name)}\'}})">🧹 Clear keyframes</button>' f'onclick="delVid(event,\'keyframes\',{{match:\'{_esc(name)}\'}})">🧹 Clear keyframes</button>'
...@@ -4647,6 +4663,8 @@ document.addEventListener('DOMContentLoaded', resumeMatchJobs); ...@@ -4647,6 +4663,8 @@ document.addEventListener('DOMContentLoaded', resumeMatchJobs);
f'<b>Re-render</b> the matching clip(s) on the match page to rebuild ' f'<b>Re-render</b> the matching clip(s) on the match page to rebuild '
f'the video from the new keyframe.</p>' f'the video from the new keyframe.</p>'
f' <div class=pf-actions>' f' <div class=pf-actions>'
f' <button class="btn btn-primary" style="font-size:.82rem;padding:.35rem .9rem" '
f'onclick="reMatch(event,\'keyframes-missing\',{{match:\'{_esc(name)}\'}})">➕ Generate missing</button>'
f' <button class="btn btn-secondary" style="font-size:.82rem;padding:.35rem .9rem" ' f' <button class="btn btn-secondary" style="font-size:.82rem;padding:.35rem .9rem" '
f'onclick="reMatch(event,\'keyframes\',{{match:\'{_esc(name)}\'}})">🖼 Regenerate all</button>' f'onclick="reMatch(event,\'keyframes\',{{match:\'{_esc(name)}\'}})">🖼 Regenerate all</button>'
f' <button class="btn btn-danger" style="font-size:.82rem;padding:.35rem .9rem" ' f' <button class="btn btn-danger" style="font-size:.82rem;padding:.35rem .9rem" '
...@@ -5223,7 +5241,8 @@ async function pollJob(){ ...@@ -5223,7 +5241,8 @@ async function pollJob(){
scope = _fv("scope") scope = _fv("scope")
if scope not in ("match-clips", "clip", "reassemble", "outcomes", if scope not in ("match-clips", "clip", "reassemble", "outcomes",
"outcome", "enhance", "keyframes", "keyframe"): "outcome", "enhance", "keyframes",
"keyframes-missing", "keyframe"):
self._send(400, "application/json", self._send(400, "application/json",
_j.dumps({"error": "invalid scope"})) _j.dumps({"error": "invalid scope"}))
return return
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment