slide-stream 2.3.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.3.0 → slide_stream-2.4.0}/PKG-INFO +30 -1
  2. {slide_stream-2.3.0 → slide_stream-2.4.0}/README.md +27 -0
  3. {slide_stream-2.3.0 → slide_stream-2.4.0}/pyproject.toml +3 -2
  4. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/__init__.py +1 -1
  5. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/cli.py +162 -0
  6. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/config_loader.py +9 -2
  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.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/factory.py +9 -0
  10. {slide_stream-2.3.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.4.0/tests/test_enrich.py +199 -0
  13. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_parser.py +56 -0
  14. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_providers.py +150 -0
  15. {slide_stream-2.3.0 → slide_stream-2.4.0}/uv.lock +54 -27
  16. slide_stream-2.3.0/src/slide_stream/parser.py +0 -52
  17. {slide_stream-2.3.0 → slide_stream-2.4.0}/.github/workflows/ci.yml +0 -0
  18. {slide_stream-2.3.0 → slide_stream-2.4.0}/.gitignore +0 -0
  19. {slide_stream-2.3.0 → slide_stream-2.4.0}/CLAUDE.md +0 -0
  20. {slide_stream-2.3.0 → slide_stream-2.4.0}/LICENSE +0 -0
  21. {slide_stream-2.3.0 → slide_stream-2.4.0}/MANIFEST.in +0 -0
  22. {slide_stream-2.3.0 → slide_stream-2.4.0}/contrib/chatterbox/cleanup_uuid_voices.sh +0 -0
  23. {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/DEVELOPMENT_WORKFLOW.md +0 -0
  24. {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/TYPE_SAFETY.md +0 -0
  25. {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/TYPING_IMPROVEMENTS.md +0 -0
  26. {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/USER_GUIDE.md +0 -0
  27. {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/avatar-feature-plan.md +0 -0
  28. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/llm.py +0 -0
  29. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/media.py +0 -0
  30. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/narration.py +0 -0
  31. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/powerpoint.py +0 -0
  32. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/__init__.py +0 -0
  33. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/avatar.py +0 -0
  34. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/base.py +0 -0
  35. {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/tts.py +0 -0
  36. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/__init__.py +0 -0
  37. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/fixtures/mini_lecture.md +0 -0
  38. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_avatar.py +0 -0
  39. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_cli.py +0 -0
  40. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_config_loader.py +0 -0
  41. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_llm.py +0 -0
  42. {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_narration.py +0 -0
  43. {slide_stream-2.3.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.3.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'
@@ -369,10 +371,37 @@ sudo apt update && sudo apt install ffmpeg
369
371
 
370
372
  **Image Providers:**
371
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
372
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`
373
378
  - `pexels`: Requires `PEXELS_API_KEY`
374
379
  - `unsplash`: Requires `UNSPLASH_ACCESS_KEY`
375
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
+
376
405
  **TTS Providers:**
377
406
  - `gtts`: Free, always available (needs internet)
378
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`)
@@ -306,10 +306,37 @@ sudo apt update && sudo apt install ffmpeg
306
306
 
307
307
  **Image Providers:**
308
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
309
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`
310
313
  - `pexels`: Requires `PEXELS_API_KEY`
311
314
  - `unsplash`: Requires `UNSPLASH_ACCESS_KEY`
312
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
+
313
340
  **TTS Providers:**
314
341
  - `gtts`: Free, always available (needs internet)
315
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.3.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.3.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",
@@ -242,8 +243,14 @@ providers:
242
243
  # narrated via vision-capable providers (claude, openai, gemini).
243
244
 
244
245
  images:
245
- provider: dalle3 # text, dalle3, openai-compatible, pexels, unsplash
246
+ provider: dalle3 # text, local, dalle3, gemini, swarmui, openai-compatible, pexels, unsplash
246
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
247
254
 
248
255
  tts:
249
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))
@@ -0,0 +1,118 @@
1
+ """Markdown parsing functionality for Slide Stream."""
2
+
3
+ from typing import Any
4
+
5
+ import markdown
6
+ from bs4 import BeautifulSoup, Tag
7
+
8
+
9
+ def _strip_front_matter(text: str) -> str:
10
+ """Drop a leading YAML front-matter block (``---`` ... ``---``).
11
+
12
+ Only stripped when the opening ``---`` is the first line and the block
13
+ contains no markdown heading, so a deck that merely opens with a ``---``
14
+ slide separator is left untouched. Ported from slide-vision.
15
+ """
16
+ lines = text.splitlines()
17
+ if not lines or lines[0].strip() != "---":
18
+ return text
19
+ for i in range(1, len(lines)):
20
+ if lines[i].strip() == "---":
21
+ block = lines[1:i]
22
+ if any(line.lstrip().startswith("#") for line in block):
23
+ return text
24
+ return "\n".join(lines[i + 1:])
25
+ return text
26
+
27
+
28
+ def _has_separators(text: str) -> bool:
29
+ return any(line.strip() == "---" for line in text.splitlines())
30
+
31
+
32
+ def _parse_separator_style(text: str) -> list[dict[str, Any]]:
33
+ """Parse a ``---``-separated deck (Marp/reveal-style) into slides.
34
+
35
+ Each block between ``---`` lines is one slide: the first heading line (or
36
+ the first line) is the title, remaining non-empty lines become content
37
+ (leading bullet markers stripped). Ported from slide-vision.
38
+ """
39
+ blocks: list[list[str]] = [[]]
40
+ for line in text.splitlines():
41
+ if line.strip() == "---":
42
+ blocks.append([])
43
+ else:
44
+ blocks[-1].append(line)
45
+
46
+ slides: list[dict[str, Any]] = []
47
+ for block_lines in blocks:
48
+ block = "\n".join(block_lines).strip()
49
+ if not block:
50
+ continue
51
+ title = ""
52
+ content: list[str] = []
53
+ for line in block.splitlines():
54
+ stripped = line.strip()
55
+ if not title and stripped.startswith("#"):
56
+ title = stripped.lstrip("#").strip()
57
+ elif stripped:
58
+ content.append(stripped.lstrip("-*").strip())
59
+ if not title and content:
60
+ title, content = content[0], content[1:]
61
+ slides.append({"title": title, "content": content})
62
+ return slides
63
+
64
+
65
+ def parse_markdown(markdown_text: str) -> list[dict[str, Any]]:
66
+ """Parse markdown text into slide data.
67
+
68
+ A leading YAML front-matter block is stripped. Decks that use ``---`` lines
69
+ as slide separators are parsed by separator; otherwise each top-level
70
+ (``#``) heading starts a new slide.
71
+
72
+ In heading style, everything between an ``h1`` and the next ``h1`` is
73
+ collected as slide content so nothing is silently dropped: multiple lists,
74
+ paragraphs, and ``h2``/``h3`` (or deeper) sub-headings are all preserved in
75
+ document order. Sub-headings are kept as content lines rather than starting
76
+ separate slides.
77
+ """
78
+ markdown_text = _strip_front_matter(
79
+ markdown_text.replace("\r\n", "\n").replace("\r", "\n")
80
+ )
81
+ if _has_separators(markdown_text):
82
+ return _parse_separator_style(markdown_text)
83
+
84
+ html = markdown.markdown(markdown_text)
85
+ soup = BeautifulSoup(html, "html.parser")
86
+ slides = []
87
+
88
+ for header in soup.find_all("h1"):
89
+ slide_title = header.get_text()
90
+ content_items: list[str] = []
91
+
92
+ next_sibling = header.find_next_sibling()
93
+ while next_sibling:
94
+ # Type guard: only process Tag elements, skip NavigableString
95
+ if isinstance(next_sibling, Tag):
96
+ name = next_sibling.name
97
+ if name == "h1":
98
+ # The next slide begins here; stop collecting content.
99
+ break
100
+ elif name in ("ul", "ol"):
101
+ # Capture every list (not just the first) in document order.
102
+ content_items.extend(
103
+ item.get_text() for item in next_sibling.find_all("li")
104
+ )
105
+ elif name == "p":
106
+ text = next_sibling.get_text()
107
+ if text:
108
+ content_items.append(text)
109
+ elif name in ("h2", "h3", "h4", "h5", "h6"):
110
+ # Preserve sub-headings as content rather than dropping them.
111
+ text = next_sibling.get_text()
112
+ if text:
113
+ content_items.append(text)
114
+ next_sibling = next_sibling.find_next_sibling()
115
+
116
+ slides.append({"title": slide_title, "content": content_items})
117
+
118
+ return slides
@@ -14,8 +14,11 @@ from .base import (
14
14
  )
15
15
  from .images import (
16
16
  DalleImageProvider,
17
+ GeminiImageProvider,
18
+ LocalImageProvider,
17
19
  OpenAICompatImageProvider,
18
20
  PexelsImageProvider,
21
+ SwarmUIImageProvider,
19
22
  TextImageProvider,
20
23
  UnsplashImageProvider,
21
24
  )
@@ -38,7 +41,10 @@ class ProviderFactory:
38
41
  # Registry of available providers
39
42
  IMAGE_PROVIDERS: dict[str, type[ImageProvider]] = {
40
43
  "text": TextImageProvider,
44
+ "local": LocalImageProvider,
41
45
  "dalle3": DalleImageProvider,
46
+ "gemini": GeminiImageProvider,
47
+ "swarmui": SwarmUIImageProvider,
42
48
  "openai-compatible": OpenAICompatImageProvider,
43
49
  "pexels": PexelsImageProvider,
44
50
  "unsplash": UnsplashImageProvider,
@@ -166,7 +172,10 @@ class ProviderFactory:
166
172
  """Get list of available image providers."""
167
173
  return {
168
174
  "text": "Text-based images (always available)",
175
+ "local": "Local folder images matched by filename keywords (set providers.images.folder)",
169
176
  "dalle3": "DALL-E 3 AI image generation (requires OpenAI API key)",
177
+ "gemini": "Google Imagen generation, cheap (~$0.02/image; requires GEMINI_API_KEY and slide-stream[gemini])",
178
+ "swarmui": "Self-hosted SwarmUI server (set base_url; native SwarmUI API, no OpenAI shim needed)",
170
179
  "openai-compatible": "Any OpenAI-compatible image endpoint (set base_url; local or hosted)",
171
180
  "pexels": "Pexels stock photos (requires Pexels API key)",
172
181
  "unsplash": "Unsplash stock photos (requires Unsplash API key)",