| 1 | /** |
| 2 | * 阶段数据工具函数 |
| 3 | * - 路径转 URL |
| 4 | * - 剧本/分镜结构化文本解析与重建 |
| 5 | */ |
| 6 | |
| 7 | /** 将后端本地文件路径转换为浏览器可访问的 URL */ |
| 8 | export function assetUrl(path: string): string { |
| 9 | if (!path) return ''; |
| 10 | if (path.startsWith('http') || path.startsWith('/') || path.startsWith('blob:') || path.startsWith('data:')) return path; |
| 11 | return '/' + path; |
| 12 | } |
| 13 | |
| 14 | export function assetVersionLabel(path: string, index: number): string { |
| 15 | const file = (path || '').split('/').pop() || ''; |
| 16 | const uploadMatch = file.match(/_upload_(v\d+)/i); |
| 17 | if (uploadMatch) return `用户上传: ${uploadMatch[1].toLowerCase()}`; |
| 18 | const aiMatch = file.match(/_v(\d+)\.[^.]+$/i); |
| 19 | const version = aiMatch ? `v${aiMatch[1]}` : `v${index + 1}`; |
| 20 | return `AI生成: ${version}`; |
| 21 | } |
| 22 | |
| 23 | /* ─── 剧本 / 分镜 结构化文本解析 ─── */ |
| 24 | |
| 25 | export interface ParsedCharacter { |
| 26 | name: string; |
| 27 | description: string; |
| 28 | } |
| 29 | |
| 30 | export interface ParsedSetting { |
| 31 | name: string; |
| 32 | description: string; |
| 33 | } |
| 34 | |
| 35 | export interface ParsedScene { |
| 36 | id: string; |
| 37 | characters: string[]; |
| 38 | settings: string[]; |
| 39 | description: string; |
| 40 | raw: string; |
| 41 | } |
| 42 | |
| 43 | export interface ParsedScript { |
| 44 | characters: ParsedCharacter[]; |
| 45 | settings: ParsedSetting[]; |
| 46 | scenes: ParsedScene[]; |
| 47 | isZh: boolean; |
| 48 | } |
| 49 | |
| 50 | /** 解析结构化剧本/分镜文本 → 角色 + 场景 + 故事线 */ |
| 51 | export function parseScriptText(text: string): ParsedScript { |
| 52 | if (!text) return { characters: [], settings: [], scenes: [], isZh: false }; |
| 53 | |
| 54 | const normalized = text.replace(/\\n/g, '\n'); |
| 55 | const isZh = /角色[::]|场景设置[::]|视频片段/.test(normalized); |
| 56 | |
| 57 | const charHeader = isZh ? /角色[::]/ : /Characters[::]/; |
| 58 | const settingHeader = isZh ? /场景设置[::]/ : /Settings[::]/; |
| 59 | const sceneHeader = isZh ? /视频片段[::]/ : /Scenes[::]/; |
| 60 | |
| 61 | function findMatch(src: string, pat: RegExp) { |
| 62 | const m = src.match(pat); |
| 63 | return m ? { index: src.indexOf(m[0]), length: m[0].length } : null; |
| 64 | } |
| 65 | |
| 66 | function extractBlock(src: string, startPat: RegExp, endPat: RegExp | null): string { |
| 67 | const sm = findMatch(src, startPat); |
| 68 | if (!sm) return ''; |
| 69 | const sp = sm.index + sm.length; |
| 70 | if (endPat) { |
| 71 | const em = findMatch(src.slice(sp), endPat); |
| 72 | return em ? src.slice(sp, sp + em.index) : src.slice(sp); |
| 73 | } |
| 74 | return src.slice(sp); |
| 75 | } |
| 76 | |
| 77 | const charBlock = extractBlock(normalized, charHeader, settingHeader).trim(); |
| 78 | const settingBlock = extractBlock(normalized, settingHeader, sceneHeader).trim(); |
| 79 | const sceneBlock = extractBlock(normalized, sceneHeader, null).trim(); |
| 80 | |
| 81 | const characters: ParsedCharacter[] = []; |
| 82 | for (const line of charBlock.split('\n')) { |
| 83 | const t = line.trim(); |
| 84 | if (!t) continue; |
| 85 | const ci = t.search(/[::]/); |
| 86 | if (ci > 0) { |
| 87 | characters.push({ |
| 88 | name: t.slice(0, ci).trim(), |
| 89 | description: t.slice(ci + 1).trim().replace(/\.\s*$/, ''), |
| 90 | }); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | const settings: ParsedSetting[] = []; |
| 95 | for (const line of settingBlock.split('\n')) { |
| 96 | const t = line.trim(); |
| 97 | if (!t) continue; |
| 98 | const ci = t.search(/[::]/); |
| 99 | if (ci > 0) { |
| 100 | settings.push({ |
| 101 | name: t.slice(0, ci).trim(), |
| 102 | description: t.slice(ci + 1).trim().replace(/\.\s*$/, ''), |
| 103 | }); |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | const scenes: ParsedScene[] = []; |
| 108 | const scenePat = isZh |
| 109 | ? /^视频片段\s*(\d+)\s*[::]\s*(.*)/ |
| 110 | : /^Scene\s+(\d+)\s*[::]\s*(.*)/i; |
| 111 | |
| 112 | for (const line of sceneBlock.split('\n')) { |
| 113 | const m = line.trim().match(scenePat); |
| 114 | if (m) { |
| 115 | const raw = m[2]; |
| 116 | const cm = raw.match(/\[(?:Characters|角色)\s*[::]\s*([^\]]+)\]/i); |
| 117 | const sm2 = raw.match(/\[(?:Settings|场景(?:设置)?)\s*[::]\s*([^\]]+)\]/i); |
| 118 | const chars = cm ? cm[1].split(/[,,]/).map(s => s.trim()).filter(Boolean) : []; |
| 119 | const sets = sm2 ? sm2[1].split(/[,,]/).map(s => s.trim()).filter(Boolean) : []; |
| 120 | const desc = raw.replace(/\[.*?\]/g, '').trim(); |
| 121 | scenes.push({ id: m[1], characters: chars, settings: sets, description: desc, raw }); |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | return { characters, settings, scenes, isZh }; |
| 126 | } |
| 127 | |
| 128 | /** 将解析后的结构重建为标准文本格式 */ |
| 129 | export function reconstructScriptText(parsed: ParsedScript): string { |
| 130 | const { isZh, characters, settings, scenes } = parsed; |
| 131 | const charH = isZh ? '角色:' : 'Characters:'; |
| 132 | const settH = isZh ? '场景设置:' : 'Settings:'; |
| 133 | const sceneH = isZh ? '视频片段:' : 'Scenes:'; |
| 134 | const scenePrefix = isZh ? '视频片段' : 'Scene'; |
| 135 | const charLabel = isZh ? '角色' : 'Characters'; |
| 136 | const settLabel = isZh ? '场景' : 'Settings'; |
| 137 | |
| 138 | const lines: string[] = []; |
| 139 | lines.push(charH); |
| 140 | for (const c of characters) { |
| 141 | lines.push(`${c.name}: ${c.description}`); |
| 142 | } |
| 143 | lines.push(settH); |
| 144 | for (const s of settings) { |
| 145 | lines.push(`${s.name}: ${s.description}`); |
| 146 | } |
| 147 | lines.push(sceneH); |
| 148 | for (const sc of scenes) { |
| 149 | const cp = sc.characters.length ? `[${charLabel}: ${sc.characters.join(', ')}]` : ''; |
| 150 | const sp = sc.settings.length ? `[${settLabel}: ${sc.settings.join(', ')}]` : ''; |
| 151 | const parts = [`${scenePrefix} ${sc.id}:`, cp, sp, sc.description].filter(Boolean); |
| 152 | lines.push(parts.join(' ')); |
| 153 | } |
| 154 | return lines.join('\n'); |
| 155 | } |
| 156 |