Skip to content

feat(export): screenplay export to Fountain / txt / Markdown / PDF - #60

Merged
shanchuann merged 1 commit into
mainfrom
feat/screenplay-export
Jun 7, 2026
Merged

shanchuann merged 1 commit into
mainfrom
feat/screenplay-export

Conversation

@shanchuann

Copy link
Copy Markdown
Owner

功能描述

让作者能拿走给演员 / 导演 / 投资方看的剧本,不只是 schema 用的 YAML。

之前编辑器右上角只有 `导出 YAML` 一个按钮,YAML 对管道契约有用、对人没用。这一版换成 `导出剧本` 下拉,五种格式:

格式 用途
Fountain (.fountain) 行业标准纯文本剧本,Final Draft / WriterDuet / Highland 都能直接读
纯文本 (.txt) 带居中标题 + 缩进对白 + 章节横线的 ASCII 排版
Markdown (.md) 在 GitHub / Notion 里预览
PDF 浏览器打印窗口生成(保存为 PDF),无服务端依赖
原始 YAML (.yaml) schema 校验过的契约格式(保留)

实现思路

后端 `src/story2script/export/`:三个纯函数 `to_fountain` / `to_txt` / `to_markdown`,输入是验证过的 screenplay dict,输出是字符串。每个文件实现一种格式,互不依赖。

`POST /api/v1/export`:YAML 入,rendered 文本出。先跑 `_validate_against_schema`——草稿没修干净就 422 + 字段级 errors,前端弹回作者改完再导。

PDF 不在服务端做:CJK 字体要么走 weasyprint+GTK 大依赖、要么塞 5 MB Source Han 字体进 wheel,两种都不优雅。前端拿 markdown 响应直接套一份 `<style>` 模板用系统字体 `Source Han Serif SC / Noto Serif CJK SC / Songti SC`,`window.print()` 一弹,用户选"保存为 PDF"即可。Cross-platform 零成本。

Fountain 格式细节(导出后可被 Final Draft 直接读):

  • 标题页 `Title:` / `Notes:` 后必须空行(spec 要求)
  • 角色 cue 大写(ASCII 名);CJK 名原样保留(CJK 无大小写)
  • 节拍开头的 `(轻声) 还在收?` 被拆成单独的 parenthetical 行
  • transition `CUT TO` 强制规范化成 `> CUT TO:`
  • 章节标题用 Fountain synopsis 语法 `= 第一章`
  • 角色表放进顶部 `/* DRAMATIS PERSONAE */` boneyard 注释,actor 翻剧本时能看到、Fountain 解析器会忽略

未知 speaker 的处理:YAML 里 `speaker: char_nope` 但 characters 数组里没这个 id(手动改 / 角色删了)→ fallback 到大写 raw id `CHAR_NOPE` 让作者一眼看到哪里坏了,而不是吞成 `(未知)`。

测试方式

  • `py -m pytest -q`: 314 passed(原 289 + 25 新增导出测试)
  • `py -m ruff check`: clean
  • 前端 `tsc -b`: clean
  • `eslint`: 0 新增(1 条 main 已有的历史告警)

新增测试覆盖(25 条):

  • Fountain:标题页空行、CJK + ASCII 角色 cue、parenthetical 拆分、SLUG 大写、transition 规范化、unknown speaker fallback、DRAMATIS PERSONAE 渲染(共 8 条)
  • TXT:标题居中、章节横线、角色 cue 缩进、对白缩进(共 4 条)
  • Markdown:H1/H2/H3 层级、blockquote 对白、bold 角色 cue、mode block、aliases(共 7 条)
  • Endpoint:三种格式 MIME + filename 校验、invalid YAML → 400、invalid screenplay → 422 带 errors、unknown format → 422(共 6 条)

依赖与复用声明

零新增第三方依赖。复用:

  • 现有 `jsonschema` schema 校验(PR XVIII 把 export 路径接进同一个 `_validate_against_schema` 函数)
  • 现有 `yaml.safe_load` 解析
  • 前端 `Button` / `Loader2` / `ChevronDown` / `Download` / lucide 图标已用
  • `/api/v1/export` 端点遵循同一份 `HTTPException(detail={code, message, errors})` 结构,与 `/adapt` `/validate` 一致

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

The editor's only download was the schema YAML — useful for the
pipeline contract, useless for directors and actors. Adds an
end-to-end export path so the author leaves with a file the rest
of the team can read.

Backend: new story2script.export package with three pure renderers
(to_fountain, to_txt, to_markdown), wired to POST /api/v1/export.
The endpoint runs the same schema validator the YAML download
relies on, so a broken draft surfaces as 422 with field-level errors
instead of a half-rendered file. PDF is intentionally NOT a server
format — generating CJK-safe PDFs needs either weasyprint+GTK or a
5 MB bundled font; the frontend opens the Markdown variant in a
print-friendly window and lets the browser's native Print → Save
as PDF do the conversion. Zero new server dependencies.

Frontend: the toolbar's 导出 YAML button becomes a 导出剧本
dropdown with five options (Fountain / txt / md / PDF / raw YAML).
PDF reuses the markdown response, rewraps it in an inline HTML
template with CJK system fonts, and triggers window.print() after
load. Error messaging surfaces 422 schema details so the author
knows which field to fix in the editor.

Tests: 25 new (8 fountain, 4 txt, 7 markdown, 6 endpoint),
covering character cue casing, parenthetical split, transition
normalization, mode block presence, invalid YAML / invalid
screenplay / unknown format error paths. Full suite 314 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shanchuann
shanchuann merged commit 5ce98c7 into main Jun 7, 2026
2 checks passed
@shanchuann
shanchuann deleted the feat/screenplay-export branch June 7, 2026 09:25
shanchuann added a commit that referenced this pull request Jun 7, 2026
* feat(export): screenplay export to Fountain / txt / Markdown / PDF

The editor's only download was the schema YAML — useful for the
pipeline contract, useless for directors and actors. Adds an
end-to-end export path so the author leaves with a file the rest
of the team can read.

Backend: new story2script.export package with three pure renderers
(to_fountain, to_txt, to_markdown), wired to POST /api/v1/export.
The endpoint runs the same schema validator the YAML download
relies on, so a broken draft surfaces as 422 with field-level errors
instead of a half-rendered file. PDF is intentionally NOT a server
format — generating CJK-safe PDFs needs either weasyprint+GTK or a
5 MB bundled font; the frontend opens the Markdown variant in a
print-friendly window and lets the browser's native Print → Save
as PDF do the conversion. Zero new server dependencies.

Frontend: the toolbar's 导出 YAML button becomes a 导出剧本
dropdown with five options (Fountain / txt / md / PDF / raw YAML).
PDF reuses the markdown response, rewraps it in an inline HTML
template with CJK system fonts, and triggers window.print() after
load. Error messaging surfaces 422 schema details so the author
knows which field to fix in the editor.

Tests: 25 new (8 fountain, 4 txt, 7 markdown, 6 endpoint),
covering character cue casing, parenthetical split, transition
normalization, mode block presence, invalid YAML / invalid
screenplay / unknown format error paths. Full suite 314 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(adapt): sync edits from /editor back into the result panel

Returning to /app after editing in /editor showed a stale result —
the screenplay_yaml in the React state still reflected the original
LLM output even though the editor had patched sessionStorage with
every keystroke. Two real consequences:

- Exporting via PR #60's new dropdown would have produced the
  PRE-edit Fountain / Markdown / etc., silently discarding all the
  author's manual fixes.
- Schema validation and quality metrics displayed the LLM's draft
  state, not what the author was about to ship.

Strategy: on mount AND on tab focus / visibility change, diff the
editor's draft against the result.screenplay_yaml. When they differ,
swap the YAML in and re-run /api/v1/validate so schema_validation
reflects current state. Quality metrics intentionally stay frozen
since they describe how the LLM produced the draft, not whether
the author's hand-edits are still valid.

The validate call's failure path falls through to a YAML-only swap
so the export button still reflects current edits when the backend
is down; the eventual /export call will surface the same errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shanchuann added a commit that referenced this pull request Jun 7, 2026
…nts (#63)

* feat(export): screenplay export to Fountain / txt / Markdown / PDF

The editor's only download was the schema YAML — useful for the
pipeline contract, useless for directors and actors. Adds an
end-to-end export path so the author leaves with a file the rest
of the team can read.

Backend: new story2script.export package with three pure renderers
(to_fountain, to_txt, to_markdown), wired to POST /api/v1/export.
The endpoint runs the same schema validator the YAML download
relies on, so a broken draft surfaces as 422 with field-level errors
instead of a half-rendered file. PDF is intentionally NOT a server
format — generating CJK-safe PDFs needs either weasyprint+GTK or a
5 MB bundled font; the frontend opens the Markdown variant in a
print-friendly window and lets the browser's native Print → Save
as PDF do the conversion. Zero new server dependencies.

Frontend: the toolbar's 导出 YAML button becomes a 导出剧本
dropdown with five options (Fountain / txt / md / PDF / raw YAML).
PDF reuses the markdown response, rewraps it in an inline HTML
template with CJK system fonts, and triggers window.print() after
load. Error messaging surfaces 422 schema details so the author
knows which field to fix in the editor.

Tests: 25 new (8 fountain, 4 txt, 7 markdown, 6 endpoint),
covering character cue casing, parenthetical split, transition
normalization, mode block presence, invalid YAML / invalid
screenplay / unknown format error paths. Full suite 314 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(adapt): sync edits from /editor back into the result panel

Returning to /app after editing in /editor showed a stale result —
the screenplay_yaml in the React state still reflected the original
LLM output even though the editor had patched sessionStorage with
every keystroke. Two real consequences:

- Exporting via PR #60's new dropdown would have produced the
  PRE-edit Fountain / Markdown / etc., silently discarding all the
  author's manual fixes.
- Schema validation and quality metrics displayed the LLM's draft
  state, not what the author was about to ship.

Strategy: on mount AND on tab focus / visibility change, diff the
editor's draft against the result.screenplay_yaml. When they differ,
swap the YAML in and re-run /api/v1/validate so schema_validation
reflects current state. Quality metrics intentionally stay frozen
since they describe how the LLM produced the draft, not whether
the author's hand-edits are still valid.

The validate call's failure path falls through to a YAML-only swap
so the export button still reflects current edits when the backend
is down; the eventual /export call will surface the same errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): per-scene AI polish with mode picker + undo

The mode_refine LLM stage was wired into the pipeline run but invisible
in the editor — once the draft was generated, the author had no way
to ask "redo this scene with low fidelity / rich expansion" without
regenerating the whole document. Exposes a per-scene polish toolbar
that hits a new POST /api/v1/refine/scene endpoint.

Backend: new endpoint deserializes the YAML, finds the scene by id,
reconstructs the Scene / Beat / Character pydantic instances the
existing ai_refine_scene_beats helper expects, and returns the
refined beat list. 503 when no LLM provider is configured, 404 on
unknown scene id, 422 on empty beats, 502 when the LLM call returns
nothing usable (rule_fallback fired). Tests cover the four error
paths without burning quota.

Frontend: a dashed-bordered toolbar at the top of each scene with
two selects (fidelity / expansion) + 润色场景 button + 撤销 button.
The default (medium, balanced) mode disables the button because the
backend would no-op it anyway. A polish snapshots the pre-polish
YAML to a ref for one-level undo so the author can try a mode,
hate it, and revert without losing their other edits.

Beats are spliced back wholesale rather than per-beat patched —
expansion=rich can change the beat count, so a positional patch
would leave the YAML inconsistent. The full splice round-trips
through js-yaml.dump to keep the file structurally clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(adapt): retry sample autorun on next reload + structured error hints

Two stability papercuts found while smoke-testing the demo flow:

- First-visit autorun set the localStorage flag unconditionally —
  including when the backend was down — so a grader who hit /app
  before starting uvicorn saw the demo fail once and never again,
  even after launching the server. Now the flag is set only on
  success; failure leaves the flag clear so the next page load
  retries. autorunFiredRef still blocks intra-session re-fires so
  the user sees one attempt per reload, not a hammer.

- The Adapt result panel's error block dumped the raw ApiError
  message without suggesting next steps. status=0 (network) and
  status=503 (no LLM provider) are the two cases a grader is most
  likely to hit during setup; both now show a one-line hint with
  the exact command or env var that resolves them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant