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:
- Extract prose: reads each entry's panel JSON, extracting panel narration + dialogue into clean
chapter_prose. - Optional prose rewrite: if
prose_mode: 'rewrite', every chapter goes throughrewriteChapterProse()(GPT-4o) for novelistic polish + optional length expansion (1× / 1.5× / 2× / 3×). Default is'verbatim'— the writer's words ship as-is. - Create season: stamps a new
story_seasonsrow (novel-mode) + onestory_episodesper notebook entry. - Generate script: calls
generateNovelScript()— skips the LLM and uses the (possibly rewritten) chapter prose verbatim. - Render audio (optional): fire-and-forget
renderSeasonAudio()to call ElevenLabs TTS per segment. - 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:
-
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.
-
POST /api/notebooks/:id/port-to-graphenevalidates the notebook, creates:story_seasonsrow:status='draft',is_published_to_graphene=false,mode='novel',owner_user_id=authed_user- One
story_episodesper entry: populated withchapter_proseextracted from the panel JSON (narration + dialogue joined, image descriptions skipped)
-
generateNovelScript()reads the cached prose fromstory_episodesand SKIPS the LLM call (no GPT). Composesseason_audio_segmentsdirectly:- 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)
- Each chapter gets segment_type
-
Fire-and-forget
renderSeasonAudio()(only ifkick_off_audio: true):- Calls ElevenLabs
eleven_multilingual_v2per 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_urlon eachstory_episodesrow as segments complete
- Calls ElevenLabs
-
PublishStrip polls every 8s while renders are mid-flight:
- Fetches
GET /api/story-seasons/:idto check per-episodechapter_audio_urlprogress - Shows live "rendered N of M chapters" progress bar
- Stops polling once all chapters have URLs (all_rendered=true)
- Fetches
-
Owner clicks "🚀 Publish to Graphene" (
PATCH /api/story-seasons/:id/published):- Stamps
is_published_to_graphene=true - Season now appears on
/grapheneslate + in the "Your stories" grid (listed by newest) - Followers (if followed) get an email about the new show
- Stamps
What It Touches
Backend Routes
POST /api/notebooks/:id/port-to-graphene— main entry point (notebooks.ts)PATCH /api/story-seasons/:id/published— publish togglePOST /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_untilstamping 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_idstamped for back-linkstory_episodes— one per entry;chapter_prosepopulated,chapter_audio_urlfilled as audio rendersseason_audio_segments— script chunks (each ~150 words for natural TTS breaks)season_publishings— publishes (audio_url, video_url, youtube_url, podcast_published_at)notebooks—graphic_novel_config.ported_season_idstamped to mark the notebook as ported
Cross-Links
- 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 fromGET /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_urlstays null for novel-mode seasons- Readers/listeners fetch
story_episodes.chapter_audio_urlper 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).