It prints preview metadata first, then the HTML document. Redirect stdout to a local `.html` file if you want to inspect it in a browser; the CLI itself still writes nothing.

`autoshorts.rendering.local_preview.build_local_preview_plan()` turns a validated `RenderManifest` into a deterministic `LocalPreviewPlan` for later frame/video generation. The default plan uses reduced local preview settings `540x960` at `24fps`, creates one `LocalPreviewFrame` per manifest timeline item, converts scene timing to milliseconds, and assigns deterministic placeholder image paths under `data/previews/<script_id>/`.

`validate_local_preview_plan()` rejects non-vertical preview sizes, non-positive fps, blank output paths, empty frame lists, missing frame fields, bad frame durations, non-zero first-frame start time, overlapping frames, side effects, and external tool calls. This is still a plan only: it writes no PNGs, calls no FFmpeg, and renders no MP4.

## Social media draft

It prints preview metadata first, then the HTML document. Redirect stdout to a local `.html` file if you want to inspect it in a browser; the CLI itself still writes nothing.

## Social media draft


patch

--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -111,6 +111,10 @@

 It prints preview metadata first, then the HTML document. Redirect stdout to a local `.html` file if you want to inspect it in a browser; the CLI itself still writes nothing.

+`autoshorts.rendering.local_preview.build_local_preview_plan()` turns a validated `RenderManifest` into a deterministic `LocalPreviewPlan` for later frame/video generation. The default plan uses reduced local preview settings `540x960` at `24fps`, creates one `LocalPreviewFrame` per manifest timeline item, converts scene timing to milliseconds, and assigns deterministic placeholder image paths under `data/previews/<script_id>/`.
+
+`validate_local_preview_plan()` rejects non-vertical preview sizes, non-positive fps, blank output paths, empty frame lists, missing frame fields, bad frame durations, non-zero first-frame start time, overlapping frames, side effects, and external tool calls. This is still a plan only: it writes no PNGs, calls no FFmpeg, and renders no MP4.
+
 ## Social media draft

 `autoshorts.social.draft.build_social_media_draft()` turns a validated `VideoCandidate` plus `RenderPreview` into an approval-first `SocialMediaDraft` containing:

No linter for .md files


terminal

python -m pytest tests -q && python - <<'PY'
from autoshorts.content.brief import create_content_brief
from autoshorts.content.script_draft import create_script_draft
from autoshorts.rendering.shot_plan import create_shot_plan
from autoshorts.rendering.manifest import build_render_manifest
from autoshorts.rendering.local_preview import build_local_preview_plan, validate_local_preview_plan
brief = create_content_brief(
    pillar_id='ai_life_systems',
    topic='AI weekly review routine',
    audience_problem='I use AI tools but still lose control of priorities.',
    payoff='a 10-minute Sunday review that keeps AI as assistant, not boss',
)
script = create_script_draft(brief)
plan = build_local_preview_plan(build_render_manifest(create_shot_plan(script)))
print(plan.script_id)
print((plan.width, plan.height, plan.fps))
print(len(plan.frames))
print(plan.frames[0].frame_id, plan.frames[0].start_ms, plan.frames[0].duration_ms)
print(plan.frames[-1].start_ms, plan.frames[-1].duration_ms)
print(plan.side_effects, plan.external_calls)
print(validate_local_preview_plan(plan).is_valid)
PY


Output:
........................................................................ [ 74%]
.........................                                                [100%]
97 passed in 0.15s
ai_life_systems-ai-weekly-review-routine
(540, 960, 24)
5
frame-01-first_frame_hook 0 3000
30000 5000
() ()
True


git status --short && git add README.md autoshorts/rendering/local_preview.py tests/test_local_preview.py && git commit -m "feat: add local preview frame planning" && git log --oneline -n 8 && git status --short


Output:
M README.md
?? autoshorts/rendering/local_preview.py
?? tests/test_local_preview.py
[feat/core-approval-quality-gates 02ecf59] feat: add local preview frame planning
 3 files changed, 264 insertions(+)
 create mode 100644 autoshorts/rendering/local_preview.py
 create mode 100644 tests/test_local_preview.py
02ecf59 feat: add local preview frame planning
3b387f4 feat: add demo social draft cli
515c549 feat: add social draft delivery preparation
a7d10fa feat: render social media draft reviews
1f6aa2c feat: add social media draft model
f8c5588 feat: add demo render preview cli
42ac32f feat: add side-effect-free render preview
e258635 feat: add render manifest planning


Erledigt, Sir.

## Neu eingebaut: Local Preview Frame Planning

Neue Dateien:

```text
autoshorts/rendering/local_preview.py
tests/test_local_preview.py
```

## Neue API

```python
build_local_preview_plan(manifest: RenderManifest) -> LocalPreviewPlan
validate_local_preview_plan(plan: LocalPreviewPlan) -> ValidationResult
```

Der Flow ist jetzt:

```text
RenderManifest
→ LocalPreviewPlan
```

Noch kein Dateischreiben, kein PNG, kein FFmpeg, kein MP4. Nur ein sauberer, deterministischer Plan für den späteren lokalen Preview-Renderer.

## LocalPreviewPlan

Default Settings:

```text
width: 540
height: 960
fps: 24
output_dir: data/previews/<script_id>
side_effects: ()
external_calls: ()
```

Warum `540x960` statt direkt `1080x1920`?

Weil das der schnelle Timing-/Approval-Preview ist. Finale Qualität kommt später. Erst prüfen wir Timing, Textgewicht und Scene Flow — dann darf die Maschine schwitzen.

## LocalPreviewFrame

Pro Manifest-Timeline-Item wird ein Frame-Plan erzeugt:

```text
frame_id
scene_purpose
start_ms
duration_ms
caption_text
label_text
asset_mode
motion_instruction
placeholder_image_path
```

Beispiel verifiziert:

```text
frame-01-first_frame_hook
start_ms: 0
duration_ms: 3000
placeholder:
data/previews/ai_life_systems-ai-weekly-review-routine/frame-01-first_frame_hook.png
```

Letzte Szene:

```text
start_ms: 30000
duration_ms: 5000
```

## Guardrails

`validate_local_preview_plan()` rejected:

- nicht-vertikale Preview Size
- `fps <= 0`
- leerer Output-Pfad
- keine Frames
- fehlende Frame-Felder
- schlechte Frame-Durations
- erster Frame startet nicht bei `0ms`
- überlappende Frames
- Side Effects
- externe Tool Calls

Also wieder ein netter kleiner Zaun gegen “mal eben rendern wir irgendwas”. Zäune sind gut. Besonders um Bots.

## TDD eingehalten

RED:

```text
ModuleNotFoundError: No module named 'autoshorts.rendering.local_preview'
```

GREEN:

```text
5 passed
```

Full suite:

```text
97 passed in 0.15s
```

## Manuell verifiziert

```text
ai_life_systems-ai-weekly-review-routine
(540, 960, 24)
5
frame-01-first_frame_hook 0 3000
30000 5000
() ()
True
```

## README aktualisiert

Dokumentiert:

- `autoshorts.rendering.local_preview`
- `LocalPreviewPlan`
- `LocalPreviewFrame`
- Default Preview Settings
- Guardrails
- weiterhin keine Datei-/Renderer-Side-Effects

## Commit

```text
02ecf59 feat: add local preview frame planning
```

Arbeitsverzeichnis ist sauber.

## Nächster sinnvoller Schritt

Jetzt können wir den ersten echten lokalen Artefakt-Schritt bauen:

```text
LocalPreviewPlan
→ PNG scene cards
```

Vorschlag:

```text
autoshorts/rendering/local_frame_writer.py
tests/test_local_frame_writer.py
```

Ziel:

- schreibt deterministische PNG Scene Cards in ein explizites Output-Verzeichnis
- nutzt `Pillow`, falls verfügbar
- pro Frame:
  - vertikale Canvas
  - Scene Label
  - Caption
  - Asset Mode
  - Motion Instruction kurz
  - Timing
- Rückgabe als `LocalFrameWriteResult`
  - `output_dir`
  - `frame_paths`
  - `side_effects=("write_png",)`
  - keine externen Calls
- keine MP4-Erzeugung yet
- kein FFmpeg yet
- Medien bleiben unter `data/previews/...` und müssen gitignored sein

Das wäre der erste kontrollierte Schritt von “Plan” zu “sichtbarem Artefakt”. Danach bauen wir erst:

```text
PNG scene cards
→ FFmpeg concat plan
→ MP4 timing preview
```


Explanation:
**Considering next steps**

I need to think about loading relevant skills for this task. Should I load them now? It could be essential to focus on TDD and social aspects, along with ensuring we have a local frame writer for rendering. I also wonder if I should check dependencies like Pillow. Using the right tools is vital here, so I'll definitely keep TDD in mind as part of my process moving forward.


test-driven-development

---
name: test-driven-development
description: "TDD: enforce RED-GREEN-REFACTOR, tests before code."
version: 1.1.0
author: Hermes Agent (adapted from obra/superpowers)
license: MIT
platforms: [linux, macos, windows]
metadata:
  hermes:
    tags: [testing, tdd, development, quality, red-green-refactor]
    related_skills: [systematic-debugging, writing-plans, subagent-driven-development]
---

# Test-Driven Development (TDD)

## Overview

Write the test first. Watch it fail. Write minimal code to pass.

**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing.

**Violating the letter of the rules is violating the spirit of the rules.**

## When to Use

**Always:**
- New features
- Bug fixes
- Refactoring
- Behavior changes

**Exceptions (ask the user first):**
- Throwaway prototypes
- Generated code
- Configuration files

Thinking "skip TDD just this once"? Stop. That's rationalization.

## The Iron Law

```
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
```

Write code before the test? Delete it. Start over.

**No exceptions:**
- Don't keep it as "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete

Implement fresh from tests. Period.

## Red-Green-Refactor Cycle

### RED — Write Failing Test

Write one minimal test showing what should happen.

**Good test:**
```python
def test_retries_failed_operations_3_times():
    attempts = 0
    def operation():
        nonlocal attempts
        attempts += 1
        if attempts < 3:
            raise Exception('fail')
        return 'success'

    result = retry_operation(operation)

    assert result == 'success'
    assert attempts == 3
```
Clear name, tests real behavior, one thing.

**Bad test:**
```python
def test_retry_works():
    mock = MagicMock()
    mock.side_effect = [Exception(), Exception(), 'success']
    result = retry_operation(mock)
    assert result == 'success'  # What about retry count? Timing?
```
Vague name, tests mock not real code.

**Requirements:**
- One behavior per test
- Clear descriptive name ("and" in name? Split it)
- Real code, not mocks (unless truly unavoidable)
- Name describes behavior, not implementation

### Verify RED — Watch It Fail

**MANDATORY. Never skip.**

```bash
# Use terminal tool to run the specific test
pytest tests/test_feature.py::test_specific_behavior -v
```

Confirm:
- Test fails (not errors from typos)
- Failure message is expected
- Fails because the feature is missing

**Test passes immediately?** You're testing existing behavior. Fix the test.

**Test errors?** Fix the error, re-run until it fails correctly.

### GREEN — Minimal Code

Write the simplest code to pass the test. Nothing more.

**Good:**
```python
def add(a, b):
    return a + b  # Nothing extra
```

**Bad:**
```python
def add(a, b):
    result = a + b
    logging.info(f"Adding {a} + {b} = {result}")  # Extra!
    return result
```

Don't add features, refactor other code, or "improve" beyond the test.

**Cheating is OK in GREEN:**
- Hardcode return values
- Copy-paste
- Duplicate code
- Skip edge cases

We'll fix it in REFACTOR.

### Verify GREEN — Watch It Pass

**MANDATORY.**

```bash
# Run the specific test
pytest tests/test_feature.py::test_specific_behavior -v

# Then run ALL tests to check for regressions
pytest tests/ -q
```

Confirm:
- Test passes
- Other tests still pass
- Output pristine (no errors, warnings)

**Test fails?** Fix the code, not the test.

**Other tests fail?** Fix regressions now.

### REFACTOR — Clean Up

After green only:
- Remove duplication
- Improve names
- Extract helpers
- Simplify expressions

Keep tests green throughout. Don't add behavior.

**If tests fail during refactor:** Undo immediately. Take smaller steps.

### Repeat

Next failing test for next behavior. One cycle at a time.

## Why Order Matters

**"I'll write tests after to verify it works"**

Tests written after code pass immediately. Passing immediately proves nothing:
- Might test the wrong thing
- Might test implementation, not behavior
- Might miss edge cases you forgot
- You never saw it catch the bug

Test-first forces you to see the test fail, proving it actually tests something.

**"I already manually tested all the edge cases"**

Manual testing is ad-hoc. You think you tested everything but:
- No record of what you tested
- Can't re-run when code changes
- Easy to forget cases under pressure
- "It worked when I tried it" ≠ comprehensive

Automated tests are systematic. They run the same way every time.

**"Deleting X hours of work is wasteful"**

Sunk cost fallacy. The time is already gone. Your choice now:
- Delete and rewrite with TDD (high confidence)
- Keep it and add tests after (low confidence, likely bugs)

The "waste" is keeping code you can't trust.

**"TDD is dogmatic, being pragmatic means adapting"**

TDD IS pragmatic:
- Finds bugs before commit (faster than debugging after)
- Prevents regressions (tests catch breaks immediately)
- Documents behavior (tests show how to use code)
- Enables refactoring (change freely, tests catch breaks)

"Pragmatic" shortcuts = debugging in production = slower.

**"Tests after achieve the same goals — it's spirit not ritual"**

No. Tests-after answer "What does this do?" Tests-first answer "What should this do?"

Tests-after are biased by your implementation. You test what you built, not what's required. Tests-first force edge case discovery before implementing.

## Common Rationalizations

| Excuse | Reality |
|--------|---------|
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
| "I'll test after" | Tests passing immediately prove nothing. |
| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" |
| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. |
| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. |
| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. |
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
| "Test hard = design unclear" | Listen to the test. Hard to test = hard to use. |
| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. |
| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. |
| "Existing code has no tests" | You're improving it. Add tests for the code you touch. |

## Red Flags — STOP and Start Over

If you catch yourself doing any of these, delete the code and restart with TDD:

- Code before test
- Test after implementation
- Test passes immediately on first run
- Can't explain why test failed
- Tests added "later"
- Rationalizing "just this once"
- "I already manually tested it"
- "Tests after achieve the same purpose"
- "Keep as reference" or "adapt existing code"
- "Already spent X hours, deleting is wasteful"
- "TDD is dogmatic, I'm being pragmatic"
- "This is different because..."

**All of these mean: Delete code. Start over with TDD.**

## Verification Checklist

Before marking work complete:

- [ ] Every new function/method has a test
- [ ] Watched each test fail before implementing
- [ ] Each test failed for expected reason (feature missing, not typo)
- [ ] Wrote minimal code to pass each test
- [ ] All tests pass
- [ ] Output pristine (no errors, warnings)
- [ ] Tests use real code (mocks only if unavoidable)
- [ ] Edge cases and errors covered

Can't check all boxes? You skipped TDD. Start over.

## When Stuck

| Problem | Solution |
|---------|----------|
| Don't know how to test | Write the wished-for API. Write the assertion first. Ask the user. |
| Test too complicated | Design too complicated. Simplify the interface. |
| Must mock everything | Code too coupled. Use dependency injection. |
| Test setup huge | Extract helpers. Still complex? Simplify the design. |

## Hermes Agent Integration

### Running Tests

Use the `terminal` tool to run tests at each step:

```python
# RED — verify failure
terminal("pytest tests/test_feature.py::test_name -v")

# GREEN — verify pass
terminal("pytest tests/test_feature.py::test_name -v")

# Full suite — verify no regressions
terminal("pytest tests/ -q")
```

### CLI Entry Points

For new `python -m package.cli.command` style entry points, keep the TDD loop boring and side-effect free:

1. Write a test that imports `main` from the missing CLI module and calls `main([])`.
2. Capture stdout/stderr with the project test framework (`capsys` in pytest) or inject a `stdout` file-like object if supported.
3. RED should usually be an import failure for the missing CLI module/package, not a vague end-to-end shell failure.
4. Implement `main(argv: Sequence[str] | None = None, *, stdout: TextIO | None = None) -> int` so tests can call it without spawning a subprocess.
5. Add the `if __name__ == "__main__": raise SystemExit(main())` guard only after the callable path is green.
6. Verify both the unit test and the real module invocation, e.g. `python -m package.cli.command`, before committing.

This catches command wiring while avoiding accidental network calls, publishing actions, or other theatrical nonsense masquerading as a CLI test.

### With delegate_task

When dispatching subagents for implementation, enforce TDD in the goal:

```python
delegate_task(
    goal="Implement [feature] using strict TDD",
    context="""
    Follow test-driven-development skill:
    1. Write failing test FIRST
    2. Run test to verify it fails
    3. Write minimal code to pass
    4. Run test to verify it passes
    5. Refactor if needed
    6. Commit

    Project test command: pytest tests/ -q
    Project structure: [describe relevant files]
    """,
    toolsets=['terminal', 'file']
)
```

### With systematic-debugging

Bug found? Write failing test reproducing it. Follow TDD cycle. The test proves the fix and prevents regression.

Never fix bugs without a test.

## Demo / Fixture Features

When adding a deterministic demo fixture for later integration wiring, still use RED-GREEN-REFACTOR. Write the wished-for import/API in the test first, verify the missing module/API failure, then implement the smallest fixture that proves the real workflow path. Assert stable IDs/order, bucket counts/classifications, and safety invariants such as review text not implying publication. See `references/deterministic-demo-fixtures.md` for the concise pattern.

## Side-Effect-Free Integration Boundaries

When building toward an external integration (Telegram, email, publishing APIs, render farms, payment flows, etc.), add a tested preparation boundary before any real I/O:

1. Write the wished-for pure function in the test first, e.g. `prepare_review_delivery(text) -> DeliveryPlan`.
2. RED should be a missing module/API or missing validation failure, not a real network/send failure.
3. The GREEN implementation should return a typed/dataclass plan object with explicit metadata: destination/topic, delivery type, human-approval requirement, and `side_effects=()`.
4. Validate safety constraints at the boundary: non-empty content, platform size limits, no forbidden action language, no implicit approval, no credentials or generated media unless explicitly expected.
5. Add a demo CLI only after the pure boundary is green. The CLI should print metadata first, then payload, and still perform no external I/O.
6. Verify both the unit tests and a real `python -m package.cli.demo_*` invocation before committing.

This pattern lets later sending/rendering/publishing adapters consume a boring, audited object instead of smuggling side effects into formatting code. Boring is how we avoid building a content catapult with a moustache.

### Media / Rendering Pipeline Boundaries

When building toward image/video generation or renderer integration, add deterministic planning artifacts before any GPU/API work:

1. Write tests for the wished-for pure API first, e.g. `create_shot_plan(script)`, `build_render_manifest(plan)`, or `prepare_preview(manifest)`.
2. RED should be a missing module/API or missing validation behavior — never a failed ComfyUI/Wan/Qwen/API invocation.
3. Keep each stage inspectable and side-effect-free until the explicit adapter layer: `ScriptDraft -> ShotPlan -> RenderManifest -> PreviewPlan/PreviewArtifact -> renderer adapter`.
4. Assert stable IDs/order, scene counts, timing continuity, vertical settings (`1080x1920`, `30fps` when building Shorts/Reels/TikTok), and placeholder paths before generating media.
5. Add guardrail tests that reject static slideshows, baked-in/generated text inside assets, non-renderer-owned captions/labels, unsafe render settings, missing fields, and bad timings.
6. Only after the manifest/preview boundary is green should a later adapter call local GPU tools or external services. Planning first; GPU roulette later.

This prevents wasting render time on unclear direction and keeps expensive media generation behind a small, audited boundary.

### Approval-First Media/Social Pipelines

When a feature crosses from content planning toward media/social delivery, prefer many small RED-GREEN-README-COMMIT slices instead of one large integration jump:

1. typed artifact/model (`SocialMediaDraft`, `RenderManifest`, etc.)
2. renderer/formatter for human review text
3. side-effect-free delivery/preparation boundary
4. demo CLI that prints metadata first, then payload

For each slice, write the missing import/API test first, verify the expected import/API failure, implement only that slice, run the focused test, run the full suite, update docs, and commit. Keep invariant tests explicit: `requires_human_approval=True`, `side_effects=()`, no external calls, no credential access, no file writes unless that adapter is the explicit feature, and no accidental action language such as `publish`/`post` in review/delivery text.

This staged approach keeps approval semantics auditable and prevents a later renderer, Telegram sender, or platform adapter from being smuggled into formatting code.

## Testing Anti-Patterns

- **Testing mock behavior instead of real behavior** — mocks should verify interactions, not replace the system under test
- **Testing implementation details** — test behavior/results, not internal method calls
- **Happy path only** — always test edge cases, errors, and boundaries
- **Brittle tests** — tests should verify behavior, not structure; refactoring shouldn't break them
- **Substring sentinels that collide with generated IDs** — when asserting that amounts or fixture values are excluded from a result, assert the full formatted token (e.g. `999.00`) or a typed field, not a short substring like `999` that can appear inside UUIDs/hash-like IDs.
- **Domain classes named `Test*` trigger pytest collection warnings** — if a production dataclass/model must be named like `TestBatch`, set `__test__ = False` on the class or choose a non-`Test` prefix. Keep the fix in production code when the name is part of the domain language, and verify the warning is gone before committing.
- **Defaulting with `x or default` hides invalid explicit inputs** — when a factory/test helper accepts optional overrides, use `x if x is not None else default` so tests can pass intentionally blank strings (`""`) or zero values and watch validation reject them. Otherwise invalid-input tests may silently receive defaults and give false confidence.

## Final Rule

```
Production code → test exists and failed first
Otherwise → not TDD
```

No exceptions without the user's explicit permission.

To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'


social-media-shorts-automation

# Manifest-driven preview renderer for Shorts

Use this when a shorts automation project already has render manifests and needs the first video output without slipping into generic AI slideshow rendering.

## Durable pattern

Keep the renderer deterministic and manifest-driven:

```text
Render manifest JSON -> scene/card frames -> FFmpeg MP4 -> preview/approval
```

The LLM should not render frames. It may help create scripts, shot plans, and manifests, but the renderer should consume structured timeline fields and produce reproducible preview media.

## Renderer v1 architecture

A pragmatic first renderer can use:

- Pillow/PIL to draw vertical scene frames/cards.
- FFmpeg concat demuxer to turn frame images into an MP4.
- Render manifest fields for timing, text, asset type, visual direction, retention goal, resolution, aspect ratio, and fps.

Recommended module shape:

- `build_preview_frames(manifest, width, height)`
  - one representative image per visual timeline item
  - uses the manifest text, asset type, timing, and retention goal
- `write_frame_images(frames, output_dir)`
  - writes numbered PNGs for inspection/debugging
- `render_preview_video(manifest, output_path, width, height, fps)`
  - renders frames to temporary PNGs
  - writes an FFmpeg concat file with per-scene durations
  - exports `yuv420p` MP4 with `+faststart`

This is a preview/timing renderer, not the final beauty renderer. It validates scene flow, text weight, timing, and approval review before spending time on polished assets.

## TDD checks worth preserving

Write tests before implementation that assert:

- preview frames equal the number of visual scene items
- first frame starts at `0ms`
- image dimensions match requested vertical preview size
- first frame text matches the first visual timeline item
- PNG frame files are written and valid
- MP4 output exists and is non-empty
- `ffprobe` verifies width, height, frame rate, and duration for real render smoke tests

## CLI pattern

Provide a small script such as `scripts/render_preview.py`:

```bash
python3 scripts/render_preview.py \
  data/render_manifests/<batch>/<video>.json \
  --out-dir data/previews/<batch> \
  --width 540 \
  --height 960 \
  --fps 24
```

Use reduced preview resolution like `540x960` for fast local review. Keep final `1080x1920` as a later renderer-quality target.

## Repo hygiene

- Keep generated MP4/PNG media out of Git via `.gitignore`.
- Commit renderer code, tests, scripts, manifests, and lightweight planning artifacts.
- Do not commit rendered previews unless explicitly needed as small fixtures.
- Add runtime dependencies explicitly, e.g. `Pillow>=10.0`; treat FFmpeg as an external system prerequisite checked at runtime.

## Pitfalls

- Do not let the renderer infer timing or visuals from prose when the manifest already encodes them.
- Do not start with AI video generation as the renderer; use it later only as an asset supplier.
- Do not over-polish the first renderer. Its job is approval/timing validation.
- Do not publish from this step. Preview rendering still requires human approval before posting.
- Watch typography consistency in early previews: fixed-size fonts can make some scenes look huge and others tiny. Add tests/metadata for fitted font sizes, max line counts, and minimum retention-goal readability before scaling to more videos.


Explanation:
**Considering implementation requirements**

I need to implement a frame writer, which means I'll have to check if Pillow is installed. There's also the possibility that I need to look into the dependencies listed in the pyproject file. I wonder if using the terminal will be necessary to verify the current package version, as it might provide important information. It's crucial to confirm that everything is set up correctly before moving forward!

terminal

python - <<'PY'
try:
    import PIL
    print('PIL available', PIL.__version__)
except Exception as exc:
    print('PIL missing', type(exc).__name__, exc)
PY

git status --short


Output: PIL available 12.2.0


     1|# Secrets / local config
     2|.env
     3|.env.*
     4|*.token
     5|*secret*
     6|
     7|# Python
     8|__pycache__/
     9|*.py[cod]
    10|.pytest_cache/
    11|.ruff_cache/
    12|.mypy_cache/
    13|.venv/
    14|venv/
    15|
    16|# Generated media and runtime data
    17|data/local_motion_renders/
    18|data/imported_ai_clips/
    19|data/generated/
    20|outputs/
    21|*.mp4
    22|*.mov
    23|*.webm
    24|*.wav
    25|*.mp3
    26|*.png
    27|*.jpg
    28|*.jpeg
    29|*.webp
    30|
    31|# OS/editor
    32|.DS_Store
    33|.vscode/
    34|.idea/
    35|


File unchanged since last read. The content from the earlier read_file result in this conversation is still current — refer to that instead of re-reading.


     1|[project]
     2|name = "autoshortsbot"
     3|version = "0.1.0"
     4|description = "Semi-automatic short-form video growth engine with human approval gates."
     5|requires-python = ">=3.11"
     6|dependencies = []
     7|
     8|[tool.pytest.ini_options]
     9|pythonpath = ["."]
    10|testpaths = ["tests"]
    11|


Explanation:
**Evaluating dependencies and tests**

I need to consider adding the Pillow dependency because it's installed, but the pyproject dependencies are empty. If I'm using PIL, I should definitely add that dependency. For my TDD tests, I’ll write tests that import local_frame_writer and use tmp_path to verify dimensions with PIL. I’m thinking maybe I shouldn’t commit the PNG since it's temporary. I also want to focus on result side effects without external calls. So, let's get started on the writing!

from pathlib import Path

import pytest
from PIL import Image

from autoshorts.content.brief import create_content_brief
from autoshorts.content.script_draft import create_script_draft
from autoshorts.rendering.local_frame_writer import LocalFrameWriteResult, write_local_preview_frames
from autoshorts.rendering.local_preview import LocalPreviewPlan, build_local_preview_plan
from autoshorts.rendering.manifest import build_render_manifest
from autoshorts.rendering.shot_plan import create_shot_plan

def _plan():
    brief = create_content_brief(
        pillar_id="ai_life_systems",
        topic="AI weekly review routine",
        audience_problem="I use AI tools but still lose control of priorities.",
        payoff="a 10-minute Sunday review that keeps AI as assistant, not boss",
    )
    script = create_script_draft(brief)
    return build_local_preview_plan(build_render_manifest(create_shot_plan(script)))

def test_write_local_preview_frames_writes_valid_png_scene_cards(tmp_path):
    plan = _plan()

    result = write_local_preview_frames(plan, output_dir=tmp_path)

    assert isinstance(result, LocalFrameWriteResult)
    assert result.script_id == "ai_life_systems-ai-weekly-review-routine"
    assert result.output_dir == str(tmp_path)
    assert result.side_effects == ("write_png",)
    assert result.external_calls == ()
    assert len(result.frame_paths) == len(plan.frames)
    assert result.frame_paths[0].endswith("frame-01-first_frame_hook.png")

    first_path = Path(result.frame_paths[0])
    assert first_path.exists()
    assert first_path.stat().st_size > 0
    with Image.open(first_path) as image:
        assert image.format == "PNG"
        assert image.size == (540, 960)

def test_write_local_preview_frames_preserves_plan_order_and_paths(tmp_path):
    plan = _plan()

    result = write_local_preview_frames(plan, output_dir=tmp_path)

    assert [Path(path).name for path in result.frame_paths] == [
        "frame-01-first_frame_hook.png",
        "frame-02-pattern_interrupt.png",
        "frame-03-core_explanation.png",
        "frame-04-screen_or_card_visual.png",
        "frame-05-cta.png",
    ]

def test_write_local_preview_frames_rejects_invalid_plan(tmp_path):
    plan = _plan()
    invalid = LocalPreviewPlan(
        script_id=plan.script_id,
        title=plan.title,
        width=1920,
        height=1080,
        fps=plan.fps,
        output_dir=plan.output_dir,
        frames=plan.frames,
        side_effects=plan.side_effects,
        external_calls=plan.external_calls,
    )

    with pytest.raises(ValueError, match="preview size must be vertical"):
        write_local_preview_frames(invalid, output_dir=tmp_path)

def test_write_local_preview_frames_rejects_output_dir_outside_runtime_area(tmp_path):
    plan = _plan()
    unsafe_dir = tmp_path / ".." / "unsafe"

    with pytest.raises(ValueError, match="output_dir must be explicit and normalized"):
        write_local_preview_frames(plan, output_dir=unsafe_dir)


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_local_frame_writer.py">
ERROR [8:6] Import "autoshorts.rendering.local_frame_writer" could not be resolved [reportMissingImports] (Pyright)
</diagnostics>


python -m pytest tests/test_local_frame_writer.py -q


Output:
==================================== ERRORS ====================================
______________ ERROR collecting tests/test_local_frame_writer.py _______________
ImportError while importing test module '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_local_frame_writer.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_local_frame_writer.py:8: in <module>
    from autoshorts.rendering.local_frame_writer import LocalFrameWriteResult, write_local_preview_frames
E   ModuleNotFoundError: No module named 'autoshorts.rendering.local_frame_writer'
=========================== short test summary info ============================
ERROR tests/test_local_frame_writer.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13s


"""Write deterministic local preview PNG scene cards."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from textwrap import wrap

from PIL import Image, ImageDraw, ImageFont

from autoshorts.rendering.local_preview import LocalPreviewPlan, validate_local_preview_plan

@dataclass(frozen=True)
class LocalFrameWriteResult:
    script_id: str
    output_dir: str
    frame_paths: tuple[str, ...]
    side_effects: tuple[str, ...] = ("write_png",)
    external_calls: tuple[str, ...] = ()

def _validate_output_dir(output_dir: str | Path) -> Path:
    path = Path(output_dir)
    if not str(path).strip() or ".." in path.parts:
        raise ValueError("output_dir must be explicit and normalized")
    return path

def _font(size: int):
    try:
        return ImageFont.truetype("DejaVuSans.ttf", size=size)
    except OSError:
        return ImageFont.load_default()

def _wrapped(text: str, width: int) -> str:
    lines: list[str] = []
    for paragraph in text.splitlines() or [text]:
        lines.extend(wrap(paragraph, width=width) or [""])
    return "\n".join(lines)

def _palette(asset_mode: str) -> tuple[tuple[int, int, int], tuple[int, int, int]]:
    palettes = {
        "kinetic_text_overlay": ((15, 23, 42), (96, 165, 250)),
        "mechanism_animation": ((17, 24, 39), (52, 211, 153)),
        "screen_recording_simulation": ((24, 24, 27), (250, 204, 21)),
        "cinematic_ai_video": ((28, 25, 23), (251, 146, 60)),
    }
    return palettes.get(asset_mode, ((17, 24, 39), (148, 163, 184)))

def _draw_card(plan: LocalPreviewPlan, frame) -> Image.Image:
    background, accent = _palette(frame.asset_mode)
    image = Image.new("RGB", (plan.width, plan.height), background)
    draw = ImageDraw.Draw(image)

    margin = max(24, plan.width // 18)
    title_font = _font(max(26, plan.width // 13))
    body_font = _font(max(18, plan.width // 24))
    small_font = _font(max(14, plan.width // 34))

    draw.rectangle((0, 0, plan.width, 18), fill=accent)
    draw.text((margin, margin), frame.scene_purpose.replace("_", " ").upper(), fill=accent, font=small_font)
    draw.text((margin, margin + 38), _wrapped(frame.caption_text, 24), fill=(248, 250, 252), font=title_font, spacing=8)

    info_top = plan.height // 2
    draw.rounded_rectangle(
        (margin, info_top, plan.width - margin, plan.height - margin),
        radius=18,
        outline=accent,
        width=2,
        fill=(background[0] + 8, background[1] + 8, background[2] + 8),
    )
    body = (
        f"Timing: {frame.start_ms}ms + {frame.duration_ms}ms\n"
        f"Asset: {frame.asset_mode}\n"
        f"Label: {frame.label_text}\n\n"
        f"Motion: {frame.motion_instruction}"
    )
    draw.multiline_text((margin + 18, info_top + 18), _wrapped(body, 42), fill=(226, 232, 240), font=body_font, spacing=6)
    return image

def write_local_preview_frames(
    plan: LocalPreviewPlan,
    *,
    output_dir: str | Path | None = None,
) -> LocalFrameWriteResult:
    """Write deterministic PNG scene cards for a validated local preview plan."""

    validation = validate_local_preview_plan(plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))

    target_dir = _validate_output_dir(output_dir or plan.output_dir)
    target_dir.mkdir(parents=True, exist_ok=True)

    frame_paths: list[str] = []
    for frame in plan.frames:
        path = target_dir / f"{frame.frame_id}.png"
        image = _draw_card(plan, frame)
        image.save(path, format="PNG")
        frame_paths.append(str(path))

    return LocalFrameWriteResult(
        script_id=plan.script_id,
        output_dir=str(target_dir),
        frame_paths=tuple(frame_paths),
        side_effects=("write_png",),
        external_calls=(),
    )


patch

--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/pyproject.toml
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/pyproject.toml
@@ -3,7 +3,9 @@
 version = "0.1.0"
 description = "Semi-automatic short-form video growth engine with human approval gates."
 requires-python = ">=3.11"
-dependencies = []
+dependencies = [
+    "Pillow>=10.0",
+]

 [tool.pytest.ini_options]
 pythonpath = ["."]


terminal

Output:
....                                                                     [100%]
4 passed in 0.31s


Output:
........................................................................ [ 71%]
.............................                                            [100%]
101 passed in 0.37s


Explanation:
**Updating documentation**

I think I might need to update the README. I’m considering whether to verify things manually and possibly write to the /tmp path instead of using /data. It could be easier to generate temporary files there. I also realize that I might have to update the documentation further. And hey, I wonder if running a git status might show the status of the pyproject file. I’ll have to keep an eye on that!

patch

- `autoshorts.rendering.local_frame_writer` — deterministic Pillow PNG scene-card writer for local preview plans.
- `autoshorts.rendering.local_preview` — side-effect-free local preview frame planning from render manifests.
- `autoshorts.rendering.manifest` — side-effect-free vertical render manifest generation from shot plans.


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -22,6 +22,7 @@
 - `autoshorts.ideas.factory` — draft-to-candidate factory with stable ids, default hashtags, and duration classes.
 - `autoshorts.ideas.testbatch` — testbatch validation and renderable-candidate filtering.
 - `autoshorts.review.approvals` — deterministic Telegram approval command parser.
+- `autoshorts.rendering.local_frame_writer` — deterministic Pillow PNG scene-card writer for local preview plans.
 - `autoshorts.rendering.local_preview` — side-effect-free local preview frame planning from render manifests.
 - `autoshorts.rendering.manifest` — side-effect-free vertical render manifest generation from shot plans.
 - `autoshorts.rendering.preview` — side-effect-free HTML timeline previews for render manifests.

No linter for .md files


`validate_local_preview_plan()` rejects non-vertical preview sizes, non-positive fps, blank output paths, empty frame lists, missing frame fields, bad frame durations, non-zero first-frame start time, overlapping frames, side effects, and external tool calls. This is still a plan only: it writes no PNGs, calls no FFmpeg, and renders no MP4.

`autoshorts.rendering.local_frame_writer.write_local_preview_frames()` is the first explicit artifact-writing boundary. It consumes a validated `LocalPreviewPlan` and writes deterministic PNG scene cards using Pillow. Each card uses manifest-derived frame data: scene purpose, caption, label, timing, asset mode, and motion instruction. It returns `LocalFrameWriteResult` with `side_effects=("write_png",)` and `external_calls=()`.

Generated preview frames should stay under runtime preview directories such as `data/previews/<script_id>/` or temporary test directories. PNG media is ignored by Git.

## Social media draft

`validate_local_preview_plan()` rejects non-vertical preview sizes, non-positive fps, blank output paths, empty frame lists, missing frame fields, bad frame durations, non-zero first-frame start time, overlapping frames, side effects, and external tool calls. This is still a plan only: it writes no PNGs, calls no FFmpeg, and renders no MP4.

## Social media draft


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -116,6 +116,10 @@

 `validate_local_preview_plan()` rejects non-vertical preview sizes, non-positive fps, blank output paths, empty frame lists, missing frame fields, bad frame durations, non-zero first-frame start time, overlapping frames, side effects, and external tool calls. This is still a plan only: it writes no PNGs, calls no FFmpeg, and renders no MP4.

+`autoshorts.rendering.local_frame_writer.write_local_preview_frames()` is the first explicit artifact-writing boundary. It consumes a validated `LocalPreviewPlan` and writes deterministic PNG scene cards using Pillow. Each card uses manifest-derived frame data: scene purpose, caption, label, timing, asset mode, and motion instruction. It returns `LocalFrameWriteResult` with `side_effects=("write_png",)` and `external_calls=()`.
+
+Generated preview frames should stay under runtime preview directories such as `data/previews/<script_id>/` or temporary test directories. PNG media is ignored by Git.
+
 ## Social media draft

 `autoshorts.social.draft.build_social_media_draft()` turns a validated `VideoCandidate` plus `RenderPreview` into an approval-first `SocialMediaDraft` containing:

No linter for .md files


Run tests:

```bash
python -m pytest tests -q
```

Runtime dependencies currently include Pillow for deterministic local PNG preview frames. FFmpeg is not used yet.

## Planned architecture

Run tests:

```bash
python -m pytest tests -q
```

## Planned architecture


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -289,6 +289,8 @@
 python -m pytest tests -q
 ```

+Runtime dependencies currently include Pillow for deterministic local PNG preview frames. FFmpeg is not used yet.
+
 ## Planned architecture

 ```text

No linter for .md files


terminal

python -m pytest tests -q && python - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory
from PIL import Image
from autoshorts.content.brief import create_content_brief
from autoshorts.content.script_draft import create_script_draft
from autoshorts.rendering.shot_plan import create_shot_plan
from autoshorts.rendering.manifest import build_render_manifest
from autoshorts.rendering.local_preview import build_local_preview_plan
from autoshorts.rendering.local_frame_writer import write_local_preview_frames
brief = create_content_brief(
    pillar_id='ai_life_systems',
    topic='AI weekly review routine',
    audience_problem='I use AI tools but still lose control of priorities.',
    payoff='a 10-minute Sunday review that keeps AI as assistant, not boss',
)
script = create_script_draft(brief)
plan = build_local_preview_plan(build_render_manifest(create_shot_plan(script)))
with TemporaryDirectory() as tmp:
    result = write_local_preview_frames(plan, output_dir=tmp)
    first = Path(result.frame_paths[0])
    with Image.open(first) as img:
        print(result.script_id)
        print(len(result.frame_paths))
        print(first.name)
        print(img.format, img.size)
        print(result.side_effects, result.external_calls)
PY


Output:
........................................................................ [ 71%]
.............................                                            [100%]
101 passed in 0.37s
ai_life_systems-ai-weekly-review-routine
5
frame-01-first_frame_hook.png
PNG (540, 960)
('write_png',) ()


Explanation:
**Checking git status**

I need to get my commit ready, but first, it’s probably a good idea to check the git status. This way, I’ll see what changes I’ve made and what files are staged. It’ll help ensure I’m not missing anything important before I proceed. I want to make sure everything is set up correctly, so let's go ahead and see what’s currently happening in the repository!

git status --short && git add README.md pyproject.toml autoshorts/rendering/local_frame_writer.py tests/test_local_frame_writer.py && git commit -m "feat: write local preview frame cards" && git log --oneline -n 8 && git status --short


Output:
M README.md
 M pyproject.toml
?? autoshorts/rendering/local_frame_writer.py
?? tests/test_local_frame_writer.py
[feat/core-approval-quality-gates 7e881b3] feat: write local preview frame cards
 4 files changed, 206 insertions(+), 1 deletion(-)
 create mode 100644 autoshorts/rendering/local_frame_writer.py
 create mode 100644 tests/test_local_frame_writer.py
7e881b3 feat: write local preview frame cards
02ecf59 feat: add local preview frame planning
3b387f4 feat: add demo social draft cli
515c549 feat: add social draft delivery preparation
a7d10fa feat: render social media draft reviews
1f6aa2c feat: add social media draft model
f8c5588 feat: add demo render preview cli
42ac32f feat: add side-effect-free render preview


Erledigt, Sir.

## Neu eingebaut: Local Preview Frame Writer

Neue Dateien:

```text
autoshorts/rendering/local_frame_writer.py
tests/test_local_frame_writer.py
```

Geändert:

```text
pyproject.toml
README.md
```

## Neue API

```python
write_local_preview_frames(plan: LocalPreviewPlan, *, output_dir: str | Path | None = None) -> LocalFrameWriteResult
```

Der Flow ist jetzt:

```text
RenderManifest
→ LocalPreviewPlan
→ PNG scene cards
```

Das ist der erste explizite Artefakt-Schritt: hier wird wirklich geschrieben — aber nur PNG Scene Cards, keine MP4, kein FFmpeg, kein ComfyUI, kein Qwen/Wan, kein Telegram.

## LocalFrameWriteResult

```python
LocalFrameWriteResult(
    script_id=...,
    output_dir=...,
    frame_paths=(...),
    side_effects=("write_png",),
    external_calls=(),
)
```

Also klar markiert: **dieser Boundary schreibt Dateien**, ruft aber nichts Externes auf.

## Was die PNG Scene Cards enthalten

Pro `LocalPreviewFrame` wird eine vertikale PNG-Karte erzeugt mit:

- Scene Purpose
- Caption
- Timing
- Asset Mode
- Label
- Motion Instruction

Format:

```text
540x960
PNG
```

Beispiel verifiziert:

```text
frame-01-first_frame_hook.png
PNG (540, 960)
```

## Manuell verifiziert

```text
ai_life_systems-ai-weekly-review-routine
5
frame-01-first_frame_hook.png
PNG (540, 960)
('write_png',) ()
```

Bedeutung:

- Script korrekt
- 5 Scene Cards geschrieben
- erste Datei korrekt benannt
- PNG valide
- Dimensionen korrekt
- Side Effects: nur `write_png`
- keine External Calls

## Guardrails

Tests prüfen:

- PNG-Dateien existieren
- PNG-Dateien sind non-empty
- Pillow kann sie öffnen
- Dimensionen stimmen
- Frame-Reihenfolge bleibt stabil
- ungültiger PreviewPlan wird rejected
- unsaubere Output-Pfade mit `..` werden rejected

## Dependency

`pyproject.toml` enthält jetzt:

```toml
dependencies = [
    "Pillow>=10.0",
]
```

Pillow ist nötig für deterministische lokale PNG Preview Frames.

## Tests

RED war wie gewünscht:

```text
ModuleNotFoundError: No module named 'autoshorts.rendering.local_frame_writer'
```

GREEN:

```text
4 passed
```

Full suite:

```text
101 passed in 0.37s
```

## README aktualisiert

Dokumentiert:

- `autoshorts.rendering.local_frame_writer`
- PNG Scene Card Boundary
- Side Effects bewusst `("write_png",)`
- keine externen Calls
- PNGs bleiben runtime/generated media und sind durch `.gitignore` abgedeckt
- Pillow als Runtime Dependency
- FFmpeg noch nicht genutzt

## Commit

```text
7e881b3 feat: write local preview frame cards
```

Arbeitsverzeichnis ist sauber.

## Nächster sinnvoller Schritt

Jetzt kommt:

```text
PNG scene cards
→ FFmpeg concat plan
```

Noch **nicht** direkt MP4 rendern. Erst bauen wir den planbaren FFmpeg-Layer:

```text
autoshorts/rendering/ffmpeg_plan.py
tests/test_ffmpeg_plan.py
```

Ziel:

- aus `LocalFrameWriteResult + LocalPreviewPlan`
- erzeugt ein `FFmpegConcatPlan`
- enthält:
  - frame paths
  - per-frame durations
  - concat file content
  - intended output path
  - fps
  - width/height
- Side Effects:
  - erstmal `()`
- External Calls:
  - erstmal `()`
- validiert:
  - gleiche Anzahl Frames wie Plan
  - alle Pfade `.png`
  - durations > 0
  - output `.mp4`
  - keine echten FFmpeg Calls

Danach erst:

```text
FFmpegConcatPlan
→ write concat file
→ call ffmpeg
→ MP4 timing preview
```

Ein Schritt nach dem anderen. Sonst sind wir schneller bei “warum liegt da ein kaputtes 900MB Video im Repo?” als bei “gutes Short”.


Explanation:
**Planning implementation steps**

I need to take action here. First, I should load the relevant skills that I’ll need. After that, I'll implement the ffmpeg_plan using test-driven development (TDD). It’s essential to follow through on these steps systematically to ensure everything works smoothly. I'll stay focused on getting this done effectively, and that involves making sure I don’t skip any steps along the way! Let’s get to it!


Implement. Need inspect current.

Implement. Need inspect current.


     1|"""Side-effect-free local preview planning from render manifests."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|
     7|from autoshorts.ideas.candidate import ValidationResult
     8|from autoshorts.rendering.manifest import RenderManifest, validate_render_manifest
     9|
    10|
    11|@dataclass(frozen=True)
    12|class LocalPreviewFrame:
    13|    frame_id: str
    14|    scene_purpose: str
    15|    start_ms: int
    16|    duration_ms: int
    17|    caption_text: str
    18|    label_text: str
    19|    asset_mode: str
    20|    motion_instruction: str
    21|    placeholder_image_path: str
    22|
    23|
    24|@dataclass(frozen=True)
    25|class LocalPreviewPlan:
    26|    script_id: str
    27|    title: str
    28|    width: int
    29|    height: int
    30|    fps: int
    31|    output_dir: str
    32|    frames: tuple[LocalPreviewFrame, ...]
    33|    side_effects: tuple[str, ...] = ()
    34|    external_calls: tuple[str, ...] = ()
    35|
    36|
    37|def _is_blank(value: str) -> bool:
    38|    return not (value or "").strip()
    39|
    40|
    41|def _frame_id(index: int, purpose: str) -> str:
    42|    return f"frame-{index:02d}-{purpose}"
    43|
    44|
    45|def build_local_preview_plan(
    46|    manifest: RenderManifest,
    47|    *,
    48|    width: int = 540,
    49|    height: int = 960,
    50|    fps: int = 24,
    51|) -> LocalPreviewPlan:
    52|    """Build a deterministic frame plan without writing images or calling FFmpeg."""
    53|
    54|    manifest_validation = validate_render_manifest(manifest)
    55|    if not manifest_validation.is_valid:
    56|        raise ValueError("; ".join(manifest_validation.errors))
    57|
    58|    output_dir = f"data/previews/{manifest.script_id}"
    59|    frames = tuple(
    60|        LocalPreviewFrame(
    61|            frame_id=_frame_id(index, item.scene_purpose),
    62|            scene_purpose=item.scene_purpose,
    63|            start_ms=item.start_seconds * 1000,
    64|            duration_ms=(item.end_seconds - item.start_seconds) * 1000,
    65|            caption_text=item.caption_layer.text,
    66|            label_text=item.label_layer.text,
    67|            asset_mode=item.asset_mode,
    68|            motion_instruction=item.motion_instruction,
    69|            placeholder_image_path=f"{output_dir}/{_frame_id(index, item.scene_purpose)}.png",
    70|        )
    71|        for index, item in enumerate(manifest.timeline, start=1)
    72|    )
    73|    plan = LocalPreviewPlan(
    74|        script_id=manifest.script_id,
    75|        title=manifest.title,
    76|        width=width,
    77|        height=height,
    78|        fps=fps,
    79|        output_dir=output_dir,
    80|        frames=frames,
    81|        side_effects=(),
    82|        external_calls=(),
    83|    )
    84|    validation = validate_local_preview_plan(plan)
    85|    if not validation.is_valid:
    86|        raise ValueError("; ".join(validation.errors))
    87|    return plan
    88|
    89|
    90|def validate_local_preview_plan(plan: LocalPreviewPlan) -> ValidationResult:
    91|    errors: list[str] = []
    92|    if _is_blank(plan.script_id):
    93|        errors.append("script_id is required")
    94|    if _is_blank(plan.title):
    95|        errors.append("title is required")
    96|    if plan.width <= 0 or plan.height <= 0 or plan.height <= plan.width:
    97|        errors.append("preview size must be vertical")
    98|    if plan.fps <= 0:
    99|        errors.append("fps must be positive")
   100|    if _is_blank(plan.output_dir):
   101|        errors.append("output_dir is required")
   102|    if plan.side_effects:
   103|        errors.append("preview plan must not have side effects")
   104|    if plan.external_calls:
   105|        errors.append("preview plan must not call external tools")
   106|    if not plan.frames:
   107|        errors.append("at least one preview frame is required")
   108|
   109|    previous_end: int | None = None
   110|    for index, frame in enumerate(plan.frames, start=1):
   111|        for field_name, value in {
   112|            "frame_id": frame.frame_id,
   113|            "scene_purpose": frame.scene_purpose,
   114|            "caption_text": frame.caption_text,
   115|            "label_text": frame.label_text,
   116|            "asset_mode": frame.asset_mode,
   117|            "motion_instruction": frame.motion_instruction,
   118|            "placeholder_image_path": frame.placeholder_image_path,
   119|        }.items():
   120|            if _is_blank(value):
   121|                errors.append(f"frame {index} {field_name} is required")
   122|        if frame.duration_ms <= 0:
   123|            errors.append(f"frame {index} duration_ms must be positive")
   124|        if index == 1 and frame.start_ms != 0:
   125|            errors.append("first frame must start at 0ms")
   126|        if previous_end is not None and frame.start_ms < previous_end:
   127|            errors.append(f"frame {index} start_ms overlaps previous frame")
   128|        previous_end = frame.start_ms + frame.duration_ms
   129|
   130|    return ValidationResult(is_valid=not errors, errors=tuple(errors))
   131|


     1|"""Write deterministic local preview PNG scene cards."""
     2|
     3|from __future__ import annotations
     4|
     5|from dataclasses import dataclass
     6|from pathlib import Path
     7|from textwrap import wrap
     8|
     9|from PIL import Image, ImageDraw, ImageFont
    10|
    11|from autoshorts.rendering.local_preview import LocalPreviewPlan, validate_local_preview_plan
    12|
    13|
    14|@dataclass(frozen=True)
    15|class LocalFrameWriteResult:
    16|    script_id: str
    17|    output_dir: str
    18|    frame_paths: tuple[str, ...]
    19|    side_effects: tuple[str, ...] = ("write_png",)
    20|    external_calls: tuple[str, ...] = ()
    21|
    22|
    23|def _validate_output_dir(output_dir: str | Path) -> Path:
    24|    path = Path(output_dir)
    25|    if not str(path).strip() or ".." in path.parts:
    26|        raise ValueError("output_dir must be explicit and normalized")
    27|    return path
    28|
    29|
    30|def _font(size: int):
    31|    try:
    32|        return ImageFont.truetype("DejaVuSans.ttf", size=size)
    33|    except OSError:
    34|        return ImageFont.load_default()
    35|
    36|
    37|def _wrapped(text: str, width: int) -> str:
    38|    lines: list[str] = []
    39|    for paragraph in text.splitlines() or [text]:
    40|        lines.extend(wrap(paragraph, width=width) or [""])
    41|    return "\n".join(lines)
    42|
    43|
    44|def _palette(asset_mode: str) -> tuple[tuple[int, int, int], tuple[int, int, int]]:
    45|    palettes = {
    46|        "kinetic_text_overlay": ((15, 23, 42), (96, 165, 250)),
    47|        "mechanism_animation": ((17, 24, 39), (52, 211, 153)),
    48|        "screen_recording_simulation": ((24, 24, 27), (250, 204, 21)),
    49|        "cinematic_ai_video": ((28, 25, 23), (251, 146, 60)),
    50|    }
    51|    return palettes.get(asset_mode, ((17, 24, 39), (148, 163, 184)))
    52|
    53|
    54|def _draw_card(plan: LocalPreviewPlan, frame) -> Image.Image:
    55|    background, accent = _palette(frame.asset_mode)
    56|    image = Image.new("RGB", (plan.width, plan.height), background)
    57|    draw = ImageDraw.Draw(image)
    58|
    59|    margin = max(24, plan.width // 18)
    60|    title_font = _font(max(26, plan.width // 13))
    61|    body_font = _font(max(18, plan.width // 24))
    62|    small_font = _font(max(14, plan.width // 34))
    63|
    64|    draw.rectangle((0, 0, plan.width, 18), fill=accent)
    65|    draw.text((margin, margin), frame.scene_purpose.replace("_", " ").upper(), fill=accent, font=small_font)
    66|    draw.text((margin, margin + 38), _wrapped(frame.caption_text, 24), fill=(248, 250, 252), font=title_font, spacing=8)
    67|
    68|    info_top = plan.height // 2
    69|    draw.rounded_rectangle(
    70|        (margin, info_top, plan.width - margin, plan.height - margin),
    71|        radius=18,
    72|        outline=accent,
    73|        width=2,
    74|        fill=(background[0] + 8, background[1] + 8, background[2] + 8),
    75|    )
    76|    body = (
    77|        f"Timing: {frame.start_ms}ms + {frame.duration_ms}ms\n"
    78|        f"Asset: {frame.asset_mode}\n"
    79|        f"Label: {frame.label_text}\n\n"
    80|        f"Motion: {frame.motion_instruction}"
    81|    )
    82|    draw.multiline_text((margin + 18, info_top + 18), _wrapped(body, 42), fill=(226, 232, 240), font=body_font, spacing=6)
    83|    return image
    84|
    85|
    86|def write_local_preview_frames(
    87|    plan: LocalPreviewPlan,
    88|    *,
    89|    output_dir: str | Path | None = None,
    90|) -> LocalFrameWriteResult:
    91|    """Write deterministic PNG scene cards for a validated local preview plan."""
    92|
    93|    validation = validate_local_preview_plan(plan)
    94|    if not validation.is_valid:
    95|        raise ValueError("; ".join(validation.errors))
    96|
    97|    target_dir = _validate_output_dir(output_dir or plan.output_dir)
    98|    target_dir.mkdir(parents=True, exist_ok=True)
    99|
   100|    frame_paths: list[str] = []
   101|    for frame in plan.frames:
   102|        path = target_dir / f"{frame.frame_id}.png"
   103|        image = _draw_card(plan, frame)
   104|        image.save(path, format="PNG")
   105|        frame_paths.append(str(path))
   106|
   107|    return LocalFrameWriteResult(
   108|        script_id=plan.script_id,
   109|        output_dir=str(target_dir),
   110|        frame_paths=tuple(frame_paths),
   111|        side_effects=("write_png",),
   112|        external_calls=(),
   113|    )
   114|


Write tests. Need maybe create temp frames using writer. Use ffmpeg_plan module.

from pathlib import Path

import pytest

from autoshorts.content.brief import create_content_brief
from autoshorts.content.script_draft import create_script_draft
from autoshorts.rendering.ffmpeg_plan import FFmpegConcatPlan, build_ffmpeg_concat_plan, validate_ffmpeg_concat_plan
from autoshorts.rendering.local_frame_writer import LocalFrameWriteResult, write_local_preview_frames
from autoshorts.rendering.local_preview import build_local_preview_plan
from autoshorts.rendering.manifest import build_render_manifest
from autoshorts.rendering.shot_plan import create_shot_plan

def _preview_plan_and_frames(tmp_path):
    brief = create_content_brief(
        pillar_id="ai_life_systems",
        topic="AI weekly review routine",
        audience_problem="I use AI tools but still lose control of priorities.",
        payoff="a 10-minute Sunday review that keeps AI as assistant, not boss",
    )
    script = create_script_draft(brief)
    preview_plan = build_local_preview_plan(build_render_manifest(create_shot_plan(script)))
    write_result = write_local_preview_frames(preview_plan, output_dir=tmp_path)
    return preview_plan, write_result

def test_build_ffmpeg_concat_plan_creates_side_effect_free_concat_plan(tmp_path):
    preview_plan, write_result = _preview_plan_and_frames(tmp_path)

    plan = build_ffmpeg_concat_plan(preview_plan, write_result, output_path=tmp_path / "preview.mp4")

    assert isinstance(plan, FFmpegConcatPlan)
    assert plan.script_id == "ai_life_systems-ai-weekly-review-routine"
    assert plan.output_path == str(tmp_path / "preview.mp4")
    assert plan.concat_file_path == str(tmp_path / "preview.concat.txt")
    assert plan.width == 540
    assert plan.height == 960
    assert plan.fps == 24
    assert plan.side_effects == ()
    assert plan.external_calls == ()
    assert len(plan.entries) == len(preview_plan.frames)
    assert plan.entries[0].image_path.endswith("frame-01-first_frame_hook.png")
    assert plan.entries[0].duration_seconds == 3.0
    assert plan.entries[-1].duration_seconds == 5.0
    assert "file '" in plan.concat_file_content
    assert "duration 3.000" in plan.concat_file_content
    assert plan.concat_file_content.strip().endswith("frame-05-cta.png'")

def test_validate_ffmpeg_concat_plan_accepts_default_plan(tmp_path):
    preview_plan, write_result = _preview_plan_and_frames(tmp_path)

    result = validate_ffmpeg_concat_plan(
        build_ffmpeg_concat_plan(preview_plan, write_result, output_path=tmp_path / "preview.mp4")
    )

    assert result.is_valid is True
    assert result.errors == ()

def test_build_ffmpeg_concat_plan_rejects_mismatched_frame_count(tmp_path):
    preview_plan, write_result = _preview_plan_and_frames(tmp_path)
    mismatched = LocalFrameWriteResult(
        script_id=write_result.script_id,
        output_dir=write_result.output_dir,
        frame_paths=write_result.frame_paths[:-1],
        side_effects=write_result.side_effects,
        external_calls=write_result.external_calls,
    )

    with pytest.raises(ValueError, match="frame count must match preview plan"):
        build_ffmpeg_concat_plan(preview_plan, mismatched, output_path=tmp_path / "preview.mp4")

def test_build_ffmpeg_concat_plan_rejects_non_png_frame_paths(tmp_path):
    preview_plan, write_result = _preview_plan_and_frames(tmp_path)
    bad = LocalFrameWriteResult(
        script_id=write_result.script_id,
        output_dir=write_result.output_dir,
        frame_paths=(str(Path(write_result.frame_paths[0]).with_suffix(".jpg")),) + write_result.frame_paths[1:],
        side_effects=write_result.side_effects,
        external_calls=write_result.external_calls,
    )

    with pytest.raises(ValueError, match="frame paths must be png files"):
        build_ffmpeg_concat_plan(preview_plan, bad, output_path=tmp_path / "preview.mp4")

def test_validate_ffmpeg_concat_plan_rejects_bad_output_and_external_calls(tmp_path):
    preview_plan, write_result = _preview_plan_and_frames(tmp_path)
    plan = build_ffmpeg_concat_plan(preview_plan, write_result, output_path=tmp_path / "preview.mp4")
    unsafe = FFmpegConcatPlan(
        script_id=plan.script_id,
        output_path=str(tmp_path / "preview.mov"),
        concat_file_path=plan.concat_file_path,
        width=0,
        height=plan.height,
        fps=0,
        entries=plan.entries,
        concat_file_content=plan.concat_file_content,
        side_effects=("write_concat",),
        external_calls=("ffmpeg",),
    )

    result = validate_ffmpeg_concat_plan(unsafe)

    assert result.is_valid is False
    assert "output_path must end with .mp4" in result.errors
    assert "width and height must be positive" in result.errors
    assert "fps must be positive" in result.errors
    assert "ffmpeg concat plans must not have side effects" in result.errors
    assert "ffmpeg concat plans must not call external tools" in result.errors


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_ffmpeg_plan.py">
ERROR [7:6] Import "autoshorts.rendering.ffmpeg_plan" could not be resolved [reportMissingImports] (Pyright)
</diagnostics>


python -m pytest tests/test_ffmpeg_plan.py -q


Output:
==================================== ERRORS ====================================
__________________ ERROR collecting tests/test_ffmpeg_plan.py __________________
ImportError while importing test module '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_ffmpeg_plan.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_ffmpeg_plan.py:7: in <module>
    from autoshorts.rendering.ffmpeg_plan import FFmpegConcatPlan, build_ffmpeg_concat_plan, validate_ffmpeg_concat_plan
E   ModuleNotFoundError: No module named 'autoshorts.rendering.ffmpeg_plan'
=========================== short test summary info ============================
ERROR tests/test_ffmpeg_plan.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13s


Implement. Need escape single quote in ffmpeg concat maybe simple. ensure content ends with last file repeated? concat demuxer requires last file repeated no duration maybe test expects ends with frame-05. Good.

"""Side-effect-free FFmpeg concat planning for local preview frames."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

from autoshorts.ideas.candidate import ValidationResult
from autoshorts.rendering.local_frame_writer import LocalFrameWriteResult
from autoshorts.rendering.local_preview import LocalPreviewPlan, validate_local_preview_plan

@dataclass(frozen=True)
class FFmpegConcatEntry:
    image_path: str
    duration_seconds: float

@dataclass(frozen=True)
class FFmpegConcatPlan:
    script_id: str
    output_path: str
    concat_file_path: str
    width: int
    height: int
    fps: int
    entries: tuple[FFmpegConcatEntry, ...]
    concat_file_content: str
    side_effects: tuple[str, ...] = ()
    external_calls: tuple[str, ...] = ()

def _is_blank(value: str) -> bool:
    return not (value or "").strip()

def _quote_concat_path(path: str) -> str:
    return path.replace("'", "'\\''")

def _concat_content(entries: tuple[FFmpegConcatEntry, ...]) -> str:
    lines: list[str] = []
    for entry in entries:
        lines.append(f"file '{_quote_concat_path(entry.image_path)}'")
        lines.append(f"duration {entry.duration_seconds:.3f}")
    if entries:
        lines.append(f"file '{_quote_concat_path(entries[-1].image_path)}'")
    return "\n".join(lines) + "\n"

def build_ffmpeg_concat_plan(
    preview_plan: LocalPreviewPlan,
    write_result: LocalFrameWriteResult,
    *,
    output_path: str | Path,
) -> FFmpegConcatPlan:
    """Build an FFmpeg concat plan without writing files or calling FFmpeg."""

    preview_validation = validate_local_preview_plan(preview_plan)
    if not preview_validation.is_valid:
        raise ValueError("; ".join(preview_validation.errors))
    if write_result.script_id != preview_plan.script_id:
        raise ValueError("write result script_id must match preview plan")
    if len(write_result.frame_paths) != len(preview_plan.frames):
        raise ValueError("frame count must match preview plan")
    if any(not path.endswith(".png") for path in write_result.frame_paths):
        raise ValueError("frame paths must be png files")
    if write_result.external_calls:
        raise ValueError("frame write result must not include external calls")

    entries = tuple(
        FFmpegConcatEntry(
            image_path=image_path,
            duration_seconds=frame.duration_ms / 1000,
        )
        for frame, image_path in zip(preview_plan.frames, write_result.frame_paths, strict=True)
    )
    out_path = Path(output_path)
    concat_path = out_path.with_suffix(".concat.txt")
    plan = FFmpegConcatPlan(
        script_id=preview_plan.script_id,
        output_path=str(out_path),
        concat_file_path=str(concat_path),
        width=preview_plan.width,
        height=preview_plan.height,
        fps=preview_plan.fps,
        entries=entries,
        concat_file_content=_concat_content(entries),
        side_effects=(),
        external_calls=(),
    )
    validation = validate_ffmpeg_concat_plan(plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))
    return plan

def validate_ffmpeg_concat_plan(plan: FFmpegConcatPlan) -> ValidationResult:
    errors: list[str] = []
    if _is_blank(plan.script_id):
        errors.append("script_id is required")
    if _is_blank(plan.output_path):
        errors.append("output_path is required")
    elif not plan.output_path.endswith(".mp4"):
        errors.append("output_path must end with .mp4")
    if _is_blank(plan.concat_file_path):
        errors.append("concat_file_path is required")
    elif not plan.concat_file_path.endswith(".concat.txt"):
        errors.append("concat_file_path must end with .concat.txt")
    if plan.width <= 0 or plan.height <= 0:
        errors.append("width and height must be positive")
    if plan.fps <= 0:
        errors.append("fps must be positive")
    if plan.side_effects:
        errors.append("ffmpeg concat plans must not have side effects")
    if plan.external_calls:
        errors.append("ffmpeg concat plans must not call external tools")
    if not plan.entries:
        errors.append("at least one concat entry is required")
    if _is_blank(plan.concat_file_content):
        errors.append("concat_file_content is required")

    for index, entry in enumerate(plan.entries, start=1):
        if _is_blank(entry.image_path):
            errors.append(f"entry {index} image_path is required")
        elif not entry.image_path.endswith(".png"):
            errors.append("frame paths must be png files")
        if entry.duration_seconds <= 0:
            errors.append(f"entry {index} duration_seconds must be positive")

    return ValidationResult(is_valid=not errors, errors=tuple(errors))


Output:
.....                                                                    [100%]
5 passed in 0.56s


Output:
........................................................................ [ 67%]
..................................                                       [100%]
106 passed in 0.85s


Patch README.

Patch README.

patch

- `autoshorts.rendering.ffmpeg_plan` — side-effect-free FFmpeg concat planning for local preview frames.
- `autoshorts.rendering.local_frame_writer` — deterministic Pillow PNG scene-card writer for local preview plans.
- `autoshorts.rendering.local_preview` — side-effect-free local preview frame planning from render manifests.


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -22,6 +22,7 @@
 - `autoshorts.ideas.factory` — draft-to-candidate factory with stable ids, default hashtags, and duration classes.
 - `autoshorts.ideas.testbatch` — testbatch validation and renderable-candidate filtering.
 - `autoshorts.review.approvals` — deterministic Telegram approval command parser.
+- `autoshorts.rendering.ffmpeg_plan` — side-effect-free FFmpeg concat planning for local preview frames.
 - `autoshorts.rendering.local_frame_writer` — deterministic Pillow PNG scene-card writer for local preview plans.
 - `autoshorts.rendering.local_preview` — side-effect-free local preview frame planning from render manifests.
 - `autoshorts.rendering.manifest` — side-effect-free vertical render manifest generation from shot plans.

No linter for .md files


Generated preview frames should stay under runtime preview directories such as `data/previews/<script_id>/` or temporary test directories. PNG media is ignored by Git.

`autoshorts.rendering.ffmpeg_plan.build_ffmpeg_concat_plan()` turns a `LocalPreviewPlan` plus `LocalFrameWriteResult` into a side-effect-free `FFmpegConcatPlan`. It records ordered PNG paths, per-frame durations, output `.mp4` path, derived `.concat.txt` path, preview dimensions, fps, and FFmpeg concat demuxer file content.

`validate_ffmpeg_concat_plan()` rejects mismatched or missing values, non-`.mp4` output paths, non-`.concat.txt` concat paths, non-positive dimensions/fps, missing entries, non-PNG frame paths, non-positive durations, side effects, and external tool calls. This layer still does not write the concat file and does not call FFmpeg.

## Social media draft

Generated preview frames should stay under runtime preview directories such as `data/previews/<script_id>/` or temporary test directories. PNG media is ignored by Git.

## Social media draft


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -121,6 +121,10 @@

 Generated preview frames should stay under runtime preview directories such as `data/previews/<script_id>/` or temporary test directories. PNG media is ignored by Git.

+`autoshorts.rendering.ffmpeg_plan.build_ffmpeg_concat_plan()` turns a `LocalPreviewPlan` plus `LocalFrameWriteResult` into a side-effect-free `FFmpegConcatPlan`. It records ordered PNG paths, per-frame durations, output `.mp4` path, derived `.concat.txt` path, preview dimensions, fps, and FFmpeg concat demuxer file content.
+
+`validate_ffmpeg_concat_plan()` rejects mismatched or missing values, non-`.mp4` output paths, non-`.concat.txt` concat paths, non-positive dimensions/fps, missing entries, non-PNG frame paths, non-positive durations, side effects, and external tool calls. This layer still does not write the concat file and does not call FFmpeg.
+
 ## Social media draft

 `autoshorts.social.draft.build_social_media_draft()` turns a validated `VideoCandidate` plus `RenderPreview` into an approval-first `SocialMediaDraft` containing:

No linter for .md files


terminal

python -m pytest tests -q && python - <<'PY'
from tempfile import TemporaryDirectory
from pathlib import Path
from autoshorts.content.brief import create_content_brief
from autoshorts.content.script_draft import create_script_draft
from autoshorts.rendering.shot_plan import create_shot_plan
from autoshorts.rendering.manifest import build_render_manifest
from autoshorts.rendering.local_preview import build_local_preview_plan
from autoshorts.rendering.local_frame_writer import write_local_preview_frames
from autoshorts.rendering.ffmpeg_plan import build_ffmpeg_concat_plan, validate_ffmpeg_concat_plan
brief = create_content_brief(
    pillar_id='ai_life_systems',
    topic='AI weekly review routine',
    audience_problem='I use AI tools but still lose control of priorities.',
    payoff='a 10-minute Sunday review that keeps AI as assistant, not boss',
)
script = create_script_draft(brief)
preview_plan = build_local_preview_plan(build_render_manifest(create_shot_plan(script)))
with TemporaryDirectory() as tmp:
    write_result = write_local_preview_frames(preview_plan, output_dir=tmp)
    plan = build_ffmpeg_concat_plan(preview_plan, write_result, output_path=Path(tmp) / 'preview.mp4')
    print(plan.script_id)
    print((plan.width, plan.height, plan.fps))
    print(len(plan.entries))
    print(plan.entries[0].duration_seconds, plan.entries[-1].duration_seconds)
    print(Path(plan.output_path).name, Path(plan.concat_file_path).name)
    print(plan.side_effects, plan.external_calls)
    print(validate_ffmpeg_concat_plan(plan).is_valid)
PY


Output:
........................................................................ [ 67%]
..................................                                       [100%]
106 passed in 0.86s
ai_life_systems-ai-weekly-review-routine
(540, 960, 24)
5
3.0 5.0
preview.mp4 preview.concat.txt
() ()
True


git status --short && git add README.md autoshorts/rendering/ffmpeg_plan.py tests/test_ffmpeg_plan.py && git commit -m "feat: add ffmpeg concat planning" && git log --oneline -n 8 && git status --short


Output:
M README.md
?? autoshorts/rendering/ffmpeg_plan.py
?? tests/test_ffmpeg_plan.py
[feat/core-approval-quality-gates 1128b7c] feat: add ffmpeg concat planning
 3 files changed, 248 insertions(+)
 create mode 100644 autoshorts/rendering/ffmpeg_plan.py
 create mode 100644 tests/test_ffmpeg_plan.py
1128b7c feat: add ffmpeg concat planning
7e881b3 feat: write local preview frame cards
02ecf59 feat: add local preview frame planning
3b387f4 feat: add demo social draft cli
515c549 feat: add social draft delivery preparation
a7d10fa feat: render social media draft reviews
1f6aa2c feat: add social media draft model
f8c5588 feat: add demo render preview cli


Erledigt, Sir.

## Neu eingebaut: FFmpeg Concat Planning

Neue Dateien:

```text
autoshorts/rendering/ffmpeg_plan.py
tests/test_ffmpeg_plan.py
```

## Neue API

```python
build_ffmpeg_concat_plan(
    preview_plan: LocalPreviewPlan,
    write_result: LocalFrameWriteResult,
    *,
    output_path: str | Path,
) -> FFmpegConcatPlan

validate_ffmpeg_concat_plan(plan: FFmpegConcatPlan) -> ValidationResult
```

Der Flow ist jetzt:

```text
RenderManifest
→ LocalPreviewPlan
→ PNG scene cards
→ FFmpegConcatPlan
```

Wichtig: Dieser Schritt ruft **noch kein FFmpeg** auf und schreibt **noch keine concat file**. Er plant nur sauber.

## FFmpegConcatPlan

Enthält:

```text
script_id
output_path
concat_file_path
width
height
fps
entries
concat_file_content
side_effects=()
external_calls=()
```

Pro Entry:

```text
image_path
duration_seconds
```

Beispiel verifiziert:

```text
ai_life_systems-ai-weekly-review-routine
(540, 960, 24)
5
3.0 5.0
preview.mp4 preview.concat.txt
() ()
True
```

Bedeutung:

- Script ID korrekt
- Preview size/fps korrekt
- 5 PNG Frames
- erste Dauer `3.0s`
- letzte Dauer `5.0s`
- Output `.mp4`
- Concat file path `.concat.txt`
- keine Side Effects
- keine External Calls
- Validation grün

## Concat File Content

Der Plan erzeugt bereits FFmpeg concat-demuxer-kompatiblen Inhalt:

```text
file '.../frame-01-first_frame_hook.png'
duration 3.000
file '.../frame-02-pattern_interrupt.png'
duration 5.000
...
file '.../frame-05-cta.png'
duration 5.000
file '.../frame-05-cta.png'
```

Das letzte File wird wiederholt, wie FFmpeg concat demuxer es für die letzte Duration gern hat. Natürlich etwas altmodisch. FFmpeg ist eben ein mächtiger Werkzeugkasten aus der Ära “was wäre, wenn UX optional wäre”.

## Guardrails

`build_ffmpeg_concat_plan()` / `validate_ffmpeg_concat_plan()` prüfen:

- gültiger `LocalPreviewPlan`
- `script_id` Match zwischen Plan und FrameWriteResult
- gleiche Anzahl Preview Frames und PNG Paths
- Frame Paths müssen `.png` sein
- Output Path muss `.mp4` sein
- Concat Path muss `.concat.txt` sein
- Width/Height/FPS positiv
- Durations positiv
- keine Side Effects
- keine External Calls

## TDD eingehalten

RED:

```text
ModuleNotFoundError: No module named 'autoshorts.rendering.ffmpeg_plan'
```

GREEN:

```text
5 passed
```

Full suite:

```text
106 passed in 0.86s
```

## README aktualisiert

Dokumentiert:

- `autoshorts.rendering.ffmpeg_plan`
- `FFmpegConcatPlan`
- Entries/Durations
- concat demuxer content
- `.mp4` / `.concat.txt` validation
- weiterhin kein FFmpeg Call

## Commit

```text
1128b7c feat: add ffmpeg concat planning
```

Arbeitsverzeichnis ist sauber.

## Nächster sinnvoller Schritt

Jetzt kommt der echte FFmpeg Boundary — aber sauber getrennt:

```text
FFmpegConcatPlan
→ write concat file
→ call ffmpeg
→ MP4 timing preview
```

Vorschlag:

```text
autoshorts/rendering/ffmpeg_renderer.py
tests/test_ffmpeg_renderer.py
```

Ziel:

- schreibt `.concat.txt`
- ruft `ffmpeg`
- erzeugt `.mp4`
- gibt `FFmpegRenderResult` zurück:
  - `output_path`
  - `concat_file_path`
  - `side_effects=("write_concat", "call_ffmpeg", "write_mp4")`
  - `external_calls=("ffmpeg",)`
- Tests:
  - `ffmpeg -version` check/skip wenn nicht installiert
  - MP4 exists
  - MP4 non-empty
  - optional `ffprobe`:
    - width `540`
    - height `960`
    - duration ungefähr `35s`
    - fps `24`

Das wäre der erste echte Video-Preview. Noch nicht schön, aber kontrolliert — und kontrolliert schlägt “hübscher Zufallsunfall” jeden Tag.


Explanation:
**Implementing ffmpeg_renderer**

I need to keep working on implementing the ffmpeg_renderer. It's important for the task I'm handling right now. I should focus on utilizing my skills effectively to ensure a smooth process. There’s definitely a lot to think about in terms of details, but it all comes together when I stay organized. I’ll just take it step by step and figure things out as I go along!


Explanation:
**Checking ffmpeg availability**

I need to implement something and first, I want to check if ffmpeg is available. It seems like testing could be skipped for now, and I can use the terminal to do this. I'll run a quick command to see if ffmpeg is installed and functioning properly. If it's there, I'll be able to proceed; if not, I’ll need to figure out how to install it. Let's see what the terminal tells me!

command -v ffmpeg || true; command -v ffprobe || true; git status --short


Output:
/usr/bin/ffmpeg
/usr/bin/ffprobe


Explanation:
**Considering test setup**

I'm thinking about writing a test and using a subprocess for ffprobe, which might work. I have to keep the duration manageable—maybe around 35 seconds—with five PNGs. I’ll need to figure out the best way to construct this to ensure it all runs smoothly. It’s a bit tricky, but I think it’s doable if I plan out how to tackle the encoding as well. Let’s make this work!

import json
import shutil
import subprocess
from pathlib import Path

import pytest

from autoshorts.content.brief import create_content_brief
from autoshorts.content.script_draft import create_script_draft
from autoshorts.rendering.ffmpeg_plan import build_ffmpeg_concat_plan
from autoshorts.rendering.ffmpeg_renderer import FFmpegRenderResult, render_ffmpeg_preview_video
from autoshorts.rendering.local_frame_writer import write_local_preview_frames
from autoshorts.rendering.local_preview import build_local_preview_plan
from autoshorts.rendering.manifest import build_render_manifest
from autoshorts.rendering.shot_plan import create_shot_plan

pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg is required")

def _concat_plan(tmp_path):
    brief = create_content_brief(
        pillar_id="ai_life_systems",
        topic="AI weekly review routine",
        audience_problem="I use AI tools but still lose control of priorities.",
        payoff="a 10-minute Sunday review that keeps AI as assistant, not boss",
    )
    script = create_script_draft(brief)
    preview_plan = build_local_preview_plan(build_render_manifest(create_shot_plan(script)))
    write_result = write_local_preview_frames(preview_plan, output_dir=tmp_path / "frames")
    return build_ffmpeg_concat_plan(preview_plan, write_result, output_path=tmp_path / "preview.mp4")

def test_render_ffmpeg_preview_video_writes_concat_file_and_mp4(tmp_path):
    plan = _concat_plan(tmp_path)

    result = render_ffmpeg_preview_video(plan)

    assert isinstance(result, FFmpegRenderResult)
    assert result.script_id == "ai_life_systems-ai-weekly-review-routine"
    assert result.output_path == plan.output_path
    assert result.concat_file_path == plan.concat_file_path
    assert result.side_effects == ("write_concat", "call_ffmpeg", "write_mp4")
    assert result.external_calls == ("ffmpeg",)
    assert result.returncode == 0
    assert Path(result.concat_file_path).exists()
    assert Path(result.concat_file_path).read_text() == plan.concat_file_content
    output = Path(result.output_path)
    assert output.exists()
    assert output.stat().st_size > 0

@pytest.mark.skipif(shutil.which("ffprobe") is None, reason="ffprobe is required")
def test_render_ffmpeg_preview_video_smoke_probes_dimensions_and_duration(tmp_path):
    plan = _concat_plan(tmp_path)

    result = render_ffmpeg_preview_video(plan)

    probe = subprocess.run(
        [
            "ffprobe",
            "-v",
            "error",
            "-select_streams",
            "v:0",
            "-show_entries",
            "stream=width,height,r_frame_rate,duration",
            "-of",
            "json",
            result.output_path,
        ],
        check=True,
        capture_output=True,
        text=True,
    )
    stream = json.loads(probe.stdout)["streams"][0]
    assert stream["width"] == 540
    assert stream["height"] == 960
    assert stream["r_frame_rate"] == "24/1"
    assert float(stream["duration"]) == pytest.approx(35.0, abs=1.0)

def test_render_ffmpeg_preview_video_rejects_invalid_plan(tmp_path):
    plan = _concat_plan(tmp_path)
    invalid = type(plan)(
        script_id=plan.script_id,
        output_path=str(tmp_path / "preview.mov"),
        concat_file_path=plan.concat_file_path,
        width=plan.width,
        height=plan.height,
        fps=plan.fps,
        entries=plan.entries,
        concat_file_content=plan.concat_file_content,
        side_effects=plan.side_effects,
        external_calls=plan.external_calls,
    )

    with pytest.raises(ValueError, match="output_path must end with .mp4"):
        render_ffmpeg_preview_video(invalid)


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_ffmpeg_renderer.py">
ERROR [11:6] Import "autoshorts.rendering.ffmpeg_renderer" could not be resolved [reportMissingImports] (Pyright)
</diagnostics>


python -m pytest tests/test_ffmpeg_renderer.py -q


Output:
==================================== ERRORS ====================================
________________ ERROR collecting tests/test_ffmpeg_renderer.py ________________
ImportError while importing test module '/home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/tests/test_ffmpeg_renderer.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_ffmpeg_renderer.py:11: in <module>
    from autoshorts.rendering.ffmpeg_renderer import FFmpegRenderResult, render_ffmpeg_preview_video
E   ModuleNotFoundError: No module named 'autoshorts.rendering.ffmpeg_renderer'
=========================== short test summary info ============================
ERROR tests/test_ffmpeg_renderer.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.14s


Explanation:
**Implementing renderer commands**

I’m working on the renderer and need to concatenate the demuxer command for ffmpeg. It looks like I’ll use `ffmpeg -y -f concat -safe 0 -i concat -vf fps=24,format=yuv420p -movflags +faststart output`. For still images, I want to ensure the output lasts about 35 seconds. I'll use `subprocess.run` and check for stdout and stderr. It’s also important to validate and ensure that the parent directories are correct, using absolute paths for the concat file from the temporary location.

"""FFmpeg rendering boundary for local preview videos."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import subprocess

from autoshorts.rendering.ffmpeg_plan import FFmpegConcatPlan, validate_ffmpeg_concat_plan

@dataclass(frozen=True)
class FFmpegRenderResult:
    script_id: str
    output_path: str
    concat_file_path: str
    returncode: int
    stdout: str
    stderr: str
    side_effects: tuple[str, ...] = ("write_concat", "call_ffmpeg", "write_mp4")
    external_calls: tuple[str, ...] = ("ffmpeg",)

def render_ffmpeg_preview_video(plan: FFmpegConcatPlan) -> FFmpegRenderResult:
    """Write the concat file and invoke FFmpeg to create a local MP4 preview."""

    validation = validate_ffmpeg_concat_plan(plan)
    if not validation.is_valid:
        raise ValueError("; ".join(validation.errors))

    concat_path = Path(plan.concat_file_path)
    output_path = Path(plan.output_path)
    concat_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    concat_path.write_text(plan.concat_file_content)

    command = [
        "ffmpeg",
        "-y",
        "-f",
        "concat",
        "-safe",
        "0",
        "-i",
        str(concat_path),
        "-vf",
        f"fps={plan.fps},format=yuv420p",
        "-movflags",
        "+faststart",
        str(output_path),
    ]
    completed = subprocess.run(command, capture_output=True, text=True, check=False)
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr or "ffmpeg failed")

    return FFmpegRenderResult(
        script_id=plan.script_id,
        output_path=str(output_path),
        concat_file_path=str(concat_path),
        returncode=completed.returncode,
        stdout=completed.stdout,
        stderr=completed.stderr,
        side_effects=("write_concat", "call_ffmpeg", "write_mp4"),
        external_calls=("ffmpeg",),
    )


Output:
...                                                                      [100%]
3 passed in 5.86s


Output:
........................................................................ [ 66%]
.....................................                                    [100%]
109 passed in 6.06s


patch

- `autoshorts.rendering.ffmpeg_plan` — side-effect-free FFmpeg concat planning for local preview frames.
- `autoshorts.rendering.ffmpeg_renderer` — explicit FFmpeg boundary that writes concat files and MP4 timing previews.
- `autoshorts.rendering.local_frame_writer` — deterministic Pillow PNG scene-card writer for local preview plans.

- `autoshorts.rendering.ffmpeg_plan` — side-effect-free FFmpeg concat planning for local preview frames.
- `autoshorts.rendering.local_frame_writer` — deterministic Pillow PNG scene-card writer for local preview plans.


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -23,6 +23,7 @@
 - `autoshorts.ideas.testbatch` — testbatch validation and renderable-candidate filtering.
 - `autoshorts.review.approvals` — deterministic Telegram approval command parser.
 - `autoshorts.rendering.ffmpeg_plan` — side-effect-free FFmpeg concat planning for local preview frames.
+- `autoshorts.rendering.ffmpeg_renderer` — explicit FFmpeg boundary that writes concat files and MP4 timing previews.
 - `autoshorts.rendering.local_frame_writer` — deterministic Pillow PNG scene-card writer for local preview plans.
 - `autoshorts.rendering.local_preview` — side-effect-free local preview frame planning from render manifests.
 - `autoshorts.rendering.manifest` — side-effect-free vertical render manifest generation from shot plans.

No linter for .md files


`validate_ffmpeg_concat_plan()` rejects mismatched or missing values, non-`.mp4` output paths, non-`.concat.txt` concat paths, non-positive dimensions/fps, missing entries, non-PNG frame paths, non-positive durations, side effects, and external tool calls. This layer still does not write the concat file and does not call FFmpeg.

`autoshorts.rendering.ffmpeg_renderer.render_ffmpeg_preview_video()` is the explicit local video-rendering boundary. It consumes a validated `FFmpegConcatPlan`, writes the `.concat.txt` file, calls `ffmpeg`, and writes the `.mp4` timing preview. It returns `FFmpegRenderResult` with `side_effects=("write_concat", "call_ffmpeg", "write_mp4")` and `external_calls=("ffmpeg",)`.

The renderer exports `yuv420p` MP4 with `+faststart`. Tests skip if `ffmpeg`/`ffprobe` are unavailable and otherwise smoke-probe width, height, frame rate, and approximate duration.

## Social media draft

`validate_ffmpeg_concat_plan()` rejects mismatched or missing values, non-`.mp4` output paths, non-`.concat.txt` concat paths, non-positive dimensions/fps, missing entries, non-PNG frame paths, non-positive durations, side effects, and external tool calls. This layer still does not write the concat file and does not call FFmpeg.

## Social media draft


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -126,6 +126,10 @@

 `validate_ffmpeg_concat_plan()` rejects mismatched or missing values, non-`.mp4` output paths, non-`.concat.txt` concat paths, non-positive dimensions/fps, missing entries, non-PNG frame paths, non-positive durations, side effects, and external tool calls. This layer still does not write the concat file and does not call FFmpeg.

+`autoshorts.rendering.ffmpeg_renderer.render_ffmpeg_preview_video()` is the explicit local video-rendering boundary. It consumes a validated `FFmpegConcatPlan`, writes the `.concat.txt` file, calls `ffmpeg`, and writes the `.mp4` timing preview. It returns `FFmpegRenderResult` with `side_effects=("write_concat", "call_ffmpeg", "write_mp4")` and `external_calls=("ffmpeg",)`.
+
+The renderer exports `yuv420p` MP4 with `+faststart`. Tests skip if `ffmpeg`/`ffprobe` are unavailable and otherwise smoke-probe width, height, frame rate, and approximate duration.
+
 ## Social media draft

 `autoshorts.social.draft.build_social_media_draft()` turns a validated `VideoCandidate` plus `RenderPreview` into an approval-first `SocialMediaDraft` containing:

No linter for .md files


Runtime dependencies currently include Pillow for deterministic local PNG preview frames. FFmpeg is used only by the explicit local preview renderer boundary and remains an external system prerequisite.

## Planned architecture

Runtime dependencies currently include Pillow for deterministic local PNG preview frames. FFmpeg is not used yet.

## Planned architecture


--- a//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
+++ b//home/agent/jarvis_runtime/autoshortsbot/AutoShortsBot/README.md
@@ -299,7 +299,7 @@
 python -m pytest tests -q
 ```

-Runtime dependencies currently include Pillow for deterministic local PNG preview frames. FFmpeg is not used yet.
+Runtime dependencies currently include Pillow for deterministic local PNG preview frames. FFmpeg is used only by the explicit local preview renderer boundary and remains an external system prerequisite.

 ## Planned architecture

No linter for .md files


Need manual verify.

Need manual verify.

terminal

python -m pytest tests -q && python - <<'PY'
from tempfile import TemporaryDirectory
from pathlib import Path
from autoshorts.content.brief import create_content_brief
from autoshorts.content.script_draft import create_script_draft
from autoshorts.rendering.shot_plan import create_shot_plan
from autoshorts.rendering.manifest import build_render_manifest
from autoshorts.rendering.local_preview import build_local_preview_plan
from autoshorts.rendering.local_frame_writer import write_local_preview_frames
from autoshorts.rendering.ffmpeg_plan import build_ffmpeg_concat_plan
from autoshorts.rendering.ffmpeg_renderer import render_ffmpeg_preview_video
brief = create_content_brief(
    pillar_id='ai_life_systems',
    topic='AI weekly review routine',
    audience_problem='I use AI tools but still lose control of priorities.',
    payoff='a 10-minute Sunday review that keeps AI as assistant, not boss',
)
script = create_script_draft(brief)
preview_plan = build_local_preview_plan(build_render_manifest(create_shot_plan(script)))
with TemporaryDirectory() as tmp:
    write_result = write_local_preview_frames(preview_plan, output_dir=Path(tmp)/'frames')
    concat_plan = build_ffmpeg_concat_plan(preview_plan, write_result, output_path=Path(tmp)/'preview.mp4')
    result = render_ffmpeg_preview_video(concat_plan)
    out = Path(result.output_path)
    concat = Path(result.concat_file_path)
    print(result.script_id)
    print(out.name, out.exists(), out.stat().st_size > 0)
    print(concat.name, concat.exists())
    print(result.side_effects, result.external_calls, result.returncode)
PY


Output:
........................................................................ [ 66%]
.....................................                                    [100%]
109 passed in 5.96s
ai_life_systems-ai-weekly-review-routine
preview.mp4 True True
preview.concat.txt True
('write_concat', 'call_ffmpeg', 'write_mp4') ('ffmpeg',) 0


Need git commit.

Need git commit.

git status --short && git add README.md autoshorts/rendering/ffmpeg_renderer.py tests/test_ffmpeg_renderer.py && git commit -m "feat: render local ffmpeg preview videos" && git log --oneline -n 10 && git status --short


Output:
M README.md
?? autoshorts/rendering/ffmpeg_renderer.py
?? tests/test_ffmpeg_renderer.py
[feat/core-approval-quality-gates 89a0ebe] feat: render local ffmpeg preview videos
 3 files changed, 169 insertions(+), 1 deletion(-)
 create mode 100644 autoshorts/rendering/ffmpeg_renderer.py
 create mode 100644 tests/test_ffmpeg_renderer.py
89a0ebe feat: render local ffmpeg preview videos
1128b7c feat: add ffmpeg concat planning
7e881b3 feat: write local preview frame cards
02ecf59 feat: add local preview frame planning
3b387f4 feat: add demo social draft cli
515c549 feat: add social draft delivery preparation
a7d10fa feat: render social media draft reviews
1f6aa2c feat: add social media draft model
f8c5588 feat: add demo render preview cli
42ac32f feat: add side-effect-free render preview