slide-stream 2.2.0__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {slide_stream-2.2.0 → slide_stream-2.4.0}/PKG-INFO +43 -2
  2. {slide_stream-2.2.0 → slide_stream-2.4.0}/README.md +40 -1
  3. {slide_stream-2.2.0 → slide_stream-2.4.0}/pyproject.toml +3 -2
  4. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/__init__.py +1 -1
  5. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/cli.py +162 -0
  6. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/config_loader.py +89 -34
  7. slide_stream-2.4.0/src/slide_stream/enrich.py +137 -0
  8. slide_stream-2.4.0/src/slide_stream/parser.py +118 -0
  9. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/factory.py +9 -0
  10. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/images.py +272 -0
  11. slide_stream-2.4.0/src/slide_stream/scan.py +105 -0
  12. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_config_loader.py +96 -2
  13. slide_stream-2.4.0/tests/test_enrich.py +199 -0
  14. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_parser.py +56 -0
  15. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_providers.py +150 -0
  16. {slide_stream-2.2.0 → slide_stream-2.4.0}/uv.lock +54 -27
  17. slide_stream-2.2.0/src/slide_stream/parser.py +0 -52
  18. {slide_stream-2.2.0 → slide_stream-2.4.0}/.github/workflows/ci.yml +0 -0
  19. {slide_stream-2.2.0 → slide_stream-2.4.0}/.gitignore +0 -0
  20. {slide_stream-2.2.0 → slide_stream-2.4.0}/CLAUDE.md +0 -0
  21. {slide_stream-2.2.0 → slide_stream-2.4.0}/LICENSE +0 -0
  22. {slide_stream-2.2.0 → slide_stream-2.4.0}/MANIFEST.in +0 -0
  23. {slide_stream-2.2.0 → slide_stream-2.4.0}/contrib/chatterbox/cleanup_uuid_voices.sh +0 -0
  24. {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/DEVELOPMENT_WORKFLOW.md +0 -0
  25. {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/TYPE_SAFETY.md +0 -0
  26. {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/TYPING_IMPROVEMENTS.md +0 -0
  27. {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/USER_GUIDE.md +0 -0
  28. {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/avatar-feature-plan.md +0 -0
  29. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/llm.py +0 -0
  30. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/media.py +0 -0
  31. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/narration.py +0 -0
  32. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/powerpoint.py +0 -0
  33. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/__init__.py +0 -0
  34. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/avatar.py +0 -0
  35. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/base.py +0 -0
  36. {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/tts.py +0 -0
  37. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/__init__.py +0 -0
  38. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/fixtures/mini_lecture.md +0 -0
  39. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_avatar.py +0 -0
  40. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_cli.py +0 -0
  41. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_llm.py +0 -0
  42. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_narration.py +0 -0
  43. {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_powerpoint.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slide-stream
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: An AI-powered tool to automatically create video presentations from Markdown and PowerPoint files
5
5
  Project-URL: Homepage, https://github.com/michael-borck/slide-stream
6
6
  Project-URL: Repository, https://github.com/michael-borck/slide-stream
@@ -36,6 +36,7 @@ Requires-Dist: typing-extensions>=4.8.0
36
36
  Provides-Extra: all-ai
37
37
  Requires-Dist: anthropic>=0.7.0; extra == 'all-ai'
38
38
  Requires-Dist: elevenlabs<3.0.0,>=1.5.0; extra == 'all-ai'
39
+ Requires-Dist: google-genai>=1.0.0; extra == 'all-ai'
39
40
  Requires-Dist: google-generativeai>=0.3.0; extra == 'all-ai'
40
41
  Requires-Dist: groq>=0.4.0; extra == 'all-ai'
41
42
  Requires-Dist: openai>=1.0.0; extra == 'all-ai'
@@ -51,6 +52,7 @@ Requires-Dist: ruff>=0.1.0; extra == 'dev'
51
52
  Provides-Extra: elevenlabs
52
53
  Requires-Dist: elevenlabs<3.0.0,>=1.5.0; extra == 'elevenlabs'
53
54
  Provides-Extra: gemini
55
+ Requires-Dist: google-genai>=1.0.0; extra == 'gemini'
54
56
  Requires-Dist: google-generativeai>=0.3.0; extra == 'gemini'
55
57
  Provides-Extra: groq
56
58
  Requires-Dist: groq>=0.4.0; extra == 'groq'
@@ -168,7 +170,19 @@ slide-stream create --config my-config.yaml presentation.pptx video.mp4
168
170
 
169
171
  ## ⚙️ Configuration
170
172
 
171
- SlideStream uses YAML configuration files for maximum flexibility:
173
+ SlideStream layers configuration so you set shared things (a TTS server URL,
174
+ API keys) once and keep per-deck settings separate. Later layers win:
175
+
176
+ 1. Built-in defaults
177
+ 2. **`~/.slidestream.yaml`** — personal: your Chatterbox/LLM server URLs and
178
+ API-key references, shared across every project
179
+ 3. **`./slidestream.yaml`** (or `--config FILE`) — settings for the deck at hand
180
+ 4. **CLI flags** (`--voice`, `--tts-base-url`, `--narration-seconds`, …) — win
181
+ over everything, for one-off runs
182
+
183
+ Run `slide-stream init` to write a starter `slidestream.yaml`. API keys are
184
+ read from the environment via `${VAR}` expansion, so secrets never live in the
185
+ files. Example:
172
186
 
173
187
  ```yaml
174
188
  # slidestream.yaml
@@ -357,10 +371,37 @@ sudo apt update && sudo apt install ffmpeg
357
371
 
358
372
  **Image Providers:**
359
373
  - `text`: Always available, no setup required
374
+ - `local`: Pick images from a local folder by filename keywords (`providers.images.folder`); pair with `slide-stream scan` to AI-name them
360
375
  - `dalle3`: Requires `OPENAI_API_KEY`
376
+ - `gemini`: Google Imagen generation, cheap (~$0.02/image) — `pip install "slide-stream[gemini]"` + `GEMINI_API_KEY`; set `providers.images.model` for the Imagen tier (default Fast)
377
+ - `swarmui`: Self-hosted [SwarmUI](https://github.com/mcmonkeyprojects/SwarmUI) server (`base_url`), free local generation via its native API — set `providers.images.model` (e.g. `juggernautXL_v9`), optional `steps`/`width`/`height`/`api_key`
361
378
  - `pexels`: Requires `PEXELS_API_KEY`
362
379
  - `unsplash`: Requires `UNSPLASH_ACCESS_KEY`
363
380
 
381
+ ### Enriching a deck with images (`enrich` / `scan`)
382
+
383
+ Beyond making videos, SlideStream can add an image to each slide and write a
384
+ **new editable deck** — no narration, no video, your original untouched:
385
+
386
+ ```bash
387
+ # Markdown deck + images/ folder (default). Add --pptx for a PowerPoint too.
388
+ slide-stream enrich deck.md out/ --image-provider dalle3
389
+ slide-stream enrich deck.md out/ --image-provider local --image-folder ./pics --pptx
390
+ ```
391
+
392
+ The output is a real artifact you can review, hand-edit, or narrate as a second
393
+ pass (`slide-stream create out/deck.md video.mp4`). For a one-pass video that
394
+ adds images *and* narrates internally, just run `create` with an image
395
+ provider configured — `enrich` is the deck-only track.
396
+
397
+ `scan` AI-renames a folder of images to keyword slugs so the `local` provider
398
+ can match them to slides (dry-run by default):
399
+
400
+ ```bash
401
+ slide-stream scan ./pics --provider claude # preview renames
402
+ slide-stream scan ./pics --provider claude --apply # actually rename + write report
403
+ ```
404
+
364
405
  **TTS Providers:**
365
406
  - `gtts`: Free, always available (needs internet)
366
407
  - `kokoro`: Fully offline, no API key — `pip install "slide-stream[local-tts]"` (~340MB one-time model download; voices include `af_sarah`, `af_bella`, `am_adam`, `am_michael`)
@@ -105,7 +105,19 @@ slide-stream create --config my-config.yaml presentation.pptx video.mp4
105
105
 
106
106
  ## ⚙️ Configuration
107
107
 
108
- SlideStream uses YAML configuration files for maximum flexibility:
108
+ SlideStream layers configuration so you set shared things (a TTS server URL,
109
+ API keys) once and keep per-deck settings separate. Later layers win:
110
+
111
+ 1. Built-in defaults
112
+ 2. **`~/.slidestream.yaml`** — personal: your Chatterbox/LLM server URLs and
113
+ API-key references, shared across every project
114
+ 3. **`./slidestream.yaml`** (or `--config FILE`) — settings for the deck at hand
115
+ 4. **CLI flags** (`--voice`, `--tts-base-url`, `--narration-seconds`, …) — win
116
+ over everything, for one-off runs
117
+
118
+ Run `slide-stream init` to write a starter `slidestream.yaml`. API keys are
119
+ read from the environment via `${VAR}` expansion, so secrets never live in the
120
+ files. Example:
109
121
 
110
122
  ```yaml
111
123
  # slidestream.yaml
@@ -294,10 +306,37 @@ sudo apt update && sudo apt install ffmpeg
294
306
 
295
307
  **Image Providers:**
296
308
  - `text`: Always available, no setup required
309
+ - `local`: Pick images from a local folder by filename keywords (`providers.images.folder`); pair with `slide-stream scan` to AI-name them
297
310
  - `dalle3`: Requires `OPENAI_API_KEY`
311
+ - `gemini`: Google Imagen generation, cheap (~$0.02/image) — `pip install "slide-stream[gemini]"` + `GEMINI_API_KEY`; set `providers.images.model` for the Imagen tier (default Fast)
312
+ - `swarmui`: Self-hosted [SwarmUI](https://github.com/mcmonkeyprojects/SwarmUI) server (`base_url`), free local generation via its native API — set `providers.images.model` (e.g. `juggernautXL_v9`), optional `steps`/`width`/`height`/`api_key`
298
313
  - `pexels`: Requires `PEXELS_API_KEY`
299
314
  - `unsplash`: Requires `UNSPLASH_ACCESS_KEY`
300
315
 
316
+ ### Enriching a deck with images (`enrich` / `scan`)
317
+
318
+ Beyond making videos, SlideStream can add an image to each slide and write a
319
+ **new editable deck** — no narration, no video, your original untouched:
320
+
321
+ ```bash
322
+ # Markdown deck + images/ folder (default). Add --pptx for a PowerPoint too.
323
+ slide-stream enrich deck.md out/ --image-provider dalle3
324
+ slide-stream enrich deck.md out/ --image-provider local --image-folder ./pics --pptx
325
+ ```
326
+
327
+ The output is a real artifact you can review, hand-edit, or narrate as a second
328
+ pass (`slide-stream create out/deck.md video.mp4`). For a one-pass video that
329
+ adds images *and* narrates internally, just run `create` with an image
330
+ provider configured — `enrich` is the deck-only track.
331
+
332
+ `scan` AI-renames a folder of images to keyword slugs so the `local` provider
333
+ can match them to slides (dry-run by default):
334
+
335
+ ```bash
336
+ slide-stream scan ./pics --provider claude # preview renames
337
+ slide-stream scan ./pics --provider claude --apply # actually rename + write report
338
+ ```
339
+
301
340
  **TTS Providers:**
302
341
  - `gtts`: Free, always available (needs internet)
303
342
  - `kokoro`: Fully offline, no API key — `pip install "slide-stream[local-tts]"` (~340MB one-time model download; voices include `af_sarah`, `af_bella`, `am_adam`, `am_michael`)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "slide-stream"
7
- version = "2.2.0"
7
+ version = "2.4.0"
8
8
  description = "An AI-powered tool to automatically create video presentations from Markdown and PowerPoint files"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  [project.optional-dependencies]
45
45
  # AI providers
46
- gemini = ["google-generativeai>=0.3.0"]
46
+ gemini = ["google-generativeai>=0.3.0", "google-genai>=1.0.0"]
47
47
  openai = ["openai>=1.0.0"]
48
48
  claude = ["anthropic>=0.7.0"]
49
49
  groq = ["groq>=0.4.0"]
@@ -55,6 +55,7 @@ local-tts = [
55
55
  ]
56
56
  all-ai = [
57
57
  "google-generativeai>=0.3.0",
58
+ "google-genai>=1.0.0",
58
59
  "openai>=1.0.0",
59
60
  "anthropic>=0.7.0",
60
61
  "groq>=0.4.0",
@@ -1,5 +1,5 @@
1
1
  """Slide Stream - AI-powered tool for creating video presentations from Markdown."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.4.0"
4
4
  __author__ = "Michael Borck"
5
5
  __email__ = "michael.borck@curtin.edu.au"
@@ -604,6 +604,168 @@ def create(
604
604
  console.print("✅ Cleanup complete.")
605
605
 
606
606
 
607
+ def _parse_deck(input_path: Path) -> list[dict[str, Any]]:
608
+ """Parse a .md or .pptx deck into slide dicts, or exit with an error."""
609
+ ext = input_path.suffix.lower()
610
+ if not input_path.exists():
611
+ err_console.print(f"Input file not found: {input_path}")
612
+ raise typer.Exit(code=1)
613
+ if ext == ".md":
614
+ with open(input_path, encoding="utf-8") as f:
615
+ text = f.read()
616
+ if not text.strip():
617
+ err_console.print("Markdown file is empty. Exiting.")
618
+ raise typer.Exit(code=1)
619
+ return parse_markdown(text)
620
+ if ext == ".pptx":
621
+ try:
622
+ return parse_powerpoint(input_path)
623
+ except ValueError as e:
624
+ err_console.print(f"Error parsing PowerPoint: {e}")
625
+ raise typer.Exit(code=1)
626
+ err_console.print(f"Unsupported file type: {ext}. Supported: .md, .pptx")
627
+ raise typer.Exit(code=1)
628
+
629
+
630
+ @app.command()
631
+ def enrich(
632
+ input_path: Annotated[
633
+ Path,
634
+ typer.Argument(help="Input deck (Markdown .md or PowerPoint .pptx)."),
635
+ ],
636
+ output_dir: Annotated[
637
+ str,
638
+ typer.Argument(help="Directory for the enriched deck and images."),
639
+ ] = "enriched",
640
+ config_file: Annotated[
641
+ str | None, typer.Option("--config", "-c", help="Config file (YAML).")
642
+ ] = None,
643
+ image_provider_option: Annotated[
644
+ str | None,
645
+ typer.Option(
646
+ "--image-provider",
647
+ help="Image provider (local, dalle3, pexels, unsplash, openai-compatible).",
648
+ ),
649
+ ] = None,
650
+ image_folder: Annotated[
651
+ str | None,
652
+ typer.Option("--image-folder", help="Folder of images for the 'local' provider."),
653
+ ] = None,
654
+ pptx: Annotated[
655
+ bool,
656
+ typer.Option("--pptx", help="Also write an enriched PowerPoint (.pptx)."),
657
+ ] = False,
658
+ zip_output: Annotated[
659
+ bool, typer.Option("--zip", help="Also write a .zip of the output folder.")
660
+ ] = False,
661
+ ) -> None:
662
+ """Add an image to each slide and write a new deck (no video).
663
+
664
+ The output is an editable Markdown deck plus an images/ folder — run
665
+ 'create' on it to narrate, or use 'create' directly for a one-pass video.
666
+ """
667
+ console.print(
668
+ Panel.fit("[bold cyan]🖼️ Enriching deck[/bold cyan]", border_style="green")
669
+ )
670
+ try:
671
+ config = load_config(config_file)
672
+ except ConfigurationError as e:
673
+ err_console.print(f"Configuration Error: {e}")
674
+ raise typer.Exit(code=1)
675
+
676
+ if image_provider_option:
677
+ config["providers"]["images"]["provider"] = image_provider_option
678
+ if image_folder:
679
+ config["providers"]["images"]["folder"] = image_folder
680
+
681
+ slides = _parse_deck(input_path)
682
+ if not slides:
683
+ err_console.print("No slides found. Exiting.")
684
+ raise typer.Exit(code=1)
685
+ console.print(f"📄 Found [bold yellow]{len(slides)}[/bold yellow] slides.")
686
+
687
+ try:
688
+ image_provider = ProviderFactory.create_image_provider(config)
689
+ except StrictModeError as e:
690
+ err_console.print(f"{e}")
691
+ raise typer.Exit(code=1)
692
+
693
+ from .enrich import enrich_deck
694
+
695
+ out = enrich_deck(
696
+ slides,
697
+ image_provider,
698
+ Path(output_dir),
699
+ input_path.stem,
700
+ also_pptx=pptx,
701
+ also_zip=zip_output,
702
+ )
703
+ console.print(
704
+ Panel(
705
+ f"🎉 [bold green]Enriched deck written[/bold green]\n\n"
706
+ f"Markdown: [yellow]{out / (input_path.stem + '.md')}[/yellow]"
707
+ + (f"\nPowerPoint: [yellow]{out / (input_path.stem + '.pptx')}[/yellow]" if pptx else "")
708
+ + f"\nImages: [yellow]{out / 'images'}[/yellow]",
709
+ border_style="green",
710
+ expand=False,
711
+ )
712
+ )
713
+
714
+
715
+ @app.command()
716
+ def scan(
717
+ folder: Annotated[
718
+ Path, typer.Argument(help="Folder of images to AI-rename to keyword slugs.")
719
+ ],
720
+ provider: Annotated[
721
+ str,
722
+ typer.Option("--provider", help="Vision LLM provider (claude, openai, gemini)."),
723
+ ] = "claude",
724
+ model: Annotated[
725
+ str | None, typer.Option("--model", help="Specific vision model to use.")
726
+ ] = None,
727
+ apply: Annotated[
728
+ bool,
729
+ typer.Option(
730
+ "--apply",
731
+ help="Actually rename files (default is a dry-run preview).",
732
+ ),
733
+ ] = False,
734
+ ) -> None:
735
+ """AI-rename images in a folder to keyword slugs for the 'local' provider."""
736
+ if not folder.is_dir():
737
+ err_console.print(f"Not a directory: {folder}")
738
+ raise typer.Exit(code=1)
739
+
740
+ from .scan import apply_renames, build_rename_records, write_scan_report
741
+
742
+ try:
743
+ records = build_rename_records(folder, provider, model)
744
+ except (ImportError, ValueError) as e:
745
+ err_console.print(f"Vision provider error: {e}")
746
+ raise typer.Exit(code=1)
747
+
748
+ if not records:
749
+ console.print("No images found in the folder.")
750
+ return
751
+
752
+ applied = apply_renames(records, dry_run=not apply)
753
+
754
+ table = Table(title="Dry run — no files changed" if not apply else "Renamed")
755
+ table.add_column("Original", style="cyan")
756
+ table.add_column("→", style="dim")
757
+ table.add_column("New name", style="green")
758
+ for orig, new in applied:
759
+ table.add_row(orig.name, "→", new.name)
760
+ console.print(table)
761
+
762
+ if apply:
763
+ report = write_scan_report(folder, applied)
764
+ console.print(f"📝 Report: [yellow]{report}[/yellow]")
765
+ else:
766
+ console.print("\n[dim]Re-run with --apply to rename the files.[/dim]")
767
+
768
+
607
769
  @app.command()
608
770
  def init(
609
771
  output_path: Annotated[
@@ -26,7 +26,8 @@ DEFAULT_CONFIG = {
26
26
  "images": {
27
27
  "provider": "text",
28
28
  "fallback": "text",
29
- "base_url": None # used by the openai-compatible provider
29
+ "base_url": None, # used by the openai-compatible provider
30
+ "folder": None # used by the 'local' provider (enrich/create)
30
31
  },
31
32
  "tts": {
32
33
  "provider": "gtts",
@@ -91,47 +92,88 @@ def expand_env_vars(value: Any) -> Any:
91
92
  return value
92
93
 
93
94
 
94
- def find_config_file() -> Path | None:
95
- """Find configuration file in standard locations."""
96
- possible_locations = [
95
+ def _first_existing(*candidates: Path) -> Path | None:
96
+ """Return the first path that exists, or None."""
97
+ for path in candidates:
98
+ if path.exists():
99
+ return path
100
+ return None
101
+
102
+
103
+ def find_home_config() -> Path | None:
104
+ """Find the user-level config (~/.slidestream.yaml) — personal defaults
105
+ like a TTS server URL and API keys, shared across all projects."""
106
+ return _first_existing(
107
+ Path.home() / ".slidestream.yaml",
108
+ Path.home() / ".slidestream.yml",
109
+ )
110
+
111
+
112
+ def find_project_config() -> Path | None:
113
+ """Find the project-level config (./slidestream.yaml) — settings for the
114
+ deck at hand."""
115
+ return _first_existing(
97
116
  Path("./slidestream.yaml"),
98
117
  Path("./slidestream.yml"),
99
- Path.home() / ".slidestream.yaml",
100
- Path.home() / ".slidestream.yml"
101
- ]
118
+ )
102
119
 
103
- for location in possible_locations:
104
- if location.exists():
105
- return location
106
120
 
107
- return None
121
+ def find_config_file() -> Path | None:
122
+ """Find a single config file (project preferred, then home).
108
123
 
124
+ Retained for backwards compatibility; ``load_config`` layers home and
125
+ project configs rather than using this.
126
+ """
127
+ return find_project_config() or find_home_config()
128
+
129
+
130
+ def _read_config_file(config_file: Path) -> dict[str, Any] | None:
131
+ """Read and parse one YAML config file (None if empty)."""
132
+ try:
133
+ with open(config_file, encoding="utf-8") as f:
134
+ return yaml.safe_load(f)
135
+ except yaml.YAMLError as e:
136
+ raise ConfigurationError(f"Invalid YAML in config file: {e}")
137
+ except Exception as e:
138
+ raise ConfigurationError(f"Error reading config file: {e}")
109
139
 
110
- def load_config(config_path: str | None = None) -> dict[str, Any]:
111
- """Load configuration from file or return defaults."""
112
- config = DEFAULT_CONFIG.copy()
113
140
 
141
+ def load_config(config_path: str | None = None) -> dict[str, Any]:
142
+ """Load configuration by layering, later layers winning:
143
+
144
+ 1. Built-in defaults
145
+ 2. User-level config (~/.slidestream.yaml) — personal server URLs / keys
146
+ 3. Project-level config — the explicit ``config_path`` if given, otherwise
147
+ ./slidestream.yaml
148
+
149
+ So a personal home config can hold your TTS server and API keys once, and
150
+ each project's config only needs its deck-specific overrides.
151
+ """
152
+ import copy
153
+
154
+ config = copy.deepcopy(DEFAULT_CONFIG)
155
+
156
+ # Layer sources: (label, path). Home is always layered underneath; an
157
+ # explicit --config replaces the auto-discovered project file but still
158
+ # sits on top of home.
159
+ sources: list[Path] = []
160
+ if home_config := find_home_config():
161
+ sources.append(home_config)
114
162
  if config_path:
115
- config_file = Path(config_path)
116
- if not config_file.exists():
163
+ project_config = Path(config_path)
164
+ if not project_config.exists():
117
165
  raise ConfigurationError(f"Configuration file not found: {config_path}")
118
- else:
119
- config_file = find_config_file()
120
-
121
- if config_file:
122
- try:
123
- with open(config_file, encoding='utf-8') as f:
124
- file_config = yaml.safe_load(f)
125
-
126
- if file_config:
127
- # Deep merge with defaults
128
- config = merge_configs(config, file_config)
129
- console.print(f"✅ Loaded configuration from: {config_file}")
130
- except yaml.YAMLError as e:
131
- raise ConfigurationError(f"Invalid YAML in config file: {e}")
132
- except Exception as e:
133
- raise ConfigurationError(f"Error reading config file: {e}")
134
- else:
166
+ sources.append(project_config)
167
+ elif project_config := find_project_config():
168
+ sources.append(project_config)
169
+
170
+ for source in sources:
171
+ file_config = _read_config_file(source)
172
+ if file_config:
173
+ config = merge_configs(config, file_config)
174
+ console.print(f"✅ Loaded configuration from: {source}")
175
+
176
+ if not sources:
135
177
  console.print("📋 Using default configuration")
136
178
 
137
179
  # Expand environment variables
@@ -181,6 +223,13 @@ def validate_config(config: dict[str, Any]) -> None:
181
223
  def create_example_config() -> str:
182
224
  """Create example configuration file content."""
183
225
  return """# SlideStream Configuration File
226
+ #
227
+ # Config is layered, later winning:
228
+ # 1. built-in defaults
229
+ # 2. ~/.slidestream.yaml (personal: TTS server URL, API keys — set once)
230
+ # 3. ./slidestream.yaml (this deck's settings; or pass --config FILE)
231
+ # So keep your server and keys in the home file and only put per-deck
232
+ # overrides here. Any CLI flag (e.g. --voice, --tts-base-url) wins over both.
184
233
 
185
234
  providers:
186
235
  llm:
@@ -194,8 +243,14 @@ providers:
194
243
  # narrated via vision-capable providers (claude, openai, gemini).
195
244
 
196
245
  images:
197
- provider: dalle3 # text, dalle3, openai-compatible, pexels, unsplash
246
+ provider: dalle3 # text, local, dalle3, gemini, swarmui, openai-compatible, pexels, unsplash
198
247
  fallback: text # fallback when primary fails
248
+ # folder: ./images # for the 'local' provider — matched by filename keywords
249
+ # model: imagen-4.0-fast-generate-001 # for the 'gemini' (Imagen) provider
250
+ # --- self-hosted SwarmUI ---
251
+ # base_url: https://image.example.org
252
+ # model: juggernautXL_v9 # SwarmUI model name
253
+ # steps: 20
199
254
 
200
255
  tts:
201
256
  provider: elevenlabs # gtts, kokoro, elevenlabs, openai, openai-compatible
@@ -0,0 +1,137 @@
1
+ """Deck enrichment: add an image to each slide and write a new deck.
2
+
3
+ This is the ``enrich`` output track (ported/adapted from slide-vision): the
4
+ same slide input, but instead of a narrated video the output is a new,
5
+ editable deck — a Markdown file plus an ``images/`` folder, and optionally a
6
+ PowerPoint. Run ``create`` on the result to narrate it, or use ``create``
7
+ directly (it enriches internally) for a one-pass video.
8
+ """
9
+
10
+ import zipfile
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .providers.base import ImageProvider
15
+
16
+
17
+ def _slide_query(slide: dict[str, Any]) -> str:
18
+ """A search/keyword query for a slide's image."""
19
+ title = str(slide.get("title", "")).strip()
20
+ if title:
21
+ return title
22
+ for item in slide.get("content", []):
23
+ text = str(item).strip()
24
+ if text:
25
+ return text
26
+ return "presentation slide"
27
+
28
+
29
+ def enrich_deck(
30
+ slides: list[dict[str, Any]],
31
+ image_provider: ImageProvider,
32
+ output_dir: Path,
33
+ input_stem: str,
34
+ *,
35
+ also_pptx: bool = False,
36
+ also_zip: bool = False,
37
+ ) -> Path:
38
+ """Write an enriched Markdown deck (and optional PPTX) into output_dir.
39
+
40
+ Returns the output directory. Each slide gets an image from
41
+ ``image_provider``; slides the ``local`` provider could not match are
42
+ listed in ``prompts.md`` with ready-to-paste AI-image prompts.
43
+ """
44
+ images_dir = output_dir / "images"
45
+ images_dir.mkdir(parents=True, exist_ok=True)
46
+
47
+ enriched: list[dict[str, Any]] = []
48
+ for i, slide in enumerate(slides, 1):
49
+ img_path = images_dir / f"slide_{i}.png"
50
+ image_provider.generate_image(_slide_query(slide), str(img_path), slide=slide)
51
+ # The local provider reports whether it matched a real folder image;
52
+ # other providers always produce an image (or their own text fallback).
53
+ matched = getattr(image_provider, "matched_last", True)
54
+ enriched.append(
55
+ {
56
+ "index": i,
57
+ "title": str(slide.get("title", "")).strip(),
58
+ "content": [str(c).strip() for c in slide.get("content", []) if str(c).strip()],
59
+ "image": img_path.name,
60
+ "matched": matched,
61
+ }
62
+ )
63
+
64
+ md_path = output_dir / f"{input_stem}.md"
65
+ md_path.write_text(_build_markdown(enriched), encoding="utf-8")
66
+
67
+ missing = [s for s in enriched if not s["matched"]]
68
+ if missing:
69
+ (output_dir / "prompts.md").write_text(_build_prompts(missing), encoding="utf-8")
70
+
71
+ if also_pptx:
72
+ _write_pptx(enriched, images_dir, output_dir / f"{input_stem}.pptx")
73
+
74
+ if also_zip:
75
+ zip_path = output_dir.parent / f"{output_dir.name}.zip"
76
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
77
+ for f in output_dir.rglob("*"):
78
+ if f.is_file():
79
+ zf.write(f, f.relative_to(output_dir.parent))
80
+
81
+ return output_dir
82
+
83
+
84
+ def _build_markdown(slides: list[dict[str, Any]]) -> str:
85
+ blocks: list[str] = []
86
+ for slide in slides:
87
+ lines = [f"# {slide['title']}" if slide["title"] else f"# Slide {slide['index']}"]
88
+ lines.append("")
89
+ lines.append(f"![{slide['title']}](images/{slide['image']})")
90
+ if slide["content"]:
91
+ lines.append("")
92
+ for item in slide["content"]:
93
+ lines.append(f"- {item}")
94
+ blocks.append("\n".join(lines))
95
+ return "\n\n---\n\n".join(blocks) + "\n"
96
+
97
+
98
+ def _build_prompts(missing: list[dict[str, Any]]) -> str:
99
+ lines = ["# Image Prompts", "",
100
+ "Slides with no matching local image. Paste a prompt into an AI "
101
+ "image tool (DALL-E, Midjourney, ...) and drop the result into "
102
+ "the images/ folder.", ""]
103
+ for slide in missing:
104
+ preview = " ".join(slide["content"])[:300]
105
+ lines += [
106
+ f"## Slide {slide['index']}: {slide['title']}",
107
+ "",
108
+ f'A high-quality, professional illustration for a presentation slide '
109
+ f'titled "{slide["title"]}".',
110
+ ]
111
+ if preview:
112
+ lines.append(f"The slide covers: {preview}.")
113
+ lines += ["Style: clean, modern, no text overlays.", "", "---", ""]
114
+ return "\n".join(lines)
115
+
116
+
117
+ def _write_pptx(slides: list[dict[str, Any]], images_dir: Path, out_path: Path) -> None:
118
+ """Build a PowerPoint deck with one image slide per entry."""
119
+ from pptx import Presentation
120
+ from pptx.util import Emu, Inches
121
+
122
+ prs = Presentation()
123
+ # Default deck is 10" x 7.5"; fall back to that if the stubs report None.
124
+ width = Emu(prs.slide_width) if prs.slide_width else Inches(10)
125
+ blank = prs.slide_layouts[6]
126
+ content_width = Emu(width - Inches(1))
127
+ image_width = Emu(width - Inches(2))
128
+ for slide in slides:
129
+ s = prs.slides.add_slide(blank)
130
+ # Title textbox across the top.
131
+ title_box = s.shapes.add_textbox(Inches(0.5), Inches(0.3), content_width, Inches(1))
132
+ title_box.text_frame.text = slide["title"] or f"Slide {slide['index']}"
133
+ # Image centered below the title, scaled to fit.
134
+ img = images_dir / slide["image"]
135
+ if img.is_file():
136
+ s.shapes.add_picture(str(img), Inches(1), Inches(1.5), width=image_width)
137
+ prs.save(str(out_path))