Updated Jun 21, 2026 Decisions

Key Decisions — Chapter I

Decision: genai/ imports credentials from os.getenv, not dashboard/constants.py

Why: dashboard/constants.py imports from dashboard/models.py, which pulls in Django ORM. Importing it from genai/ would drag the entire Django stack in — breaking the zero-Django-import rule and making test_llm.py impossible to run without a configured Django environment.

Result: genai/llm_client.py reads GEMINI_API_KEY and MODEL_NAME directly from os.getenv(). Credentials live in .env and are loaded by python-dotenv in the test script.


Decision: google-genai SDK (new), not google-generativeai (old)

Why: requirements.txt uses google-genai — the new unified SDK. The old SDK (google-generativeai) used GenerativeModel, start_chat, GenerationConfig. The new SDK uses genai.Client, client.models.generate_content, types.GenerateContentConfig.

Result: llm_client.py uses the new SDK API throughout.


Decision: Registry holds classes, never instances

Why: Agents need fresh state per request. A persistent instance would carry stale conversation history across requests. The registry is a factory — it gives you the blueprint, you build the object with the dependencies you need right now.

Result: AgentRegistry.register() stores the class. get_by_type() returns the class. Caller instantiates.


Decision: agent_service/apps.py ready() registers agents

Why: ready() fires once per Passenger process on startup — after all Django apps are loaded but before any requests are served. It's the correct Django hook for one-time setup that depends on the app registry being ready.

Result: AgentServiceConfig.ready() imports and registers TeoAgent. Future agents (Vega, Dash, Arthur) are added here as each chapter is built.


Decision: Notifications marked read only after greet, not on every request

Why: First implementation marked notifications read before teo.run() — so if Gemini failed or the greeting didn't mention them, they were gone silently. Fixing to mark read only after a successful __greet__ response ensures Teo actually surfaces them before they disappear.

Result: run_teo() marks notifications read only when user_message == '__greet__'.


Decision: greet exchanges not saved to ConversationMessage

Why: The greeting is a UI trigger, not a real conversation turn. Saving it would pollute the history Teo uses for context — he'd see "the user said greet" as the last thing in the conversation.

Result: _save_messages() is skipped when user_message == '__greet__'.


Decision: Greeting fires on every page load unconditionally

Why: Early approaches used sessionStorage or checked for existing .msg elements to avoid re-greeting. Both caused problems — sessionStorage prevented greeting after navigation, and the DOM check prevented greeting when history was pre-loaded from DB. The correct behaviour is: always greet on page load. The greeting is never saved to history so it never pollutes context.

Result: greet() fires unconditionally on every page load. Pre-loaded history and greeting coexist correctly.


Decision: ConversationMessage scoped by user for authenticated agents, session for anonymous

Why: Session keys are anonymous browser identifiers — if Tomas uses two devices, he gets two separate conversation histories. Scoping by Django user instead means history follows the authenticated user across all devices. Anonymous agents (Vega visitor chat) still use session keys since there's no user to scope by.

Result: ConversationMessage has both a nullable user FK and a nullable session_key. Service layer uses user when request.user.is_authenticated, session_key otherwise. This applies to all agents, not just Teo.


Decision: _parse_response aggressively strips markdown fences

Why: Gemini occasionally wraps JSON responses in ```json ... ``` fences despite being instructed not to. The original check only stripped fences when the response started with backticks. The fix uses regex to strip any fence variant from both ends unconditionally, and also handles escaped \n sequences in the message field.

Result: _parse_response() in base.py uses re.sub to strip fences before JSON parsing, and applies replace('\\n', '\n') to the extracted message.

Why: Previously the chat opened empty even when previous messages existed in the DB. Pre-loading history in the Django template GET response means messages are immediately visible without an extra API call.

Result: TeoChatView.get() calls get_conversation_history() and passes it to the template. Messages render as Django template loops before JS executes.


Key Decisions — Chapter II

Decision: Wiki is a Django app with DB-backed pages, not a flat file system

Why: Flat files would require filesystem access for every read, make access control harder, and give agents no structured way to query or update content. A Django app with models gives us queryable, permission-aware, agent-writable pages.

Result: wiki/ Django app with WikiProject and WikiPage models. DB is the single source of truth.


Decision: DB is the source of truth — markdown file is backup only

Why: Having both the file and DB as live sources creates a sync problem. Agents write to the DB. The markdown file is a human-readable weekly backup for disaster recovery.

Result: export_wiki dumps DB → markdown. seed_wiki restores markdown → DB. They are inverses. The file is never edited directly for production use.


Decision: export_wiki uses project/slug format to avoid collisions

Why: Multiple projects can have pages with the same slug (e.g. both t0-d0 and trading-bot have an overview page). Using just slug as the section header would cause the parser to overwrite one with the other.

Result: export_wiki writes ## Page: t0-d0/overview and ## Page: trading-bot/overview. seed_wiki parses project/slug keys and looks them up correctly.


Decision: seed_wiki has fallback inline content for pages not yet in the backup file

Why: New pages added to PAGE_MAP won't exist in the backup file until after the first export_wiki run. Without a fallback the seed would skip them silently.

Result: FALLBACK_PAGES dict in seed_wiki.py provides inline content for new pages. Once export_wiki runs, the file takes over as the source.


Decision: Access control in views, not middleware

Why: Middleware would apply the same rule to all wiki pages. Different pages have different visibility — is_public controls per-page anonymous access. The view is the right place to filter.

Result: WikiProjectView and WikiPageView filter queryset by is_public=True for anonymous users. Authenticated users see all published pages. Anonymous users get 404 for non-public pages.


Decision: Teo references wiki URLs directly, does not read content yet

Why: Teo has no ReadWikiTool until Chapter III. Adding wiki-reading capability to Teo before Vega exists would duplicate work and blur the architectural boundary — Teo reads his own section, Vega owns everything else.

Result: Teo's system prompt includes wiki URLs. He can direct Tomas to the right page but cannot pull content. Full wiki reading comes in Chapter III with Vega's tools.


Decision: export_wiki / seed_wiki as backup and restore pair

Why: The DB is the source of truth but needs a human-readable backup. A weekly markdown export gives a second line of defence alongside the full DB backup. If only the wiki is corrupted, seed_wiki restores it from the markdown file without a full DB restore.

Result: export_wiki dumps all published WikiPages to media/wiki/wiki_t0-d0.md, moving the previous file to media/wiki/backup/ with a timestamp. seed_wiki is the inverse — reads the file and creates/updates DB records. Run weekly via cron.


Decision: export_wiki uses project/slug format for page headers

Why: Multiple projects can have pages with the same slug (both t0-d0 and trading-bot have overview). Using slug alone as the section header causes the parser to overwrite one with the other.

Result: export_wiki writes ## Page: t0-d0/overview. seed_wiki parses project/slug keys. No collisions possible across projects.


Decision: markdown filter strips blank lines between table rows

Why: The Django admin on Windows saves content with blank lines between table rows (\n\n between | rows). The Python markdown library treats a blank line as a paragraph break, which breaks table rendering. This affected the status page on the live Linux server but not locally.

Result: wiki_extras.py markdown filter uses regex to strip blank lines between | rows before passing to the markdown parser. Tables render correctly regardless of how content was stored.


Decision: Management commands as the operational backbone

Why: Wiki lifecycle operations (seed, export, backup) need to be runnable from the command line without a browser. Management commands are the correct Django pattern — they're scriptable, cronnable, and testable.

Result: Four wiki management commands exist: - seed_wiki — restore wiki from markdown file (fresh environment or disaster recovery) - export_wiki — backup wiki DB to markdown file (run weekly) - add_chapter2_decisions — one-time: appended Chapter II decisions (stub now) - add_chapter2_decisions_2 — one-time: appended remaining Chapter II decisions (stub now)

Cron schedule on live server:

0 0 * * *    python manage.py sync_runs
0 1 * * 0    python manage.py backup_db && python manage.py export_wiki
0 2 * * 0    python manage.py cleanup_conversations --days 14
30 3 1 * *   python manage.py cleanup_notifications --days 30
0 8 * * *    python manage.py run_vega

Key Decisions — Chapter III

Decision: ConversationMessage model refactored — agent_name + scope + role

Why: Old model used user FK and session_key — ambiguous when querying per-agent history for authenticated users. Both Teo and Vega messages had user=tomas, role='user', making it impossible to separate them cleanly. Adding more agents would make it worse.

Result: New fields: agent_name (which agent), scope (user:TomasD or session:abc123), role (user or agent). Composite index on (agent_name, scope, created_at). Generic helpers _get_history() and _save_messages() in service.py shared by all agents. Migration: 0005_remove_conversationmessage_session_key_and_more.


Decision: Two-request Teo→Vega delegation flow

Why: A single request would make the user wait for both Teo's response and Vega's full round-trip before seeing anything. That's a 5-10 second blank wait.

Result: First request to /api/teo/chat/ returns immediately with Teo's "I'll ask Vega" message and needs_vega=True. JS shows the message, then fires a second request to /api/teo/delegate/ which calls Vega and returns Teo's synthesised final answer. Better UX, same total latency.


Decision: Vega does not create wiki pages from scratch

Why: Vega has no independent source of truth for implementation details. She can only synthesise from what's already documented. Allowing her to create pages would risk hallucinated "documentation".

Result: Vega maintains existing pages only — edits sections, updates status, marks things complete. New pages are seeded from authoritative .md files written by Tomas after each chapter, then Vega takes over maintenance.


Decision: Wiki content injected directly into Vega's system prompt

Why: Using tool calls to fetch wiki content on every question adds a full LLM round-trip for the most common case (answering from known content). Most visitor questions can be answered directly from the injected content.

Result: Full page bodies injected at instantiation via get_vega() in service.py. Tools (ReadWikiTool, SearchWikiTool) remain available for edge cases where content isn't in the prompt. Trade-off: larger prompt as wiki grows — addressed in Chapter VI with pgvector semantic retrieval.


Decision: Session-based rate limiting for anonymous Vega chat

Why: IP-based rate limiting requires persistent cache infrastructure (Redis or file cache). Session-based is simpler, zero config, and sufficient for token cost protection on a personal portfolio site. A bot sophisticated enough to manage sessions isn't the primary threat model.

Result: Rate limit stored in request.session['vega_rl_count'] and request.session['vega_rl_start']. 20 questions per 10-minute fixed window. Resets automatically. Response includes minutes remaining.


Decision: Shared navbar snippet — main/templates/main/_navbar.html

Why: Each page had its own navbar HTML and CSS. Any change had to be made in 4+ places. The snippet approach reduces duplication and enforces consistency.

Result: Pure HTML include, no <style> block in the snippet (CSS lives in each page's <style> to avoid rendering issues). Context variables: nav_wiki, nav_project, nav_page, nav_show_teo. Used on Teo's page and all three wiki templates.


Decision: Homepage card colour system — teal for public, gold for private

Why: Visitors need to instantly understand what they can access. Colour is the fastest signal.

Result: Public cards (NowLink, Vega): teal accent, label, dots, button. Private cards (Teo, Trading Bot): gold/amber (#c9920a) accent. Lock icon on private card buttons for unauthenticated users, arrow for authenticated. Gold chosen over red (alarming) or grey (invisible) — matches the warm wood aesthetic of the background.


Decision: Vega banner above card grid on homepage

Why: Vega is the only thing on the site that public visitors can actually interact with. She deserved a proper introduction before they hit the wiki.

Result: A wood-toned banner above the project cards: Vega's satisfied avatar, her intro text in her own voice, "Follow me to the wiki →" button. Not a card alongside the projects — a meta layer that frames all of them.


Key Decisions — Chapter IV

Decision: DashAgent mood derived from last run outcome, not arbitrary

Why: An agent whose mood is random or arbitrary feels fake. Dash's emotional state should reflect actual trading performance — that's what makes it meaningful.

Result: get_mood() reads the last run outcome. BUY → neutral, SELL → satisfied, no_action/offline → bored, error/failure → stressed. Mood injected into system prompt at instantiation.


Decision: Dash reports JitoSOL accumulated, not USD profit

Why: The USD value fluctuates with market price and is misleading as a performance metric. JitoSOL accumulated is the actual result of the arbitrage strategy — what we actually earned in token terms.

Result: total_profit_usd removed from Dash's injected data. total_jitosol_accumulated injected instead. All amounts reported to 5 decimal places.


Decision: Jupiter price fetch uses 3-retry loop, no TeoNotification on timeout

Why: Jupiter API occasionally times out — this is normal network noise, not a bot failure. Creating urgent notifications for every timeout would spam Teo's morning briefing with meaningless alerts.

Result: sync_and_trigger retries Jupiter price fetch up to 3 times with 5s delay. On all 3 failures: logs to dash.log, returns silently. No TeoNotification created.


Decision: Heartbeat triggers on whole hour (XX:00), not "60 minutes since last run"

Why: The "60 minutes since last run" approach drifted — if a run happened at 12:01, the heartbeat fired at 13:11, not 13:00. Drift accumulated over time.

Result: Heartbeat fires if current_minute < 10 — i.e. cron fires at XX:00 within the tolerance window. Clean, predictable, no drift.


Decision: Dash DB query system uses parametric queries with ordering and filtering

Why: Fixed named queries (e.g. last_completed_roundtrip) are inflexible. Dash needs to answer analytical questions like "roundtrip with longest duration" or "last 5 sell runs" without a new named query for each case.

Result: _execute_dash_query() accepts query, order_by, order_dir, limit, filter_* params. Annotated fields (duration, jitosol_profit, swap_price) are applied via queryset manager methods before ordering. Result headers state "showing X of Y matching — there are more" or "complete" so Dash is always transparent about data coverage.


Decision: _parse_response searches for JSON anywhere in the string

Why: Gemini 2.5 Flash with thinking_budget=0 sometimes outputs preamble text before the JSON object (e.g. "I need to check that. { ... }"). The original parser only tried to parse the full string, causing the preamble to become the message and the JSON to leak visibly into the chat.

Result: _parse_response() in base.py now has a second attempt — if full-string JSON parse fails, it uses regex to find the first {...} block anywhere in the string. This correctly extracts the JSON even when Gemini prepends natural language.


Decision: Dashboard visual theme — dark glass, gold accents, Share Tech Mono

Why: The dashboard needed a coherent visual identity distinct from Bootstrap defaults. The trading bot aesthetic should feel data-terminal, not generic web app.

Result: Consistent dark glass cards (rgba(20,18,10,0.55-0.92)), gold borders (rgba(201,160,30,0.2-0.4)), navy blue table headers, Share Tech Mono font throughout. BUY rows amber, SELL rows teal, ERROR rows dark red. Applied to all dashboard pages: overview, run list, swap list, roundtrip list, run detail.


Key Decisions — Chapter V

Decision: Per-agent model environment variables

Why: Arthur uses a more capable model (Gemini 3.5 Flash) than other agents (2.5 Flash). Hardcoding model names makes switching expensive. Each agent needs its own model configuration.

Result: .env has TEO_MODEL_NAME, VEGA_MODEL_NAME, DASH_MODEL_NAME, ARTHUR_MODEL_NAME. Each agent class has a model_name class attribute reading from os.getenv(). GeminiClient requires explicit model parameter — no silent fallback.


Decision: Concept file AES-256 encrypted per book

Why: Arthur's full concept (plot, character arcs, chapter outline, twists, ending) must never be readable from the filesystem or returned to any caller. Plaintext on disk is a security risk.

Result: Each book gets its own AES-256 key generated at commission time. Key stored in ArthurSecret DB model. Arthur receives key via load_context(). Encrypted file stored as concept.md.enc. No plaintext version exists on disk.


Decision: Chapter locking is always explicit — no auto-lock

Why: An auto-lock timer would lock chapters Tomas hasn't reviewed. The chapter must be read and approved before it's permanent.

Result: Tomas explicitly says "lock chapter N". Only then does the post-lock pass run: summary, chunks, craft notes.


Decision: Background tasks for long-running Arthur operations

Why: Chapter writing takes 1-2 minutes. Holding an HTTP request open that long causes timeouts and prevents navigation.

Result: ArthurTask DB model. Write/rewrite/lock operations start a background thread and return a task_id immediately. Frontend polls GET /api/arthur/task/<id>/ every 4 seconds.


Decision: ChapterFeedback separate from ConversationMessage

Why: Rewrite feedback is operationally distinct from conversation history. Mixing it with general chat made craft note extraction unreliable — required fragile keyword scanning to find relevant messages.

Result: New ChapterFeedback model. Only Tomas's feedback text saved there. Arthur's acknowledgements are UI-only, never persisted. Lock time reads from ChapterFeedback directly.


Decision: Language rules moved from system prompt to craft files

Why: Czech and English literary rules were hardcoded in arthur.py. Editing them required a code deployment. Rules should be editable without touching code.

Result: media/arthur/craft/general/cs.md and en.md contain all language rules. System prompt says "read the general craft file for {language} and follow all rules strictly." Arthur's primary language is English; Czech is fully supported per-book.


Decision: Post-lock LLM calls use neutral system prompts, not Arthur's book-context prompt

Why: Arthur's system prompt requires a loaded book context. Summary, chunk extraction, and craft note calls at lock time have no loaded project — the system prompt caused "No active project" responses, silently producing empty results.

Result: Summary, chunk, and craft note generation use direct GeminiClient calls with neutral system prompts ("You are a literary editor..."). Results are identical in quality without the context dependency.


Decision: export_bookstore / import_bookstore management commands

Why: Moving the bookstore to a new server requires migrating all DB records. Media files can be copied manually, but DB records need a structured export/import pair.

Result: export_bookstore dumps all Series, Book, Chapter, ChapterChunk, ArthurSecret, ChapterFeedback records to JSON. import_bookstore reads it on the target and creates/updates records. --force flag overwrites existing records.


Key Decisions — Chapter VI

Decision: sqlite-vec over a separate vector database

Why: A separate vector DB (pgvector, Chroma, Pinecone) would require additional infrastructure, a separate connection, and a migration away from SQLite. The project is on shared hosting with SQLite. sqlite-vec extends SQLite in-process with zero infrastructure cost.

Result: sqlite-vec installed as a Python package. Vectors live in vec_chunks virtual table inside the existing db.sqlite3 file. No separate service, no migration.


Decision: sqlite-vec loaded via module-level signal handler, not inside ready()

Why: A signal handler defined inside ready() as a local function gets garbage collected in some environments (confirmed on Linux/Passenger), causing the signal to silently stop firing. The symptom was vec_version() working in direct tests but failing via Django.

Result: load_sqlite_vec() defined at module level in agent_service/apps.py. ready() only calls connection_created.connect(load_sqlite_vec). Module-level functions are never garbage collected.


Decision: Cross-platform extension path resolution

Why: sqlite_vec.loadable_path() returns the path without file extension. On Linux, load_extension() requires the explicit .so suffix. On Windows it needs .dll. The bare path works on neither platform.

Result: load_sqlite_vec() checks if the bare path exists; if not, tries .so, .dll, .dylib in order. First match wins. Works on Windows local dev and Linux production without any environment-specific configuration.


Decision: vec_chunks created via post_migrate signal, not a migration

Why: vec_chunks is a sqlite-vec virtual table — it cannot be a Django model and cannot be created via a normal migration. It needs to be created with raw SQL after sqlite-vec is loaded.

Result: init_vec_chunks() defined at module level in apps.py, connected to post_migrate signal. Runs CREATE VIRTUAL TABLE IF NOT EXISTS on every manage.py migrate. Idempotent — no-op after first creation.


Decision: 3072 dimensions, not 768

Why: text-embedding-004 (768 dimensions) is not available in the installed SDK version. Available models are gemini-embedding-001 (3072), gemini-embedding-2-preview, and gemini-embedding-2. At this project scale (hundreds of chunks), the storage cost of 3072 vs 768 is negligible (~7MB per book). Using the model's native output avoids truncation quality loss.

Result: EMBEDDING_DIMENSIONS = 3072 in genai/embeddings.py. vec_chunks schema uses FLOAT[3072]. Each serialized embedding is 12288 bytes.


Decision: Raw sqlite3 connection for vec_chunks operations

Why: Django's cursor wrapper calls last_executed_query() after every execute, which tries to format the SQL with params using Python's % operator. Binary blob params trigger TypeError: not all arguments converted during string formatting, crashing every insert.

Result: All vec_chunks reads and writes use connection.connection (the raw sqlite3 connection object) directly, bypassing Django's cursor wrapper entirely.


Key Decisions — Chapter VII

Decision: embed_chunks management command, not inline migration

Why: Embedding all existing chunks at migration time would make manage.py migrate block for minutes (one Gemini API call per chunk). A dedicated management command keeps migrations fast and gives explicit control over when embedding runs.

Result: agent_service/management/commands/embed_chunks.py — delta mode by default (skips already-embedded chunks), --force to re-embed all, --dry-run to preview. Run manually after migrations on any fresh environment.


Decision: Post-save signal at module level in apps.py, not inside ready()

Why: Same garbage collection issue as load_sqlite_vec in Chapter VI. Signal handlers defined as local functions inside ready() get GC'd on Linux/Passenger.

Result: on_chunk_saved and on_chunk_deleted defined at module level in agent_service/apps.py. Connected in ready() with post_save.connect(on_chunk_saved, sender=ChapterChunk).


Decision: Embedding failures in signal handler are logged, never raised

Why: The signal fires inside the lock pipeline. A failed embedding (network timeout, API error) must never crash or roll back the lock. Missing embeddings can always be backfilled with embed_chunks --force.

Result: on_chunk_saved wraps the entire embedding + insert in try/except. On failure: logs the error, returns silently. Lock pipeline continues unaffected.


Decision: MAX_TOKENS_HARD_CAP raised from 8192 to 32000

Why: Arthur's rewrite pipeline was hitting the 8192 cap mid-response. Czech chapter prose runs 3000–4000 words; with JSON wrapper and context, the response easily exceeds 8192 tokens. Truncated JSON fails to parse, returning "could not be parsed" to Tomas.

Result: MAX_TOKENS_HARD_CAP = 32000 in genai/llm_client.py. Rewrite call uses max_tokens=16000. Chunk extraction raised from 2048 to 4096 per the original handover recommendation.


Decision: Duplicate ChapterFeedback records prevented at save time

Why: Every click of the Rewrite button (including failed attempts) was creating a new ChapterFeedback record. 12 identical records for the same feedback text meant Arthur synthesised craft notes from 12 duplicates at lock time.

Result: Rewrite view checks ChapterFeedback.objects.filter(..., feedback_text=feedback).exists() before creating. Identical feedback for the same chapter is stored only once.


Decision: Rewrite and acknowledge prompts in English, prose in book language

Why: Arthur's system prompt says he mirrors the language of the person speaking to him. Czech prompts worked for Czech books but would break for English books. Instructions should be in English (Arthur's primary language); the book's language is enforced by the craft rules already loaded in the system prompt.

Result: Both prompts use English instructions. Acknowledge prompt says "in the same language as the feedback". Rewrite prompt says "Write the prose in the book's language as defined in the craft rules above."


Key Decisions — Chapter VIII

Decision: search_vectors takes raw_connection as a parameter, not imported from Django

Why: genai/embeddings.py must have zero Django imports. The raw sqlite3 connection lives in Django's connection.connection. Passing it as a parameter keeps the function pure Python — the caller in service.py supplies the connection.

Result: search_vectors(raw_connection, query_vector, limit) signature. service.py passes connection.connection after bootstrapping it with an ORM query.


Decision: KNN over-fetches by 4x when filters are active

Why: sqlite-vec has no knowledge of chunk_type or book_slug — those fields live in Django's ORM. If KNN returns exactly limit results and half are the wrong type, the caller gets fewer results than requested. Over-fetching gives the ORM filter enough to work with.

Result: knn_limit = limit * 4 when chunk_type or book_slug is set. Sliced back to limit after filtering. At current data scale (30–300 chunks) the overhead is negligible.


Decision: Similarity threshold applied before ORM fetch, not after

Why: Fetching ORM objects for chunks that will be immediately discarded wastes DB round-trips. Filtering on distance first means the ORM query only fetches chunks that will actually be returned.

Result: max_distance filter applied to knn_results list before chunk_ids is built. Zero ORM queries for dropped results.


Decision: check_genai_imports.py replaces findstr as the import verification tool

Why: findstr is Windows-only and produces false positives on comment lines and string literals containing "from django". A Python script can check actual line content and be run on any platform.

Result: check_genai_imports.py at project root. Walks genai/, flags any line containing from django. Returns exit code 1 on violations. Now absorbed into the pytest suite as test_system.py::test_zero_django_imports_in_genai — runs automatically on every pytest tests/ -v.


Key Decisions — Chapter IX

Decision: Retrieval context injected into Arthur's write prompt, not passed as user message

Why: Injecting retrieved facts and style examples as a user message would pollute the conversation history and confuse the LLM about who is speaking. The system prompt is the correct location for context that shapes how Arthur writes — it's persistent and authoritative.

Result: assemble_arthur_context() returns a formatted string injected as === RETRIEVED CONTEXT === in the write prompt body, alongside the other context sections (concept, summaries, craft files). Arthur reads it before writing.


Decision: format_retrieval_context uses XML tags, not markdown headers

Why: Markdown headers (##) inside a large prompt block are ambiguous — Arthur might treat them as section boundaries rather than content boundaries. XML tags (<retrieved_facts>, <retrieved_style>) are unambiguous delimiters that Arthur's system prompt explicitly describes as boundary markers.

Result: Facts formatted as a bulleted list inside <retrieved_facts>. Style examples as raw prose blocks inside <retrieved_style>. Both are described in Arthur's system prompt under CONTEXT SECTIONS.


Decision: scene_outline uses thread if provided, falls back to chapter + book_slug

Why: The thread (e.g. "Voss discovers the breach in corridor 7") is the most specific description of what Arthur is about to write — it produces the most relevant KNN results. Without a thread, a generic fallback still gives the embedding something to work with.

Result: scene_outline = thread if thread else f"chapter {chapter_number} {book_slug}" in run_write_chapter().


Decision: Retrieval failures silently swallowed — write pipeline never blocked

Why: A retrieval failure (network timeout, API error, empty vec_chunks) must never prevent Arthur from writing a chapter. The retrieval context is enhancement, not a dependency. A chapter written without retrieved context is still a valid chapter.

Result: The retrieval block in run_write_chapter() is wrapped in try/except Exception: pass. Any failure is silently ignored. The write continues with whatever context was already assembled.


Decision: pytest suite replaces loose diagnostic scripts as the verification standard

Why: test_sprint39.py and check_genai_imports.py at the project root were ad-hoc scripts with no structure, no shared fixtures, and no consistent run command. As the codebase grew, this became unmanageable.

Result: tests/ directory with conftest.py, MockLLMClient, and per-chapter test files (test_ch1_genai_core.py through test_ch9_context.py). test_system.py absorbs check_genai_imports.py and manage.py check. Fast suite (no API calls): pytest tests/ -v. Slow suite (live Gemini + DB): pytest tests/ -v -m slow. pytest.ini excludes slow tests by default.


Decision: Craft note extraction changed from ONE to ALL distinct lessons

Why: Arthur was instructed to distil ONE craft lesson from feedback. Detailed feedback containing five distinct rules produced only one saved rule — the rest were discarded silently.

Result: run_write_craft_note() prompt changed to "Distil ALL distinct craft lessons". Response format changed from a single {scope, note} object to a {notes: [{scope, note}, ...]} array. Each note is filed to its correct scope path independently.


Decision: Craft rules correctly scoped across general, genre, series, and book levels

Why: Rules were being filed only at book level even when they applied series-wide or genre-wide. A rule about avoiding triplets applies to all English horror, not just Nekrosis Book 1.

Result: Craft hierarchy clarified: horror/en.md (all English horror), series/craft.md (all books in this series), books/{slug}/craft.md (this book only). Arthur's scope classification prompt updated to reflect this hierarchy.


Decision: Series architecture rules in series_rules.md, loaded into every write and commission prompt

Why: The commercial publishing requirements (26 chapters, no franchise names, three-level endings, protagonist continuity, arc structure) needed to be enforced consistently across all series, not just remembered in the handover document.

Result: media/arthur/craft/series_rules.md — loaded as === SERIES RULES === at the top of every write prompt and commission prompt. Arthur reads it before concept, before world canon, before everything else.


Decision: English franchise uses original IP only — no existing franchise names

Why: Commercial publishing and YouTube adaptation require fully original IP. Using names from Alien, Dead Space, or other franchises would create copyright liability and prevent publication.

Result: en.md craft rules updated with explicit originality rule listing prohibited names (Weyland-Yutani, Nostromo, Xenomorph, etc.). series_rules.md reinforces this under Commercial Viability. Arthur's sketch prompt updated to use Arthur's system prompt instead of generic "literary editor" prompt.


Decision: run_arthur_sketch uses Arthur's system prompt and series rules, not a generic prompt

Why: The sketch call was using "You are a literary editor..." as the system prompt — bypassing all of Arthur's personality, craft rules, and series architecture rules. This caused the sketch to produce generic cyber-thrillers instead of dark sci-fi horror.

Result: run_arthur_sketch() now instantiates ArthurAgent, uses arthur.system_prompt, and injects series_rules.md into the sketch prompt. Sketch results are now consistent with the series' genre and tone.


Decision: Series concept encryption deferred to Chapter X

Why: The universe-level concept (Arthur's private long-game across all arcs) needs its own encrypted file (series_concept.md.enc) separate from the book-level concept. Building this correctly requires extending ArthurSecret, the commission flow, and the context loading pipeline — a full sprint's worth of work.

Result: Deferred to Chapter X. Interim: universe intent lives inside Book 1's concept. world.md serves as the public canon bridge between books.


Key Decisions — Chapter X

Decision: Series concept encryption separate from book concept

Why: The universe-level concept (Arthur's long-game across all arcs — the shape of the unknown danger, the arc vision, the universe rules) must survive across multiple books and protagonist changes. Book-level concept.md.enc is per-book and gets loaded at write time. Series-level concept needs its own file and its own key, loaded at book commission time.

Result: ArthurSecret extended with secret_type field (series, arc, book). series_concept.md.enc created at series commission time. Loaded into context when commissioning each new book in the series.


Decision: world.md as plain-text public canon bridge between books

Why: Arthur's private concept files are encrypted. When commissioning Book 2, Arthur needs to know what happened in Book 1 — but not in the form of raw chapter prose (too long) or the private concept (too secret). A curated public canon file is the right bridge.

Result: run_update_world_md(book_slug) called at end of each book lock. Arthur reads all chapter summaries and updates world.md with canon facts, closed threads, and open questions. Readable by Tomas. Loaded at Book 2 commission time.


Decision: EPUB compiler lives in bookstore/, not a standalone app

Why: EPUB export is a bookstore operation — it reads Series, Book, Chapter, and ChapterChunk records. There is no new domain model needed. Adding exporter.py to bookstore/ keeps it adjacent to the data it reads without creating a new app for a single utility.

Result: bookstore/exporter.py. "Export to EPUB" button on Book detail panel. Output streamed as download.


Decision: ArthurSecret uses book_slug string key with series: prefix for series secrets

Why: ArthurSecret already used book_slug CharField as its unique identifier — a FK to Series would require a schema change and break existing book secret lookups. Using a series:{slug} prefix convention keeps the same lookup pattern without a new FK field.

Result: Series-level secrets stored as book_slug='series:nekrosis'. _series_concept_slug(series_slug) helper encodes this. Book secrets unchanged.


Decision: run_series_commission creates ArthurTask directly, not via start_arthur_task

Why: start_arthur_task has a generic background dispatcher that handles write_chapter, rewrite_chapter, lock_chapter, and commission — but not series_commission. Routing through it caused the task to fall through to the else branch and return "Unknown task type". Creating the task record directly and spawning the thread inline avoids the dispatcher entirely.

Result: run_series_commission uses ArthurTask.objects.create() directly and manages its own thread lifecycle, same pattern as other background operations that predate start_arthur_task.


Decision: Series commission uses a purpose-built system prompt, not Arthur's conversational prompt

Why: Arthur's system prompt makes him guard his work and refuse requests without a loaded book context. A direct llm_client.send() call with Arthur's system prompt returned a JSON refusal instead of the concept document. The series commission is a planning call, not a conversation.

Result: run_series_commission uses a minimal system prompt: "You are Arthur, a literary architect designing the private universe-level concept for a series. Write in plain prose, first person. No JSON. No refusals."


Decision: EPUB built with stdlib zipfile only — no external epub library

Why: EPUB 2.0 is a ZIP file containing XML and XHTML. Python's zipfile, html.escape, and string formatting cover everything needed. An external library (ebooklib, etc.) adds a dependency for no capability gain at this scale.

Result: bookstore/exporter.py uses only stdlib. mimetype written uncompressed first per EPUB spec. All XML generated as strings — no DOM manipulation needed.


Decision: world.md auto-populated on final chapter lock, not as a separate manual step

Why: The book being complete is the natural trigger for world.md population — Arthur has all the summaries at that point and the next session may want to commission Book 2 immediately. A manual command exists as a fallback but the automatic hook means it never gets forgotten.

Result: run_update_world_md() called inside run_lock_chapter when book.status is set to complete. Failures are caught and logged — they never block the lock pipeline. manage.py update_world_md available for backfill.


Key Decisions — Chapter XI

Decision: Studio is a production pipeline app, not an agent

Why: Studio's work is a sequential production workflow — segment, narrate, generate images, create music, merge. This is fundamentally different from the conversational agent pattern. There is no LLM reasoning loop, no tool dispatch, no emotion state. Forcing it into the agent framework would add complexity with no benefit.

Result: studio/ is a standalone Django app with its own models, views, and templates. No genai/ dependency. The intelligence is distributed across individual pipeline steps, each backed by a specific service (Gemini, Flux, MusicGen, FFmpeg).


Decision: Project = Book, Episode = Chapter, Scene = segment of a chapter

Why: Clear 1:1 mapping to existing bookstore concepts. A project is the video adaptation of a book. An episode is the video for one chapter. A scene is a 1–2 minute narrative segment within that episode. Consistent terminology prevents confusion.

Result: StudioProject has OneToOne FK → bookstore.Book. StudioEpisode has OneToOne FK → bookstore.Chapter (must be locked). StudioScene is created during segmentation and has no direct bookstore FK.


Decision: Production is manually triggered, not automatic on chapter lock

Why: Tomas may lock a chapter online from any device. Automatically starting a generation pipeline (which requires local GPU hardware) on every lock would fail silently when the local machine is not available. Production is a deliberate creative act, not a background job.

Result: Episodes are created manually by Tomas. Each step is triggered explicitly via the UI. The workflow UI shows what is complete, what is ready to run, and what is blocked.


Decision: StudioTask follows the ArthurTask background pattern

Why: Generation steps (segmentation, narration, image generation, soundtrack, merge) are long-running operations. The same pattern that works for Arthur's 1–2 minute writes works here — background thread, DB task record, frontend polling.

Result: StudioTask model with task_type, status, error_message. Background threads created per pipeline step. Episode workflow page polls task status every 3–5 seconds.


Decision: LOCAL_STUDIO env var gates generation steps

Why: GPU/FFmpeg generation steps can only run on the local machine. The server needs to display completed assets and workflow state without being able to trigger generation. A single env var is cleaner than per-view logic.

Result: LOCAL_STUDIO=True in local .env. Missing on server → defaults to False. The episode workflow view passes local_studio to the template. A prominent orange banner warns when in read-only mode.


Decision: DB sync via export_studio / import_studio — media files stay local

Why: Generated media files (narration, images, ambient, video) are large and only needed locally. Only DB state (workflow_status, scene paths, durations, mood briefs) needs to sync. Same pattern as export_bookstore / import_bookstore.

Result: manage.py export_studio dumps all Studio DB records to media/studio/studio_export_YYYY-MM-DD_HHMM.json. manage.py import_studio --input <file> restores them. JSON committed to git. Server pulls and imports. Local pulls and imports before each session.


Decision: Studio teal theme (#2dd4c0) distinct from bookstore gold and wiki blue

Why: Each section of the site has a distinct colour identity. Studio needed its own — teal sits between wiki's blue and bookstore's gold, appropriate for a production/technical tool.

Result: All studio templates use #2dd4c0 / rgba(45,212,192,x) throughout. Glass column style (rgba(20,18,10,0.55) + backdrop-filter: blur(10px)) consistent with dashboard cards.


Key Decisions — Chapter XII

Decision: segmentation.py is pure Python with injected LLM client

Why: Following the same pattern as genai/embeddings.py — pure Python, zero Django imports, testable without Django running. The LLM client is injected as a parameter so tests can use MockLLMClient without any API calls or Django setup.

Result: studio/segmentation.py has zero Django imports. segment_chapter(chapter_text, llm_client, min_scenes, max_scenes) is fully testable. test_system.py centrally verifies all studio service modules via STUDIO_PURE_PYTHON_MODULES list.


Decision: Segmentation does not auto-advance episode status — explicit approve required

Why: Auto-advancing on task completion meant Tomas had no chance to review the scenes before Step 3 unlocked. Scenes 5 and 6 in the first real run were 500+ words — over the target. Tomas needs to be able to re-segment with different min/max settings before committing to the segmentation.

Result: run_segmentation() saves scenes but leaves workflow_status = 'draft'. approve_segmentation() advances to 'segmented'. The UI shows an "Approve Segmentation" button only after scenes exist. Re-segmenting a previously approved episode resets back to 'draft'.


Decision: min_scenes / max_scenes as per-segmentation inputs, not project-level config

Why: Different chapters have different length and pacing. A 1500-word chapter and a 4000-word chapter need different scene counts. Project-level defaults would either over-segment short chapters or under-segment long ones. Per-segmentation control with sensible defaults (8/12) gives the right granularity.

Result: Two number inputs on the Step 2 panel. Defaults 8/12. Passed in the POST body to SegmentEpisodeView, forwarded to run_segmentation(), injected into the Gemini prompt as a hard constraint.


Decision: Gemini 2.5-flash thinking output handled by largest-JSON-block strategy

Why: Gemini 2.5-flash with thinking enabled outputs its reasoning chain before or around the JSON. The original parser found the first {...} block, which could be inside a thinking fragment. The fix tries all {...} blocks largest-first — the actual JSON answer is always the largest valid block in the response.

Result: _parse_response() in segmentation.py collects all candidates, deduplicates, sorts by length descending, and returns the first that parses as a dict. This handles preamble text, mid-response reasoning, and markdown fences.


Decision: STUDIO_MODEL_NAME env var for Studio pipeline, independent of agent models

Why: Studio pipeline calls (segmentation, future TTS, image description) are a different workload from agent conversations. Studio needs reliable JSON output from a thinking model — different requirements from Arthur's prose generation or Teo's conversation. A separate env var means Studio can be tuned independently without affecting agents.

Result: STUDIO_MODEL_NAME=gemini-2.5-flash in .env. _get_llm_client() in studio/service.py reads this var, defaulting to gemini-2.5-flash.


Key Decisions — Chapter XIII

Decision: WAV over MP3 for narration output

Why: Gemini TTS outputs raw PCM — wrapping in WAV is one stdlib wave call with no dependencies. MP3 would require encoding. WAV is lossless and preferred by MoviePy/FFmpeg for intermediate files in the merge pipeline.

Result: studio/tts.py writes WAV at 24kHz 16-bit mono. No conversion step needed.


Decision: narrator_brief replaces mood_brief as TTS Director's Notes

Why: mood_brief was designed as a MusicGen/image prompt — music production language. Feeding it to a TTS narrator as Director's Notes is a category mismatch. The narrator needs performance language ("slow and deliberate, weight on every word").

Result: Segmentation prompt updated to produce both mood_brief (visual/image description for Step 4) and narrator_brief (narrator performance direction for Step 3). Both stored on StudioScene, both editable inline.


Decision: Sequential per-scene approve flow

Why: Per-scene approve lets Tomas listen, adjust narrator_brief or voice, and regenerate before committing. Auto-advancing after batch generation would mean waiting 10+ minutes before hearing anything.

Result: Each scene has its own Generate → Listen → Approve cycle. Next scene only unlocks after previous is explicitly approved.


Decision: Archive old WAV on regenerate rather than overwrite

Why: Voice comparison requires hearing both takes. Overwriting destroys the original.

Result: _archive_narration() renames existing file to scene_NN_narration_VOICE_TIMESTAMP.wav before each new generation. narration_path is preserved (not cleared) so archive can access it.


Decision: Media paths stored as relative to BASE_DIR, never absolute

Why: Absolute paths (e.g. C:\Users\tomas.dolejsek\...) are machine-specific and break on any other machine — server, second laptop, or after moving the project. Storing relative paths (media/studio/...) makes DB records portable.

Result: _narration_output_path() returns a relative path. Only _absolute_path() reconstructs the full path at file access time (open(), FFmpeg, etc). Export/import pass relative paths as-is — no conversion needed. fix_narration_paths management command migrates legacy absolute paths.


Decision: model_name is a required parameter in tts.py, not a default constant

Why: Having DEFAULT_TTS_MODEL = 'gemini-3.1-flash-tts-preview' hardcoded in tts.py means the model name lives in two places — the constant and .env. The .env value should always win. Making model_name required forces the caller (service.py) to always inject it from env.

Result: generate_narration(..., model_name: str) — no default. service.py reads TTS_MODEL_NAME from env and passes it. Same pattern as STUDIO_MODEL_NAME for segmentation.


Key Decisions — Chapter XIV

Decision: Imagen 4 Fast over Flux.1-dev

Why: Flux.1-dev required a local RTX 5070 GPU, a 24GB model download, and had a 77-token CLIP limit that truncated scene descriptions. Imagen 4 Fast via Gemini API produces equal or better quality, accepts unlimited prompt length, costs $0.02/image, and requires no local hardware or model download.

Result: studio/image_gen.py uses client.models.generate_images() with imagen-4.0-fast-generate-001. IMAGE_MODEL_NAME env var. imageio-ffmpeg pip package provides FFmpeg binary — no system install needed.


Decision: Per-scene approve flow instead of batch generation

Why: Batch generation processed all scenes at once with no review opportunity. Per-scene flow matches the narration pattern — generate, review, approve, next unlocks. This allows prompt tuning and regeneration per scene before committing.

Result: run_scene_image(episode_id, scene_id, skip_reuse) — single scene thread. approve_scene_image — Vision description, mood_brief update, embedding, rename. regenerate_scene_image — skips reuse, next proposal number.


Decision: Proposal → canonical file naming

Why: Multiple attempts at generating an image for one scene need to be kept for comparison. Overwriting the canonical file loses previous attempts.

Result: Generated files named scene_NNN_bg_proposalNN.png. On approval: existing canonical archived as scene_NNN_bg_oldNN.png, proposal renamed to scene_NNN_bg.png. All stored at media/studio/images/ global store — agnostic to project/episode/scene.


Decision: Gemini Vision auto-description overwrites mood_brief on approval

Why: mood_brief is written before generation — it describes intent, not reality. The actual generated image may differ from the intent. Embedding the pre-generation description leads to poor reuse matching. Gemini Vision describes what's actually in the image.

Result: _describe_image(abs_path) calls Gemini 2.5-flash Vision with the approved PNG. Returned description overwrites mood_brief. This description is then embedded into vec_chunks. mood_brief before approval = generation hint. mood_brief after approval = accurate visual description.


Decision: Two prompt fields — mood_brief and image_prompt

Why: mood_brief serves two purposes: a short description for embeddings (needs to be concise) and the generation prompt (benefits from being detailed). These are contradictory requirements.

Result: mood_brief — short, for embeddings and reuse lookup. image_prompt — full prompt sent to Imagen, defaults to mood_brief if empty. Tomas can paste full scene prose into image_prompt for richer generation without affecting the embedding index.


Decision: Reuse threshold tightened to 0.4

Why: 0.6 threshold (from story memory design) was too loose — semantically similar but visually different scenes (space exterior vs shuttle cockpit interior) matched and the wrong image was reused.

Result: search_scene_images(mood, threshold=0.4). Only genuinely similar settings reuse images. Regeneration always skips reuse (skip_reuse=True).


Decision: Visual style field on StudioProject, not hardcoded

Why: The visual aesthetic (color palette, lighting, atmosphere) is series-specific and should be editable without code changes. Hardcoding it in the service layer couples the aesthetic to the code.

Result: StudioProject.visual_style TextField. Generated by Gemini at project creation from series metadata. Editable on project detail page. Appended to every Imagen prompt. Strip markdown markers (**bold**) before use.


Key Decisions — Chapter XVI

Decision: ACE-Step over MusicGen for soundtrack generation

Why: The architecture plan specified Meta MusicGen medium model. During implementation, MusicGen produced incoherent output for dark ambient prompts and required a large local model download. ACE-Step 1.5 (turbo, MIT license, trained on royalty-free data) produces better results, has a clean HTTP API, and runs on the existing RTX 5070.

Result: studio/music_gen.py is an HTTP client to the ACE-Step local server. MusicGen never implemented.


Decision: SoundtrackTake model — takes system instead of single file

Why: Music generation is non-deterministic. The first take is rarely the best one. A takes system lets Tomas generate multiple versions, compare them via the picker dropdown, and approve the best one. Non-approved takes are deleted on approval to keep the filesystem clean.

Result: SoundtrackTake model with take_number, settings snapshot (bpm, key_scale, loop_length, temperature, prompt), and is_approved flag. Approved files renamed to ambient_{episode_number:03d}_v{version:03d}.wav. Duplicate button copies file + settings for tweaking.


Decision: lm_temperature=0.8, lm_top_p=0.95 as locked params

Why: Extensive testing found temperature 0.3 (conservative) produced MIDI-like mechanical output. Temperature 1.0+ introduced too much randomness. 0.8 with top_p=0.95 gives organic variation without incoherence. Temperature is also exposed as a per-generation UI control (TEMP input) so Tomas can override per take.

Result: FIXED_PARAMS in music_gen.py sets lm_temperature=0.8, lm_top_p=0.95. UI TEMP input overrides lm_temperature per generation.


Decision: Prompt style — emotional scene description over sound design language

Why: ACE-Step is a music model trained on songs, not a soundscape generator. Prompts using sound design language ("drone", "texture", "no melody") produced noise or incoherent output. Prompts describing the emotional scene ("dying space station, emergency lighting flickers, slow horror underscore") produced usable results.

Result: Mood synthesis prompt rewritten to produce music-model-friendly language. Manual prompt input preserved — Tomas can write prompts directly in the mood textarea.


Decision: Fade-out post-processing via _smooth_bass

Why: ACE-Step generates audio that ends abruptly at the specified duration. At merge time the track loops, causing a jarring cut. A 3-second fade-out applied in _smooth_bass eliminates the cut.

Result: _smooth_bass() applies 18Hz high-pass filter + soft limiter + 3-second fade-out. Called on every generated take.


Decision: Approval stops browser audio players before file rename

Why: On Windows, open file handles prevent rename operations. The browser <audio> element holds a file handle while paused. First approval attempt raised PermissionError [WinError 32]. Fix: JS stops all audio elements (audio.pause(); audio.src = '') before posting to approve endpoint. Service also retries rename up to 5 times with 0.5s delay as a fallback.

Result: approveSoundtrack() JS function clears all audio sources before the approve POST. approve_soundtrack() service retries rename with backoff.

Decision: Effects as a standalone chapter (XV), not combined with XIV

Why: Background images (Chapter XIV) and effects (Chapter XV) are distinct pipeline steps with different concerns. Combining them made the chapter too broad and the sprint planning unclear. Each studio step now maps 1:1 to a chapter.

Result: Chapter structure: XI=Scaffold, XII=Segment, XIII=Narration, XIV=Backgrounds, XV=Effects, XVI=Soundtrack, XVII=Merge, XVIII=Review & Approve, XIX=Store, XX=Publish.


Decision: MoviePy + PIL over FFmpeg zoompan for smooth effects

Why: FFmpeg's zoompan filter with frame-number expressions (in, on) produced static output — the expressions were evaluated once at init, not per-frame. The crop filter with t variable was rejected by this FFmpeg build as invalid in init mode. MoviePy's per-frame Python approach with PIL LANCZOS resize produces smooth, high-quality results.

Result: All motion effects use PIL.Image.crop() + PIL.Image.resize(LANCZOS) per frame inside moviepy.VideoClip(make_frame). FFmpeg only used for fade_through_black via MoviePy vfx. imageio-ffmpeg provides the binary.


Decision: Smoothstep easing for all motion effects

Why: Linear motion at constant speed looks mechanical. Cinematic camera moves accelerate at the start and decelerate at the end.

Result: _smoothstep(t, duration)p = t/duration; return p*p*(3-2*p). Applied to all zoom and pan progress calculations. Start and end are gentle; middle is fast. Effect is subtle but significantly more cinematic.


Decision: 9 steps instead of 10 — Review and Approve merged

Why: Review (Step 8) was purely a viewing step with no status change, followed immediately by Approve (Step 9) which did the status change. These are the same interaction — watch the video then decide. Separating them added a step with no functional distinction.

Result: Step 8 = Review & Approve (combined). Steps 9 = Store, Step 10 = Publish. Total 10 steps preserved by adding Publish as a new Step 10.


Decision: Publish as Step 10

Why: The original pipeline ended at Store. But YouTube upload metadata generation, URL tracking, and published status are a distinct workflow step worth making explicit.

Result: Step 10 = Publish. Generates YouTube title, description, tags from chapter summary. Copy-to-clipboard. youtube_url field. Status: storedpublished.


Decision: MoviePy + PIL over FFmpeg zoompan for motion effects

Why: FFmpeg's zoompan filter with frame-number expressions produced static output — the expressions were evaluated once at init, not per-frame. MoviePy's per-frame Python approach with PIL LANCZOS resize produces smooth, high-quality results.

Result: All motion effects use PIL.Image.crop() + PIL.Image.resize(LANCZOS) per frame inside moviepy.VideoClip(make_frame). FFmpeg only used for fade_through_black via MoviePy vfx.


Decision: Cosine oscillation for ping-pong motion

Why: Ping-pong with smoothstep per half-cycle produces a discontinuity at the reversal point — two back-to-back smoothstep curves meet and the transition feels abrupt. A cosine wave is mathematically continuous through all peaks and troughs.

Result: _cosine_progress(t, cycle) uses (1 - cos(π × t / cycle)) / 2. All motion handlers use it. Reversal points are perfectly smooth.


Decision: 4× in-memory upscale for sub-pixel smoothness

Why: Integer rounding of crop coordinates causes visible frame-to-frame jitter on slow motion — a 1-pixel jump in the crop box produces a jarring step. Upscaling the source image 4× in memory before the frame loop means a 1-pixel jump in the upscaled space is a 0.25-pixel change at output resolution — invisible.

Result: effect_upscale field (1–4, default 2) on StudioScene. All zoom and pan handlers upscale pil2 = pil.resize((iw * effect_upscale, ih * effect_upscale)) before the frame loop. Editable per scene via slider in Step 5 UI.


Decision: effect_speed and effect_cycle as per-scene DB fields

Why: A fixed zoom range and cycle duration that works for a 30-second scene is imperceptible on a 4-minute scene. Tomas needs per-scene control over how much motion and how fast it oscillates.

Result: effect_speed (0.5–3.0, scales zoom/pan magnitude) and effect_cycle (0–narration duration, seconds per half-cycle) stored on StudioScene. Both editable via sliders in Step 5. Cycle=0 auto-switches effect to Static.


Decision: 14 effect types across 4 categories

Why: 6 base effects were insufficient for a horror audiobook. Different scenes need different motion character — directional pans, off-centre zooms, and horror-specific effects (flicker, heartbeat, shake, vignette pulse).

Result: 20 total effect types: 6 base + 4 directional (drift up/down, diagonal ×4) + 4 off-centre zooms (TL/TR/BL/BR) + 4 horror (flicker, heartbeat_zoom, shake, vignette_pulse). All registered in EFFECT_HANDLERS. Dropdown labels describe viewer perspective (Move Left ← rather than Drift Right).


Decision: effect_type_2 — alternating effects at reversal

Why: A single effect on a 4-minute scene is monotonous. Allowing a second effect that activates at each cosine reversal point gives variety without a complex timeline editor.

Result: effect_type_2 field on StudioScene (blank = same effect throughout). When set, apply_effect splits duration into half-cycle segments, alternates between effect_type and effect_type_2, renders each segment, and concatenates into one .mp4. Second effect picker shown as "→ after reversal →" next to the main effect dropdown in Step 5.


Decision: 60fps output

Why: 24fps at slow motion (Ken Burns over 20+ seconds) produces visible frame steps between positions. 60fps reduces the per-frame displacement to the point where motion appears continuous.

Result: FPS = 60 in effects.py. Render time increases ~2.5× but motion quality is significantly better.


Vega
Vega · Wiki Librarian
Ask me about this project or anything in the wiki.