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.
- {slide_stream-2.3.0 → slide_stream-2.4.0}/PKG-INFO +30 -1
- {slide_stream-2.3.0 → slide_stream-2.4.0}/README.md +27 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/pyproject.toml +3 -2
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/__init__.py +1 -1
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/cli.py +162 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/config_loader.py +9 -2
- slide_stream-2.4.0/src/slide_stream/enrich.py +137 -0
- slide_stream-2.4.0/src/slide_stream/parser.py +118 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/factory.py +9 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/images.py +272 -0
- slide_stream-2.4.0/src/slide_stream/scan.py +105 -0
- slide_stream-2.4.0/tests/test_enrich.py +199 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_parser.py +56 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_providers.py +150 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/uv.lock +54 -27
- slide_stream-2.3.0/src/slide_stream/parser.py +0 -52
- {slide_stream-2.3.0 → slide_stream-2.4.0}/.github/workflows/ci.yml +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/.gitignore +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/CLAUDE.md +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/LICENSE +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/MANIFEST.in +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/contrib/chatterbox/cleanup_uuid_voices.sh +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/DEVELOPMENT_WORKFLOW.md +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/TYPE_SAFETY.md +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/TYPING_IMPROVEMENTS.md +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/USER_GUIDE.md +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/docs/avatar-feature-plan.md +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/llm.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/media.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/narration.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/powerpoint.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/__init__.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/avatar.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/base.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/src/slide_stream/providers/tts.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/__init__.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/fixtures/mini_lecture.md +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_avatar.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_cli.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_config_loader.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_llm.py +0 -0
- {slide_stream-2.3.0 → slide_stream-2.4.0}/tests/test_narration.py +0 -0
- {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
|
+
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.
|
|
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",
|
|
@@ -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)",
|