Skip to content

Commit e9fc962

Browse files
feat(gameassets): migrate manifest from CSV to YAML with nested audio config
Add YAML manifest parser with pipeline: [3d, audio, rig, animate, parts] syntax replacing flat boolean columns. Support nested audio config per row (duration, profile, trim, preset, steps, cfg_scale). Auto-detect manifest format by extension (.yaml/.yml/.csv). Improve build_audio_prompt() with duration-aware hints (≤5s: brief/no repetition; ≥20s: loop-friendly) and profile-aware hints (effects vs music). Add _text2sound_args_for_row() for per-row audio overrides on top of global profile. gameassets init now generates manifest.yaml by default. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 8a573e0 commit e9fc962

5 files changed

Lines changed: 227 additions & 28 deletions

File tree

GameAssets/src/gameassets/cli.py

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
)
4343
from .prompt_builder import build_audio_prompt, build_prompt
4444
from .runner import merge_subprocess_output, resolve_binary, run_cmd
45-
from .templates import GAME_YAML, MANIFEST_CSV
45+
from .templates import GAME_YAML, MANIFEST_YAML
4646

4747
console = Console()
4848

@@ -167,6 +167,46 @@ def _append_text2sound_profile_args(ts: Text2SoundProfile, argv: list[str]) -> N
167167
argv.append("--no-half")
168168

169169

170+
def _text2sound_args_for_row(
171+
profile: Text2SoundProfile,
172+
row: ManifestRow,
173+
argv: list[str],
174+
) -> None:
175+
"""Append text2sound args: per-row overrides take precedence over global profile."""
176+
duration = row.audio_duration if row.audio_duration is not None else profile.duration
177+
steps = row.audio_steps if row.audio_steps is not None else profile.steps
178+
cfg = row.audio_cfg_scale if row.audio_cfg_scale is not None else profile.cfg_scale
179+
trim = row.audio_trim if row.audio_trim is not None else profile.trim
180+
preset = row.audio_preset if row.audio_preset is not None else profile.preset
181+
182+
if duration is not None:
183+
argv.extend(["-d", str(duration)])
184+
if steps is not None:
185+
argv.extend(["-s", str(steps)])
186+
if cfg is not None:
187+
argv.extend(["-c", str(cfg)])
188+
fmt = (profile.audio_format or "wav").lower().strip().lstrip(".")
189+
argv.extend(["-f", fmt])
190+
if preset and preset.lower() != "none":
191+
argv.extend(["-p", preset])
192+
if profile.sigma_min is not None:
193+
argv.extend(["--sigma-min", str(profile.sigma_min)])
194+
if profile.sigma_max is not None:
195+
argv.extend(["--sigma-max", str(profile.sigma_max)])
196+
if profile.sampler:
197+
argv.extend(["--sampler", profile.sampler])
198+
if trim is not None:
199+
argv.append("--trim" if trim else "--no-trim")
200+
if row.audio_profile:
201+
argv.extend(["--profile", row.audio_profile])
202+
elif profile.model_id:
203+
argv.extend(["-m", profile.model_id])
204+
if profile.half_precision is True:
205+
argv.append("--half")
206+
elif profile.half_precision is False:
207+
argv.append("--no-half")
208+
209+
170210
def _texture2d_profile_effective(profile: GameProfile) -> Texture2DProfile:
171211
"""Opções Texture2D do perfil ou defaults (para linhas CSV texture2d sem bloco no YAML)."""
172212
return profile.texture2d or Texture2DProfile()
@@ -770,8 +810,11 @@ def _build_context(
770810
manifest_path: Path,
771811
presets_local: Path | None,
772812
) -> tuple[GameProfile, list[ManifestRow], dict[str, Any], dict[str, Any]]:
813+
resolved = _resolve_manifest_path(manifest_path)
814+
if not resolved.is_file():
815+
raise click.ClickException(f"Manifest não encontrado: {manifest_path} (tentado {resolved})")
773816
profile = load_profile(profile_path)
774-
rows = load_manifest(manifest_path)
817+
rows = load_manifest(resolved)
775818
bundle = load_presets_bundle(presets_local)
776819
preset = get_preset(bundle, profile.style_preset)
777820
return profile, rows, bundle, preset
@@ -871,30 +914,42 @@ def mesh_reorigin_feet_cmd(path: Path, recursive: bool, dry_run: bool, excludes:
871914
console.print(Panel(f"[bold green]{ok}[/bold green] GLB(s) actualizados.", border_style="green"))
872915

873916

917+
def _resolve_manifest_path(raw: str | Path) -> Path:
918+
"""Resolve manifest path: if no extension, try .yaml, .yml, then .csv."""
919+
p = Path(raw)
920+
if p.suffix.lower() in (".csv", ".yaml", ".yml"):
921+
return p
922+
for ext in (".yaml", ".yml", ".csv"):
923+
candidate = p.with_suffix(ext)
924+
if candidate.is_file():
925+
return candidate
926+
return p.with_suffix(".yaml")
927+
928+
874929
@main.command("init")
875930
@click.option(
876931
"--path",
877932
"target_dir",
878933
type=click.Path(file_okay=False, writable=True, path_type=Path),
879934
default=".",
880-
help="Diretório onde criar game.yaml e manifest.csv",
935+
help="Diretório onde criar game.yaml e manifest.yaml",
881936
)
882937
@click.option("--force", is_flag=True, help="Sobrescrever ficheiros existentes")
883938
def init_cmd(target_dir: Path, force: bool) -> None:
884-
"""Cria game.yaml e manifest.csv de exemplo."""
939+
"""Cria game.yaml e manifest.yaml de exemplo."""
885940
target_dir = target_dir.resolve()
886941
target_dir.mkdir(parents=True, exist_ok=True)
887942
gy = target_dir / "game.yaml"
888-
mc = target_dir / "manifest.csv"
943+
my = target_dir / "manifest.yaml"
889944
if gy.exists() and not force:
890945
raise click.ClickException(f"Já existe {gy} (usa --force para sobrescrever)")
891-
if mc.exists() and not force:
892-
raise click.ClickException(f"Já existe {mc} (usa --force para sobrescrever)")
946+
if my.exists() and not force:
947+
raise click.ClickException(f"Já existe {my} (usa --force para sobrescrever)")
893948
gy.write_text(GAME_YAML, encoding="utf-8")
894-
mc.write_text(MANIFEST_CSV, encoding="utf-8")
949+
my.write_text(MANIFEST_YAML, encoding="utf-8")
895950
console.print(
896951
Panel(
897-
f"Criados [bold cyan]{gy}[/bold cyan] e [bold cyan]{mc}[/bold cyan].\n\n"
952+
f"Criados [bold cyan]{gy}[/bold cyan] e [bold cyan]{my}[/bold cyan].\n\n"
898953
"Seguinte: edita o perfil, preenche o manifest, depois "
899954
"[bold]gameassets prompts[/bold] ou [bold]gameassets batch[/bold].",
900955
title="[bold green]init[/bold green]",
@@ -958,9 +1013,9 @@ def row(name: str, env: str, exe: str) -> None:
9581013
@click.option(
9591014
"--manifest",
9601015
"manifest_path",
961-
type=click.Path(exists=True, dir_okay=False, path_type=Path),
962-
default="manifest.csv",
963-
help="CSV com id, idea e colunas opcionais",
1016+
type=click.Path(dir_okay=False, path_type=Path),
1017+
default="manifest",
1018+
help="CSV/YAML com id, idea e colunas opcionais",
9641019
)
9651020
@click.option(
9661021
"--presets-local",
@@ -1052,8 +1107,8 @@ def prompts_cmd(
10521107
@click.option(
10531108
"--manifest",
10541109
"manifest_path",
1055-
type=click.Path(exists=True, dir_okay=False, path_type=Path),
1056-
default="manifest.csv",
1110+
type=click.Path(dir_okay=False, path_type=Path),
1111+
default="manifest",
10571112
)
10581113
@click.option(
10591114
"--presets-local",
@@ -1117,9 +1172,13 @@ def handoff_cmd(
11171172
"""Copia GLB/áudio do ``output_dir`` do perfil para ``public/assets`` e grava ``gameassets_handoff.json``."""
11181173
from .handoff_export import handoff_command_impl
11191174

1175+
resolved = _resolve_manifest_path(manifest_path)
1176+
if not resolved.is_file():
1177+
raise click.ClickException(f"Manifest não encontrado: {manifest_path} (tentado {resolved})")
1178+
11201179
handoff_command_impl(
11211180
profile_path,
1122-
manifest_path,
1181+
resolved,
11231182
presets_local,
11241183
public_dir,
11251184
copy=use_copy,
@@ -1141,8 +1200,8 @@ def handoff_cmd(
11411200
@click.option(
11421201
"--manifest",
11431202
"manifest_path",
1144-
type=click.Path(exists=True, dir_okay=False, path_type=Path),
1145-
default="manifest.csv",
1203+
type=click.Path(dir_okay=False, path_type=Path),
1204+
default="manifest",
11461205
)
11471206
@click.option(
11481207
"--presets-local",
@@ -1268,6 +1327,7 @@ def batch_cmd(
12681327
) -> None:
12691328
"""Gera imagens (e opcionalmente meshes) para cada linha do manifest."""
12701329
profile, rows, _bundle, preset = _build_context(profile_path, manifest_path, presets_local)
1330+
manifest_path = _resolve_manifest_path(manifest_path)
12711331

12721332
has_rigging_profile = profile.rigging3d is not None
12731333
has_parts_profile = profile.part3d is not None
@@ -1550,7 +1610,7 @@ def append_log(rec: dict[str, Any]) -> None:
15501610
seed_a = _seed_for_row(profile, f"{row.id}:audio")
15511611
if seed_a is not None:
15521612
argv_au.extend(["--seed", str(seed_a)])
1553-
_append_text2sound_profile_args(ts_line, argv_au)
1613+
_text2sound_args_for_row(ts_line, row, argv_au)
15541614
if profile.text3d and profile.text3d.low_vram:
15551615
argv_au.append("--low-vram")
15561616
_dry_run_emit(dry_plan, phase=p1_title + " text2sound", row_id=row.id, argv=argv_au)
@@ -1577,7 +1637,7 @@ def append_log(rec: dict[str, Any]) -> None:
15771637
seed_a = _seed_for_row(profile, f"{row.id}:audio")
15781638
if seed_a is not None:
15791639
argv_au.extend(["--seed", str(seed_a)])
1580-
_append_text2sound_profile_args(ts_line, argv_au)
1640+
_text2sound_args_for_row(ts_line, row, argv_au)
15811641
if profile.text3d and profile.text3d.low_vram:
15821642
argv_au.append("--low-vram")
15831643
_dry_run_emit(
@@ -2035,7 +2095,7 @@ def append_log(rec: dict[str, Any]) -> None:
20352095
seed_a = _seed_for_row(profile, f"{row.id}:audio")
20362096
if seed_a is not None:
20372097
argv_au.extend(["--seed", str(seed_a)])
2038-
_append_text2sound_profile_args(ts_line, argv_au)
2098+
_text2sound_args_for_row(ts_line, row, argv_au)
20392099
if profile.text3d and profile.text3d.low_vram:
20402100
argv_au.append("--low-vram")
20412101
t_au = time.perf_counter()
@@ -2592,8 +2652,8 @@ def _text3d_argv(
25922652
@click.option(
25932653
"--manifest",
25942654
"manifest_path",
2595-
type=click.Path(exists=True, dir_okay=False, path_type=Path),
2596-
default="manifest.csv",
2655+
type=click.Path(dir_okay=False, path_type=Path),
2656+
default="manifest",
25972657
)
25982658
@click.option(
25992659
"--presets-local",
@@ -2648,6 +2708,7 @@ def resume_cmd(
26482708
raise click.ClickException("--gpu-ids deve ser lista separada por vírgulas (ex.: '0,1')") from _err
26492709

26502710
profile, rows, _bundle, preset = _build_context(profile_path, manifest_path, presets_local)
2711+
manifest_path = _resolve_manifest_path(manifest_path)
26512712
manifest_dir = manifest_path.resolve().parent
26522713
t3_opts = profile.text3d
26532714

GameAssets/src/gameassets/dream/emitter.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,33 @@ def emit_manifest_csv(plan: DreamPlan) -> str:
100100
return buf.getvalue()
101101

102102

103+
def emit_manifest_yaml(plan: DreamPlan) -> str:
104+
"""Gera string YAML do manifest a partir do DreamPlan."""
105+
assets: list[dict[str, Any]] = []
106+
for a in plan.assets:
107+
pipeline: list[str] = []
108+
if a.generate_3d:
109+
pipeline.append("3d")
110+
if a.generate_audio:
111+
pipeline.append("audio")
112+
if a.generate_rig:
113+
pipeline.append("rig")
114+
if a.generate_animate:
115+
pipeline.append("animate")
116+
if a.generate_parts:
117+
pipeline.append("parts")
118+
entry: dict[str, Any] = {
119+
"id": a.id,
120+
"idea": a.idea,
121+
"kind": a.kind or "prop",
122+
"pipeline": pipeline,
123+
}
124+
if a.generate_audio:
125+
entry["audio"] = {"duration": 2, "profile": "effects"}
126+
assets.append(entry)
127+
return yaml.dump({"assets": assets}, default_flow_style=False, allow_unicode=True, sort_keys=False)
128+
129+
103130
# ---------------------------------------------------------------------------
104131
# world XML (scene for VibeGame <Scene>)
105132
# ---------------------------------------------------------------------------
@@ -255,9 +282,9 @@ def emit_all(
255282
game_yaml.write_text(emit_game_yaml(plan, with_audio=with_audio), encoding="utf-8")
256283
paths["game_yaml"] = game_yaml
257284

258-
manifest = output_dir / "manifest.csv"
259-
manifest.write_text(emit_manifest_csv(plan), encoding="utf-8")
260-
paths["manifest_csv"] = manifest
285+
manifest = output_dir / "manifest.yaml"
286+
manifest.write_text(emit_manifest_yaml(plan), encoding="utf-8")
287+
paths["manifest_yaml"] = manifest
261288

262289
world_xml_str = emit_world_xml(plan)
263290
world_xml_path = output_dir / "world.xml"

GameAssets/src/gameassets/manifest.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
"""Leitura do manifest CSV."""
1+
"""Leitura do manifest CSV ou YAML."""
22

33
from __future__ import annotations
44

55
import csv
6+
import io
67
from collections.abc import Iterator
78
from dataclasses import dataclass
89
from pathlib import Path
@@ -32,6 +33,13 @@ class ManifestRow:
3233
part3d_steps: int | None = None
3334
part3d_octree_resolution: int | None = None
3435
part3d_segment_only: bool | None = None
36+
# Per-row audio config (from YAML only; CSV falls back to profile global)
37+
audio_duration: float | None = None
38+
audio_profile: str | None = None # "music" or "effects"
39+
audio_trim: bool | None = None
40+
audio_preset: str | None = None
41+
audio_steps: int | None = None
42+
audio_cfg_scale: float | None = None
3543

3644

3745
def effective_image_source(profile: GameProfile, row: ManifestRow) -> str:
@@ -66,12 +74,20 @@ def _parse_int(value: str | None) -> int | None:
6674
return None
6775

6876

69-
def load_manifest(path: Path) -> list[ManifestRow]:
77+
def _parse_float(value: str | None) -> float | None:
78+
if value is None or str(value).strip() == "":
79+
return None
80+
try:
81+
return float(str(value).strip())
82+
except ValueError:
83+
return None
84+
85+
86+
def _load_manifest_csv(path: Path) -> list[ManifestRow]:
7087
"""Lê CSV: id, idea; opcionais kind, generate_3d, image_source, generate_audio,
7188
generate_rig, generate_animate, generate_parts, category, part3d_steps,
7289
part3d_octree_resolution, part3d_segment_only."""
7390
rows: list[ManifestRow] = []
74-
import io
7591

7692
with path.open("r", encoding="utf-8", newline="") as f:
7793
# Skip comment lines (starting with #) and blank lines before the header
@@ -166,5 +182,60 @@ def load_manifest(path: Path) -> list[ManifestRow]:
166182
return rows
167183

168184

185+
def _load_manifest_yaml(path: Path) -> list[ManifestRow]:
186+
"""Lê YAML: assets com pipeline, audio, part3d sub-configs."""
187+
import yaml
188+
189+
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
190+
assets = doc if isinstance(doc, list) else doc.get("assets", [])
191+
rows: list[ManifestRow] = []
192+
for entry in assets:
193+
pipeline = entry.get("pipeline", [])
194+
pipeline_items = [p.strip().lower() for p in pipeline] if isinstance(pipeline, list) else []
195+
196+
audio_cfg = entry.get("audio") or {}
197+
if not isinstance(audio_cfg, dict):
198+
audio_cfg = {}
199+
200+
part3d_cfg = entry.get("part3d") or {}
201+
if not isinstance(part3d_cfg, dict):
202+
part3d_cfg = {}
203+
204+
rows.append(
205+
ManifestRow(
206+
id=entry["id"],
207+
idea=entry["idea"],
208+
kind=entry.get("kind"),
209+
generate_3d="3d" in pipeline_items,
210+
generate_audio="audio" in pipeline_items,
211+
generate_rig="rig" in pipeline_items,
212+
generate_animate="animate" in pipeline_items,
213+
generate_parts="parts" in pipeline_items,
214+
image_source=entry.get("image_source"),
215+
category=(entry.get("category") or "").lower(),
216+
part3d_steps=part3d_cfg.get("steps"),
217+
part3d_octree_resolution=part3d_cfg.get("octree_resolution"),
218+
part3d_segment_only=part3d_cfg.get("segment_only"),
219+
audio_duration=audio_cfg.get("duration"),
220+
audio_profile=audio_cfg.get("profile"),
221+
audio_trim=audio_cfg.get("trim"),
222+
audio_preset=audio_cfg.get("preset"),
223+
audio_steps=audio_cfg.get("steps"),
224+
audio_cfg_scale=audio_cfg.get("cfg_scale"),
225+
)
226+
)
227+
if not rows:
228+
raise ValueError("Nenhuma linha válida no manifest (id + idea obrigatórios)")
229+
return rows
230+
231+
232+
def load_manifest(path: Path) -> list[ManifestRow]:
233+
"""Lê manifest CSV ou YAML (auto-detect pela extensão)."""
234+
suffix = path.suffix.lower()
235+
if suffix in (".yaml", ".yml"):
236+
return _load_manifest_yaml(path)
237+
return _load_manifest_csv(path)
238+
239+
169240
def iter_manifest(path: Path) -> Iterator[ManifestRow]:
170241
yield from load_manifest(path)

0 commit comments

Comments
 (0)