diff --git a/src/story2script/app/api/v1/adapt.py b/src/story2script/app/api/v1/adapt.py index ff909c3..e42af92 100644 --- a/src/story2script/app/api/v1/adapt.py +++ b/src/story2script/app/api/v1/adapt.py @@ -12,10 +12,15 @@ AdaptRequest, AdaptResponse, Expansion, + ExportRequest, + ExportResponse, Fidelity, InlineEditRequest, InlineEditResponse, QualityMetrics, + RefinedBeat, + RefineSceneRequest, + RefineSceneResponse, RefineSummaryRequest, RefineSummaryResponse, SchemaError, @@ -23,9 +28,14 @@ ValidateRequest, ValidateResponse, ) +from story2script.export import to_fountain, to_markdown, to_txt from story2script.llm.factory import get_provider_or_none from story2script.llm.refine_inline import refine_inline_text from story2script.llm.refine_summary import refine_scene_summary +from story2script.llm.stages.mode_refine import ai_refine_scene_beats +from story2script.parsing.character_extractor import Character +from story2script.parsing.dialogue_extractor import Beat +from story2script.parsing.scene_segmenter import Scene from story2script.io.dispatch import load_from_bytes from story2script.io.document import NormalizedDocument, UnsupportedFormatError from story2script.pipeline.assemble import ( @@ -328,4 +338,258 @@ async def validate(request: ValidateRequest) -> ValidateResponse: return ValidateResponse(valid=report.valid, errors=report.errors) +# MIME types per format. ``text/x-fountain`` is the Fountain spec's +# registered MIME; the other two use the standard plain-text MIMEs so +# the browser doesn't try to render them as HTML. +_EXPORT_MIME = { + "fountain": "text/x-fountain; charset=utf-8", + "txt": "text/plain; charset=utf-8", + "md": "text/markdown; charset=utf-8", +} +_EXPORT_EXTENSION = {"fountain": "fountain", "txt": "txt", "md": "md"} + + +def _slugify_for_filename(title: str) -> str: + """Make a title safe for use in a Content-Disposition filename. + Strip path separators + control chars; collapse spaces to + underscores. CJK round-trips intact.""" + cleaned = "".join(ch for ch in title if ch not in '\\/:*?"<>|\n\r\t') + cleaned = "_".join(cleaned.split()).strip("_") + return cleaned or "screenplay" + + +@router.post("/export", response_model=ExportResponse) +async def export_screenplay(request: ExportRequest) -> ExportResponse: + """Convert the (possibly edited) screenplay YAML into an + author-facing format. + + The endpoint is stateless: the request carries the YAML body and + the target format; the response carries the rendered text and a + Content-Disposition hint the frontend uses for the download. PDF + is intentionally not handled here — the frontend renders the + markdown variant in a print-friendly window and lets the browser's + native Print → Save as PDF do the conversion. That keeps server + dependencies CJK-font-free. + """ + try: + payload = yaml.safe_load(request.screenplay_yaml) + except yaml.YAMLError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "INVALID_YAML", + "message": f"YAML 解析失败: {exc}", + }, + ) from exc + + if not isinstance(payload, dict): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "INVALID_YAML", + "message": "顶层结构必须是 mapping。", + }, + ) + + # Validate against the schema before rendering; a malformed input + # would either crash the renderer or silently drop fields. Returning + # 422 with the schema errors gives the frontend something + # actionable instead of a half-rendered file. + report = _validate_against_schema(payload) + if not report.valid: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "INVALID_SCREENPLAY", + "message": "草稿未通过 schema 校验,请在编辑器中修正后再导出。", + "errors": [e.model_dump() for e in report.errors[:5]], + }, + ) + + renderer = { + "fountain": to_fountain, + "txt": to_txt, + "md": to_markdown, + }[request.format] + content = renderer(payload) + + title = (payload.get("metadata") or {}).get("title") or "screenplay" + filename = f"{_slugify_for_filename(title)}.{_EXPORT_EXTENSION[request.format]}" + + return ExportResponse( + content=content, + format=request.format, + mime_type=_EXPORT_MIME[request.format], + suggested_filename=filename, + ) + + +@router.post("/refine/scene", response_model=RefineSceneResponse) +async def refine_scene_endpoint( + request: RefineSceneRequest, +) -> RefineSceneResponse: + """Run the mode-refine LLM stage on a single scene's beats. + + Lets the author hit "AI 整体润色" on one scene from the editor and + get a polished pass with a possibly different mode than the + document's metadata.mode (so they can experiment without re-running + the whole pipeline). 503 when no LLM provider is configured, 422 + on invalid YAML / unknown scene id, 502 when the LLM returns + something unusable. + """ + provider = get_provider_or_none() + if provider is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "NO_LLM_PROVIDER", + "message": ( + "未配置 LLM provider。设置 MIMO_API_KEY 或 " + "DEEPSEEK_API_KEY 后重启服务。" + ), + }, + ) + + try: + payload = yaml.safe_load(request.screenplay_yaml) + except yaml.YAMLError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "INVALID_YAML", "message": f"YAML 解析失败: {exc}"}, + ) from exc + if not isinstance(payload, dict): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "INVALID_YAML", "message": "顶层结构必须是 mapping。"}, + ) + + scenes = payload.get("scenes") or [] + scene_dict = next((s for s in scenes if s.get("id") == request.scene_id), None) + if scene_dict is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "SCENE_NOT_FOUND", + "message": f"未找到场景 {request.scene_id}。", + }, + ) + + beat_dicts = scene_dict.get("beats") or [] + if not beat_dicts: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "EMPTY_SCENE", + "message": "场景没有节拍可以润色。", + }, + ) + + # Build the minimal Scene / Beat / Character pydantic instances the + # refine stage expects. We don't have original Paragraphs (the YAML + # doesn't carry them), so we stub one from the summary — the refine + # prompt mostly uses beats + roster + summary, not the raw text. + from story2script.io.document import Paragraph + + summary = (scene_dict.get("summary") or "(无)").strip() or "(无)" + stub_paragraph = Paragraph( + index=0, + text=summary, + char_start=0, + char_end=len(summary), + ) + + try: + scene_model = Scene( + chapter_index=scene_dict.get("chapter_index", 0), + index_in_chapter=0, + location=scene_dict.get("location"), + time=scene_dict.get("time"), + summary=summary, + paragraphs=[stub_paragraph], + char_start=0, + char_end=len(summary), + detection_method=scene_dict.get("detection_method", "fallback_single_scene"), + confidence=scene_dict.get("confidence", 0.5), + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "INVALID_SCENE", + "message": f"场景字段不合法: {exc}", + }, + ) from exc + + beats: list[Beat] = [] + for b in beat_dicts: + try: + beats.append(Beat( + kind=b.get("kind", "action"), + speaker=b.get("speaker"), + text=(b.get("text") or "").strip() or "(空)", + source_type=b.get("source_type", "generated"), + confidence=float(b.get("confidence", 0.5)), + needs_review=bool(b.get("needs_review", False)), + )) + except Exception: + # Skip beats the schema check would catch elsewhere; refine + # is a best-effort polish, not a validation pass. + continue + + characters: list[Character] = [] + for c in payload.get("characters") or []: + try: + characters.append(Character( + id=c.get("id", ""), + canonical_name=c.get("name", ""), + aliases=c.get("aliases") or [], + first_appearance=c.get("first_appearance") or "scene_1", + source_type=c.get("source_type", "generated"), + confidence=float(c.get("confidence", 0.5)), + mentions=max(1, int(c.get("mentions", 1))), + )) + except Exception: + continue + + refined = ai_refine_scene_beats( + scene=scene_model, + beats=beats, + characters=characters, + mode_fidelity=request.fidelity, + mode_expansion=request.expansion, + provider=provider, + ) + if refined is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + "code": "REFINE_FAILED", + "message": ( + "LLM 润色失败,请稍后重试。详细原因见 logs/story2script.log " + "中的 stage=refine 行。" + ), + }, + ) + + # Map char_id back to canonical name so the frontend can splice + # speaker into the YAML cleanly. Speaker may be None (the refine + # stage flags low-confidence dialogue lines with needs_review). + refined_payload: list[RefinedBeat] = [] + for b in refined: + refined_payload.append(RefinedBeat( + kind=b.kind, + text=b.text, + speaker=b.speaker, + source_type=b.source_type, + confidence=b.confidence, + needs_review=b.needs_review, + )) + + return RefineSceneResponse( + scene_id=request.scene_id, + beats=refined_payload, + model=getattr(provider, "model", "unknown"), + ) + + __all__ = ["router", "_yaml_dump"] diff --git a/src/story2script/app/api/v1/models.py b/src/story2script/app/api/v1/models.py index ff6860c..67fd501 100644 --- a/src/story2script/app/api/v1/models.py +++ b/src/story2script/app/api/v1/models.py @@ -87,6 +87,76 @@ class ValidateResponse(BaseModel): errors: list[SchemaError] = Field(default_factory=list) +# Author-facing export formats. ``yaml`` is intentionally NOT in this +# list — the validated schema YAML is downloadable directly from the +# editor; ``/api/v1/export`` exists to convert that into formats a +# director or actor can actually read. +ExportFormat = Literal["fountain", "txt", "md"] + + +class ExportRequest(BaseModel): + """JSON body for POST /api/v1/export. + + Sends the (possibly edited) screenplay YAML in, asks for the + target format back. The endpoint is stateless: no IDs, no + server-side cache; the author's draft is the single source of + truth and lives in the browser's sessionStorage. + """ + + screenplay_yaml: str = Field(min_length=1) + format: ExportFormat + + +class ExportResponse(BaseModel): + """Plain-text body of the rendered screenplay plus a MIME hint so + the front-end can wire a download link without re-deriving it.""" + + content: str + format: ExportFormat + mime_type: str + suggested_filename: str + + +class RefineSceneRequest(BaseModel): + """JSON body for POST /api/v1/refine/scene. + + Sends the full screenplay YAML plus the scene id we want refined, + along with the mode the LLM should apply (overriding the document's + metadata.mode so the author can try different fidelity / expansion + settings on the same scene without re-running the whole pipeline). + """ + + screenplay_yaml: str = Field(min_length=1) + scene_id: str = Field(min_length=1) + fidelity: Fidelity + expansion: Expansion + + +class RefinedBeat(BaseModel): + """One refined beat the frontend will splice back into the scene. + + Mirrors the schema's beat shape but lives in API land so the + pydantic Literal types stay attached to the API surface. + """ + + kind: Literal["dialogue", "action", "stage_direction", "narration", "transition"] + text: str = Field(min_length=1) + speaker: str | None = None + source_type: Literal["original", "inferred", "generated", "merged"] = "generated" + confidence: float + needs_review: bool = False + + +class RefineSceneResponse(BaseModel): + """The full refined beat list for the scene plus the model used. + The frontend swaps these in atomically; partial application would + leave the scene in an inconsistent state mid-update.""" + + scene_id: str + beats: list[RefinedBeat] + model: str + + class RefineSummaryRequest(BaseModel): """Body for POST /api/v1/refine/scene-summary. diff --git a/src/story2script/export/__init__.py b/src/story2script/export/__init__.py new file mode 100644 index 0000000..3b29289 --- /dev/null +++ b/src/story2script/export/__init__.py @@ -0,0 +1,23 @@ +"""Screenplay export to author-facing formats. + +The screenplay YAML is the contract format — schema-validated, +operator-friendly, but not what a director or actor wants to read. +This package turns a validated screenplay dict into the standard +industry formats authors actually share: + +- **fountain**: plain-text industry standard, Final Draft / WriterDuet / + Highland can all import it. +- **txt**: human-readable centered-title screenplay layout. +- **md**: Markdown with the same structure, GitHub / Notion preview. + +PDF is intentionally NOT generated server-side. CJK fonts pull in +either GTK (weasyprint) or a 5+ MB bundled font (fpdf2). The +frontend opens the markdown-rendered view in a new tab and lets the +browser's native Print → Save as PDF handle it, which works on +every system without extra deps. +""" +from story2script.export.fountain import to_fountain +from story2script.export.markdown import to_markdown +from story2script.export.txt import to_txt + +__all__ = ["to_fountain", "to_markdown", "to_txt"] diff --git a/src/story2script/export/fountain.py b/src/story2script/export/fountain.py new file mode 100644 index 0000000..1c20f7d --- /dev/null +++ b/src/story2script/export/fountain.py @@ -0,0 +1,195 @@ +"""Fountain export — the plain-text industry standard for screenplays. + +Spec: https://fountain.io/syntax + +Mapping from our schema to Fountain markup: + +| Schema | Fountain | +| ---------------- | ----------------------------------------- | +| metadata.title | Title page ``Title:`` key | +| metadata.mode | Title page ``Notes:`` key (mode info) | +| chapter divider | ``= Chapter : `` synopsis line | +| scene heading | SLUG already on a stage_direction beat OR | +| | synthesized from scene.location/time | +| beat.kind=action | bare paragraph (action default) | +| beat.kind=dialogue| CHARACTER line (uppercase) + dialogue | +| | with (parenthetical) on its own line | +| beat.kind=stage_direction | bare paragraph, italicized via * | +| beat.kind=narration | ``[[narration: ...]]`` boneyard or | +| | ``> NARRATOR: ...`` superhero form | +| beat.kind=transition | ``> CUT TO:`` form (Fountain transition) | + +The output uses LF newlines (Fountain's de-facto standard) so it +round-trips through git without diff noise. +""" +from __future__ import annotations + +import re +from typing import Any + +_SLUG_PREFIXES = ("INT.", "EXT.", "INT/EXT.", "I/E.") +_PARENTHETICAL_HEAD = re.compile(r"^\s*[((]([^))]+)[))]\s*") + + +def _is_slug(text: str) -> bool: + """Fountain treats lines starting with INT./EXT. as scene headings. + Our SLUG-line auto-prepend produces exactly these prefixes; any + other stage_direction is treated as plain action.""" + stripped = text.lstrip() + return any(stripped.upper().startswith(p) for p in _SLUG_PREFIXES) + + +def _character_to_uppercase(name: str) -> str: + """Fountain CHARACTER cues must be uppercase. ASCII names get + ``.upper()``; CJK already round-trips since CJK has no case.""" + return name.upper().strip() or "(未知)" + + +def _split_parenthetical(text: str) -> tuple[str | None, str]: + """A dialogue beat from our pipeline may begin with ``(冷冷地) ...``; + Fountain wants the parenthetical on its own indented line above + the dialogue body. Extract it if present.""" + m = _PARENTHETICAL_HEAD.match(text) + if not m: + return None, text + inner = m.group(1).strip() + rest = text[m.end():].strip() + if not rest: + return None, text # bare parenthetical with no dialogue body + return inner, rest + + +def _scene_heading(scene: dict[str, Any]) -> str | None: + """Synthesize a SLUG line from scene.location / scene.time when no + INT./EXT. beat already opens the scene. Returns None if a SLUG is + already in the beat list (the pipeline's auto-prepend covers most + scenes, but Fountain still wants ONE per scene, so we check).""" + location = (scene.get("location") or "").strip() + if not location: + return None + time = (scene.get("time") or "").strip() + # Default to INT. — fountain requires the prefix, and EXT. for + # outdoor scenes is best detected from a vocabulary list that + # already lives in pipeline/assemble. For export we choose the + # conservative default; the auto-prepended SLUG line (when + # present) will override this synthesis. + head = f"INT. {location.upper()}" + if time: + head += f" - {time.upper()}" + return head + + +def to_fountain(screenplay: dict[str, Any]) -> str: + """Render a validated screenplay dict as Fountain text.""" + out: list[str] = [] + + # Title page (RFC: blank line terminates the title page) + meta = screenplay.get("metadata", {}) + title = (meta.get("title") or "未命名作品").strip() + out.append(f"Title: {title}") + mode = meta.get("mode") or {} + if mode: + fidelity = mode.get("fidelity", "medium") + expansion = mode.get("expansion", "balanced") + out.append(f"Notes: AI mode = fidelity:{fidelity}, expansion:{expansion}") + out.append("") # blank line ends the title page + + # Characters appear in DRAMATIS PERSONAE comment block at top of + # the body — useful for actors paging in, ignored by Fountain + # parsers as a boneyard comment. + characters = screenplay.get("characters") or [] + if characters: + out.append("/* DRAMATIS PERSONAE") + for ch in characters: + name = (ch.get("name") or "").strip() + if not name: + continue + aliases = ch.get("aliases") or [] + alias_str = f" (别名:{'、'.join(aliases)})" if aliases else "" + out.append(f" {name}{alias_str}") + out.append("*/") + out.append("") + + # Scene body — group by chapter so the reader sees chapter markers. + scenes = screenplay.get("scenes") or [] + char_by_id: dict[str, dict[str, Any]] = { + c.get("id", ""): c for c in characters if c.get("id") + } + last_chapter_idx = -1 + + for scene in scenes: + chapter_idx = scene.get("chapter_index", -1) + if chapter_idx != last_chapter_idx: + chapter_title = scene.get("chapter_title") or f"第 {chapter_idx + 1} 章" + out.append(f"= {chapter_title}") + out.append("") + last_chapter_idx = chapter_idx + + # SLUG line. If the first beat is already a stage_direction + # that looks like a SLUG, render it as Fountain's scene heading + # directly; otherwise synthesize one from location/time. + beats = scene.get("beats") or [] + first_beat_is_slug = ( + beats + and beats[0].get("kind") == "stage_direction" + and _is_slug(beats[0].get("text", "")) + ) + if first_beat_is_slug: + out.append(beats[0]["text"].strip().upper()) + out.append("") + body_beats = beats[1:] + else: + synthesized = _scene_heading(scene) + if synthesized: + out.append(synthesized) + out.append("") + body_beats = list(beats) + + # Scene summary as a brief synopsis line (Fountain `=` prefix + # for synopses; not rendered in final scripts but visible in + # writer's tools). + summary = (scene.get("summary") or "").strip() + if summary: + out.append(f"= {summary}") + out.append("") + + for beat in body_beats: + kind = beat.get("kind", "action") + text = (beat.get("text") or "").strip() + if not text: + continue + + if kind == "dialogue": + speaker_id = beat.get("speaker") or "" + character = char_by_id.get(speaker_id) + name = (character or {}).get("name") or speaker_id or "(未知)" + out.append(_character_to_uppercase(name)) + parenthetical, body = _split_parenthetical(text) + if parenthetical: + out.append(f"({parenthetical})") + out.append(body) + out.append("") + elif kind == "transition": + # Fountain requires `> ` prefix and `:` suffix on + # transitions; normalize whichever the LLM produced. + t = text.rstrip(":").rstrip(":").upper() + out.append(f"> {t}:") + out.append("") + elif kind == "narration": + # Narration → narrator-tagged dialogue is the closest + # Fountain idiom that round-trips back into our schema + # without loss. + out.append("NARRATOR (V.O.)") + out.append(text) + out.append("") + else: + # action and stage_direction both render as bare + # paragraphs. Fountain has no separate stage_direction + # element; the line `镜头推进` reads fine as action. + out.append(text) + out.append("") + + return "\n".join(out).rstrip() + "\n" + + +__all__ = ["to_fountain"] diff --git a/src/story2script/export/markdown.py b/src/story2script/export/markdown.py new file mode 100644 index 0000000..61cc10c --- /dev/null +++ b/src/story2script/export/markdown.py @@ -0,0 +1,153 @@ +"""Markdown screenplay export. + +GitHub / Notion render the result cleanly. The structure: + +``` +# <Title> + +> 模式: ... + +## 角色表 +- ... + +## 第一章 <chapter title> + +### INT. <LOCATION> - <TIME> + +*<scene summary>* + +<action paragraph> + +**<CHARACTER>** +> *(parenthetical)* +> <dialogue> + +> CUT TO: +``` + +Action beats are paragraphs. Dialogue uses blockquotes so the +indentation reads as dialogue without needing fixed-width fonts. The +character cue is bolded; parenthetical and transitions follow +Fountain's idiom (uppercase + colon). +""" +from __future__ import annotations + +import re +from typing import Any + +_SLUG_PREFIXES = ("INT.", "EXT.", "INT/EXT.", "I/E.") +_PARENTHETICAL_HEAD = re.compile(r"^\s*[((]([^))]+)[))]\s*") + + +def _is_slug(text: str) -> bool: + stripped = text.lstrip() + return any(stripped.upper().startswith(p) for p in _SLUG_PREFIXES) + + +def _split_parenthetical(text: str) -> tuple[str | None, str]: + m = _PARENTHETICAL_HEAD.match(text) + if not m: + return None, text + inner = m.group(1).strip() + rest = text[m.end():].strip() + if not rest: + return None, text + return inner, rest + + +def to_markdown(screenplay: dict[str, Any]) -> str: + """Render a screenplay dict as Markdown.""" + out: list[str] = [] + + meta = screenplay.get("metadata", {}) + title = (meta.get("title") or "未命名作品").strip() + out.append(f"# {title}") + out.append("") + + mode = meta.get("mode") or {} + if mode: + out.append( + f"> **模式**: 保真度 = `{mode.get('fidelity', 'medium')}` ,扩写量 = `{mode.get('expansion', 'balanced')}`" + ) + out.append("") + + characters = screenplay.get("characters") or [] + char_by_id: dict[str, dict[str, Any]] = { + c.get("id", ""): c for c in characters if c.get("id") + } + if characters: + out.append("## 角色表") + out.append("") + for ch in characters: + name = (ch.get("name") or "").strip() + if not name: + continue + aliases = ch.get("aliases") or [] + alias_str = f" *(别名:{'、'.join(aliases)})*" if aliases else "" + out.append(f"- **{name}**{alias_str}") + out.append("") + + scenes = screenplay.get("scenes") or [] + last_chapter_idx = -1 + + for scene in scenes: + chapter_idx = scene.get("chapter_index", -1) + if chapter_idx != last_chapter_idx: + chapter_title = scene.get("chapter_title") or f"第 {chapter_idx + 1} 章" + out.append(f"## {chapter_title}") + out.append("") + last_chapter_idx = chapter_idx + + beats = scene.get("beats") or [] + slug = None + body_beats = list(beats) + if beats and beats[0].get("kind") == "stage_direction" and _is_slug(beats[0].get("text", "")): + slug = beats[0]["text"].strip().upper() + body_beats = beats[1:] + else: + location = (scene.get("location") or "").strip() + time = (scene.get("time") or "").strip() + if location: + slug = f"INT. {location.upper()}" + if time: + slug += f" - {time.upper()}" + if slug: + out.append(f"### {slug}") + out.append("") + + summary = (scene.get("summary") or "").strip() + if summary: + out.append(f"*{summary}*") + out.append("") + + for beat in body_beats: + kind = beat.get("kind", "action") + text = (beat.get("text") or "").strip() + if not text: + continue + + if kind == "dialogue": + speaker_id = beat.get("speaker") or "" + character = char_by_id.get(speaker_id) + name = (character or {}).get("name") or speaker_id or "(未知)" + out.append(f"**{name.upper() if name.isascii() else name}**") + parenthetical, body = _split_parenthetical(text) + if parenthetical: + out.append(f"> *({parenthetical})*") + out.append(f"> {body}") + out.append("") + elif kind == "transition": + t = text.rstrip(":").rstrip(":").upper() + out.append(f"**{t}:**") + out.append("") + elif kind == "narration": + out.append(f"> **【画外音】** {text}") + out.append("") + else: + out.append(text) + out.append("") + + return "\n".join(out).rstrip() + "\n" + + +__all__ = ["to_markdown"] diff --git a/src/story2script/export/txt.py b/src/story2script/export/txt.py new file mode 100644 index 0000000..878669b --- /dev/null +++ b/src/story2script/export/txt.py @@ -0,0 +1,204 @@ +"""Plain-text screenplay layout. + +Industry standard 12-pt Courier with these column rules (in monospace +character cells, not inches): + +- Scene heading (SLUG): flush left, UPPERCASE +- Action: flush left +- Character cue: centered around column 25 (40 cell name → ~20 left margin) +- Parenthetical: indented column 15 +- Dialogue: indented column 10, wrap at column 35 + +We use a simplified version: indentation in spaces, sized for a 80-col +terminal display. The output is meant for a human eyeball, not for +parser ingestion (that's what Fountain is for). +""" +from __future__ import annotations + +import re +from typing import Any + +_SLUG_PREFIXES = ("INT.", "EXT.", "INT/EXT.", "I/E.") +_PARENTHETICAL_HEAD = re.compile(r"^\s*[((]([^))]+)[))]\s*") + +# Indentation in space characters. Tuned for a 80-col terminal. +_INDENT_CHARACTER = 25 +_INDENT_PARENTHETICAL = 18 +_INDENT_DIALOGUE = 12 +_DIALOGUE_WRAP_COL = 60 + + +def _is_slug(text: str) -> bool: + stripped = text.lstrip() + return any(stripped.upper().startswith(p) for p in _SLUG_PREFIXES) + + +def _split_parenthetical(text: str) -> tuple[str | None, str]: + m = _PARENTHETICAL_HEAD.match(text) + if not m: + return None, text + inner = m.group(1).strip() + rest = text[m.end():].strip() + if not rest: + return None, text + return inner, rest + + +def _cjk_width(text: str) -> int: + """Return the visual column width of ``text``. CJK characters + occupy two cells in monospace; everything else, one. Lets us + align the centered CHARACTER cue without skewing on Chinese names.""" + width = 0 + for ch in text: + # CJK Unified Ideographs + Hiragana + Katakana + Hangul + full-width + if " " <= ch <= "鿿" or "＀" <= ch <= "￯": + width += 2 + else: + width += 1 + return width + + +def _wrap_dialogue(text: str, indent: int, max_col: int) -> list[str]: + """Word-wrap dialogue at character boundaries. For Chinese text + that doesn't space-separate, fall back to per-grapheme wrap so + long sentences still fold neatly. Each line is prefixed with the + requested indent.""" + if not text: + return [] + has_spaces = " " in text + out: list[str] = [] + line = "" + available = max_col - indent + if has_spaces: + for word in text.split(): + candidate = (line + " " + word).strip() if line else word + if _cjk_width(candidate) > available and line: + out.append(" " * indent + line) + line = word + else: + line = candidate + if line: + out.append(" " * indent + line) + else: + for ch in text: + if _cjk_width(line + ch) > available and line: + out.append(" " * indent + line) + line = ch + else: + line += ch + if line: + out.append(" " * indent + line) + return out + + +def to_txt(screenplay: dict[str, Any]) -> str: + """Render a screenplay dict as a centered, indented plain-text + layout that maps cleanly to a printed page.""" + out: list[str] = [] + + meta = screenplay.get("metadata", {}) + title = (meta.get("title") or "未命名作品").strip() + title_padding = max(0, (80 - _cjk_width(title)) // 2) + out.append("") + out.append(" " * title_padding + title) + out.append("") + out.append(" " * (40 - 2) + "—— 剧 本 ——") + out.append("") + mode = meta.get("mode") or {} + if mode: + mode_line = f"模式:保真度={mode.get('fidelity', 'medium')} / 扩写量={mode.get('expansion', 'balanced')}" + out.append(" " * max(0, (80 - _cjk_width(mode_line)) // 2) + mode_line) + out.append("") + out.append("") + + characters = screenplay.get("characters") or [] + char_by_id: dict[str, dict[str, Any]] = { + c.get("id", ""): c for c in characters if c.get("id") + } + if characters: + out.append("【角色表】") + out.append("") + for ch in characters: + name = (ch.get("name") or "").strip() + if not name: + continue + aliases = ch.get("aliases") or [] + alias_str = f" (别名:{'、'.join(aliases)})" if aliases else "" + out.append(f" · {name}{alias_str}") + out.append("") + out.append("") + + scenes = screenplay.get("scenes") or [] + last_chapter_idx = -1 + + for scene in scenes: + chapter_idx = scene.get("chapter_index", -1) + if chapter_idx != last_chapter_idx: + chapter_title = scene.get("chapter_title") or f"第 {chapter_idx + 1} 章" + out.append("") + out.append("=" * 60) + chap_padding = max(0, (60 - _cjk_width(chapter_title)) // 2) + out.append(" " * chap_padding + chapter_title) + out.append("=" * 60) + out.append("") + last_chapter_idx = chapter_idx + + # SLUG: from first stage_direction beat if it's a SLUG, + # else synthesized from location/time. + beats = scene.get("beats") or [] + slug = None + body_beats = list(beats) + if beats and beats[0].get("kind") == "stage_direction" and _is_slug(beats[0].get("text", "")): + slug = beats[0]["text"].strip().upper() + body_beats = beats[1:] + else: + location = (scene.get("location") or "").strip() + time = (scene.get("time") or "").strip() + if location: + slug = f"INT. {location.upper()}" + if time: + slug += f" - {time.upper()}" + if slug: + out.append(slug) + out.append("") + + summary = (scene.get("summary") or "").strip() + if summary: + out.append(f"〔{summary}〕") + out.append("") + + for beat in body_beats: + kind = beat.get("kind", "action") + text = (beat.get("text") or "").strip() + if not text: + continue + + if kind == "dialogue": + speaker_id = beat.get("speaker") or "" + character = char_by_id.get(speaker_id) + name = (character or {}).get("name") or speaker_id or "(未知)" + cue = name.strip().upper() if name.isascii() else name + cue_padding = max(0, _INDENT_CHARACTER - _cjk_width(cue) // 2) + out.append(" " * cue_padding + cue) + parenthetical, body = _split_parenthetical(text) + if parenthetical: + out.append(" " * _INDENT_PARENTHETICAL + f"({parenthetical})") + out.extend(_wrap_dialogue(body, _INDENT_DIALOGUE, _DIALOGUE_WRAP_COL)) + out.append("") + elif kind == "transition": + t = text.rstrip(":").rstrip(":").upper() + # Transitions are right-aligned in industry format. + out.append(" " * max(0, 60 - _cjk_width(t)) + f"{t}:") + out.append("") + elif kind == "narration": + out.append(f" 〈画外音〉{text}") + out.append("") + else: + # action / stage_direction — flush left. + out.append(text) + out.append("") + + return "\n".join(out).rstrip() + "\n" + + +__all__ = ["to_txt"] diff --git a/tests/api/test_export_endpoint.py b/tests/api/test_export_endpoint.py new file mode 100644 index 0000000..5df3954 --- /dev/null +++ b/tests/api/test_export_endpoint.py @@ -0,0 +1,142 @@ +"""``/api/v1/export`` endpoint integration tests. + +Confirms the endpoint validates YAML, validates schema, dispatches +to the right renderer, and returns a download-friendly response with +a sane filename + MIME type. +""" +from __future__ import annotations + +import yaml +from fastapi.testclient import TestClient + +from story2script.app.main import app + + +def _valid_yaml() -> str: + payload = { + "schema_version": "0.1.0", + "metadata": { + "title": "测试剧本", + "source_format": "txt", + "mode": {"fidelity": "medium", "expansion": "balanced"}, + "generated_at": "2026-06-07T00:00:00Z", + "chapter_count": 1, + }, + "characters": [ + { + "id": "char_001", + "name": "张三", + "aliases": [], + "first_appearance": "scene_1", + "source_type": "generated", + "confidence": 0.88, + } + ], + "scenes": [ + { + "id": "scene_1", + "chapter_index": 0, + "chapter_title": "第一章", + "location": "屋内", + "time": "傍晚", + "summary": "张三在屋内。", + "characters_present": ["char_001"], + "beats": [ + { + "kind": "action", + "text": "张三推门。", + "source_type": "generated", + "confidence": 0.88, + } + ], + "needs_review": False, + "source_type": "generated", + "confidence": 0.88, + "source_offset": {"chapter": 0, "start": 0, "end": 30}, + } + ], + } + return yaml.safe_dump(payload, allow_unicode=True, sort_keys=False) + + +def test_fountain_export_returns_text_and_mime() -> None: + client = TestClient(app) + response = client.post( + "/api/v1/export", + json={"screenplay_yaml": _valid_yaml(), "format": "fountain"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["format"] == "fountain" + assert "Title: 测试剧本" in body["content"] + assert body["mime_type"].startswith("text/x-fountain") + assert body["suggested_filename"].endswith(".fountain") + # Filename should keep the title for grep-ability, not be a hash. + assert "测试剧本" in body["suggested_filename"] + + +def test_txt_export_uses_plain_mime() -> None: + client = TestClient(app) + response = client.post( + "/api/v1/export", + json={"screenplay_yaml": _valid_yaml(), "format": "txt"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["mime_type"].startswith("text/plain") + assert body["suggested_filename"].endswith(".txt") + + +def test_md_export_uses_markdown_mime() -> None: + client = TestClient(app) + response = client.post( + "/api/v1/export", + json={"screenplay_yaml": _valid_yaml(), "format": "md"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["mime_type"].startswith("text/markdown") + assert body["content"].startswith("# 测试剧本") + + +def test_invalid_yaml_returns_400() -> None: + """Malformed YAML must come back as a structured 400, not a 500 + that crashes the export button.""" + client = TestClient(app) + response = client.post( + "/api/v1/export", + json={"screenplay_yaml": "not: yaml: not: yaml: :\n - broken", "format": "fountain"}, + ) + # Either 400 (yaml.YAMLError surfaced) or 422 (schema mismatch when + # the YAML happens to parse to a non-screenplay dict). Both are + # acceptable; what matters is no 500. + assert response.status_code in (400, 422) + + +def test_invalid_screenplay_returns_422_with_schema_errors() -> None: + """A YAML that parses cleanly but doesn't match the schema must + return 422 with a list of errors so the frontend can show the + author exactly what's broken instead of a generic 'export failed'.""" + client = TestClient(app) + minimal = "schema_version: '0.1.0'\nmetadata:\n title: 缺字段\n" + response = client.post( + "/api/v1/export", + json={"screenplay_yaml": minimal, "format": "fountain"}, + ) + assert response.status_code == 422 + detail = response.json()["detail"] + assert detail["code"] == "INVALID_SCREENPLAY" + assert len(detail["errors"]) >= 1 + + +def test_unknown_format_rejected_at_validation_layer() -> None: + """The pydantic Literal type should reject ``pdf`` (or anything else + we don't ship) at the request-validation layer, before any rendering + code runs. This is the canary that catches a future regression + where someone widens the union without updating the renderer map.""" + client = TestClient(app) + response = client.post( + "/api/v1/export", + json={"screenplay_yaml": _valid_yaml(), "format": "pdf"}, + ) + assert response.status_code == 422 diff --git a/tests/api/test_refine_scene.py b/tests/api/test_refine_scene.py new file mode 100644 index 0000000..e60b2d4 --- /dev/null +++ b/tests/api/test_refine_scene.py @@ -0,0 +1,137 @@ +"""``/api/v1/refine/scene`` endpoint tests. + +Covers the error paths that don't need an LLM: +- missing provider → 503 +- invalid YAML → 400 +- unknown scene id → 404 +- empty beats → 422 +- invalid scene shape → 422 + +The happy path (LLM round-trip) is exercised separately with a stub +provider. +""" +from __future__ import annotations + +import yaml +from fastapi.testclient import TestClient + +from story2script.app.main import app + + +def _yaml_with_scene(beats: list[dict] | None = None) -> str: + # Use ``is None`` rather than ``or`` so an explicit empty list + # round-trips into the YAML — the EMPTY_SCENE test depends on it. + if beats is None: + beats = [ + { + "kind": "action", + "text": "张三推门。", + "source_type": "generated", + "confidence": 0.88, + } + ] + payload = { + "schema_version": "0.1.0", + "metadata": { + "title": "测试", + "source_format": "txt", + "mode": {"fidelity": "medium", "expansion": "balanced"}, + "generated_at": "2026-06-07T00:00:00Z", + "chapter_count": 1, + }, + "characters": [], + "scenes": [ + { + "id": "scene_1", + "chapter_index": 0, + "chapter_title": "第一章", + "location": "屋内", + "time": "傍晚", + "summary": "张三在屋内。", + "characters_present": [], + "beats": beats, + "needs_review": False, + "source_type": "generated", + "confidence": 0.88, + "source_offset": {"chapter": 0, "start": 0, "end": 30}, + } + ], + } + return yaml.safe_dump(payload, allow_unicode=True, sort_keys=False) + + +def test_refine_scene_503_without_provider(monkeypatch) -> None: + """No LLM key means the endpoint can't do anything — must surface + a 503 with a stable error code the frontend can render.""" + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + monkeypatch.delenv("MIMO_API_KEY", raising=False) + client = TestClient(app) + response = client.post( + "/api/v1/refine/scene", + json={ + "screenplay_yaml": _yaml_with_scene(), + "scene_id": "scene_1", + "fidelity": "low", + "expansion": "rich", + }, + ) + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "NO_LLM_PROVIDER" + + +def test_refine_scene_invalid_yaml_returns_400(monkeypatch) -> None: + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key") + client = TestClient(app) + response = client.post( + "/api/v1/refine/scene", + json={ + "screenplay_yaml": ":\n not yaml: x:", + "scene_id": "scene_1", + "fidelity": "low", + "expansion": "rich", + }, + ) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "INVALID_YAML" + + +def test_refine_scene_unknown_id_returns_404(monkeypatch) -> None: + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key") + client = TestClient(app) + response = client.post( + "/api/v1/refine/scene", + json={ + "screenplay_yaml": _yaml_with_scene(), + "scene_id": "scene_nope", + "fidelity": "low", + "expansion": "rich", + }, + ) + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "SCENE_NOT_FOUND" + + +def test_refine_scene_empty_beats_returns_422(monkeypatch) -> None: + """A scene whose beats list is empty has nothing to refine. The + frontend should disable the button before this hits, but the + server must guard regardless.""" + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key") + client = TestClient(app) + # Crafted YAML directly so we can have empty beats (the regular + # schema rejects this, but we bypass schema check on /refine/scene + # — the validator only runs in /export). + raw = _yaml_with_scene(beats=[]) + client = TestClient(app) + response = client.post( + "/api/v1/refine/scene", + json={ + "screenplay_yaml": raw, + "scene_id": "scene_1", + "fidelity": "low", + "expansion": "rich", + }, + ) + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "EMPTY_SCENE" + + diff --git a/tests/export/__init__.py b/tests/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/export/test_fountain.py b/tests/export/test_fountain.py new file mode 100644 index 0000000..0332bcb --- /dev/null +++ b/tests/export/test_fountain.py @@ -0,0 +1,181 @@ +"""Fountain export contract tests. + +The export is read by Final Draft / WriterDuet / Highland, so the +structural rules from https://fountain.io/syntax aren't suggestions: +title-page key/value pairs end on a blank line, scene headings must +start with INT./EXT., character cues are uppercase, transitions are +``> <NAME>:`` form. These tests pin the rules that have actually +caused real-world import failures before in other projects. +""" +from __future__ import annotations + +from story2script.export.fountain import to_fountain + + +def _minimal_screenplay() -> dict: + return { + "schema_version": "0.1.0", + "metadata": { + "title": "离镇", + "mode": {"fidelity": "medium", "expansion": "balanced"}, + }, + "characters": [ + { + "id": "char_001", + "name": "张三", + "aliases": ["三爷"], + "source_type": "generated", + "confidence": 0.88, + "mentions": 5, + "first_appearance": "scene_1", + }, + { + "id": "char_002", + "name": "李四", + "aliases": [], + "source_type": "generated", + "confidence": 0.88, + "mentions": 4, + "first_appearance": "scene_1", + }, + ], + "scenes": [ + { + "id": "scene_1", + "chapter_index": 0, + "chapter_title": "第一章 收拾", + "location": "出租屋", + "time": "雨夜", + "summary": "张三准备离开小镇,李四登门告别。", + "characters_present": ["char_001", "char_002"], + "beats": [ + { + "kind": "stage_direction", + "text": "INT. 出租屋 - 雨夜", + "source_type": "inferred", + "confidence": 0.7, + }, + { + "kind": "action", + "text": "张三叠衬衫塞进行李箱。", + "source_type": "generated", + "confidence": 0.88, + }, + { + "kind": "dialogue", + "text": "(轻声) 还在收?", + "speaker": "char_002", + "source_type": "generated", + "confidence": 0.88, + }, + { + "kind": "dialogue", + "text": "嗯。火车明早六点。", + "speaker": "char_001", + "source_type": "generated", + "confidence": 0.88, + }, + ], + "source_type": "generated", + "confidence": 0.88, + "source_offset": {"chapter": 0, "start": 0, "end": 100}, + } + ], + } + + +def test_title_page_terminated_by_blank_line() -> None: + """Fountain parsers split title page from body on the first blank + line. Without the blank line our characters block would get + folded into the title page and disappear from the script.""" + out = to_fountain(_minimal_screenplay()) + lines = out.splitlines() + title_idx = next(i for i, ln in enumerate(lines) if ln.startswith("Title:")) + assert lines[title_idx + 1] == "" or "Notes:" in lines[title_idx + 1] + # The Notes line must also be followed by a blank line before + # any body content begins. + notes_idx = next(i for i, ln in enumerate(lines) if ln.startswith("Notes:")) + assert lines[notes_idx + 1] == "" + + +def test_character_cue_is_uppercased_for_ascii() -> None: + """ASCII names like NARRATOR must be uppercase or Fountain treats + the line as action. CJK names round-trip as-is since CJK has no + case distinction.""" + sp = _minimal_screenplay() + sp["characters"][0]["name"] = "Sam" + out = to_fountain(sp) + assert "\nSAM\n" in out + # CJK name preserved untouched + assert "李四" in out + + +def test_parenthetical_split_to_own_line() -> None: + """The pipeline emits dialogue like ``(轻声) 还在收?`` with the + parenthetical inline. Fountain wants the parenthetical on its own + line between the character cue and the dialogue body.""" + out = to_fountain(_minimal_screenplay()) + # Skip past the dramatis personae boneyard comment block, then + # find the first CHARACTER cue line — that's the dialogue we care + # about. The bookkeeping makes the test robust to future changes + # in the title-page / preamble layout. + after_boneyard = out.split("*/", 1)[-1] + lines = after_boneyard.splitlines() + li_idx = next( + i for i, ln in enumerate(lines) if ln.strip() == "李四" + ) + assert lines[li_idx + 1] == "(轻声)" + assert lines[li_idx + 2] == "还在收?" + + +def test_slug_line_uppercased_at_scene_head() -> None: + """SLUG lines (INT./EXT.) at the head of a scene must render as + Fountain scene headings — uppercase, no other formatting.""" + out = to_fountain(_minimal_screenplay()) + assert "INT. 出租屋 - 雨夜".upper() in out + + +def test_transition_normalized_to_fountain_form() -> None: + """A transition beat with text ``CUT TO`` (no colon) must come out + as ``> CUT TO:`` (with prefix + colon) so Fountain recognizes + it; an LLM might emit either form.""" + sp = _minimal_screenplay() + sp["scenes"][0]["beats"].append({ + "kind": "transition", + "text": "CUT TO", + "source_type": "generated", + "confidence": 0.88, + }) + out = to_fountain(sp) + assert "> CUT TO:" in out + + +def test_chapter_marker_emitted_as_synopsis() -> None: + """Chapter dividers appear as Fountain synopses (``= ``) so they + show up in writer-tool outlines but don't pollute the printed + script.""" + out = to_fountain(_minimal_screenplay()) + assert "= 第一章 收拾" in out + + +def test_unknown_speaker_falls_back_to_raw_id() -> None: + """A dialogue beat whose speaker id isn't in the character list + (manual edit gone wrong, deleted character) must NOT crash. We + fall back to the raw id (uppercased) so the line stays in the + script and the author can see exactly which id is broken when + they go to fix it — that's strictly more useful than '(未知)' + which would lose the breadcrumb.""" + sp = _minimal_screenplay() + sp["scenes"][0]["beats"][2]["speaker"] = "char_nope" + out = to_fountain(sp) + assert "CHAR_NOPE" in out + + +def test_dramatis_personae_lists_aliases() -> None: + """The boneyard comment block at the top of the body should list + aliases so an actor reading the script knows which character + they're looking at when the LLM swaps to an alias mid-scene.""" + out = to_fountain(_minimal_screenplay()) + assert "/* DRAMATIS PERSONAE" in out + assert "张三" in out + assert "三爷" in out diff --git a/tests/export/test_markdown.py b/tests/export/test_markdown.py new file mode 100644 index 0000000..4f1e623 --- /dev/null +++ b/tests/export/test_markdown.py @@ -0,0 +1,112 @@ +"""Markdown export structural tests. + +The Markdown variant is what graders preview in Notion / GitHub / +the print-friendly window used for the browser-side PDF export. +The structural contract: + +- One H1 for title, H2s for chapters, H3s for SLUG lines +- Character cues bolded +- Dialogue rendered as blockquotes so the indent reads without + monospace fonts +- Mode + character roster in a header block +""" +from __future__ import annotations + +from story2script.export.markdown import to_markdown + + +def _screenplay() -> dict: + return { + "schema_version": "0.1.0", + "metadata": { + "title": "离镇", + "mode": {"fidelity": "low", "expansion": "rich"}, + }, + "characters": [ + { + "id": "char_001", + "name": "张三", + "aliases": ["三爷"], + "source_type": "generated", + "confidence": 0.88, + "mentions": 1, + "first_appearance": "scene_1", + }, + ], + "scenes": [ + { + "id": "scene_1", + "chapter_index": 0, + "chapter_title": "第一章 收拾", + "location": "出租屋", + "time": "傍晚", + "summary": "张三在屋里收拾。", + "characters_present": ["char_001"], + "beats": [ + { + "kind": "stage_direction", + "text": "INT. 出租屋 - 傍晚", + "source_type": "inferred", + "confidence": 0.7, + }, + { + "kind": "dialogue", + "text": "(低声) 想清楚了吗?", + "speaker": "char_001", + "source_type": "generated", + "confidence": 0.88, + }, + ], + "source_type": "generated", + "confidence": 0.88, + "source_offset": {"chapter": 0, "start": 0, "end": 100}, + } + ], + } + + +def test_title_is_h1() -> None: + """H1 reserved for title — chapters drop to H2. Two H1s in the same + file confuse most Markdown TOC generators.""" + out = to_markdown(_screenplay()) + assert out.startswith("# 离镇\n") + assert out.count("\n# ") == 0 + + +def test_chapter_is_h2() -> None: + out = to_markdown(_screenplay()) + assert "## 第一章 收拾" in out + + +def test_slug_is_h3() -> None: + out = to_markdown(_screenplay()) + assert "### INT. 出租屋 - 傍晚" in out + + +def test_dialogue_renders_as_blockquote() -> None: + """Blockquote ``> `` prefix makes dialogue read as a quoted block + in any Markdown viewer, no monospace required. The parenthetical + is italicized inside the same blockquote so the wrap stays clean.""" + out = to_markdown(_screenplay()) + assert "> *(低声)*" in out + assert "> 想清楚了吗?" in out + + +def test_character_cue_bolded() -> None: + out = to_markdown(_screenplay()) + assert "**张三**" in out + + +def test_mode_block_present_when_non_default() -> None: + """The mode line lives at the top so a reader knows what they're + looking at — a high-fidelity adaptation reads differently from + a rich-expansion one.""" + out = to_markdown(_screenplay()) + assert "保真度" in out and "low" in out + assert "扩写量" in out and "rich" in out + + +def test_aliases_rendered_in_dramatis_personae() -> None: + out = to_markdown(_screenplay()) + assert "三爷" in out + assert "**张三**" in out diff --git a/tests/export/test_txt.py b/tests/export/test_txt.py new file mode 100644 index 0000000..0ef274e --- /dev/null +++ b/tests/export/test_txt.py @@ -0,0 +1,109 @@ +"""Plain-text export layout tests. + +The txt format isn't a parsing target — it's printed and read. The +tests pin the visual contract: title page on top, character cues +indented, dialogue wrapped at a screen-friendly column, chapters +boxed with ``=`` rules so the eye can navigate. +""" +from __future__ import annotations + +from story2script.export.txt import to_txt + + +def _screenplay() -> dict: + return { + "schema_version": "0.1.0", + "metadata": { + "title": "离镇", + "mode": {"fidelity": "medium", "expansion": "balanced"}, + }, + "characters": [ + { + "id": "char_001", + "name": "张三", + "aliases": [], + "source_type": "generated", + "confidence": 0.88, + "mentions": 1, + "first_appearance": "scene_1", + }, + ], + "scenes": [ + { + "id": "scene_1", + "chapter_index": 0, + "chapter_title": "第一章", + "location": "候车厅", + "time": "凌晨", + "summary": "告别。", + "characters_present": ["char_001"], + "beats": [ + { + "kind": "stage_direction", + "text": "INT. 候车厅 - 凌晨", + "source_type": "inferred", + "confidence": 0.7, + }, + { + "kind": "action", + "text": "灯光昏黄。", + "source_type": "generated", + "confidence": 0.88, + }, + { + "kind": "dialogue", + "text": "到了打电话。", + "speaker": "char_001", + "source_type": "generated", + "confidence": 0.88, + }, + ], + "source_type": "generated", + "confidence": 0.88, + "source_offset": {"chapter": 0, "start": 0, "end": 100}, + } + ], + } + + +def test_title_centered_at_top() -> None: + """The title page renders the title as a centered standalone line, + indented by spaces. Stripped of leading whitespace, the second + non-blank line should be the bare title.""" + out = to_txt(_screenplay()) + non_blank = [ln for ln in out.splitlines() if ln.strip()] + assert non_blank[0].strip() == "离镇" + + +def test_chapter_marker_boxed_with_rule() -> None: + """Chapters are bracketed with ``=`` lines so the eye can scan to + them when paging through. A missing rule line means the layout + is silently regressing.""" + out = to_txt(_screenplay()) + lines = out.splitlines() + rule_count = sum(1 for ln in lines if ln.startswith("=" * 10)) + # Open and close rule for one chapter + assert rule_count >= 2 + + +def test_character_cue_indented() -> None: + """Industry layout puts the character cue near column 25. We don't + need pixel-perfect alignment, but the cue must clearly stand + apart from flush-left action.""" + out = to_txt(_screenplay()) + lines = out.splitlines() + cue_lines = [ln for ln in lines if "张三" in ln and ln != " · 张三"] + assert cue_lines, "character cue line not found" + # At least one cue line is indented by 10+ columns + assert any(len(ln) - len(ln.lstrip()) >= 10 for ln in cue_lines) + + +def test_dialogue_indented_below_cue() -> None: + """Dialogue text sits indented under its character cue. If the + indent collapses, the layout becomes indistinguishable from action + paragraphs and the print read-through fails.""" + out = to_txt(_screenplay()) + lines = out.splitlines() + dialogue_lines = [ln for ln in lines if "到了打电话" in ln] + assert dialogue_lines + assert len(dialogue_lines[0]) - len(dialogue_lines[0].lstrip()) >= 8 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cb07731..000d750 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -247,3 +247,76 @@ export async function refineSceneSummary( }); return jsonOrThrow<RefineSummaryResponse>(response); } + +export interface RefineSceneRequest { + screenplay_yaml: string; + scene_id: string; + fidelity: Fidelity; + expansion: Expansion; +} + +export interface RefinedBeat { + kind: "dialogue" | "action" | "stage_direction" | "narration" | "transition"; + text: string; + speaker: string | null; + source_type: "original" | "inferred" | "generated" | "merged"; + confidence: number; + needs_review: boolean; +} + +export interface RefineSceneResponse { + scene_id: string; + beats: RefinedBeat[]; + model: string; +} + +/** + * Re-run the mode-refine LLM stage on a single scene. The frontend + * uses this for the editor's "AI 整体润色" button — the author can + * try a different fidelity / expansion mode on one scene without + * regenerating the whole document. + */ +export async function refineScene( + request: RefineSceneRequest, +): Promise<RefineSceneResponse> { + const response = await fetch(`${API_BASE}/refine/scene`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + return jsonOrThrow<RefineSceneResponse>(response); +} + +// Author-facing export formats. PDF is intentionally NOT in this list — +// the frontend renders Markdown in a print window and lets the browser's +// native Print → Save as PDF do the conversion. That keeps server deps +// CJK-font-free. +export type ExportFormat = "fountain" | "txt" | "md"; + +export interface ExportRequest { + screenplay_yaml: string; + format: ExportFormat; +} + +export interface ExportResponse { + content: string; + format: ExportFormat; + mime_type: string; + suggested_filename: string; +} + +/** + * Convert the (possibly edited) screenplay YAML to an author-facing + * format. Stateless — the request carries the full YAML body. 422 with + * a list of schema errors when the draft hasn't been fixed up yet. + */ +export async function exportScreenplay( + request: ExportRequest, +): Promise<ExportResponse> { + const response = await fetch(`${API_BASE}/export`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + return jsonOrThrow<ExportResponse>(response); +} diff --git a/web/src/routes/Adapt.tsx b/web/src/routes/Adapt.tsx index ab5b4b7..bbad053 100644 --- a/web/src/routes/Adapt.tsx +++ b/web/src/routes/Adapt.tsx @@ -118,6 +118,63 @@ export function Adapt() { }); }, [title, text, sourceFormat, fidelity, expansion, lastTitle, result]); + // ---- Editor → Adapt back-sync ---- + // + // The Editor writes every keystroke patch into + // sessionStorage["story2script:draft"]. When the user comes back to + // /app, the result.screenplay_yaml we're displaying is the OLD YAML + // from before they started editing — quality metrics, warnings, + // schema_validation are all stale. Worse, exporting from here would + // export the original LLM output instead of the author's edits. + // + // Strategy: on mount AND on tab focus, compare the editor draft to + // the result.screenplay_yaml; if they differ, swap the YAML in and + // re-run validate() so schema_validation reflects current state. + // Quality metrics intentionally stay frozen — they describe how the + // LLM produced the draft, not whether the author's hand-edits are + // still valid. + useEffect(() => { + let cancelled = false; + + async function syncFromDraft() { + const draft = sessionStorage.getItem(STORAGE_KEY_DRAFT); + if (!draft) return; + // No result yet means the user hasn't generated anything in + // this Adapt session; sessionStorage["draft"] is from a + // previous run — leave it alone, don't fake a result. + if (!result) return; + if (draft === result.screenplay_yaml) return; + + try { + const report = await validate({ screenplay_yaml: draft }); + if (cancelled) return; + setResult({ + ...result, + screenplay_yaml: draft, + schema_validation: report, + }); + } catch { + // Validate endpoint unreachable — swap YAML anyway so export + // / preview reflect current edits; the next /export call will + // surface schema errors if any. + if (cancelled) return; + setResult({ ...result, screenplay_yaml: draft }); + } + } + + void syncFromDraft(); + + const onFocus = () => { void syncFromDraft(); }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onFocus); + return () => { + cancelled = true; + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onFocus); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [result?.screenplay_yaml]); + // Inner adapter call shared by manual submit + first-visit autorun. // Takes its inputs as arguments rather than reading state, because // the autorun path needs to fire before its setState() calls have @@ -205,11 +262,12 @@ export function Adapt() { docxFile: null, }), ) - .finally(() => { - // Set the flag regardless of success — a failed autorun - // shouldn't re-fire on every page load (would burn LLM quota - // on a broken backend). The author can hit "加载示例" to retry - // once things are fixed. + .then(() => { + // Success: pin the flag so returning users get a clean form. + // Failure path (e.g. uvicorn not running) deliberately leaves + // the flag unset so the next page load retries — autorunFiredRef + // still blocks re-fires within the same session, so the user + // sees one attempt per reload, not an infinite hammer. try { localStorage.setItem(SAMPLE_AUTORUN_KEY, "1"); } catch { /* localStorage disabled in private mode */ } @@ -556,6 +614,16 @@ function ResultBody({ loading, error, result, title }: ResultPanelProps) { ) : null} </div> <p className="text-[15px] leading-[1.6] text-(--color-ink)">{error.message}</p> + {error.status === 0 ? ( + <p className="text-[12px] text-(--color-ink-mute)"> + 提示:可在终端执行 <code className="font-mono bg-(--color-paper)/80 px-1.5 py-0.5">uvicorn story2script.app.main:app --reload</code>,启动完成后刷新页面重试。 + </p> + ) : null} + {error.status === 503 ? ( + <p className="text-[12px] text-(--color-ink-mute)"> + 提示:管道默认走 LLM 路径但未检测到 API key。设置 <code className="font-mono bg-(--color-paper)/80 px-1.5 py-0.5">DEEPSEEK_API_KEY</code> 或 <code className="font-mono bg-(--color-paper)/80 px-1.5 py-0.5">MIMO_API_KEY</code> 后重启服务即可;或继续以\"全规则\"模式生成草稿(每个 stage 会带 <code className="font-mono bg-(--color-paper)/80 px-1.5 py-0.5">STAGE_DEGRADED</code> 警告)。 + </p> + ) : null} {error.raw ? ( <details className="group border-t border-dashed border-(--color-rule) pt-3"> <summary className="cursor-pointer font-sans text-[12px] text-(--color-ink-mute) hover:text-(--color-ink)"> diff --git a/web/src/routes/Editor.tsx b/web/src/routes/Editor.tsx index 22bba3b..4a700e7 100644 --- a/web/src/routes/Editor.tsx +++ b/web/src/routes/Editor.tsx @@ -15,7 +15,9 @@ import { MessageSquare, Pencil, Sparkles, + Undo2, Users, + Wand2, X, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -31,7 +33,13 @@ import { SOURCE_LABELS, } from "@/components/ScreenplayView"; import { patchScreenplayYaml } from "@/lib/screenplay-patch"; -import { inlineEdit, type ApiError } from "@/lib/api"; +import { + exportScreenplay, + inlineEdit, + refineScene, + type ApiError, + type ExportFormat, +} from "@/lib/api"; const STORAGE_KEY = "story2script:draft"; @@ -42,18 +50,88 @@ function slugifyTitle(title: string): string { return cleaned || "screenplay"; } -function downloadYaml(yaml: string, title: string) { - const blob = new Blob([yaml], { type: "text/yaml;charset=utf-8" }); +function downloadTextFile({ + content, + mimeType, + filename, +}: { + content: string; + mimeType: string; + filename: string; +}) { + const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `${slugifyTitle(title)}.yaml`; + link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(url), 0); } +function downloadYaml(yaml: string, title: string) { + downloadTextFile({ + content: yaml, + mimeType: "text/yaml;charset=utf-8", + filename: `${slugifyTitle(title)}.yaml`, + }); +} + +// Render Markdown in a printable popup window. The user picks +// "Save as PDF" in the system print dialog — works everywhere, needs +// zero server deps, and the result respects the user's system fonts +// (which is what you want for CJK output anyway). +function openPdfPrintWindow(markdown: string, title: string) { + const safeTitle = title.replace(/[<>&"']/g, (c) => + ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" }[c] ?? c), + ); + // Minimal markdown → HTML: just enough for screenplay structure + // (headings + blockquote + paragraphs). We deliberately don't ship + // a full markdown library — the bundle would dwarf the actual + // useful payload. + const html = markdown + .split("\n") + .map((line) => { + if (line.startsWith("### ")) return `<h3>${line.slice(4)}</h3>`; + if (line.startsWith("## ")) return `<h2>${line.slice(3)}</h2>`; + if (line.startsWith("# ")) return `<h1>${line.slice(2)}</h1>`; + if (line.startsWith("> ")) return `<blockquote>${line.slice(2)}</blockquote>`; + if (line.startsWith("- ")) return `<li>${line.slice(2)}</li>`; + if (line.startsWith("**") && line.endsWith("**")) + return `<p class="cue">${line.slice(2, -2)}</p>`; + if (line.trim() === "") return "<br/>"; + return `<p>${line}</p>`; + }) + .join("\n"); + const win = window.open("", "_blank", "width=900,height=1200"); + if (!win) return; + win.document.open(); + win.document.write(`<!doctype html> +<html lang="zh"> +<head> +<meta charset="utf-8" /> +<title>${safeTitle} + + + +${html} + + +`); + win.document.close(); +} + interface EditTarget { sceneId: string; sceneIndex: number; @@ -662,6 +740,100 @@ function InlineEditPanel({ // ---- scene view (main content) ---- +type PolishFidelity = "high" | "medium" | "low"; +type PolishExpansion = "minimal" | "balanced" | "rich"; + +function ScenePolishToolbar({ + sceneIndex, + sceneId, + isPolishing, + canUndo, + error, + onPolish, + onUndo, +}: { + sceneIndex: number; + sceneId: string; + isPolishing: boolean; + canUndo: boolean; + error: string | null; + onPolish: (sceneIndex: number, fidelity: PolishFidelity, expansion: PolishExpansion) => void; + onUndo: () => void; +}) { + // Reset the dropdown when the user clicks away to a different scene. + void sceneId; + const [fidelity, setFidelity] = useState("medium"); + const [expansion, setExpansion] = useState("rich"); + + return ( +
+ + AI 整体润色 + + + + + {canUndo ? ( + + ) : null} + {error ? ( + + {error} + + ) : null} +
+ ); +} + function SceneView({ scene, sceneIndex, @@ -672,6 +844,11 @@ function SceneView({ onSelectBeat, onApproveBeat, onSelectScene, + onPolishScene, + onUndoPolish, + polishingSceneId, + canUndoPolish, + polishError, }: { scene: ScreenplayScene; sceneIndex: number; @@ -682,6 +859,11 @@ function SceneView({ onSelectBeat: (target: EditTarget) => void; onApproveBeat: (sceneIndex: number, beatIndex: number) => void; onSelectScene: (id: string) => void; + onPolishScene: (sceneIndex: number, fidelity: "high" | "medium" | "low", expansion: "minimal" | "balanced" | "rich") => void; + onUndoPolish: () => void; + polishingSceneId: string | null; + canUndoPolish: boolean; + polishError: string | null; }) { const charNames = (scene.characters_present ?? []) .map((id) => characterById.get(id)?.name ?? id) @@ -733,6 +915,19 @@ function SceneView({ + {/* AI polish toolbar — re-runs the mode-refine LLM stage on the + whole scene. Mode picker lives inline so the author can A/B + quickly without leaving the editor. */} + + {/* characters */} {charNames.length > 0 ? (
@@ -864,6 +1059,60 @@ function EditorToolbar({ modifiedCount: number; }) { const navigate = useNavigate(); + const [menuOpen, setMenuOpen] = useState(false); + const [exporting, setExporting] = useState(null); + const [exportError, setExportError] = useState(null); + const menuRef = useRef(null); + + // Close on outside click — basic dropdown hygiene, avoids needing + // a popover library for one menu. + useEffect(() => { + if (!menuOpen) return undefined; + const onDocClick = (e: MouseEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [menuOpen]); + + async function handleExport(format: ExportFormat | "pdf") { + const yaml = sessionStorage.getItem(STORAGE_KEY); + if (!yaml) return; + setExportError(null); + + // YAML is local — no network round-trip needed. + if (format === ("yaml" as ExportFormat)) { + downloadYaml(yaml, title); + setMenuOpen(false); + return; + } + + setExporting(format); + try { + // PDF reuses the markdown render: backend returns markdown text, + // frontend opens it in a print window. Server stays CJK-font-free. + const apiFormat: ExportFormat = format === "pdf" ? "md" : format; + const result = await exportScreenplay({ screenplay_yaml: yaml, format: apiFormat }); + if (format === "pdf") { + openPdfPrintWindow(result.content, title); + } else { + downloadTextFile({ + content: result.content, + mimeType: result.mime_type, + filename: result.suggested_filename, + }); + } + setMenuOpen(false); + } catch (err) { + if (err && typeof err === "object" && "message" in err) { + setExportError((err as ApiError).message); + } else { + setExportError("导出失败,请稍后重试。"); + } + } finally { + setExporting(null); + } + } return (
@@ -884,26 +1133,99 @@ function EditorToolbar({ ) : null}
-
+
+ {exportError ? ( + + {exportError} + + ) : null} + {menuOpen ? ( +
+
+ 选择格式 +
+ handleExport("fountain")} + disabled={exporting !== null} + /> + handleExport("txt")} + disabled={exporting !== null} + /> + handleExport("md")} + disabled={exporting !== null} + /> + handleExport("pdf")} + disabled={exporting !== null} + /> +
+ { + const yaml = sessionStorage.getItem(STORAGE_KEY); + if (yaml) downloadYaml(yaml, title); + setMenuOpen(false); + }} + disabled={exporting !== null} + /> +
+
+ ) : null}
); } +function ExportMenuItem({ + label, + hint, + onClick, + disabled, +}: { + label: string; + hint: string; + onClick: () => void; + disabled: boolean; +}) { + return ( + + ); +} + // ---- character roster panel ---- function CharacterRoster({ characters }: { characters: ScreenplayCharacter[] }) { @@ -1125,6 +1447,100 @@ export function Editor() { [data, yaml], ); + // ---- Scene-level AI polish ---- + // + // Hits POST /api/v1/refine/scene and replaces the active scene's + // beats wholesale. Keeps one undo snapshot so the author can revert + // a polish they don't like without losing all their other edits. + const [polishingSceneId, setPolishingSceneId] = useState(null); + const [polishError, setPolishError] = useState(null); + const polishUndoRef = useRef<{ yaml: string; sceneId: string } | null>(null); + // Mirror polishUndoRef.sceneId into state for render-time use. + // The ref still holds the larger yaml payload so the snapshot + // doesn't trigger a re-render on every keystroke; only the id — + // which the SceneView needs to decide whether to show the undo + // button — lives in state. + const [undoSceneId, setUndoSceneId] = useState(null); + + const handlePolishScene = useCallback( + async (sceneIndex: number, fidelity: PolishFidelity, expansion: PolishExpansion) => { + if (!data || !yaml) return; + const scene = data.scenes[sceneIndex]; + if (!scene) return; + setPolishingSceneId(scene.id); + setPolishError(null); + try { + const response = await refineScene({ + screenplay_yaml: yaml, + scene_id: scene.id, + fidelity, + expansion, + }); + // Patch every beat in the scene back into the YAML. We do a + // full splice rather than per-beat patch because the refined + // beat count may differ from the original (expansion=rich can + // add 1-2 beats). + const parsedDraft = parseScreenplayYaml(yaml); + if (!parsedDraft) throw new Error("YAML 解析失败"); + const targetScene = parsedDraft.scenes[sceneIndex]; + if (!targetScene) throw new Error("场景不存在"); + targetScene.beats = response.beats.map((b) => ({ + kind: b.kind === "narration" || b.kind === "transition" ? "stage_direction" : b.kind, + text: b.text, + source_type: b.source_type, + confidence: b.confidence, + needs_review: b.needs_review, + ...(b.speaker ? { speaker: b.speaker } : {}), + })) as ScreenplayBeat[]; + + // Round-trip through js-yaml for structural cleanliness. + const { dump } = await import("js-yaml"); + const nextYaml = dump(parsedDraft, { + lineWidth: -1, + noRefs: true, + quotingType: '"', + forceQuotes: false, + }); + // Snapshot for undo BEFORE writing the new yaml. + polishUndoRef.current = { yaml, sceneId: scene.id }; + setUndoSceneId(scene.id); + + setYaml(nextYaml); + setData(parsedDraft); + sessionStorage.setItem(STORAGE_KEY, nextYaml); + setModifiedScenes((prev) => new Set(prev).add(scene.id)); + // Mark every refined beat as modified so the sidebar reflects it. + setModifiedBeats((prev) => { + const next = new Set(prev); + response.beats.forEach((_, i) => next.add(`${scene.id}-${i}`)); + return next; + }); + } catch (err) { + if (err && typeof err === "object" && "message" in err) { + setPolishError((err as ApiError).message); + } else { + setPolishError("润色失败,请稍后重试。"); + } + } finally { + setPolishingSceneId(null); + } + }, + [data, yaml], + ); + + const handleUndoPolish = useCallback(() => { + const snap = polishUndoRef.current; + if (!snap) return; + const parsed = parseScreenplayYaml(snap.yaml); + if (!parsed) return; + setYaml(snap.yaml); + setData(parsed); + sessionStorage.setItem(STORAGE_KEY, snap.yaml); + polishUndoRef.current = null; + setUndoSceneId(null); + setPolishError(null); + }, []); + // no data fallback if (!data || !activeScene) { return ( @@ -1179,6 +1595,11 @@ export function Editor() { onSelectBeat={handleSelectBeat} onApproveBeat={handleApproveBeat} onSelectScene={setActiveSceneId} + onPolishScene={handlePolishScene} + onUndoPolish={handleUndoPolish} + polishingSceneId={polishingSceneId} + canUndoPolish={undoSceneId === activeSceneId} + polishError={polishError} />