features

Port to Graphene — Notebook → Season Transformation Pipeline

One-click conversion of a graphic-novel notebook into a narrated Graphene novel-mode season with optional auto-generated audio. A writer clicks "🎙️ Port to Graphene" and a few minutes later has a published show with per-chapter audiobook narration.

What It Does

A graphic-novel notebook (typically Odessa-generated) contains panel metadata + narration text. The port flow:

  1. Extract prose: reads each entry's panel JSON, extracting panel narration + dialogue into clean chapter_prose.
  2. Optional prose rewrite: if prose_mode: 'rewrite', every chapter goes through rewriteChapterProse() (GPT-4o) for novelistic polish + optional length expansion (1× / 1.5× / 2× / 3×). Default is 'verbatim' — the writer's words ship as-is.
  3. Create season: stamps a new story_seasons row (novel-mode) + one story_episodes per notebook entry.
  4. Generate script: calls generateNovelScript()skips the LLM and uses the (possibly rewritten) chapter prose verbatim.
  5. Render audio (optional): fire-and-forget renderSeasonAudio() to call ElevenLabs TTS per segment.
  6. Publish (deferred): owner clicks "🚀 Publish to Graphene" to make it discoverable on /graphene + "Your stories" row.

The Pipeline

flowchart TD
    A[Notebook entries] -->|POST /api/notebooks/:id/<br/>port-to-graphene| B[port route]
    B --> C[(story_seasons<br/>draft mode)]
    B --> D[(story_episodes<br/>chapter_prose)]
    D --> E[generateNovelScript]
    E --> F[(season_audio_segments)]
    F --> G{kick_off_audio?}
    G -->|Yes| H[renderSeasonAudio<br/>ElevenLabs TTS]
    G -->|No| I[Manual trigger later]
    H --> J[(story_episodes<br/>chapter_audio_url)]
    I --> J
    J --> K[PublishStrip shows<br/>live progress 8s poll]
    K --> L[Owner clicks<br/>🚀 Publish to Graphene]
    L --> M[/graphene slate<br/>+ Your stories]

Step-by-step walkthrough:

  1. Click "🎙️ Port to Graphene" → modal with voice picker (Grace/Sarah/Charlotte/Alice/Daniel/Antoni) + "Generate audio now" toggle. Located on notebook detail page, owner-only.

  2. POST /api/notebooks/:id/port-to-graphene validates the notebook, creates:

    • story_seasons row: status='draft', is_published_to_graphene=false, mode='novel', owner_user_id=authed_user
    • One story_episodes per entry: populated with chapter_prose extracted from the panel JSON (narration + dialogue joined, image descriptions skipped)
  3. generateNovelScript() reads the cached prose from story_episodes and SKIPS the LLM call (no GPT). Composes season_audio_segments directly:

    • Each chapter gets segment_type 'chapter_intro' (TTS the chapter number + title)
    • Each segment of prose (paragraph-chunked for natural TTS breaks) becomes a 'journal_entry' segment
    • Reuses the existing narrator voice assignment (from the voice picker or backend defaults)
  4. Fire-and-forget renderSeasonAudio() (only if kick_off_audio: true):

    • Calls ElevenLabs eleven_multilingual_v2 per segment
    • Ffprobes each output for duration
    • Uploads per-segment MP3s to season-assets/audio/{seasonId}/segments/
    • Concats with ffmpeg into a compiled.mp3 (one big audiobook file — though future splits to per-chapter are possible)
    • Stamps chapter_audio_url on each story_episodes row as segments complete
  5. PublishStrip polls every 8s while renders are mid-flight:

    • Fetches GET /api/story-seasons/:id to check per-episode chapter_audio_url progress
    • Shows live "rendered N of M chapters" progress bar
    • Stops polling once all chapters have URLs (all_rendered=true)
  6. Owner clicks "🚀 Publish to Graphene" (PATCH /api/story-seasons/:id/published):

    • Stamps is_published_to_graphene=true
    • Season now appears on /graphene slate + in the "Your stories" grid (listed by newest)
    • Followers (if followed) get an email about the new show

What It Touches

Backend Routes

  • POST /api/notebooks/:id/port-to-graphene — main entry point (notebooks.ts)
  • PATCH /api/story-seasons/:id/published — publish toggle
  • POST /api/story-seasons/:id/render-audio — fire off TTS (with { force: true } to re-render all chapters)

Backend Services

  • [services/season-novel-script.ts](../../apps/backend/src/services/season-novel-script.ts)generateNovelScript() composes script from cached prose (skips LLM)
  • [services/season-audio.ts](../../apps/backend/src/services/season-audio.ts)renderSeasonAudio() calls ElevenLabs TTS + uploads
  • [services/season-novel-chapter-audio.ts](../../apps/backend/src/services/season-novel-chapter-audio.ts) — per-chapter audio state + chapter_free_until stamping for paywall
  • [services/port-prose-rewrite.ts](../../apps/backend/src/services/port-prose-rewrite.ts) — optional GPT-4o prose-polish + expansion pass (1×/1.5×/2×/3×), parallel per-chapter, failure-isolated

Frontend UI

  • Port button + modal on notebook detail page — "🎙️ Port to Graphene" button right next to Share Story
  • PublishStrip on SeasonClient.tsx — owner-only banner with publish toggle + "🎙️ Re-render" one-click button
  • Season polling in SeasonClient — 8s interval refetch of episode state while renders are mid-flight, stops once all chapters render
  • Live progress display — PublishStrip swaps button text for "rendered N of M chapters" during render, swaps back when done

Database Tables

  • story_seasons — the new season row; mode='novel', source_notebook_id stamped for back-link
  • story_episodes — one per entry; chapter_prose populated, chapter_audio_url filled as audio renders
  • season_audio_segments — script chunks (each ~150 words for natural TTS breaks)
  • season_publishings — publishes (audio_url, video_url, youtube_url, podcast_published_at)
  • notebooksgraphic_novel_config.ported_season_id stamped to mark the notebook as ported
  • Notebook back-link — the notebook's detail page swaps "🎙️ Port to Graphene" button to "🎧 View on Graphene →" link once stamped with ported_season_id
  • Season back-link — SeasonClient fetches and renders "← Source notebook: <name>" link pointing back to the source notebook
  • "Your stories" row on /graphene — displays all the user's seasons (published + draft), fetches from GET /api/story-seasons/mine (auth-gated)

Edge Cases the Code Handles

Title Cleanup at Port Time

Odessa-generated chapters have redundant naming: "Chapter 1: Story Name: Act 1, Scene 1". The port route strips:

  • Prefix: ^Odessa:\s* from the season title (notebook keeps its full name)
  • Suffix: [:\-—|]\s*<story name>\s*$ from every chapter (removes the story name from the end)
  • Fallback: empty titles become "Chapter N" so no episode is ever titleless

"In a Realm..." Setting → Season Premise

Odessa stores the story world description in a special graphic_novel_config.setting field. The port route promotes it to story_seasons.premise (the public-facing opening line on /graphene), distinct from the journal-author-facing notebook description.

Auto-Publish Toggle

The port modal offers a kick_off_audio checkbox (defaults to off). Owner chooses whether to start rendering immediately or defer it. This gives wiggle room for prose edits before committing to the TTS render.

Prose Mode + Length Multiplier

The port modal asks whether to keep words verbatim (default — fast, free, the writer's actual prose) or rewrite into novelistic prose (GPT-4o pass, with a target-length picker: 1× polish-only, 1.5× / 2× / 3× expansion). Each chapter is rewritten in parallel via rewriteAllChapters() (failure-isolated per chapter — one failing rewrite falls back to verbatim for that chapter, doesn't kill the port). The rewrite prompt preserves every plot point, character, location, and image — only style/texture/length change. Implementation: services/port-prose-rewrite.ts.

Re-Render Flow

After the port, if the writer edits chapter prose or title, the PublishStrip "🎙️ Re-render" button (visible only when audio is complete) calls POST /:id/render-audio { force: true } to re-TTS every chapter. The writer can refresh audio as many times as they want without re-porting the notebook.

Paywall Timestamps

When renderChapterAudio successfully renders a chapter, it stamps chapter_free_until = now() + 7 days on the row (migration 130). Re-renders reset the window — so refreshing audio re-opens the 7-day free window for Graphene+ listeners. (This is a design choice: every render = "new release" rather than "bugfix re-render." A future TODO is admin "fix audio without resetting free window" workflows.)

Known Gotchas

renderSeasonAudio Is Synchronous, Long-Running

The TTS + ffmpeg concat can take 5+ minutes for a 75-minute season. Fire-and-forget is the only sane pattern — the port route doesn't block, and PublishStrip polls for completion in the background. The UI shows "rendering" state gracefully so the author doesn't think the page broke.

Novel Mode Doesn't Use the Compiled Audiobook Directly

Unlike some platforms, listeners don't download a single compiled.mp3. Instead:

  • season_publishings.audio_url stays null for novel-mode seasons
  • Readers/listeners fetch story_episodes.chapter_audio_url per chapter (from the ChapterPlaylistPlayer)
  • RSS feeds (for podcast clients) emit per-chapter items, not a single "season" enclosure

This gives flexibility: chapters can have different audio quality, be re-recorded independently, or fail to render without breaking the whole season.

Non-Admin Owners Can't Publish Yet

PATCH /:id/published requires super-admin. The PublishStrip shows a "Admin can publish" hint for non-admin owners. This is a temporary gate — future work lets owners toggle publish themselves (likely with a one-click "Admin, publish this" flow instead).

PORT TO GRAPHENE — Docs | HiveJournal