diff --git a/common/jsonio.py b/common/jsonio.py index a400d75..7cf0ecc 100644 --- a/common/jsonio.py +++ b/common/jsonio.py @@ -4,6 +4,8 @@ import json import math import os +import shutil +import tempfile from pathlib import Path from typing import Any @@ -33,9 +35,19 @@ def write_json(path: str | Path, value: Any, indent: int | None = 1, backup: boo """Write via a temporary file and atomic replace; optionally keep the previous file as .bak.""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) - temp = path.with_name(path.name + ".tmp") - temp.write_text(dumps(value, indent), encoding="utf-8") - if backup and path.exists(): - os.replace(path, path.with_name(path.name + ".bak")) - os.replace(temp, path) + text = dumps(value, indent) + handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="", dir=path.parent, + prefix=path.name + ".", suffix=".tmp", delete=False) + temp = Path(handle.name) + try: + with handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + if backup and path.exists(): + shutil.copy2(path, path.with_name(path.name + ".bak")) # the target itself is never moved away + os.replace(temp, path) + except BaseException: + temp.unlink(missing_ok=True) + raise return path diff --git a/common/pipeline/versions.py b/common/pipeline/versions.py index 415da63..a3fc483 100644 --- a/common/pipeline/versions.py +++ b/common/pipeline/versions.py @@ -129,7 +129,10 @@ def restore(path: str | Path, version: int) -> dict: source = version_file(path, version) if not source.exists(): if source_entry is history["versions"][-1] and path.exists(): - return source_entry # already current + if _sha256(path) == source_entry.get("sha256"): + return source_entry # already current, verified by hash + raise FileNotFoundError(f"{path.name} has changed since version {version} was recorded and that version " + f"was never archived ({source.name} missing), so it cannot be restored.") raise FileNotFoundError(f"Version {version} was never archived ({source.name} missing).") archive_current(path) shutil.copy2(source, path) diff --git a/games/ultima-online/outfit-lab/uo_vd_writer.py b/games/ultima-online/outfit-lab/uo_vd_writer.py index 9d61356..328525a 100644 --- a/games/ultima-online/outfit-lab/uo_vd_writer.py +++ b/games/ultima-online/outfit-lab/uo_vd_writer.py @@ -94,7 +94,10 @@ def encode_frame(idx, cx, cy): x0 = x while x < w and row[x] >= 0 and x - x0 < 0xFFF: x += 1 - out += struct.pack(">22)&0x3ff); y=yb+((hdr>>12)&0x3ff); n=hdr&0xfff idx=d[p:p+n]; p+=n - for k,ci in enumerate(idx): - img[y,x+k,:3]=pal[ci]; img[y,x+k,3]=255 + if 0<=y tuple[dict, dict]: + """Annotated pose targets for the sequence; unreadable annotations are reported, not silently dropped.""" + try: + return select_targets(dataset, sequence, names, "all", allow_dependent=True) + except (KeyError, ValueError, OSError) as error: # malformed, mismatched or unreadable annotation files + LOG.warning("Ignoring annotations for sequence %s (%s: %s); fitting to the silhouette alone.", + sequence, type(error).__name__, error) + return {}, {"mode": "all", "used": [], "mirrored_into_partner": []} + + def fit_one(job: dict) -> dict: started = time.time() dataset = Dataset.load(job["dataset"]) @@ -222,10 +235,7 @@ def fit_one(job: dict) -> dict: fitter = SilhouetteFitter(rig, mapping, projection, names, direction_rotations(dataset.directions, (0.0, -1.0)), settings, edges=edges, mask_weight=job["mask_weight"], skin=skin, coverage_weight=job.get("coverage_weight", 0.0)) - try: - targets, selection = select_targets(dataset, sequence, names, "all", allow_dependent=True) - except Exception: - targets, selection = {}, {"mode": "all", "used": [], "mirrored_into_partner": []} + targets, selection = annotation_targets(dataset, sequence, names) if skin is not None: # every frame, every stored view; frames without marks are fitted to the silhouette alone stored = [d["id"] for d in dataset.directions if dataset.mirror_source(d["id"]) is None] count = next(s["frame_count"] for s in dataset.sequences if s["id"] == sequence) diff --git a/tools/uo-content/pipeline.py b/tools/uo-content/pipeline.py index 3cfd442..9236689 100644 --- a/tools/uo-content/pipeline.py +++ b/tools/uo-content/pipeline.py @@ -61,7 +61,7 @@ def blender_path(): found = shutil.which('blender') if found: return found - choices = sorted(Path('C:/Program Files/Blender Foundation').glob('Blender */blender.exe'), reverse=True) + choices = sorted(Path(os.environ.get('ProgramFiles', 'C:/Program Files'), 'Blender Foundation').glob('Blender */blender.exe'), reverse=True) if choices: return str(choices[0]) raise ValueError('Set SPRITEMOTION_BLENDER to your Blender 4.2+ executable.') diff --git a/tools/uo-content/studio.py b/tools/uo-content/studio.py index 000c9ac..1f04106 100644 --- a/tools/uo-content/studio.py +++ b/tools/uo-content/studio.py @@ -126,14 +126,25 @@ def job(identifier): if not (path/'job.json').exists(): raise ValueError('Job not found.') return path +def recover_interrupted_jobs(jobs): + """Mark jobs left queued/building as failed. Returns [(status file, error)] for unreadable status files, which are skipped.""" + skipped=[] + for file in Path(jobs).glob('*/status.json'): + try: + status=json.loads(file.read_text(encoding='utf-8')) + interrupted=status['state'] in ('queued','building') + except (OSError,ValueError,KeyError,TypeError) as e: + skipped.append((file,e)); continue + if interrupted: + status.update(state='failed',error='Studio stopped during build. Load the settings and build again.') + pipeline.write_json(file,status) + return skipped + if __name__=='__main__': p=argparse.ArgumentParser(description=__doc__); p.add_argument('--port',type=int,default=8772) args=p.parse_args(); (pipeline.HOME/'jobs').mkdir(parents=True,exist_ok=True) # Jobs interrupted by a server shutdown are never presented as still rendering. - for file in (pipeline.HOME/'jobs').glob('*/status.json'): - status=json.loads(file.read_text(encoding='utf-8')) - if status['state'] in ('queued','building'): - status.update(state='failed',error='Studio stopped during build. Load the settings and build again.') - pipeline.write_json(file,status) + for file,error in recover_interrupted_jobs(pipeline.HOME/'jobs'): + print(f'Skipped job {file.parent.name}: unreadable status.json ({error})',flush=True) print(f'SpriteMotion content studio: http://127.0.0.1:{args.port}',flush=True) ThreadingHTTPServer(('127.0.0.1',args.port),Handler).serve_forever() diff --git a/tools/vd/vdtool.py b/tools/vd/vdtool.py index a55b590..b976fdd 100644 --- a/tools/vd/vdtool.py +++ b/tools/vd/vdtool.py @@ -124,8 +124,11 @@ def encode_frame(idx, cx, cy): x0 = x while x < w and row[x] >= 0 and x - x0 < 0xFFF: x += 1 - dx = (x0 - cx) & 0x3FF - dy = (y - cy - h) & 0x3FF + dx, dy = x0 - cx, y - cy - h + if not (-512 <= dx <= 511 and -512 <= dy <= 511): + raise ValueError(f"run offset out of 10-bit range (-512..511): dx={dx}, dy={dy} at row {y}") + dx &= 0x3FF + dy &= 0x3FF out += struct.pack(" [--raw]", "pack": " ", "verify": " "} + print(f"usage: vdtool.py {cmd} {names[cmd]}") + return 2 if cmd == "info": cmd_info(argv[2]) elif cmd == "extract":