Oliver: "look at the headless men — the trouser color changed." He was right and every number in kit() said he was not. Brightness sat at 85 against the healthy frame's 89, comfortably inside tolerance, while the shorts' blue-minus-red walked from a warm charcoal -8.7 to a cold +0.2 across the wound stages. All three existing measures are greyscale or red-channel; none of them can see a grey going blue, which is most of what "the colour changed" ever means here. Fourth number: the shorts' hue. It immediately fails the headless men AND finds a second drift in the wounded women that the brightness test had passed. The tolerance default went to four entries deliberately — kit_off zips got/ref/ tol, so a three-long tuple would have SKIPPED the new check rather than failed it. That is the same shape of silent no-op as the read-only floodfill. Also: DAMAGE NOW ACCUMULATES. Stage 2 said only "headless", so the arm torn off at stage 1 came back — in a mode where the score IS the body, taking a point off it made the body look healthier. Stage 2 is now headless AND still one-armed. And the stride rule got its counterweight: "copy it exactly, only the legs change" has a second failure mode where the model copies it exactly INCLUDING the legs and returns the same picture twice (upper 0.017, legs 0.022 — a body sliding along with frozen feet, the very thing being fixed). Both halves are stated now: identical above the hips, obviously different below. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
336 lines
21 KiB
Python
336 lines
21 KiB
Python
# _regen.py — redraw a batch of sprites at real pixel-art resolution, end to end.
|
|
#
|
|
# python3 assets/_regen.py shambler_a # one batch
|
|
# python3 assets/_regen.py --list # what batches exist
|
|
# python3 assets/_regen.py --all # everything not already done
|
|
#
|
|
# Per batch it: builds a reference sheet from the sprites in the repo, asks the generator to redraw
|
|
# them at HIGH art resolution (the style block below is the whole point — the previous prompts asked
|
|
# for "big chunky pixel blocks", which is a specification for coarse art), slices with _fine.py,
|
|
# colour-matches each frame onto what it replaces, and installs — but only if the sheet passes its
|
|
# checks: right number of frames, nothing touching a canvas edge (that is what clipped z_f1), and a
|
|
# real gain in inner-edge density (the number that tracks "is the drawing actually finer").
|
|
#
|
|
# Failures leave the sprites alone and print why, so a batch can just be re-run.
|
|
import json, os, subprocess, sys
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SCRATCH = os.environ.get('SPLAT_SCRATCH', '/tmp/splatter-regen')
|
|
GEN = os.path.expanduser('~/.claude/skills/generate-image/scripts/generate_image.py')
|
|
PY = os.environ.get('SPLAT_PY', sys.executable)
|
|
# Nano Banana Pro via OpenRouter, pinned here so the skill script's default can't drift under us.
|
|
MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
|
|
|
|
# The style block is in three parts because the middle one is a REDRAW instruction and only a redraw
|
|
# wants it. "Keep every pose and colour exactly as in the reference" is right when the job is
|
|
# remastering a sprite that already exists, and it fights every word of the subject when the job is a
|
|
# NEW character drawn in the reference's style — set "newart": true on the batch to drop it.
|
|
RES = (
|
|
"Redraw them as HIGH-RESOLUTION pixel art. The art pixels must be SMALL: build each figure out "
|
|
"of roughly 90 to 110 pixels of height instead of the reference's 40, so there is room for real "
|
|
"detail, and spend it — a face with teeth, sunken eyes and a jawline; individual fingers; "
|
|
"clothing with visible seams, hems, folds and frayed torn edges; laced shoes; four or five "
|
|
"distinct shades per material with fine hatched shading where the light falls away. "
|
|
)
|
|
FIDELITY = (
|
|
"Keep every pose, every body proportion, every height, every facing and every wound EXACTLY as "
|
|
"in the reference — only the drawing resolution and the amount of fine detail change. Keep the "
|
|
"same colours. "
|
|
)
|
|
CANVAS = (
|
|
"Hard black outline around each figure. The background must be ONE FLAT solid magenta "
|
|
"#FF00FF across the entire canvas — no scenery, no gradient, no vignette. "
|
|
# NOTHING ON THE FIGURE MAY BE MAGENTA. The background colour is the transparency key, so any
|
|
# magenta the generator paints INSIDE a body is cut out of the sprite. It filled a racket's
|
|
# strings with the backdrop colour and installed four men holding an empty hoop — and the
|
|
# not_magenta check cannot see it, because that check only reads the corner pixel.
|
|
"MAGENTA AND PINK ARE THE BACKGROUND COLOUR AND MAY NOT APPEAR ANYWHERE ON A FIGURE: no magenta "
|
|
"clothing, no pink skin, and racket strings are drawn as fine pale grey-white crosshatch over "
|
|
"the figure's own body or clothes — NEVER left as the background colour showing through. "
|
|
"Keep at least 200 "
|
|
"pixels of empty magenta between figures and at least 150 pixels of empty magenta at every edge "
|
|
"of the canvas: no figure or limb may touch or run off an edge. No shadow, no ground line, no text. "
|
|
"The canvas is a WIDE LANDSCAPE STRIP and every figure stands side by side in ONE SINGLE HORIZONTAL "
|
|
"ROW on the same ground line — never a grid, never two rows, never stacked."
|
|
)
|
|
STYLE = RES + FIDELITY + CANVAS
|
|
NEW_STYLE = RES + (
|
|
"Match the reference's DRAWING STYLE, its overall figure height and its facing — but the "
|
|
"character, the clothes, the colours and the poses are the ones described above, not the "
|
|
"reference's. Do not copy the reference's body or outfit. "
|
|
) + CANVAS
|
|
# The third case, and the one that is easy to miss: the SAME character in a different state (wounded,
|
|
# mid-throw). "newart" is wrong for it — it licenses the generator to repaint the character, and it
|
|
# did: wound frames came back with living tan skin and a redesigned shirt while the healthy frame
|
|
# stayed a grey-green corpse. Set "state": true for those.
|
|
SAME_CHAR = (
|
|
"This is the SAME character as the reference, not a new one. Their colours, their hair colour, "
|
|
"their clothing, their build, their proportions and their height must be IDENTICAL to the "
|
|
"reference — the only things that change are the pose and any injuries described above. Do not "
|
|
"redesign or recolour any garment. Draw them in FULL COLOUR in exactly the reference's drawing "
|
|
"style — never greyscale, never line art, never a different rendering style. "
|
|
)
|
|
CORPSE = (
|
|
"Their skin stays exactly the same dead grey-green as the reference: this is a walking corpse, "
|
|
"never pink, never tan, never living flesh. "
|
|
)
|
|
STATE_STYLE = RES + SAME_CHAR + CORPSE + CANVAS
|
|
# ...and the fourth case, which the three above between them could not express: the same LIVING
|
|
# character in a NEW POSE. "newart" repaints them (a women's-tour batch anchored on the men's frames
|
|
# came back blonde in one batch, auburn in the next, and greyscale line art in a third — a different
|
|
# woman every sheet), and "state" is welded to the corpse palette. Set "pose": true, and anchor the
|
|
# batch on a frame OF THAT CHARACTER rather than on whoever the pose originally belonged to.
|
|
POSE_STYLE = RES + SAME_CHAR + CANVAS
|
|
|
|
def batches():
|
|
with open(os.path.join(HERE, '_regen_batches.json')) as f:
|
|
return json.load(f)
|
|
|
|
# ---------------------------------------------------------------- helpers ----
|
|
def ref_sheet(names, out, scale=2.0, gap=220, pad=150, frames=1):
|
|
"""THE REFERENCE'S ASPECT IS THE ANSWER'S ASPECT. One tall reference figure makes a portrait
|
|
reference canvas, and the generator then answers in portrait — it laid four figures out as a 2x2
|
|
GRID, which _fine.py cannot cut (it segments columns). So the canvas is widened to a landscape
|
|
strip with room for every frame that was asked for, and the figures are spread across it."""
|
|
ims = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in names]
|
|
ims = [i.resize((max(1, int(i.width * scale)), max(1, int(i.height * scale))), Image.NEAREST) for i in ims]
|
|
w = sum(i.width for i in ims) + gap * (len(ims) - 1) + pad * 2
|
|
h = max(i.height for i in ims) + pad * 2
|
|
w = max(w, int(h * 0.62 * max(frames, len(ims)))) # one landscape slot per frame asked for
|
|
s = Image.new('RGBA', (w, h), (255, 0, 255, 255))
|
|
step = (w - pad * 2 - sum(i.width for i in ims)) // max(1, len(ims) - 1) if len(ims) > 1 else 0
|
|
x = pad
|
|
for i in ims:
|
|
s.paste(i, (x, h - pad - i.height), i); x += i.width + max(gap, step)
|
|
s.save(out)
|
|
return out
|
|
|
|
def density(path):
|
|
"""Inner-edge density: how much detail lives INSIDE the silhouette. Coarse art ~0.15, the
|
|
redrawn runner ~0.45. This is the number that matched Oliver's eye."""
|
|
a = np.array(Image.open(path).convert('RGBA'))
|
|
solid = a[..., 3] > 128
|
|
d = np.abs(np.diff(a[..., :3].astype(int), axis=1)).sum(2)
|
|
return round(float(((d > 40) & solid[:, :-1]).sum() / max(1, solid.sum())), 3)
|
|
|
|
def kit(path):
|
|
"""Four numbers that say whether a roll is wearing the right clothes:
|
|
REDNESS how red the shirt is (r minus the mean of g,b, over the saturated warm pixels).
|
|
Brightness alone will NOT do — a brick-red shirt is as bright as a red one.
|
|
SHORTS mean brightness of the mid-tone neutral pixels. Charcoal ~61, pale grey ~83.
|
|
BRIGHT fraction of the body that is near-white — the shoes and socks. Brown shoes drop it,
|
|
pale shorts push it up.
|
|
HUE the shorts' blue-minus-red. THE ONE THE FIRST THREE ARE BLIND TO: Oliver spotted the
|
|
headless men's trousers had changed colour and every number above said they had not
|
|
— brightness sat at 85 against the healthy frame's 89 while the hue walked from a
|
|
warm charcoal (-8.7) to a cold blue-grey (+0.2). A greyscale measure cannot see a
|
|
grey turning blue, which is most of what "the colour changed" ever means here.
|
|
Calibrated on frames known to be right (idle/bounce/run): redness 99-110, shorts 60-63,
|
|
bright 2.7-2.9%. Every frame that had visibly drifted fell outside on at least one axis."""
|
|
a = np.array(Image.open(path).convert('RGBA')).astype(int)
|
|
px = a[..., :3][a[..., 3] > 128]
|
|
if not len(px): return (0.0, 0.0, 0.0, 0.0)
|
|
red = px[(px[:, 0] > px[:, 1] + 45) & (px[:, 0] > px[:, 2] + 45)]
|
|
neutral = px[(px.max(1) - px.min(1)) < 55]
|
|
shorts = neutral[(neutral.mean(1) > 25) & (neutral.mean(1) < 145)]
|
|
bright = neutral[neutral.mean(1) >= 150]
|
|
return (round(float((red[:, 0] - (red[:, 1] + red[:, 2]) / 2).mean()) if len(red) else 0, 1),
|
|
round(float(shorts.mean()) if len(shorts) else 0, 1),
|
|
round(len(bright) / len(px) * 100, 1),
|
|
round(float((shorts[:, 2] - shorts[:, 0]).mean()) if len(shorts) else 0, 1))
|
|
|
|
# NOTE the fourth tolerance: kit() returns four numbers and kit_off zips them against this, so a
|
|
# three-long tuple would silently skip the hue check rather than fail — the same shape of no-op the
|
|
# read-only floodfill in _fine.py was. Every caller passing its own `palette_tol` needs four too.
|
|
def kit_off(outs, ref_name, tol=(12, 10, 1.5, 4)):
|
|
"""Is this roll wearing the right kit? Generative rolls drift the palette EVERY TIME and nothing
|
|
else here catches it: colour_match only runs for `check: finer`, and it is a global mean/sd fit
|
|
far too weak to pull brick-red back to red. Before this existed, six frames shipped with a white
|
|
headband, the toss shipped twice in pale grey shorts, and a swing pair shipped in brown shoes.
|
|
Compared against a canonical frame that is NOT part of the batch (o_idle1 for the opponent)."""
|
|
ref = kit(os.path.join(HERE, ref_name + '.png'))
|
|
for o in outs:
|
|
got = kit(o)
|
|
if any(abs(g - r) > t for g, r, t in zip(got, ref, tol)):
|
|
return (f'kit drifted vs {ref_name}: redness/shorts/bright {got} vs {ref} '
|
|
f'(tolerance {tol})')
|
|
return None
|
|
|
|
# ---- THE STRIDE GATE. A walk cycle is ONE DRAWING WITH THE LEGS MOVED. The generator does not know
|
|
# that: asked for "frame 2, then the same with the other leg forward", it draws two independent
|
|
# illustrations of the character — different torso twist, different arm positions, different camera
|
|
# distance — and every other check in this file passes on that happily (the frame count is right, the
|
|
# background is magenta, nothing touches an edge, the detail is fine). So the failure has been
|
|
# INVISIBLE and it has shipped repeatedly: the turned CPU's z_c1/z_c2 differ by 0.85 above the hip.
|
|
#
|
|
# Three numbers, silhouette only, on a feet-aligned centred overlay — which is how drawStatic puts
|
|
# them on screen, so this measures what the eye actually sees:
|
|
# HGT height ratio. drawStatic sizes every frame by its own PNG height, so anything off 1.00 is a
|
|
# body that GROWS AND SHRINKS once per step (the shambler's walk does, by 8%).
|
|
# TOP how much differs ABOVE the hip. Near zero for a real stride pair — same man, same camera.
|
|
# LEG how much differs BELOW it. This is the step. Near zero means the legs never moved.
|
|
# Calibrated on the pairs that read: the brute's wound lurch is (1.00, 0.000, 0.638) and the
|
|
# shambler's is (1.00, 0.070, 0.520); the failures sit at TOP 0.47-0.85 or LEG 0.09. The player's
|
|
# four-frame RUN sits at TOP 0.23 and is fine — a run swings the arms too — which is why this gate is
|
|
# opt-in per batch (`stride: [[i, j]]`) rather than applied to everything with two frames in it.
|
|
STRIDE_H, STRIDE_TOP, STRIDE_LEG, STRIDE_HIP = 0.04, 0.20, 0.30, 0.62
|
|
|
|
def stride_stats(pa, pb):
|
|
a, b = Image.open(pa).convert('RGBA'), Image.open(pb).convert('RGBA')
|
|
w, h = max(a.width, b.width), max(a.height, b.height)
|
|
def m(im):
|
|
c = Image.new('RGBA', (w, h)); c.paste(im, ((w - im.width) // 2, h - im.height))
|
|
return np.array(c)[..., 3] > 110
|
|
ma, mb = m(a), m(b)
|
|
hip = int(h * STRIDE_HIP)
|
|
d = lambda x, y: round(float((x ^ y).sum() / max(1, (x | y).sum())), 3)
|
|
return round(a.height / max(1, b.height), 3), d(ma[:hip], mb[:hip]), d(ma[hip:], mb[hip:])
|
|
|
|
def stride_off(outs, pairs):
|
|
for i, j in pairs:
|
|
hgt, top, leg = stride_stats(outs[i], outs[j])
|
|
why = ('body pulsates' if abs(hgt - 1) > STRIDE_H else
|
|
'whole body redrawn, not a leg swap' if top > STRIDE_TOP else
|
|
'the legs do not move' if leg < STRIDE_LEG else None)
|
|
if why:
|
|
return (f'frames {i}/{j} are not one body with the legs moved — {why} '
|
|
f'(height x{hgt:.2f}, upper {top:.3f}, legs {leg:.3f}; '
|
|
f'want ~1.00 / <={STRIDE_TOP} / >={STRIDE_LEG})')
|
|
return None
|
|
|
|
def not_magenta(sheet):
|
|
"""The generator sometimes ignores the magenta and paints its own background. That was written
|
|
off as harmless — the key uses the corner colour — right up until it returned a GREEN sheet and
|
|
keyed away four grey-green racket heads with it, installing four men holding a grip and nothing
|
|
else. A wrong background is now a rejected roll, because the art that dies to it is exactly the
|
|
thin art the sprites are made of."""
|
|
a = np.array(Image.open(sheet).convert('RGB'))[0, 0].astype(int)
|
|
return not (a[0] > 140 and a[2] > 140 and a[1] < a[0] - 60 and a[1] < a[2] - 60)
|
|
|
|
def touches_edge(sheet):
|
|
"""A CLIPPED figure, not a speck of noise: a run of 8+ ink pixels along a border. The strict
|
|
version rejected sheets over single stray pixels and cost several regenerations."""
|
|
a = np.array(Image.open(sheet).convert('RGB')).astype(int)
|
|
bg = a[0, 0]
|
|
fg = (((a - bg) ** 2).sum(2) > 3600) & ~(np.minimum(a[..., 0], a[..., 2]) > a[..., 1] + 40)
|
|
def run(line):
|
|
n = best = 0
|
|
for v in line:
|
|
n = n + 1 if v else 0
|
|
best = max(best, n)
|
|
return best
|
|
return max(run(fg[0]), run(fg[-1]), run(fg[:, 0]), run(fg[:, -1])) >= 8
|
|
|
|
def colour_match(src, dst_name):
|
|
"""Fit the new frame onto the palette statistics of the sprite it replaces, so a redrawn body
|
|
does not change colour halfway through a walk cycle."""
|
|
op = lambda x: x[..., 3] > 200
|
|
old = np.array(Image.open(os.path.join(HERE, dst_name + '.png')).convert('RGBA'), dtype=float)
|
|
new = np.array(Image.open(src).convert('RGBA'), dtype=float)
|
|
po, pn = old[op(old)][:, :3], new[op(new)][:, :3]
|
|
m = op(new)
|
|
px = (new[m][:, :3] - pn.mean(0)) / np.maximum(pn.std(0), 1e-6) * po.std(0) + po.mean(0)
|
|
new[m] = np.concatenate([np.clip(px, 0, 255), new[m][:, 3:]], axis=1)
|
|
Image.fromarray(new.astype('uint8'), 'RGBA').save(src)
|
|
|
|
# ------------------------------------------------------------------- run ----
|
|
def run(name, spec, dry=False):
|
|
os.makedirs(SCRATCH, exist_ok=True)
|
|
sprites = spec['sprites']
|
|
lead = spec.get('ref') # generated for scale, thrown away after
|
|
shown = spec.get('ref_only') or (([lead] if lead else []) + sprites)
|
|
if spec.get('ref_only'): lead = None # ref_only sheets return exactly the frames asked for
|
|
ref = ref_sheet(shown, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0),
|
|
frames=len(sprites) + (1 if lead else 0))
|
|
sheet = f'{SCRATCH}/{name}_hi.png'
|
|
prompt = f"{spec['subject']} {STATE_STYLE if spec.get('state') else POSE_STYLE if spec.get('pose') else NEW_STYLE if spec.get('newart') else STYLE}"
|
|
if dry:
|
|
print(f'[{name}] would generate {len(sprites)} frames: {", ".join(sprites)}'); return True
|
|
print(f'[{name}] generating {len(sprites)} frames...')
|
|
r = subprocess.run([PY, GEN, prompt, '--model', MODEL, '--input', ref, '--output', sheet],
|
|
capture_output=True, text=True, cwd=os.path.dirname(HERE))
|
|
if not os.path.exists(sheet):
|
|
print(f'[{name}] FAILED: generator returned no image\n{r.stdout[-400:]}'); return False
|
|
if not_magenta(sheet):
|
|
print(f'[{name}] REJECTED: background is not magenta — thin art keys away with it'); return False
|
|
if touches_edge(sheet):
|
|
print(f'[{name}] REJECTED: a figure runs off the canvas edge — re-run'); return False
|
|
outs = [f'{SCRATCH}/{name}_{i}.png' for i in range(len(sprites) + (1 if lead else 0))]
|
|
cut = ['--cut', spec['cut']] if spec.get('cut') else []
|
|
r = subprocess.run([PY, os.path.join(HERE, '_fine.py'), sheet, *outs,
|
|
'--anchor', str(spec.get('anchor', 0)), '--h', str(spec.get('h', 224)), *cut],
|
|
capture_output=True, text=True)
|
|
print(r.stdout.strip() or r.stderr.strip())
|
|
if r.returncode or not all(os.path.exists(o) for o in outs):
|
|
print(f'[{name}] REJECTED: slicing failed (frames merged?) — re-run or add "cut"'); return False
|
|
if lead: outs = outs[1:] # the scale reference is not installed
|
|
before = [density(os.path.join(HERE, s + '.png')) for s in sprites]
|
|
after = [density(o) for o in outs]
|
|
# Two jobs, two bars. A coarse->fine REDRAW has to be measurably finer (1.25x, not 1.5x: a sheet
|
|
# that already has blood on it starts high, so a genuinely finer wounded body gains less). A
|
|
# REPOSE — turning the player around, pointing the runner at the camera, matching a wound between
|
|
# two frames — is already fine art and only has to come back no worse.
|
|
# 'any' turns the detail gate OFF, and there is exactly one job it is for: RE-ROLLING art that is
|
|
# already fine to fix something the gate cannot see, such as a colour. The bar is measured against
|
|
# the file being replaced, so once a good sheet installs it RATCHETS — a second roll of equal
|
|
# quality now has to beat the first, and three colour-fix re-rolls were rejected for being 0.39
|
|
# against a 0.46 that was itself only there because the first roll happened to be dense. Magenta
|
|
# and edge checks still apply; verify the thing you were actually fixing by measuring it.
|
|
bar = {'finer': 1.25, 'same': 0.85, 'loose': 0.7, 'any': 0.0}[spec.get('check', 'finer')]
|
|
if sum(after) / len(after) < sum(before) / len(before) * bar:
|
|
print(f'[{name}] REJECTED: not finer than what it replaces {before} -> {after}'); return False
|
|
if spec.get('palette_ref'):
|
|
bad = kit_off(outs, spec['palette_ref'], tuple(spec.get('palette_tol', (12, 10, 1.5))))
|
|
if bad: print(f'[{name}] REJECTED: {bad}'); return False
|
|
if spec.get('stride'):
|
|
bad = stride_off(outs, spec['stride'])
|
|
if bad: print(f'[{name}] REJECTED: {bad}'); return False
|
|
for o, s in zip(outs, sprites):
|
|
if spec.get('check', 'finer') == 'finer': colour_match(o, s)
|
|
Image.open(o).save(os.path.join(HERE, s + '.png'))
|
|
print(f'[{name}] installed. detail {before} -> {after}')
|
|
return True
|
|
|
|
# --audit runs the stride gate over the sprites ALREADY INSTALLED, which is the other half of it
|
|
# being useful: a gate that only fires on new rolls says nothing about the art already in the game,
|
|
# and the art already in the game is where this was first noticed.
|
|
def audit(B, names):
|
|
print(f"{'':34s} {'HGT':>6s} {'TOP':>6s} {'LEG':>6s} verdict")
|
|
bad = []
|
|
for n in names:
|
|
for i, j in B[n].get('stride', []):
|
|
a, b = (os.path.join(HERE, B[n]['sprites'][k] + '.png') for k in (i, j))
|
|
if not (os.path.exists(a) and os.path.exists(b)): continue
|
|
hgt, top, leg = stride_stats(a, b)
|
|
why = stride_off([a, b], [[0, 1]])
|
|
label = f"{n} {B[n]['sprites'][i]}/{B[n]['sprites'][j]}"
|
|
print(f'{label:34s} {hgt:6.2f} {top:6.3f} {leg:6.3f} {"FAIL" if why else "ok"}')
|
|
if why: bad.append(label)
|
|
print('\nFAILS THE GATE:', ', '.join(bad) if bad else 'none')
|
|
return not bad
|
|
|
|
if __name__ == '__main__':
|
|
args = sys.argv[1:]
|
|
B = batches()
|
|
if '--audit' in args:
|
|
names = [a for a in args if not a.startswith('--')] or [k for k, v in B.items() if v.get('stride')]
|
|
sys.exit(0 if audit(B, names) else 1)
|
|
if not args or '--list' in args:
|
|
for k, v in B.items(): print(f'{k:16s} {len(v["sprites"]):2d} frames {", ".join(v["sprites"])}')
|
|
sys.exit()
|
|
dry = '--dry' in args
|
|
tries = 3 # a rejected sheet is usually just a bad roll:
|
|
for a in args: # frames merged, or a limb off the canvas
|
|
if a.startswith('--tries='): tries = int(a.split('=')[1])
|
|
names = list(B) if '--all' in args else [a for a in args if not a.startswith('--')]
|
|
bad = []
|
|
for n in names:
|
|
for attempt in range(1, tries + 1):
|
|
if run(n, B[n], dry): break
|
|
print(f'[{n}] attempt {attempt}/{tries} rejected')
|
|
else:
|
|
bad.append(n)
|
|
print('\nFAILED BATCHES (re-run):', ' '.join(bad) if bad else 'none')
|