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.
- {slide_stream-2.2.0 → slide_stream-2.4.0}/PKG-INFO +43 -2
- {slide_stream-2.2.0 → slide_stream-2.4.0}/README.md +40 -1
- {slide_stream-2.2.0 → slide_stream-2.4.0}/pyproject.toml +3 -2
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/__init__.py +1 -1
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/cli.py +162 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/config_loader.py +89 -34
- 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.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/factory.py +9 -0
- {slide_stream-2.2.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.2.0 → slide_stream-2.4.0}/tests/test_config_loader.py +96 -2
- slide_stream-2.4.0/tests/test_enrich.py +199 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_parser.py +56 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_providers.py +150 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/uv.lock +54 -27
- slide_stream-2.2.0/src/slide_stream/parser.py +0 -52
- {slide_stream-2.2.0 → slide_stream-2.4.0}/.github/workflows/ci.yml +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/.gitignore +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/CLAUDE.md +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/LICENSE +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/MANIFEST.in +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/contrib/chatterbox/cleanup_uuid_voices.sh +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/DEVELOPMENT_WORKFLOW.md +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/TYPE_SAFETY.md +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/TYPING_IMPROVEMENTS.md +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/USER_GUIDE.md +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/docs/avatar-feature-plan.md +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/llm.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/media.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/narration.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/powerpoint.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/__init__.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/avatar.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/base.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/src/slide_stream/providers/tts.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/__init__.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/fixtures/mini_lecture.md +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_avatar.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_cli.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_llm.py +0 -0
- {slide_stream-2.2.0 → slide_stream-2.4.0}/tests/test_narration.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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",
|
|
@@ -91,47 +92,88 @@ def expand_env_vars(value: Any) -> Any:
|
|
|
91
92
|
return value
|
|
92
93
|
|
|
93
94
|
|
|
94
|
-
def
|
|
95
|
-
"""
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
if not
|
|
163
|
+
project_config = Path(config_path)
|
|
164
|
+
if not project_config.exists():
|
|
117
165
|
raise ConfigurationError(f"Configuration file not found: {config_path}")
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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))
|