from __future__ import annotations import argparse import json import pathlib import shutil import tempfile import time import modulus.filesystem import generator.arrange.generate import generator.catalog import generator.engraving.cache import generator.engraving.fonts import generator.engraving.pipeline import generator.engraving.types import generator.parts import generator.pico_artwork import generator.performance import generator.score import generator.summary import generator.solo.export import platform import os RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt") def copy_runtime_fonts(static_assets_dir: pathlib.Path, output_dir: pathlib.Path) -> None: source = static_assets_dir / "fonts" / "petaluma" destination = output_dir / "fonts" / "petaluma" destination.mkdir(parents=True, exist_ok=True) for name in RUNTIME_FONT_FILES: shutil.copy2(source / name, destination / name) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.") parser.add_argument("--data-dir", type=pathlib.Path) parser.add_argument("--output-dir", type=pathlib.Path) parser.add_argument("--renderer-fingerprint", action="store_true", help="Print the engraving cache identity without generating assets.") parser.add_argument("--static-assets-dir", type=pathlib.Path, default=pathlib.Path("package/assets")) parser.add_argument("--check", action="store_true") parser.add_argument("--engraving-cache", type=pathlib.Path) parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16") parser.add_argument("--composition-jobs", type=int, default=2, choices=range(1, 17), metavar="1-16") parser.add_argument("--composition-cache", type=pathlib.Path) parser.add_argument("--export-solo-xml", action="store_true", help="Export completed solo roles as comparison MusicXML, without recomposing.") parser.add_argument("--trace-level", choices=("summary", "full"), default="summary", help="Diagnostic detail only; does not seed composition.") parser.add_argument("--profile-output", type=pathlib.Path, help="Write build and per-role elapsed timings (not a search timeout).") parser.add_argument("--progress", action="store_true") args = parser.parse_args() if not args.renderer_fingerprint and (args.data_dir is None or args.output_dir is None): parser.error('--data-dir and --output-dir are required for generation') return args def generate_assets(args: argparse.Namespace) -> None: data_dir = args.data_dir.resolve() output_dir = args.output_dir.resolve() static_assets_dir = args.static_assets_dir.resolve() cache_dir = getattr(args, 'engraving_cache', None) cache_dir = cache_dir.resolve() if cache_dir else None if cache_dir and (cache_dir.is_relative_to(output_dir) or output_dir.is_relative_to(cache_dir)): raise ValueError('engraving cache and generated assets must not overlap') composition_cache = getattr(args, 'composition_cache', None) if composition_cache: composition_cache = composition_cache.resolve() if composition_cache.is_relative_to(output_dir) or output_dir.is_relative_to(composition_cache): raise ValueError('composition cache and generated assets must not overlap') progress = getattr(args, 'progress', False) started = time.monotonic() timings = [] stages = [] last_stage = started def report(message): nonlocal last_stage now = time.monotonic() stages.append({'completed_before': message, 'seconds': now - last_stage}) last_stage = now if progress: print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True) report('checking source paths') generator.parts.source_paths(data_dir / 'songs') output_dir.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary: staged = pathlib.Path(temporary) / 'assets' staged.mkdir(parents=True, exist_ok=True) copy_runtime_fonts(static_assets_dir, staged) report('generating performance parts') performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', composition_jobs=getattr(args, 'composition_jobs', 2), cache_dir=getattr(args, 'composition_cache', None), progress=progress, timings=timings) if getattr(args, 'trace_level', 'summary') == 'summary': for path in (staged / 'analysis').glob('*.roles'): roles = json.loads(path.read_text()) for role in roles.values(): trace = role.get('details', {}).get('selection_trace', {}) trace.pop('decisions', None) path.write_text(json.dumps(roles, sort_keys=True, indent=2)) if getattr(args, 'export_solo_xml', False): generator.solo.export.export_tree(staged) generator.score.validate_generated_tree(staged) if args.check: generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs') report('copying supplied recordings') song_ids = {generator.score.slugify(path.stem) for path in (data_dir / 'songs').glob('*.xml')} audio_assets = generator.performance.copy_audio(static_assets_dir, staged, song_ids) generator.performance.validate_recording_lengths(staged, audio_assets) report('engraving pages') validated_pages = generator.engraving.pipeline.engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress) report('rendering Pico titles') generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma') report('validating score, page, and audio assets') generator.score.validate_generated_tree(staged, validated_pages) report('building catalog database') generator.catalog.build_catalog(staged, audio_assets=audio_assets) with modulus.filesystem.publish_directories([(staged, output_dir)]): pass report('publishing complete') profile_path = getattr(args, 'profile_output', None) if profile_path: profile_path.parent.mkdir(parents=True, exist_ok=True) profile_path.write_text(json.dumps({'total_seconds': time.monotonic() - started, 'stages': stages, 'roles': timings, 'platform': platform.platform(), 'cpus': os.cpu_count(), 'workers': getattr(args, 'composition_jobs', 2)}, indent=2)) print(f'Generated Product Vamp assets: {performance_count} performance scores.') if args.check: print('Generator check passed.') def main() -> int: args = parse_args() if args.renderer_fingerprint: print(generator.engraving.cache.renderer_fingerprint(generator.engraving.fonts.Fonts(args.static_assets_dir / 'fonts' / 'petaluma'), generator.engraving.types.Style())) else: generate_assets(args) return 0 if __name__ == "__main__": raise SystemExit(main())