@@ -19,14 +19,26 @@ class ShotSpec:
1919 viewport_preset : str | None = None
2020 viewport : dict [str , int ] | None = None # width/height/scale
2121 full_page : bool | None = None
22+ label : str | None = None # per-shot label override
23+
24+
25+ @dataclass
26+ class ShotGroup :
27+ id : str
28+ shots : list [ShotSpec ]
29+ output : str = "png" # "png" or "pdf"
30+ label : str | None = None # template string applied to all shots
31+ label_date : bool = False # add date/time line below the label
32+ folder : str | None = None # override subfolder name (defaults to id)
2233
2334
2435@dataclass
2536class RunConfig :
2637 base_url : str
2738 start : str
2839 defaults : dict [str , Any ]
29- shots : list [ShotSpec ]
40+ groups : list [ShotGroup ]
41+ out_dir : str = "shots_out"
3042
3143
3244def _require_str (obj : dict [str , Any ], key : str ) -> str :
@@ -35,6 +47,28 @@ def _require_str(obj: dict[str, Any], key: str) -> str:
3547 return obj [key ].strip ()
3648
3749
50+ def _parse_shot (s : dict [str , Any ], ctx : str ) -> ShotSpec :
51+ """Parse a single shot dict into a ShotSpec."""
52+ if not isinstance (s , dict ):
53+ raise ValueError (f"{ ctx } must be an object." )
54+ sid = _require_str (s , "id" )
55+ desc = _require_str (s , "description" )
56+
57+ viewport = s .get ("viewport" )
58+ if viewport is not None and not isinstance (viewport , dict ):
59+ raise ValueError (f"{ ctx } .viewport must be an object if provided." )
60+
61+ return ShotSpec (
62+ id = sid ,
63+ description = desc ,
64+ url = str (s ["url" ]).strip () if s .get ("url" ) else None ,
65+ viewport_preset = str (s ["viewport_preset" ]).strip () if s .get ("viewport_preset" ) else None ,
66+ viewport = {k : int (v ) for k , v in viewport .items ()} if viewport else None ,
67+ full_page = bool (s ["full_page" ]) if "full_page" in s else None ,
68+ label = str (s ["label" ]).strip ().replace ("\\ n" , "\n " ) if s .get ("label" ) else None ,
69+ )
70+
71+
3872def load_config (path : str ) -> RunConfig :
3973 p = pathlib .Path (path ).resolve ()
4074 raw_text = p .read_text (encoding = "utf-8" )
@@ -51,34 +85,63 @@ def load_config(path: str) -> RunConfig:
5185
5286 base_url = _require_str (data , "base_url" ).rstrip ("/" )
5387 start = str (data .get ("start" , "/" )).strip () or "/"
88+ out_dir = str (data .get ("out_dir" , "shots_out" )).strip () or "shots_out"
5489 defaults = data .get ("defaults" , {}) or {}
5590 if not isinstance (defaults , dict ):
5691 raise ValueError ("defaults must be an object." )
5792
58- shots_raw = data .get ("shots" , [])
59- if not isinstance (shots_raw , list ) or not shots_raw :
60- raise ValueError ("shots must be a non-empty list." )
61-
62- shots : list [ShotSpec ] = []
63- for idx , s in enumerate (shots_raw ):
64- if not isinstance (s , dict ):
65- raise ValueError (f"shots[{ idx } ] must be an object." )
66- sid = _require_str (s , "id" )
67- desc = _require_str (s , "description" )
68-
69- viewport = s .get ("viewport" )
70- if viewport is not None and not isinstance (viewport , dict ):
71- raise ValueError (f"shots[{ idx } ].viewport must be an object if provided." )
72-
73- shots .append (
74- ShotSpec (
75- id = sid ,
76- description = desc ,
77- url = str (s ["url" ]).strip () if s .get ("url" ) else None ,
78- viewport_preset = str (s ["viewport_preset" ]).strip () if s .get ("viewport_preset" ) else None ,
79- viewport = {k : int (v ) for k , v in viewport .items ()} if viewport else None ,
80- full_page = bool (s ["full_page" ]) if "full_page" in s else None ,
81- )
82- )
83-
84- return RunConfig (base_url = base_url , start = start , defaults = defaults , shots = shots )
93+ has_groups = "groups" in data
94+ has_shots = "shots" in data
95+
96+ if has_groups and has_shots :
97+ raise ValueError ("Config cannot have both 'groups' and 'shots'. Use one or the other." )
98+ if not has_groups and not has_shots :
99+ raise ValueError ("Config must have either 'groups' or 'shots'." )
100+
101+ groups : list [ShotGroup ] = []
102+
103+ if has_groups :
104+ groups_raw = data ["groups" ]
105+ if not isinstance (groups_raw , list ) or not groups_raw :
106+ raise ValueError ("groups must be a non-empty list." )
107+
108+ for gi , g in enumerate (groups_raw ):
109+ if not isinstance (g , dict ):
110+ raise ValueError (f"groups[{ gi } ] must be an object." )
111+ gid = _require_str (g , "id" )
112+ output = str (g .get ("output" , "png" )).strip ().lower ()
113+ if output not in ("png" , "pdf" ):
114+ raise ValueError (f"groups[{ gi } ].output must be 'png' or 'pdf', got '{ output } '." )
115+
116+ shots_raw = g .get ("shots" , [])
117+ if not isinstance (shots_raw , list ) or not shots_raw :
118+ raise ValueError (f"groups[{ gi } ].shots must be a non-empty list." )
119+
120+ shots = [_parse_shot (s , f"groups[{ gi } ].shots[{ si } ]" ) for si , s in enumerate (shots_raw )]
121+
122+ if output == "png" and len (shots ) > 1 :
123+ raise ValueError (
124+ f"groups[{ gi } ] ('{ gid } '): output='png' requires exactly 1 shot, got { len (shots )} . "
125+ "Use output='pdf' for multi-shot groups."
126+ )
127+
128+ groups .append (ShotGroup (
129+ id = gid ,
130+ shots = shots ,
131+ output = output ,
132+ label = str (g ["label" ]).strip ().replace ("\\ n" , "\n " ) if g .get ("label" ) else None ,
133+ label_date = bool (g .get ("label_date" , False )),
134+ folder = str (g ["folder" ]).strip () if g .get ("folder" ) else None ,
135+ ))
136+
137+ else :
138+ # Flat shots list — auto-wrap each into its own group
139+ shots_raw = data ["shots" ]
140+ if not isinstance (shots_raw , list ) or not shots_raw :
141+ raise ValueError ("shots must be a non-empty list." )
142+
143+ for si , s in enumerate (shots_raw ):
144+ shot = _parse_shot (s , f"shots[{ si } ]" )
145+ groups .append (ShotGroup (id = shot .id , shots = [shot ]))
146+
147+ return RunConfig (base_url = base_url , start = start , defaults = defaults , groups = groups , out_dir = out_dir )
0 commit comments