feat: 完成短剧项目空间与结构化资产创作闭环 (#38)

* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-27 10:45:11 +08:00
committed by GitHub
parent 431b9e3294
commit d2fbc413b3
208 changed files with 22655 additions and 4234 deletions

View File

@@ -1,6 +1,3 @@
import type { JSONContent } from "@tiptap/core";
import type { CanvasDocumentChapter, CanvasRichDocument } from "@/types/canvas";
const maxChapterHeadingLength = 120;
const chineseChapterNumber = "[零〇○一二两三四五六七八九十百千万亿壹贰叁肆伍陆柒捌玖拾佰仟\\d]+";
const englishChapterNumber = "(?:\\d+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)";
@@ -14,44 +11,7 @@ const chapterHeadingPatterns = [
new RegExp(`^(?:prologue|preface|introduction|epilogue|afterword|appendix)${chapterTitleSuffix}$`, "i"),
];
export function emptyDocument(): JSONContent {
return { type: "doc", content: [{ type: "paragraph" }] };
}
export function textToDocument(text: string): JSONContent {
const content = text.split(/\n{2,}/).map((paragraph) => {
const value = paragraph.trim();
if (!value) return { type: "paragraph" };
const markdownHeading = /^(#{1,3})\s+(.+)$/.exec(value);
if (markdownHeading) return { type: "heading", attrs: { level: markdownHeading[1].length }, content: [{ type: "text", text: markdownHeading[2] }] };
return { type: "paragraph", content: [{ type: "text", text: value }] };
});
return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] };
}
export function createDocumentChapter(title: string, plainText = "", order = 0): CanvasDocumentChapter {
return {
id: `chapter-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title,
order,
json: textToDocument(plainText) as unknown as Record<string, unknown>,
plainText: plainText.trim(),
characterCount: Array.from(plainText.trim()).length,
storyboardStatus: "idle",
updatedAt: new Date().toISOString(),
};
}
export function normalizeDocumentChapters(document: CanvasRichDocument | undefined, fallbackText = "") {
if (document?.chapters?.length) {
return document.chapters
.map((chapter, index) => ({ ...chapter, title: chapter.title.trim() || `第 ${index + 1} 章`, order: index }))
.sort((left, right) => left.order - right.order);
}
const chapter = createDocumentChapter("第 1 章", document?.plainText || fallbackText, 0);
if (document?.json) chapter.json = document.json;
return [chapter];
}
export type ImportedNovelChapter = { title: string; plainText: string };
function chapterTitleFromLine(line: string) {
const trimmed = line.trim();
@@ -87,7 +47,7 @@ export function decodeNovelText(buffer: ArrayBuffer) {
export function splitTextIntoChapters(text: string) {
const normalized = text.replace(/\r\n?/g, "\n").trim();
if (!normalized) return [createDocumentChapter("第 1 章")];
if (!normalized) return [{ title: "第 1 章", plainText: "" }];
const lines = normalized.split("\n");
const sections: Array<{ title: string; lines: string[] }> = [];
let current: { title: string; lines: string[] } | null = null;
@@ -104,29 +64,7 @@ export function splitTextIntoChapters(text: string) {
else preface.push(line);
}
if (current) sections.push(current);
if (!sections.length) return [createDocumentChapter("第 1 章", normalized, 0)];
if (!sections.length) return [{ title: "第 1 章", plainText: normalized }];
if (preface.some((line) => line.trim())) sections.unshift({ title: "序章", lines: preface });
return sections.map((section, index) => createDocumentChapter(section.title, section.lines.join("\n").trim(), index));
}
export function buildRichDocument(
previous: CanvasRichDocument | undefined,
chapters: CanvasDocumentChapter[],
activeChapterId: string,
sourceFileName?: string,
): CanvasRichDocument {
const ordered = chapters.map((chapter, index) => ({ ...chapter, order: index }));
const active = ordered.find((chapter) => chapter.id === activeChapterId) || ordered[0];
const plainText = ordered.map((chapter) => `${chapter.title}\n\n${chapter.plainText}`.trim()).filter(Boolean).join("\n\n");
return {
kind: previous?.kind || "novel",
format: "tiptap-json",
json: active?.json || (emptyDocument() as unknown as Record<string, unknown>),
plainText,
characterCount: ordered.reduce((total, chapter) => total + chapter.characterCount, 0),
chapters: ordered,
activeChapterId: active?.id,
sourceFileName,
updatedAt: new Date().toISOString(),
};
return sections.map((section): ImportedNovelChapter => ({ title: section.title, plainText: section.lines.join("\n").trim() }));
}