From d00af916365f14962cab66b209f3e2f7cc5b5a8c Mon Sep 17 00:00:00 2001 From: David Collado Date: Sun, 14 Jun 2026 14:10:21 +0200 Subject: [PATCH 1/7] Add configurable parallel screenshot rendering --- README.md | 6 + src/koubou/cli.py | 10 ++ src/koubou/config.py | 8 + src/koubou/generator.py | 314 ++++++++++++++++++++++++++++------------ tests/test_cli.py | 31 ++++ tests/test_config.py | 18 +++ tests/test_generator.py | 178 ++++++++++++++++++++++- 7 files changed, 470 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 01465bc..f958397 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ screenshots: - `variables:` are localizable `{{key}}` substitutions - `assets:` are file-path substitutions exposed to the template as `{{key}}` - `kou generate config.yaml --setup-html` prepares HTML rendering and generates in one run +- `kou generate config.yaml --parallel-workers 4` renders multiple screenshots concurrently - `kou live config.yaml --setup-html` does the same before starting live mode - `kou inspect-frame "" --output-size --output json` exposes frame and screen geometry for layout decisions @@ -260,6 +261,7 @@ project: output_dir: "Screenshots/Generated" device: "iPhone 15 Pro Portrait" output_size: "iPhone6_9" + parallel_workers: 4 # Optional - render up to 4 screenshots concurrently localization: base_language: "en" @@ -470,6 +472,9 @@ See the YAML API Reference below for all available options including gradients, # Emit machine-readable results kou generate config.yaml --output json +# Override the worker count for this run +kou generate config.yaml --parallel-workers 4 + # Prepare HTML rendering and generate in one run kou generate config.yaml --setup-html @@ -478,6 +483,7 @@ kou generate config.yaml --verbose ``` For HTML screenshots, `--output json` includes `layout_path` alongside the PNG path so tooling can open the measured layout sidecar directly. +Set `project.parallel_workers` in YAML when you want the same concurrency level to apply to every run. #### HTML Setup ```bash diff --git a/src/koubou/cli.py b/src/koubou/cli.py index 6e6c1df..2322a81 100644 --- a/src/koubou/cli.py +++ b/src/koubou/cli.py @@ -703,6 +703,12 @@ def generate( output: str = typer.Option( "table", "--output", help="Output format: table or json" ), + parallel_workers: int = typer.Option( + None, + "--parallel-workers", + min=1, + help="Override the number of screenshots rendered concurrently", + ), setup_html: bool = typer.Option( False, "--setup-html", @@ -727,6 +733,10 @@ def generate( with open(config_file) as f: config_data = yaml.safe_load(f) + if parallel_workers is not None: + config_data.setdefault("project", {}) + config_data["project"]["parallel_workers"] = parallel_workers + try: project_config = ProjectConfig(**config_data) stderr_console.print("Using flexible content-based API", style="blue") diff --git a/src/koubou/config.py b/src/koubou/config.py index d30be10..20ee69b 100644 --- a/src/koubou/config.py +++ b/src/koubou/config.py @@ -789,6 +789,14 @@ class ProjectInfo(BaseModel): name: str = Field(..., description="Project name") output_dir: str = Field(default="output", description="Output directory") device: str = Field(..., description="Target device frame") + parallel_workers: int = Field( + default=1, + ge=1, + description=( + "Number of screenshots to render concurrently. " + "Use 1 to keep sequential rendering." + ), + ) output_size: Tuple[int, int] = Field( default=(1320, 2868), # Default to iPhone6_9 dimensions description=( diff --git a/src/koubou/generator.py b/src/koubou/generator.py index 1b9ff95..3bd2b52 100644 --- a/src/koubou/generator.py +++ b/src/koubou/generator.py @@ -4,6 +4,7 @@ import json import logging import tempfile +from concurrent.futures import ThreadPoolExecutor, as_completed from collections import deque from dataclasses import dataclass from pathlib import Path @@ -44,6 +45,27 @@ def cleanup(self) -> None: path.unlink(missing_ok=True) +@dataclass +class GenerationTask: + """Prepared screenshot generation work item.""" + + order_index: int + screenshot_id: str + language: str + output_dir: str + output_size: Tuple[int, int] + config_dir: Optional[Path] + device_frame_name: Optional[str] + screenshot_def: Any + screenshot_config: Optional[ScreenshotConfig] = None + base_language: Optional[str] = None + xcstrings_manager: Optional[Any] = None + + @property + def is_html(self) -> bool: + return self.screenshot_config is None + + def resolve_localized_asset( asset: Union[str, Dict[str, str]], language: str, @@ -1065,6 +1087,186 @@ def generate_project( # Use unified generation approach (handles both single and multi-language) return self._generate_localized_project(project_config, config_dir) + def _build_generation_tasks( + self, + project_config: ProjectConfig, + *, + config_dir: Path, + localization_config: Optional[Any], + xcstrings_manager: Optional[Any], + content_resolver: Optional[Any], + default_background: Optional[GradientConfig], + device: str, + output_size: Tuple[int, int], + languages: List[str], + ) -> List[GenerationTask]: + """Build ordered generation tasks for a project.""" + from copy import deepcopy + + tasks: List[GenerationTask] = [] + for language in languages: + logger.info( + f"๐ŸŒ Generating screenshots for device: {device}, " + f"language: {language}" + ) + + for i, (screenshot_id, screenshot_def) in enumerate( + project_config.screenshots.items(), 1 + ): + logger.info( + f"[{device}] [{language}] " + f"[{i}/{len(project_config.screenshots)}] {screenshot_id}" + ) + + if localization_config: + device_output_dir = str( + Path(project_config.project.output_dir) + / language + / device.replace(" ", "_") + ) + else: + device_output_dir = str( + Path(project_config.project.output_dir) + / device.replace(" ", "_") + ) + + base_language = ( + localization_config.base_language if localization_config else None + ) + + if screenshot_def.template: + tasks.append( + GenerationTask( + order_index=len(tasks), + screenshot_id=screenshot_id, + language=language, + output_dir=device_output_dir, + output_size=output_size, + config_dir=config_dir, + device_frame_name=device, + screenshot_def=screenshot_def, + base_language=base_language, + xcstrings_manager=xcstrings_manager, + ) + ) + continue + + if localization_config and screenshot_def.content: + assert content_resolver is not None + localized_content = content_resolver.localize_content_items( + screenshot_def.content, language + ) + processed_screenshot_def = deepcopy(screenshot_def) + processed_screenshot_def.content = localized_content + else: + processed_screenshot_def = screenshot_def + + temp_config = self._convert_to_screenshot_config( + processed_screenshot_def, + device, + default_background, + device_output_dir, + config_dir, + screenshot_id, + output_size=output_size, + language=language, + base_language=base_language, + ) + if not temp_config: + logger.warning( + f"Skipping {screenshot_id} for {device}/{language}: " + f"no source image found" + ) + continue + + tasks.append( + GenerationTask( + order_index=len(tasks), + screenshot_id=screenshot_id, + language=language, + output_dir=device_output_dir, + output_size=output_size, + config_dir=config_dir, + device_frame_name=device, + screenshot_def=processed_screenshot_def, + screenshot_config=temp_config, + base_language=base_language, + ) + ) + + return tasks + + def _run_generation_task( + self, task: GenerationTask, generator: Optional["ScreenshotGenerator"] = None + ) -> Path: + """Execute a prepared generation task.""" + task_generator = generator or ScreenshotGenerator( + frame_directory=str(self.frame_directory) + ) + + try: + if task.is_html: + return task_generator._generate_html_screenshot( + screenshot_def=task.screenshot_def, + screenshot_id=task.screenshot_id, + output_dir=task.output_dir, + output_size=task.output_size, + config_dir=task.config_dir, + device_frame_name=task.device_frame_name, + language=task.language, + base_language=task.base_language, + xcstrings_manager=task.xcstrings_manager, + ) + + assert task.screenshot_config is not None + return task_generator.generate_screenshot(task.screenshot_config) + finally: + if generator is None and task_generator._html_renderer: + task_generator._html_renderer.close() + task_generator._html_renderer = None + + def _execute_generation_tasks( + self, tasks: List[GenerationTask], parallel_workers: int + ) -> List[Path]: + """Run generation tasks sequentially or in parallel while preserving order.""" + if not tasks: + return [] + + worker_count = max(1, min(parallel_workers, len(tasks))) + if worker_count == 1: + results: List[Path] = [] + for task in tasks: + try: + results.append(self._run_generation_task(task, generator=self)) + except Exception as _e: + logger.error( + f"Failed to generate {task.screenshot_id} for " + f"{task.device_frame_name}/{task.language}: {_e}" + ) + return results + + logger.info( + f"โšก Parallel rendering enabled with {worker_count} workers " + f"for {len(tasks)} screenshot task(s)" + ) + + completed: Dict[int, Path] = {} + with ThreadPoolExecutor(max_workers=worker_count) as executor: + future_to_task = { + executor.submit(self._run_generation_task, task): task for task in tasks + } + for future in as_completed(future_to_task): + task = future_to_task[future] + try: + completed[task.order_index] = future.result() + except Exception as _e: + logger.error( + f"Failed to generate {task.screenshot_id} for " + f"{task.device_frame_name}/{task.language}: {_e}" + ) + + return [completed[index] for index in sorted(completed)] + def _generate_localized_project( self, project_config: ProjectConfig, config_dir: Optional[Path] = None ) -> List[Path]: @@ -1085,6 +1287,9 @@ def _generate_localized_project( if not config_dir: config_dir = Path.cwd() + xcstrings_manager = None + content_resolver = None + if localization_config: xcstrings_manager = XCStringsManager(localization_config, config_dir) content_resolver = LocalizedContentResolver(xcstrings_manager) @@ -1122,101 +1327,22 @@ def _generate_localized_project( ] = project_config.project.output_size # type: ignore[assignment] logger.info(f"๐Ÿ“ฑ Processing device: {device}") logger.info(f"๐Ÿ“ Output size: {output_size}") + logger.info(f"๐Ÿงต Parallel workers: {project_config.project.parallel_workers}") - all_results = [] - - # Generate screenshots for each language - for language in languages: - logger.info( - f"๐ŸŒ Generating screenshots for device: {device}, " - f"language: {language}" - ) - - for i, (screenshot_id, screenshot_def) in enumerate( - project_config.screenshots.items(), 1 - ): - logger.info( - f"[{device}] [{language}] " - f"[{i}/{len(project_config.screenshots)}] {screenshot_id}" - ) - try: - # Generate device and language-specific output directory - if localization_config: - device_output_dir = str( - Path(project_config.project.output_dir) - / language - / device.replace(" ", "_") - ) - else: - device_output_dir = str( - Path(project_config.project.output_dir) - / device.replace(" ", "_") - ) - - # HTML template mode: bypass PIL pipeline entirely - if screenshot_def.template: - xcm = xcstrings_manager if localization_config else None - output_path = self._generate_html_screenshot( - screenshot_def=screenshot_def, - screenshot_id=screenshot_id, - output_dir=device_output_dir, - output_size=output_size, - config_dir=config_dir, - device_frame_name=device, - language=language, - base_language=( - localization_config.base_language - if localization_config - else None - ), - xcstrings_manager=xcm, - ) - all_results.append(output_path) - continue - - # Content mode: standard PIL pipeline - if localization_config and screenshot_def.content: - localized_content = content_resolver.localize_content_items( - screenshot_def.content, language - ) - from copy import deepcopy - - processed_screenshot_def = deepcopy(screenshot_def) - processed_screenshot_def.content = localized_content - else: - processed_screenshot_def = screenshot_def - - base_lang = ( - localization_config.base_language - if localization_config - else None - ) - temp_config = self._convert_to_screenshot_config( - processed_screenshot_def, - device, - default_background, - device_output_dir, - config_dir, - screenshot_id, - output_size=output_size, - language=language, - base_language=base_lang, - ) - if temp_config: - output_path = self.generate_screenshot(temp_config) - all_results.append(output_path) - else: - logger.warning( - f"Skipping {screenshot_id} for {device}/{language}: " - f"no source image found" - ) - except Exception as _e: - logger.error( - f"Failed to generate {screenshot_id} for " - f"{device}/{language}: {_e}" - ) - # Continue with next screenshot instead of failing project - continue + tasks = self._build_generation_tasks( + project_config, + config_dir=config_dir, + localization_config=localization_config, + xcstrings_manager=xcstrings_manager, + content_resolver=content_resolver, + default_background=default_background, + device=device, + output_size=output_size, + languages=languages, + ) + all_results = self._execute_generation_tasks( + tasks, project_config.project.parallel_workers + ) # Clean up HTML renderer if used if self._html_renderer: diff --git a/tests/test_cli.py b/tests/test_cli.py index 6022b82..4d3e481 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -427,6 +427,37 @@ def generate_project(self, project_config, config_dir): } ] + def test_generate_parallel_workers_override(self, monkeypatch): + """CLI flag should override project.parallel_workers before generation.""" + config_data = { + "project": { + "name": "Parallel CLI Test", + "output_dir": str(self.temp_dir / "output"), + "device": "iPhone 15 Pro Portrait", + }, + "screenshots": {}, + } + config_path = self.temp_dir / "parallel_config.yaml" + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + captured = {} + + class FakeGenerator: + def generate_project(self, project_config, config_dir): + captured["parallel_workers"] = project_config.project.parallel_workers + return [] + + monkeypatch.setattr("koubou.cli.ScreenshotGenerator", FakeGenerator) + + result = self.runner.invoke( + app, + ["generate", str(config_path), "--parallel-workers", "4"], + ) + + assert result.exit_code == 0 + assert captured["parallel_workers"] == 4 + def test_generate_json_includes_layout_path_for_html(self, monkeypatch): """HTML JSON output should expose the sidecar layout path.""" template_path = self.temp_dir / "hero.html" diff --git a/tests/test_config.py b/tests/test_config.py index 7e85b1c..9e9d659 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,6 +7,7 @@ ContentItem, GradientConfig, ProjectConfig, + ProjectInfo, ScreenshotConfig, TextBoxConfig, TextOverlay, @@ -259,6 +260,23 @@ def test_alignment_rejects_invalid_value(self): ) +class TestProjectInfo: + """Tests for project-level configuration.""" + + def test_parallel_workers_defaults_to_sequential(self): + project = ProjectInfo(name="Test", output_dir="output", device="iPhone 15 Pro") + assert project.parallel_workers == 1 + + def test_parallel_workers_rejects_zero(self): + with pytest.raises(ValidationError, match="greater than or equal to 1"): + ProjectInfo( + name="Test", + output_dir="output", + device="iPhone 15 Pro", + parallel_workers=0, + ) + + class TestContentItemLocalizedAssets: """Tests for ContentItem localized asset validation.""" diff --git a/tests/test_generator.py b/tests/test_generator.py index 3283f01..15b91e0 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -2,13 +2,15 @@ import shutil import tempfile +import threading +import time from pathlib import Path import pytest from PIL import Image from koubou.config import GradientConfig, ProjectConfig, ScreenshotConfig, TextOverlay -from koubou.generator import ScreenshotGenerator, resolve_font_family +from koubou.generator import GenerationTask, ScreenshotGenerator, resolve_font_family class TestScreenshotGenerator: @@ -1274,3 +1276,177 @@ def test_project_with_highlight_zoom_and_text(self): output_image = Image.open(results[0]) assert output_image.mode == "RGB" + + def test_parallel_execution_preserves_result_order(self, monkeypatch): + """Parallel generation should still return paths in task order.""" + tasks = [ + GenerationTask( + order_index=index, + screenshot_id=f"screenshot_{index}", + language="en", + output_dir=str(self.temp_dir), + output_size=(400, 800), + config_dir=self.temp_dir, + device_frame_name="Test Frame", + screenshot_def=None, + screenshot_config=ScreenshotConfig( + name=f"Shot {index}", + source_image=str(self.source_image_path), + output_size=(400, 800), + output_path=str(self.temp_dir / f"shot_{index}.png"), + ), + ) + for index in range(3) + ] + + def fake_run_generation_task(task, generator=None): + time.sleep({0: 0.08, 1: 0.01, 2: 0.04}[task.order_index]) + output_path = Path(task.screenshot_config.output_path) + output_path.write_text(task.screenshot_id, encoding="utf-8") + return output_path + + monkeypatch.setattr( + self.generator, "_run_generation_task", fake_run_generation_task + ) + + results = self.generator._execute_generation_tasks(tasks, parallel_workers=3) + + assert [path.name for path in results] == [ + "shot_0.png", + "shot_1.png", + "shot_2.png", + ] + + def test_parallel_execution_continues_after_failure(self, monkeypatch, caplog): + """A failed parallel task should not stop the rest of the batch.""" + tasks = [ + GenerationTask( + order_index=index, + screenshot_id=f"screenshot_{index}", + language="en", + output_dir=str(self.temp_dir), + output_size=(400, 800), + config_dir=self.temp_dir, + device_frame_name="Test Frame", + screenshot_def=None, + screenshot_config=ScreenshotConfig( + name=f"Shot {index}", + source_image=str(self.source_image_path), + output_size=(400, 800), + output_path=str(self.temp_dir / f"failure_{index}.png"), + ), + ) + for index in range(3) + ] + + def fake_run_generation_task(task, generator=None): + if task.order_index == 1: + raise RuntimeError("boom") + output_path = Path(task.screenshot_config.output_path) + output_path.write_text(task.screenshot_id, encoding="utf-8") + return output_path + + monkeypatch.setattr( + self.generator, "_run_generation_task", fake_run_generation_task + ) + + with caplog.at_level("ERROR"): + results = self.generator._execute_generation_tasks( + tasks, parallel_workers=3 + ) + + assert [path.name for path in results] == ["failure_0.png", "failure_2.png"] + assert "Failed to generate screenshot_1" in caplog.text + + def test_parallel_execution_uses_multiple_workers(self, monkeypatch): + """Parallel execution should run more than one task at a time.""" + tasks = [ + GenerationTask( + order_index=index, + screenshot_id=f"screenshot_{index}", + language="en", + output_dir=str(self.temp_dir), + output_size=(400, 800), + config_dir=self.temp_dir, + device_frame_name="Test Frame", + screenshot_def=None, + screenshot_config=ScreenshotConfig( + name=f"Shot {index}", + source_image=str(self.source_image_path), + output_size=(400, 800), + output_path=str(self.temp_dir / f"parallel_{index}.png"), + ), + ) + for index in range(4) + ] + state = {"active": 0, "peak": 0} + lock = threading.Lock() + + def fake_run_generation_task(task, generator=None): + with lock: + state["active"] += 1 + state["peak"] = max(state["peak"], state["active"]) + try: + time.sleep(0.03) + output_path = Path(task.screenshot_config.output_path) + output_path.write_text(task.screenshot_id, encoding="utf-8") + return output_path + finally: + with lock: + state["active"] -= 1 + + monkeypatch.setattr( + self.generator, "_run_generation_task", fake_run_generation_task + ) + + results = self.generator._execute_generation_tasks(tasks, parallel_workers=4) + + assert len(results) == 4 + assert state["peak"] >= 2 + + def test_project_generation_parallel_workers_renders_end_to_end(self): + """Project generation should render successfully with parallel workers.""" + from koubou.config import ContentItem, ProjectInfo, ScreenshotDefinition + + project_config = ProjectConfig( + project=ProjectInfo( + name="Parallel Project", + output_dir=str(self.temp_dir / "parallel_output"), + device="iPhone 15 Pro Portrait", + parallel_workers=2, + ), + screenshots={ + "screenshot1": ScreenshotDefinition( + content=[ + ContentItem( + type="image", + asset=str(self.source_image_path), + position=("50%", "50%"), + ) + ], + frame=False, + ), + "screenshot2": ScreenshotDefinition( + content=[ + ContentItem( + type="image", + asset=str(self.source_image_path), + position=("50%", "50%"), + ), + ContentItem( + type="text", + content="Parallel", + position=("50%", "20%"), + ), + ], + frame=False, + ), + }, + ) + + results = self.generator.generate_project(project_config) + + assert len(results) == 2 + assert [path.name for path in results] == ["screenshot1.png", "screenshot2.png"] + for result_path in results: + assert result_path.exists() From a8ea35099316e3dc1f7e9c64d043209d0d7c997c Mon Sep 17 00:00:00 2001 From: David Collado Date: Sun, 14 Jun 2026 14:13:42 +0200 Subject: [PATCH 2/7] Serialize HTML rendering in parallel mode --- src/koubou/generator.py | 47 ++++++++++++++++------ tests/test_generator.py | 89 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/koubou/generator.py b/src/koubou/generator.py index 3bd2b52..8e0e879 100644 --- a/src/koubou/generator.py +++ b/src/koubou/generator.py @@ -1245,25 +1245,46 @@ def _execute_generation_tasks( ) return results + html_tasks = [task for task in tasks if task.is_html] + content_tasks = [task for task in tasks if not task.is_html] + logger.info( f"โšก Parallel rendering enabled with {worker_count} workers " f"for {len(tasks)} screenshot task(s)" ) + if html_tasks: + logger.info( + "๐Ÿ›ก๏ธ HTML screenshots will render sequentially with a shared " + "browser to avoid Chrome/Playwright crashes" + ) completed: Dict[int, Path] = {} - with ThreadPoolExecutor(max_workers=worker_count) as executor: - future_to_task = { - executor.submit(self._run_generation_task, task): task for task in tasks - } - for future in as_completed(future_to_task): - task = future_to_task[future] - try: - completed[task.order_index] = future.result() - except Exception as _e: - logger.error( - f"Failed to generate {task.screenshot_id} for " - f"{task.device_frame_name}/{task.language}: {_e}" - ) + if content_tasks: + with ThreadPoolExecutor(max_workers=worker_count) as executor: + future_to_task = { + executor.submit(self._run_generation_task, task): task + for task in content_tasks + } + for future in as_completed(future_to_task): + task = future_to_task[future] + try: + completed[task.order_index] = future.result() + except Exception as _e: + logger.error( + f"Failed to generate {task.screenshot_id} for " + f"{task.device_frame_name}/{task.language}: {_e}" + ) + + for task in html_tasks: + try: + completed[task.order_index] = self._run_generation_task( + task, generator=self + ) + except Exception as _e: + logger.error( + f"Failed to generate {task.screenshot_id} for " + f"{task.device_frame_name}/{task.language}: {_e}" + ) return [completed[index] for index in sorted(completed)] diff --git a/tests/test_generator.py b/tests/test_generator.py index 15b91e0..863a9de 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1450,3 +1450,92 @@ def test_project_generation_parallel_workers_renders_end_to_end(self): assert [path.name for path in results] == ["screenshot1.png", "screenshot2.png"] for result_path in results: assert result_path.exists() + + def test_parallel_execution_serializes_html_tasks(self, monkeypatch): + """HTML tasks should not run concurrently even when parallel mode is enabled.""" + tasks = [ + GenerationTask( + order_index=0, + screenshot_id="content_a", + language="en", + output_dir=str(self.temp_dir), + output_size=(400, 800), + config_dir=self.temp_dir, + device_frame_name="Test Frame", + screenshot_def=None, + screenshot_config=ScreenshotConfig( + name="Content A", + source_image=str(self.source_image_path), + output_size=(400, 800), + output_path=str(self.temp_dir / "content_a.png"), + ), + ), + GenerationTask( + order_index=1, + screenshot_id="html_a", + language="en", + output_dir=str(self.temp_dir), + output_size=(400, 800), + config_dir=self.temp_dir, + device_frame_name="Test Frame", + screenshot_def=object(), + ), + GenerationTask( + order_index=2, + screenshot_id="content_b", + language="en", + output_dir=str(self.temp_dir), + output_size=(400, 800), + config_dir=self.temp_dir, + device_frame_name="Test Frame", + screenshot_def=None, + screenshot_config=ScreenshotConfig( + name="Content B", + source_image=str(self.source_image_path), + output_size=(400, 800), + output_path=str(self.temp_dir / "content_b.png"), + ), + ), + GenerationTask( + order_index=3, + screenshot_id="html_b", + language="en", + output_dir=str(self.temp_dir), + output_size=(400, 800), + config_dir=self.temp_dir, + device_frame_name="Test Frame", + screenshot_def=object(), + ), + ] + + state = {"active_html": 0, "peak_html": 0} + html_generators = [] + lock = threading.Lock() + + def fake_run_generation_task(task, generator=None): + output_path = self.temp_dir / f"{task.screenshot_id}.png" + if task.is_html: + with lock: + state["active_html"] += 1 + state["peak_html"] = max(state["peak_html"], state["active_html"]) + html_generators.append(id(generator)) + try: + time.sleep(0.02) + finally: + with lock: + state["active_html"] -= 1 + else: + time.sleep(0.02) + + output_path.write_text(task.screenshot_id, encoding="utf-8") + return output_path + + monkeypatch.setattr( + self.generator, "_run_generation_task", fake_run_generation_task + ) + + results = self.generator._execute_generation_tasks(tasks, parallel_workers=4) + + assert len(results) == 4 + assert state["peak_html"] == 1 + assert html_generators == [id(self.generator), id(self.generator)] From 32ed1601bba5331e19d63ebf9c94eb826b5238b0 Mon Sep 17 00:00:00 2001 From: David Collado Date: Sun, 14 Jun 2026 14:35:49 +0200 Subject: [PATCH 3/7] Parallelize HTML rendering with shared browser --- src/koubou/generator.py | 165 ++++++++++++++++---- src/koubou/html_setup.py | 13 +- src/koubou/renderers/html_renderer.py | 215 +++++++++++++++++++++++++- tests/test_generator.py | 88 ++++++++--- tests/test_html_renderer.py | 67 ++++++++ 5 files changed, 493 insertions(+), 55 deletions(-) diff --git a/src/koubou/generator.py b/src/koubou/generator.py index 8e0e879..fea5d27 100644 --- a/src/koubou/generator.py +++ b/src/koubou/generator.py @@ -3,6 +3,7 @@ import io import json import logging +import shutil import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed from collections import deque @@ -66,6 +67,24 @@ def is_html(self) -> bool: return self.screenshot_config is None +@dataclass +class PreparedHtmlGenerationTask: + """Prepared HTML task staged on disk for batch rendering.""" + + task: GenerationTask + workspace_dir: Path + output_path: Path + layout_path: Path + cleanup_paths: List[Path] + + def cleanup(self) -> None: + for path in self.cleanup_paths: + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + else: + path.unlink(missing_ok=True) + + def resolve_localized_asset( asset: Union[str, Dict[str, str]], language: str, @@ -475,6 +494,118 @@ def _resolve_layout_output_path(self, output_path: Path) -> Path: """Resolve the sidecar layout JSON path for an HTML screenshot.""" return output_path.with_suffix(".layout.json") + def _save_html_render_result( + self, render_result: Any, output_path: Path, layout_path: Path + ) -> Path: + """Persist rendered HTML PNG bytes and sidecar layout JSON.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + + img = Image.open(io.BytesIO(render_result.png_bytes)) + rgb_img = Image.new("RGB", img.size, (255, 255, 255)) + rgb_img.paste(img, mask=img if img.mode == "RGBA" else None) + + if output_path.suffix.lower() == ".jpg": + rgb_img.save(output_path, "JPEG", quality=95) + else: + rgb_img.save(output_path, "PNG") + + layout_path.write_text( + json.dumps(render_result.layout, separators=(",", ":")), + encoding="utf-8", + ) + + logger.info(f"Generated HTML screenshot: {output_path}") + return output_path + + def _prepare_html_generation_task( + self, task: GenerationTask + ) -> PreparedHtmlGenerationTask: + """Stage one HTML task on disk so it can be rendered by the batch renderer.""" + workspace_dir = Path(tempfile.mkdtemp(prefix="koubou_html_batch_")) + prepared = self.prepare_html_screenshot( + task.screenshot_def, + task.config_dir, + device_frame_name=task.device_frame_name, + language=task.language, + base_language=task.base_language, + xcstrings_manager=task.xcstrings_manager, + assets_output_dir=workspace_dir, + ) + + try: + stage_html_workspace( + template_path=prepared.template_path, + variables=prepared.variables, + destination_dir=workspace_dir, + assets=prepared.assets, + ) + except Exception: + prepared.cleanup() + shutil.rmtree(workspace_dir, ignore_errors=True) + raise + + output_path = self._resolve_output_path( + task.output_dir, task.screenshot_id, task.config_dir + ) + layout_path = self._resolve_layout_output_path(output_path) + cleanup_paths = list(prepared.cleanup_paths) + cleanup_paths.append(workspace_dir) + return PreparedHtmlGenerationTask( + task=task, + workspace_dir=workspace_dir, + output_path=output_path, + layout_path=layout_path, + cleanup_paths=cleanup_paths, + ) + + def _execute_html_generation_tasks( + self, tasks: List[GenerationTask], parallel_workers: int + ) -> Dict[int, Path]: + """Render HTML tasks with a shared browser and isolated contexts.""" + if not tasks: + return {} + + from .renderers.html_renderer import HtmlBatchRenderTask, HtmlRenderer + + prepared_tasks: List[PreparedHtmlGenerationTask] = [] + try: + for task in tasks: + prepared_tasks.append(self._prepare_html_generation_task(task)) + + render_tasks = [ + HtmlBatchRenderTask( + workspace_dir=prepared_task.workspace_dir, + size=prepared_task.task.output_size, + ) + for prepared_task in prepared_tasks + ] + outcomes = HtmlRenderer.render_staged_batch_with_layout( + render_tasks, max_concurrency=parallel_workers + ) + + completed: Dict[int, Path] = {} + for prepared_task, outcome in zip(prepared_tasks, outcomes): + if outcome.error is not None: + logger.error( + f"Failed to generate {prepared_task.task.screenshot_id} for " + f"{prepared_task.task.device_frame_name}/" + f"{prepared_task.task.language}: {outcome.error}" + ) + continue + assert outcome.result is not None + completed[prepared_task.task.order_index] = ( + self._save_html_render_result( + outcome.result, + prepared_task.output_path, + prepared_task.layout_path, + ) + ) + + return completed + finally: + for prepared_task in prepared_tasks: + prepared_task.cleanup() + def _generate_html_screenshot( self, screenshot_def: Any, @@ -514,24 +645,7 @@ def _generate_html_screenshot( output_path = self._resolve_output_path(output_dir, screenshot_id, config_dir) layout_path = self._resolve_layout_output_path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - img = Image.open(io.BytesIO(render_result.png_bytes)) - rgb_img = Image.new("RGB", img.size, (255, 255, 255)) - rgb_img.paste(img, mask=img if img.mode == "RGBA" else None) - - if output_path.suffix.lower() == ".jpg": - rgb_img.save(output_path, "JPEG", quality=95) - else: - rgb_img.save(output_path, "PNG") - - layout_path.write_text( - json.dumps(render_result.layout, separators=(",", ":")), - encoding="utf-8", - ) - - logger.info(f"Generated HTML screenshot: {output_path}") - return output_path + return self._save_html_render_result(render_result, output_path, layout_path) def generate_screenshot(self, config: ScreenshotConfig) -> Path: """Generate a single screenshot based on configuration. @@ -1254,8 +1368,8 @@ def _execute_generation_tasks( ) if html_tasks: logger.info( - "๐Ÿ›ก๏ธ HTML screenshots will render sequentially with a shared " - "browser to avoid Chrome/Playwright crashes" + "๐ŸŒ HTML screenshots will render with a shared browser and " + "isolated contexts" ) completed: Dict[int, Path] = {} @@ -1275,16 +1389,7 @@ def _execute_generation_tasks( f"{task.device_frame_name}/{task.language}: {_e}" ) - for task in html_tasks: - try: - completed[task.order_index] = self._run_generation_task( - task, generator=self - ) - except Exception as _e: - logger.error( - f"Failed to generate {task.screenshot_id} for " - f"{task.device_frame_name}/{task.language}: {_e}" - ) + completed.update(self._execute_html_generation_tasks(html_tasks, worker_count)) return [completed[index] for index in sorted(completed)] diff --git a/src/koubou/html_setup.py b/src/koubou/html_setup.py index e282829..601d54a 100644 --- a/src/koubou/html_setup.py +++ b/src/koubou/html_setup.py @@ -46,7 +46,7 @@ def browser_setup_message(details: Optional[str] = None) -> str: def import_sync_playwright(): - """Import Playwright lazily so non-HTML workflows stay lightweight.""" + """Import Playwright sync API lazily so non-HTML workflows stay lightweight.""" try: from playwright.sync_api import sync_playwright @@ -56,6 +56,17 @@ def import_sync_playwright(): raise RuntimeError(missing_playwright_message()) from exc +def import_async_playwright(): + """Import Playwright async API lazily so non-HTML workflows stay lightweight.""" + + try: + from playwright.async_api import async_playwright + + return async_playwright + except ImportError as exc: + raise RuntimeError(missing_playwright_message()) from exc + + def check_html_environment() -> HtmlEnvironmentStatus: """Detect whether HTML rendering can run in the current environment.""" diff --git a/src/koubou/renderers/html_renderer.py b/src/koubou/renderers/html_renderer.py index 5020f4a..c6cb9aa 100644 --- a/src/koubou/renderers/html_renderer.py +++ b/src/koubou/renderers/html_renderer.py @@ -1,5 +1,6 @@ """HTML template renderer using Playwright headless browser.""" +import asyncio import logging import shutil import tempfile @@ -9,6 +10,7 @@ from ..html_setup import ( browser_setup_message, + import_async_playwright, import_sync_playwright, ) from .html_staging import stage_html_workspace @@ -24,6 +26,22 @@ class HtmlRenderResult: layout: Dict[str, Any] +@dataclass +class HtmlBatchRenderTask: + """A pre-staged HTML workspace to render.""" + + workspace_dir: Path + size: Tuple[int, int] + + +@dataclass +class HtmlBatchRenderOutcome: + """Batch render result for one HTML workspace.""" + + result: Optional[HtmlRenderResult] = None + error: Optional[Exception] = None + + def _round_ratio(value: float) -> float: """Round normalized values for smaller, stable JSON payloads.""" return round(value, 4) @@ -223,11 +241,11 @@ def render_staged_with_layout( self._ensure_browser() assert self._browser is not None - width, height = size - page = self._browser.new_page( - viewport={"width": width, "height": height}, + context = self._browser.new_context( + viewport={"width": size[0], "height": size[1]}, device_scale_factor=1, ) + page = context.new_page() try: page.goto( f"file://{workspace_dir / 'index.html'}", wait_until="networkidle" @@ -345,7 +363,7 @@ def render_staged_with_layout( layout = _build_layout_manifest(raw_elements, size) return HtmlRenderResult(png_bytes=png_bytes, layout=layout) finally: - page.close() + context.close() def close(self): if self._browser: @@ -354,3 +372,192 @@ def close(self): if self._playwright: self._playwright.stop() self._playwright = None + + @staticmethod + async def _launch_async_browser(): + """Launch a single async browser instance for batch rendering.""" + async_playwright = import_async_playwright() + playwright = await async_playwright().start() + + try: + browser = await playwright.chromium.launch(channel="chrome") + logger.info("Using system Chrome for HTML rendering") + return playwright, browser + except Exception: + try: + browser = await playwright.chromium.launch() + logger.info("Using Playwright Chromium for HTML rendering") + return playwright, browser + except Exception as e: + await playwright.stop() + raise RuntimeError(browser_setup_message(str(e))) + + @staticmethod + async def _render_staged_with_layout_async( + browser, workspace_dir: Path, size: Tuple[int, int] + ) -> HtmlRenderResult: + """Render a staged HTML workspace with a fresh isolated browser context.""" + width, height = size + context = await browser.new_context( + viewport={"width": width, "height": height}, + device_scale_factor=1, + ) + page = await context.new_page() + + try: + await page.goto( + f"file://{workspace_dir / 'index.html'}", wait_until="networkidle" + ) + raw_elements = await page.evaluate( + """async () => { + if (document.readyState !== "complete") { + await new Promise((resolve) => { + window.addEventListener("load", resolve, { once: true }); + }); + } + + const stylesheetLinks = Array.from( + document.querySelectorAll('link[rel="stylesheet"]') + ); + await Promise.all( + stylesheetLinks.map(async (link) => { + if (link.sheet) { + return; + } + + await new Promise((resolve) => { + link.addEventListener("load", resolve, { once: true }); + link.addEventListener("error", resolve, { once: true }); + }); + }) + ); + + if (document.fonts && document.fonts.ready) { + try { + await document.fonts.ready; + } catch (error) { + // Ignore font readiness errors and fall back + // to the current layout. + } + } + + const images = Array.from(document.images); + await Promise.all( + images.map(async (image) => { + if (!image.complete) { + await new Promise((resolve, reject) => { + image.addEventListener( + "load", + resolve, + { once: true } + ); + image.addEventListener( + "error", + reject, + { once: true } + ); + }).catch(() => undefined); + } + + if (typeof image.decode === "function") { + try { + await image.decode(); + } catch (error) { + // Ignore decode failures and use the + // current layout. + } + } + }) + ); + + await new Promise((resolve) => + requestAnimationFrame(() => resolve()) + ); + + return Array.from(document.querySelectorAll("[data-kou-id]")) + .map((node) => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + const element = { + id: node.getAttribute("data-kou-id"), + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + }; + + const role = node.getAttribute("data-kou-role"); + if (role) { + element.role = role; + } + + const text = (node.innerText || "") + .replace(/\\s+/g, " ") + .trim(); + if (text) { + element.text = text; + } + + if (node.tagName.toLowerCase() === "img") { + const src = node.getAttribute("src"); + if (src) { + element.src = src; + } + } + + if (style.zIndex && style.zIndex !== "auto") { + const parsedZIndex = Number(style.zIndex); + if (Number.isFinite(parsedZIndex)) { + element.zIndex = parsedZIndex; + } + } + + return element; + }) + .filter((element) => Boolean(element.id)); + }""" + ) + png_bytes = await page.screenshot(type="png", full_page=False) + layout = _build_layout_manifest(raw_elements, size) + return HtmlRenderResult(png_bytes=png_bytes, layout=layout) + finally: + await context.close() + + @classmethod + async def _render_staged_batch_async( + cls, tasks: List[HtmlBatchRenderTask], max_concurrency: int + ) -> List[HtmlBatchRenderOutcome]: + """Render a batch of staged HTML workspaces with one shared browser.""" + if not tasks: + return [] + + worker_count = max(1, min(max_concurrency, len(tasks))) + playwright, browser = await cls._launch_async_browser() + semaphore = asyncio.Semaphore(worker_count) + results: List[Optional[HtmlBatchRenderOutcome]] = [None] * len(tasks) + + async def run_task(index: int, task: HtmlBatchRenderTask) -> None: + async with semaphore: + try: + render_result = await cls._render_staged_with_layout_async( + browser, task.workspace_dir, task.size + ) + results[index] = HtmlBatchRenderOutcome(result=render_result) + except Exception as exc: + results[index] = HtmlBatchRenderOutcome(error=exc) + + try: + await asyncio.gather( + *(run_task(index, task) for index, task in enumerate(tasks)) + ) + return [result for result in results if result is not None] + finally: + await browser.close() + await playwright.stop() + + @classmethod + def render_staged_batch_with_layout( + cls, tasks: List[HtmlBatchRenderTask], max_concurrency: int + ) -> List[HtmlBatchRenderOutcome]: + """Synchronously render a batch of staged HTML workspaces.""" + return asyncio.run(cls._render_staged_batch_async(tasks, max_concurrency)) diff --git a/tests/test_generator.py b/tests/test_generator.py index 863a9de..95132f3 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,5 +1,6 @@ """Tests for the main ScreenshotGenerator class.""" +import json import shutil import tempfile import threading @@ -1451,8 +1452,14 @@ def test_project_generation_parallel_workers_renders_end_to_end(self): for result_path in results: assert result_path.exists() - def test_parallel_execution_serializes_html_tasks(self, monkeypatch): - """HTML tasks should not run concurrently even when parallel mode is enabled.""" + def test_parallel_execution_batches_html_tasks(self, monkeypatch): + """HTML tasks should use the batched shared-browser renderer path.""" + from koubou.generator import PreparedHtmlGenerationTask + from koubou.renderers.html_renderer import ( + HtmlBatchRenderOutcome, + HtmlRenderResult, + ) + tasks = [ GenerationTask( order_index=0, @@ -1508,34 +1515,75 @@ def test_parallel_execution_serializes_html_tasks(self, monkeypatch): ), ] - state = {"active_html": 0, "peak_html": 0} - html_generators = [] - lock = threading.Lock() - def fake_run_generation_task(task, generator=None): output_path = self.temp_dir / f"{task.screenshot_id}.png" - if task.is_html: - with lock: - state["active_html"] += 1 - state["peak_html"] = max(state["peak_html"], state["active_html"]) - html_generators.append(id(generator)) - try: - time.sleep(0.02) - finally: - with lock: - state["active_html"] -= 1 - else: + if not task.is_html: time.sleep(0.02) + output_path.write_text(task.screenshot_id, encoding="utf-8") + return output_path - output_path.write_text(task.screenshot_id, encoding="utf-8") + raise AssertionError("HTML tasks should not use _run_generation_task") + + prepared_html_tasks = [] + + def fake_prepare_html_generation_task(task): + workspace_dir = self.temp_dir / f"{task.screenshot_id}_workspace" + workspace_dir.mkdir() + output_path = self.temp_dir / f"{task.screenshot_id}.png" + layout_path = self.temp_dir / f"{task.screenshot_id}.layout.json" + prepared = PreparedHtmlGenerationTask( + task=task, + workspace_dir=workspace_dir, + output_path=output_path, + layout_path=layout_path, + cleanup_paths=[workspace_dir], + ) + prepared_html_tasks.append(prepared) + return prepared + + batch_calls = {} + + def fake_render_staged_batch_with_layout(render_tasks, max_concurrency): + batch_calls["task_count"] = len(render_tasks) + batch_calls["max_concurrency"] = max_concurrency + return [ + HtmlBatchRenderOutcome( + result=HtmlRenderResult( + png_bytes=b"fake", + layout={"id": prepared.task.screenshot_id}, + ) + ) + for prepared in prepared_html_tasks + ] + + def fake_save_html_render_result(render_result, output_path, layout_path): + output_path.write_text(render_result.layout["id"], encoding="utf-8") + layout_path.write_text(json.dumps(render_result.layout), encoding="utf-8") return output_path monkeypatch.setattr( self.generator, "_run_generation_task", fake_run_generation_task ) + monkeypatch.setattr( + self.generator, + "_prepare_html_generation_task", + fake_prepare_html_generation_task, + ) + monkeypatch.setattr( + "koubou.renderers.html_renderer.HtmlRenderer.render_staged_batch_with_layout", + fake_render_staged_batch_with_layout, + ) + monkeypatch.setattr( + self.generator, "_save_html_render_result", fake_save_html_render_result + ) results = self.generator._execute_generation_tasks(tasks, parallel_workers=4) assert len(results) == 4 - assert state["peak_html"] == 1 - assert html_generators == [id(self.generator), id(self.generator)] + assert batch_calls == {"task_count": 2, "max_concurrency": 4} + assert [path.name for path in results] == [ + "content_a.png", + "html_a.png", + "content_b.png", + "html_b.png", + ] diff --git a/tests/test_html_renderer.py b/tests/test_html_renderer.py index fb68c40..39f9166 100644 --- a/tests/test_html_renderer.py +++ b/tests/test_html_renderer.py @@ -1,5 +1,6 @@ """Tests for HTML template rendering.""" +import asyncio import importlib import io import json @@ -187,6 +188,72 @@ def test_build_layout_manifest_clips_to_canvas(self): } +class TestHtmlBatchRenderer: + """Unit tests for batched HTML rendering orchestration.""" + + def test_batch_renderer_limits_async_concurrency(self, monkeypatch, temp_dir): + from koubou.renderers.html_renderer import HtmlBatchRenderTask, HtmlRenderer + + state = {"active": 0, "peak": 0} + lock = asyncio.Lock() + + async def fake_launch_async_browser(): + class FakeBrowser: + async def close(self): + return None + + class FakePlaywright: + async def stop(self): + return None + + return FakePlaywright(), FakeBrowser() + + async def fake_render(browser, workspace_dir, size): + async with lock: + state["active"] += 1 + state["peak"] = max(state["peak"], state["active"]) + try: + await asyncio.sleep(0.01) + from koubou.renderers.html_renderer import HtmlRenderResult + + return HtmlRenderResult( + png_bytes=workspace_dir.name.encode("utf-8"), + layout={"workspace": workspace_dir.name, "size": size}, + ) + finally: + async with lock: + state["active"] -= 1 + + monkeypatch.setattr( + HtmlRenderer, "_launch_async_browser", fake_launch_async_browser + ) + monkeypatch.setattr( + HtmlRenderer, "_render_staged_with_layout_async", fake_render + ) + + tasks = [] + for index in range(4): + workspace_dir = temp_dir / f"workspace_{index}" + workspace_dir.mkdir() + tasks.append( + HtmlBatchRenderTask(workspace_dir=workspace_dir, size=(400, 800)) + ) + + outcomes = HtmlRenderer.render_staged_batch_with_layout( + tasks, max_concurrency=2 + ) + + assert len(outcomes) == 4 + assert all(outcome.error is None for outcome in outcomes) + assert [outcome.result.layout["workspace"] for outcome in outcomes] == [ + "workspace_0", + "workspace_1", + "workspace_2", + "workspace_3", + ] + assert state["peak"] == 2 + + @requires_playwright class TestHtmlRenderer: """Integration tests for HTML rendering (requires playwright).""" From 35bd6bf08fadce9f43b44b934a6badd0ae435f0c Mon Sep 17 00:00:00 2001 From: David Collado Date: Sun, 14 Jun 2026 14:42:22 +0200 Subject: [PATCH 4/7] Prefer Playwright Chromium for HTML rendering --- src/koubou/html_setup.py | 20 ++++++++++---------- src/koubou/renderers/html_renderer.py | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/koubou/html_setup.py b/src/koubou/html_setup.py index 601d54a..627575e 100644 --- a/src/koubou/html_setup.py +++ b/src/koubou/html_setup.py @@ -87,30 +87,30 @@ def check_html_environment() -> HtmlEnvironmentStatus: try: try: - browser = playwright.chromium.launch(channel="chrome") + browser = playwright.chromium.launch() browser.close() return HtmlEnvironmentStatus( playwright_available=True, - system_chrome_available=True, - chromium_available=False, + system_chrome_available=False, + chromium_available=True, ready=True, - browser_name="system Chrome", + browser_name="Playwright Chromium", ) except Exception as exc: - chrome_error = exc + chromium_error = exc try: - browser = playwright.chromium.launch() + browser = playwright.chromium.launch(channel="chrome") browser.close() return HtmlEnvironmentStatus( playwright_available=True, - system_chrome_available=False, - chromium_available=True, + system_chrome_available=True, + chromium_available=False, ready=True, - browser_name="Playwright Chromium", + browser_name="system Chrome", ) except Exception as exc: - chromium_error = exc + chrome_error = exc detail_parts = [] if chrome_error: diff --git a/src/koubou/renderers/html_renderer.py b/src/koubou/renderers/html_renderer.py index c6cb9aa..a8d247c 100644 --- a/src/koubou/renderers/html_renderer.py +++ b/src/koubou/renderers/html_renderer.py @@ -161,14 +161,15 @@ def _ensure_browser(self): sync_playwright = import_sync_playwright() self._playwright = sync_playwright().start() - # Try system Chrome first, fall back to Playwright's chromium + # Prefer Playwright Chromium for automation stability, then fall back + # to system Chrome when the managed browser is unavailable. try: - self._browser = self._playwright.chromium.launch(channel="chrome") - logger.info("Using system Chrome for HTML rendering") + self._browser = self._playwright.chromium.launch() + logger.info("Using Playwright Chromium for HTML rendering") except Exception: try: - self._browser = self._playwright.chromium.launch() - logger.info("Using Playwright Chromium for HTML rendering") + self._browser = self._playwright.chromium.launch(channel="chrome") + logger.info("Using system Chrome for HTML rendering") except Exception as e: raise RuntimeError(browser_setup_message(str(e))) @@ -380,13 +381,13 @@ async def _launch_async_browser(): playwright = await async_playwright().start() try: - browser = await playwright.chromium.launch(channel="chrome") - logger.info("Using system Chrome for HTML rendering") + browser = await playwright.chromium.launch() + logger.info("Using Playwright Chromium for HTML rendering") return playwright, browser except Exception: try: - browser = await playwright.chromium.launch() - logger.info("Using Playwright Chromium for HTML rendering") + browser = await playwright.chromium.launch(channel="chrome") + logger.info("Using system Chrome for HTML rendering") return playwright, browser except Exception as e: await playwright.stop() From 2814ca3e5f84a4b8f8a062f92abde16b9089e0f2 Mon Sep 17 00:00:00 2001 From: David Collado Date: Sun, 14 Jun 2026 14:44:56 +0200 Subject: [PATCH 5/7] Require Playwright Chromium for HTML rendering --- src/koubou/html_setup.py | 25 ++----------------------- src/koubou/renderers/html_renderer.py | 23 +++++++---------------- tests/test_html_setup.py | 2 +- 3 files changed, 10 insertions(+), 40 deletions(-) diff --git a/src/koubou/html_setup.py b/src/koubou/html_setup.py index 627575e..1cfda6e 100644 --- a/src/koubou/html_setup.py +++ b/src/koubou/html_setup.py @@ -37,8 +37,7 @@ def browser_setup_message(details: Optional[str] = None) -> str: message = ( "HTML rendering is not set up yet. " - f"Run `{HTML_SETUP_COMMAND}` to install Playwright Chromium, " - "or install Google Chrome." + f"Run `{HTML_SETUP_COMMAND}` to install Playwright Chromium." ) if details: return f"{message}\nDetails: {details}" @@ -82,7 +81,6 @@ def check_html_environment() -> HtmlEnvironmentStatus: ) playwright = sync_playwright().start() - chrome_error: Optional[Exception] = None chromium_error: Optional[Exception] = None try: @@ -99,31 +97,12 @@ def check_html_environment() -> HtmlEnvironmentStatus: except Exception as exc: chromium_error = exc - try: - browser = playwright.chromium.launch(channel="chrome") - browser.close() - return HtmlEnvironmentStatus( - playwright_available=True, - system_chrome_available=True, - chromium_available=False, - ready=True, - browser_name="system Chrome", - ) - except Exception as exc: - chrome_error = exc - - detail_parts = [] - if chrome_error: - detail_parts.append(f"Chrome: {chrome_error}") - if chromium_error: - detail_parts.append(f"Chromium: {chromium_error}") - return HtmlEnvironmentStatus( playwright_available=True, system_chrome_available=False, chromium_available=False, ready=False, - details=" | ".join(detail_parts) if detail_parts else None, + details=f"Chromium: {chromium_error}" if chromium_error else None, ) finally: playwright.stop() diff --git a/src/koubou/renderers/html_renderer.py b/src/koubou/renderers/html_renderer.py index a8d247c..eae3ae5 100644 --- a/src/koubou/renderers/html_renderer.py +++ b/src/koubou/renderers/html_renderer.py @@ -161,17 +161,13 @@ def _ensure_browser(self): sync_playwright = import_sync_playwright() self._playwright = sync_playwright().start() - # Prefer Playwright Chromium for automation stability, then fall back - # to system Chrome when the managed browser is unavailable. try: self._browser = self._playwright.chromium.launch() logger.info("Using Playwright Chromium for HTML rendering") - except Exception: - try: - self._browser = self._playwright.chromium.launch(channel="chrome") - logger.info("Using system Chrome for HTML rendering") - except Exception as e: - raise RuntimeError(browser_setup_message(str(e))) + except Exception as e: + self._playwright.stop() + self._playwright = None + raise RuntimeError(browser_setup_message(str(e))) def render( self, @@ -384,14 +380,9 @@ async def _launch_async_browser(): browser = await playwright.chromium.launch() logger.info("Using Playwright Chromium for HTML rendering") return playwright, browser - except Exception: - try: - browser = await playwright.chromium.launch(channel="chrome") - logger.info("Using system Chrome for HTML rendering") - return playwright, browser - except Exception as e: - await playwright.stop() - raise RuntimeError(browser_setup_message(str(e))) + except Exception as e: + await playwright.stop() + raise RuntimeError(browser_setup_message(str(e))) @staticmethod async def _render_staged_with_layout_async( diff --git a/tests/test_html_setup.py b/tests/test_html_setup.py index 3d3ff4c..9c9b0e0 100644 --- a/tests/test_html_setup.py +++ b/tests/test_html_setup.py @@ -28,7 +28,7 @@ def test_browser_setup_message_mentions_kou_setup_html(): message = browser_setup_message() assert "kou setup-html" in message - assert "Google Chrome" in message + assert "Playwright Chromium" in message def test_setup_html_environment_installs_chromium(monkeypatch): From 9131e3f451d4e15491876b591d8b55d15568f3a5 Mon Sep 17 00:00:00 2001 From: David Collado Date: Sun, 14 Jun 2026 14:54:50 +0200 Subject: [PATCH 6/7] Auto-install Playwright for HTML projects --- README.md | 21 ++--- src/koubou/cli.py | 28 +++--- tests/test_cli.py | 223 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 236 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index f958397..dba9f66 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - **๐Ÿ”„ Live Editing** - Real-time screenshot regeneration when config or assets change - **๐Ÿ–ฅ๏ธ Live Preview Dashboard** - Auto-open a local dashboard for screenshot previews with hot reload -- **๐Ÿงฉ HTML/CSS Templates** - Render polished marketing layouts with Chrome or Playwright Chromium +- **๐Ÿงฉ HTML/CSS Templates** - Render polished marketing layouts with Playwright Chromium - **๐ŸŒ Multi-Language Localization** - Generate localized screenshots using familiar xcstrings format from Xcode - **๐Ÿ–ผ๏ธ Localized Assets** - Automatic language-specific asset resolution with convention-based and explicit mapping - **๐ŸŽจ 100+ Device Frames** - iPhone 16 Pro, iPad Air M2, MacBook Pro, Apple Watch Ultra, and more @@ -47,7 +47,7 @@ brew install bitomule/tap/koubou kou setup-html ``` -If Google Chrome is already installed, HTML template rendering usually works without any extra setup. `kou setup-html` installs Playwright Chromium only when it is needed. +Koubou uses Playwright Chromium for all HTML rendering. `kou generate` and `kou live` install it automatically on first use for HTML projects, and `kou setup-html` is available when you want to preflight the environment explicitly. **Python Developers** ```bash @@ -91,9 +91,6 @@ kou --create-config my-html-screenshots.yaml --mode html # Generate screenshots kou generate my-screenshots.yaml -# Prepare HTML rendering if your project uses HTML templates -kou setup-html - # Inspect real frame geometry before designing HTML layouts kou inspect-frame "iPhone 16 Pro - Black Titanium - Portrait" --output-size iPhone6_9 @@ -104,7 +101,7 @@ kou live my-screenshots.yaml kou install-skills ``` -`kou --create-config` also creates sample PNG assets in a sibling `screenshots/` directory, so the generated YAML can be rendered immediately. In `--mode html`, it also creates sample templates in `templates/` and the generated project is ready to run with `kou generate ... --setup-html`. +`kou --create-config` also creates sample PNG assets in a sibling `screenshots/` directory, so the generated YAML can be rendered immediately. In `--mode html`, it also creates sample templates in `templates/` and the generated project is ready to run with `kou generate ...`. ## ๐Ÿงฉ HTML Templates @@ -122,9 +119,9 @@ screenshots: - `variables:` are localizable `{{key}}` substitutions - `assets:` are file-path substitutions exposed to the template as `{{key}}` -- `kou generate config.yaml --setup-html` prepares HTML rendering and generates in one run +- `kou generate config.yaml` prepares Playwright Chromium automatically when HTML templates are present - `kou generate config.yaml --parallel-workers 4` renders multiple screenshots concurrently -- `kou live config.yaml --setup-html` does the same before starting live mode +- `kou live config.yaml` does the same before starting live mode - `kou inspect-frame "" --output-size --output json` exposes frame and screen geometry for layout decisions ### Compact Layout JSON @@ -475,8 +472,8 @@ kou generate config.yaml --output json # Override the worker count for this run kou generate config.yaml --parallel-workers 4 -# Prepare HTML rendering and generate in one run -kou generate config.yaml --setup-html +# Generate HTML screenshots and auto-install Playwright Chromium on first run +kou generate config.yaml # Enable verbose logging kou generate config.yaml --verbose @@ -527,8 +524,8 @@ kou inspect-frame "iPhone 16 Pro - Black Titanium - Portrait" --output-size 1200 # Start live editing with default settings kou live config.yaml -# Prepare HTML rendering before starting live mode -kou live config.yaml --setup-html +# Start live mode and auto-install Playwright Chromium on first run +kou live config.yaml # Adjust debounce delay (default: 0.5s) kou live config.yaml --debounce 1.0 diff --git a/src/koubou/cli.py b/src/koubou/cli.py index 2322a81..10cfeb2 100644 --- a/src/koubou/cli.py +++ b/src/koubou/cli.py @@ -21,8 +21,6 @@ from .generator import ScreenshotGenerator from .html_preview import HtmlPreviewServer from .html_setup import ( - check_html_environment, - format_html_environment_error, setup_html_environment, ) from .live_generator import LiveScreenshotGenerator @@ -105,7 +103,7 @@ def _create_config_file_with_mode( ) console.print("\nEdit the configuration file and run:", style="blue") if normalized_mode == "html": - console.print(f" kou generate {output_file} --setup-html", style="cyan") + console.print(f" kou generate {output_file}", style="cyan") else: console.print(f" kou generate {output_file}", style="cyan") @@ -546,21 +544,15 @@ def _prepare_html_environment( verbose: bool, output_console: Console, ) -> None: - if setup_requested: - status = setup_html_environment(verbose=verbose) - if status.did_install_browser: - output_console.print( - f"HTML ready: installed {status.browser_name}", style="green" - ) - else: - output_console.print( - f"HTML ready: using {status.browser_name}", style="green" - ) + status = setup_html_environment(verbose=verbose) + if status.did_install_browser: + output_console.print( + f"HTML ready: installed {status.browser_name}", style="green" + ) return - status = check_html_environment() - if not status.ready: - raise KoubouError(format_html_environment_error(status)) + if setup_requested: + output_console.print(f"HTML ready: using {status.browser_name}", style="green") def _parse_output_size_option(value: str) -> Tuple[int, int]: @@ -712,7 +704,7 @@ def generate( setup_html: bool = typer.Option( False, "--setup-html", - help="Prepare HTML rendering before generating HTML template screenshots", + help="Prepare HTML rendering before generating. Usually automatic for HTML templates.", ), verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"), ): @@ -1183,7 +1175,7 @@ def live( setup_html: bool = typer.Option( False, "--setup-html", - help="Prepare HTML rendering before starting live mode", + help="Prepare HTML rendering before starting live mode. Usually automatic for HTML templates.", ), ): """Live editing mode - regenerate screenshots when config or assets change""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 4d3e481..3f72822 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -139,7 +139,7 @@ def test_create_config_html_mode_creates_templates(self): @requires_html_runtime def test_create_config_html_mode_generates_successfully(self): - """HTML sample config should render successfully with setup-html.""" + """HTML sample config should render successfully without extra setup flags.""" config_path = self.temp_dir / "sample_html_config.yaml" create_result = self.runner.invoke( @@ -155,9 +155,7 @@ def test_create_config_html_mode_generates_successfully(self): ) assert create_result.exit_code == 0 - generate_result = self.runner.invoke( - app, ["generate", str(config_path), "--setup-html"] - ) + generate_result = self.runner.invoke(app, ["generate", str(config_path)]) assert generate_result.exit_code == 0 output_files = list( @@ -427,6 +425,60 @@ def generate_project(self, project_config, config_dir): } ] + def test_generate_html_auto_prepares_runtime(self, monkeypatch): + """HTML generation should prepare Playwright automatically.""" + template_path = self.temp_dir / "hero.html" + template_path.write_text("{{headline}}") + + config_data = { + "project": { + "name": "HTML CLI Auto Setup Test", + "output_dir": str(self.temp_dir / "output"), + "device": "iPhone 16 Pro - Black Titanium - Portrait", + "output_size": "iPhone6_9", + }, + "screenshots": { + "hero": { + "template": str(template_path), + "variables": {"headline": "Hello"}, + } + }, + } + config_path = self.temp_dir / "html_auto_config.yaml" + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + setup_calls = [] + + def fake_prepare_html_environment(*, setup_requested, verbose, output_console): + setup_calls.append( + { + "setup_requested": setup_requested, + "verbose": verbose, + "console_type": type(output_console).__name__, + } + ) + + class FakeGenerator: + def generate_project(self, project_config, config_dir): + return [Path(project_config.project.output_dir) / "hero.png"] + + monkeypatch.setattr( + "koubou.cli._prepare_html_environment", fake_prepare_html_environment + ) + monkeypatch.setattr("koubou.cli.ScreenshotGenerator", FakeGenerator) + + result = self.runner.invoke(app, ["generate", str(config_path)]) + + assert result.exit_code == 0 + assert setup_calls == [ + { + "setup_requested": False, + "verbose": False, + "console_type": "Console", + } + ] + def test_generate_parallel_workers_override(self, monkeypatch): """CLI flag should override project.parallel_workers before generation.""" config_data = { @@ -526,8 +578,8 @@ def generate_project(self, project_config, config_dir): } ] - def test_generate_html_without_setup_shows_actionable_error(self, monkeypatch): - """Test generate shows kou setup-html guidance when HTML is not ready.""" + def test_generate_html_auto_setup_shows_actionable_error(self, monkeypatch): + """Test generate still shows setup guidance when automatic setup fails.""" template_path = self.temp_dir / "hero.html" template_path.write_text("{{headline}}") @@ -725,6 +777,165 @@ def stop(self): ] assert len(preview_servers) == 1 + def test_live_html_auto_prepares_runtime(self, monkeypatch): + """Live HTML mode should prepare Playwright automatically.""" + template_path = self.temp_dir / "hero.html" + template_path.write_text("{{headline}}") + + config_data = { + "project": { + "name": "HTML Live Auto Setup Test", + "output_dir": str(self.temp_dir / "output"), + "device": "iPhone 16 Pro - Black Titanium - Portrait", + "output_size": "iPhone6_9", + }, + "screenshots": { + "hero": { + "template": str(template_path), + "variables": {"headline": "Hello"}, + } + }, + } + config_path = self.temp_dir / "live_html_auto_config.yaml" + with open(config_path, "w") as f: + yaml.dump(config_data, f) + + setup_calls = [] + + def fake_prepare_html_environment(*, setup_requested, verbose, output_console): + setup_calls.append( + { + "setup_requested": setup_requested, + "verbose": verbose, + "console_type": type(output_console).__name__, + } + ) + + class FakeResult: + has_errors = False + success_count = 1 + error_count = 0 + config_errors = [] + failed_screenshots = {} + updated_preview_screenshots = [] + preview_errors = {} + preview_full_reload = False + + class FakeLiveGenerator: + def __init__(self, config_file): + self.config_file = config_file + self.preview_workspace = object() + + def initial_generation(self): + return FakeResult() + + def get_asset_paths(self): + return set() + + def get_dependency_summary(self): + return {"total_dependencies": 0} + + def has_preview_screenshots(self): + return True + + def sync_preview_workspace(self, screenshot_ids=None): + return {} + + def get_preview_slides(self): + return [] + + def close(self): + return None + + class FakeWatcher: + def __init__(self, config_file, debounce_delay): + self.config_file = config_file + self.debounce_delay = debounce_delay + + def set_change_callback(self, callback): + self.callback = callback + + def add_asset_paths(self, asset_paths): + self.asset_paths = asset_paths + + def start(self): + return None + + def stop(self): + return None + + def get_watched_files(self): + return set() + + class FakeLive: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def fake_signal(signum, handler): + handler(signum, None) + return None + + preview_servers = [] + + class FakePreviewServer: + def __init__(self, workspace): + self.workspace = workspace + self.url = "http://127.0.0.1:9999/" + preview_servers.append(self) + + def set_slides(self, slides): + self.slides = slides + + def start(self): + return None + + def open_browser(self): + return True + + def publish_slide_error(self, screenshot_id, error): + return None + + def publish_reload_slides(self, screenshot_ids): + return None + + def publish_full_reload(self): + return None + + def stop(self): + return None + + monkeypatch.setattr( + "koubou.cli._prepare_html_environment", fake_prepare_html_environment + ) + monkeypatch.setattr("koubou.cli.LiveScreenshotGenerator", FakeLiveGenerator) + monkeypatch.setattr("koubou.cli.HtmlPreviewServer", FakePreviewServer) + monkeypatch.setattr("koubou.cli.LiveWatcher", FakeWatcher) + monkeypatch.setattr("koubou.cli.Live", FakeLive) + monkeypatch.setattr("koubou.cli.signal.signal", fake_signal) + monkeypatch.setattr( + "koubou.cli._create_live_status_display", + lambda: type("StatusDisplay", (), {"renderable": None})(), + ) + monkeypatch.setattr("koubou.cli._update_live_status", lambda *args: None) + + result = self.runner.invoke(app, ["live", str(config_path)]) + + assert result.exit_code == 0 + assert setup_calls == [ + { + "setup_requested": False, + "verbose": False, + "console_type": "Console", + } + ] + assert len(preview_servers) == 1 + def test_live_without_html_starts_image_preview(self, monkeypatch): """Test live without HTML config still starts the preview dashboard.""" config_data = { From a50fe4357d66fee5d1ea191fe333f06cf7d37858 Mon Sep 17 00:00:00 2001 From: David Collado Date: Sun, 14 Jun 2026 15:03:09 +0200 Subject: [PATCH 7/7] Fix lint regressions in HTML concurrency changes --- src/koubou/cli.py | 17 +++++++++++++---- src/koubou/generator.py | 16 ++++++++++++---- tests/test_generator.py | 5 ++++- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/koubou/cli.py b/src/koubou/cli.py index 10cfeb2..0ea20ec 100644 --- a/src/koubou/cli.py +++ b/src/koubou/cli.py @@ -704,7 +704,10 @@ def generate( setup_html: bool = typer.Option( False, "--setup-html", - help="Prepare HTML rendering before generating. Usually automatic for HTML templates.", + help=( + "Prepare HTML rendering before generating. " + "Usually automatic for HTML templates." + ), ), verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"), ): @@ -851,11 +854,14 @@ def install_skills( None, "--agent", "-a", - help="Target a specific agent (e.g. claude-code, cursor). Installs to all detected agents by default.", + help=( + "Target a specific agent (e.g. claude-code, cursor). " + "Installs to all detected agents by default." + ), ), verbose: bool = typer.Option(False, "--verbose", help="Enable verbose logging"), ): - """Install the Koubou skill pack for AI coding agents (Claude Code, Cursor, Windsurf, and more).""" + """Install the Koubou skill pack for AI coding agents.""" setup_logging(verbose) @@ -1175,7 +1181,10 @@ def live( setup_html: bool = typer.Option( False, "--setup-html", - help="Prepare HTML rendering before starting live mode. Usually automatic for HTML templates.", + help=( + "Prepare HTML rendering before starting live mode. " + "Usually automatic for HTML templates." + ), ), ): """Live editing mode - regenerate screenshots when config or assets change""" diff --git a/src/koubou/generator.py b/src/koubou/generator.py index fea5d27..9dabe48 100644 --- a/src/koubou/generator.py +++ b/src/koubou/generator.py @@ -5,11 +5,11 @@ import logging import shutil import tempfile -from concurrent.futures import ThreadPoolExecutor, as_completed from collections import deque +from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Literal, Optional, Tuple, Union from PIL import Image @@ -1680,7 +1680,13 @@ def _convert_to_screenshot_config( if item.type == "text": # Convert to TextOverlay if item.content: - alignment = getattr(item, "alignment", "center") or "center" + raw_alignment = getattr(item, "alignment", "center") or "center" + if raw_alignment == "left": + alignment: Literal["left", "center", "right"] = "left" + elif raw_alignment == "right": + alignment = "right" + else: + alignment = "center" position = self._convert_position( item.position, (canvas_width, canvas_height) ) @@ -1778,7 +1784,9 @@ def _convert_to_screenshot_config( return config @staticmethod - def _text_anchor_from_alignment(alignment: str) -> str: + def _text_anchor_from_alignment( + alignment: Literal["left", "center", "right"], + ) -> Literal["center-left", "center", "center-right"]: """Map project text alignment to the corresponding horizontal anchor. Project YAML exposes only left/center/right alignment. We keep the diff --git a/tests/test_generator.py b/tests/test_generator.py index 95132f3..935b9a2 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1570,7 +1570,10 @@ def fake_save_html_render_result(render_result, output_path, layout_path): fake_prepare_html_generation_task, ) monkeypatch.setattr( - "koubou.renderers.html_renderer.HtmlRenderer.render_staged_batch_with_layout", + ( + "koubou.renderers.html_renderer." + "HtmlRenderer.render_staged_batch_with_layout" + ), fake_render_staged_batch_with_layout, ) monkeypatch.setattr(