autocrew 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/HAMLETDEER.md +562 -0
  2. package/LICENSE +21 -0
  3. package/README.md +190 -0
  4. package/README_CN.md +190 -0
  5. package/adapters/openclaw/index.ts +68 -0
  6. package/bin/autocrew.mjs +23 -0
  7. package/bin/autocrew.ts +13 -0
  8. package/openclaw.plugin.json +36 -0
  9. package/package.json +74 -0
  10. package/skills/_writing-style/SKILL.md +68 -0
  11. package/skills/audience-profiler/SKILL.md +241 -0
  12. package/skills/content-attribution/SKILL.md +128 -0
  13. package/skills/content-review/SKILL.md +257 -0
  14. package/skills/cover-generator/SKILL.md +93 -0
  15. package/skills/humanizer-zh/SKILL.md +75 -0
  16. package/skills/intel-digest/SKILL.md +57 -0
  17. package/skills/intel-pull/SKILL.md +74 -0
  18. package/skills/manage-pipeline/SKILL.md +63 -0
  19. package/skills/memory-distill/SKILL.md +89 -0
  20. package/skills/onboarding/SKILL.md +117 -0
  21. package/skills/pipeline-status/SKILL.md +51 -0
  22. package/skills/platform-rewrite/SKILL.md +125 -0
  23. package/skills/pre-publish/SKILL.md +142 -0
  24. package/skills/publish-content/SKILL.md +500 -0
  25. package/skills/remix-content/SKILL.md +77 -0
  26. package/skills/research/SKILL.md +127 -0
  27. package/skills/setup/SKILL.md +353 -0
  28. package/skills/spawn-batch-writer/SKILL.md +66 -0
  29. package/skills/spawn-planner/SKILL.md +72 -0
  30. package/skills/spawn-writer/SKILL.md +60 -0
  31. package/skills/teardown/SKILL.md +144 -0
  32. package/skills/title-craft/SKILL.md +234 -0
  33. package/skills/topic-ideas/SKILL.md +105 -0
  34. package/skills/video-timeline/SKILL.md +117 -0
  35. package/skills/write-script/SKILL.md +232 -0
  36. package/skills/xhs-cover-review/SKILL.md +48 -0
  37. package/src/adapters/browser/browser-cdp.ts +260 -0
  38. package/src/adapters/browser/browser-relay.ts +236 -0
  39. package/src/adapters/browser/gateway-client.ts +148 -0
  40. package/src/adapters/browser/types.ts +36 -0
  41. package/src/adapters/image/gemini.ts +219 -0
  42. package/src/adapters/research/tikhub.ts +19 -0
  43. package/src/cli/banner.ts +18 -0
  44. package/src/cli/bootstrap.ts +33 -0
  45. package/src/cli/commands/adapt.ts +28 -0
  46. package/src/cli/commands/advance.ts +28 -0
  47. package/src/cli/commands/assets.ts +24 -0
  48. package/src/cli/commands/audit.ts +18 -0
  49. package/src/cli/commands/contents.ts +18 -0
  50. package/src/cli/commands/cover.ts +58 -0
  51. package/src/cli/commands/events.ts +17 -0
  52. package/src/cli/commands/humanize.ts +27 -0
  53. package/src/cli/commands/index.ts +80 -0
  54. package/src/cli/commands/init.ts +28 -0
  55. package/src/cli/commands/intel.ts +55 -0
  56. package/src/cli/commands/learn.ts +34 -0
  57. package/src/cli/commands/memory.ts +18 -0
  58. package/src/cli/commands/migrate.ts +24 -0
  59. package/src/cli/commands/open.ts +21 -0
  60. package/src/cli/commands/pipelines.ts +18 -0
  61. package/src/cli/commands/pre-publish.ts +27 -0
  62. package/src/cli/commands/profile.ts +31 -0
  63. package/src/cli/commands/research.ts +36 -0
  64. package/src/cli/commands/restore.ts +28 -0
  65. package/src/cli/commands/review.ts +61 -0
  66. package/src/cli/commands/start.ts +28 -0
  67. package/src/cli/commands/status.ts +14 -0
  68. package/src/cli/commands/templates.ts +15 -0
  69. package/src/cli/commands/topics.ts +18 -0
  70. package/src/cli/commands/trash.ts +28 -0
  71. package/src/cli/commands/upgrade.ts +48 -0
  72. package/src/cli/commands/versions.ts +24 -0
  73. package/src/cli/index.ts +40 -0
  74. package/src/data/sensitive-words-builtin.json +114 -0
  75. package/src/data/source-presets.yaml +54 -0
  76. package/src/e2e.test.ts +596 -0
  77. package/src/modules/auth/cookie-manager.ts +113 -0
  78. package/src/modules/cards/template-engine.ts +74 -0
  79. package/src/modules/cards/templates/comparison-table.ts +71 -0
  80. package/src/modules/cards/templates/data-chart.ts +76 -0
  81. package/src/modules/cards/templates/flow-chart.ts +49 -0
  82. package/src/modules/cards/templates/key-points.ts +59 -0
  83. package/src/modules/cover/prompt-builder.test.ts +157 -0
  84. package/src/modules/cover/prompt-builder.ts +212 -0
  85. package/src/modules/cover/ratio-adapter.test.ts +122 -0
  86. package/src/modules/cover/ratio-adapter.ts +104 -0
  87. package/src/modules/filter/sensitive-words.test.ts +72 -0
  88. package/src/modules/filter/sensitive-words.ts +212 -0
  89. package/src/modules/humanizer/zh.test.ts +75 -0
  90. package/src/modules/humanizer/zh.ts +175 -0
  91. package/src/modules/intel/collector.ts +19 -0
  92. package/src/modules/intel/collectors/competitor.test.ts +71 -0
  93. package/src/modules/intel/collectors/competitor.ts +65 -0
  94. package/src/modules/intel/collectors/rss.test.ts +56 -0
  95. package/src/modules/intel/collectors/rss.ts +70 -0
  96. package/src/modules/intel/collectors/trends.test.ts +80 -0
  97. package/src/modules/intel/collectors/trends.ts +107 -0
  98. package/src/modules/intel/collectors/web-search.test.ts +85 -0
  99. package/src/modules/intel/collectors/web-search.ts +81 -0
  100. package/src/modules/intel/integration.test.ts +203 -0
  101. package/src/modules/intel/intel-engine.test.ts +103 -0
  102. package/src/modules/intel/intel-engine.ts +96 -0
  103. package/src/modules/intel/source-config.test.ts +113 -0
  104. package/src/modules/intel/source-config.ts +131 -0
  105. package/src/modules/learnings/diff-tracker.test.ts +144 -0
  106. package/src/modules/learnings/diff-tracker.ts +189 -0
  107. package/src/modules/learnings/rule-distiller.ts +141 -0
  108. package/src/modules/memory/distill.ts +208 -0
  109. package/src/modules/migrate/legacy-migrate.test.ts +169 -0
  110. package/src/modules/migrate/legacy-migrate.ts +229 -0
  111. package/src/modules/pro/api-client.ts +192 -0
  112. package/src/modules/pro/gate.test.ts +110 -0
  113. package/src/modules/pro/gate.ts +104 -0
  114. package/src/modules/profile/creator-profile.test.ts +178 -0
  115. package/src/modules/profile/creator-profile.ts +248 -0
  116. package/src/modules/publish/douyin-api.ts +34 -0
  117. package/src/modules/publish/wechat-mp.ts +320 -0
  118. package/src/modules/publish/xiaohongshu-api.ts +127 -0
  119. package/src/modules/research/free-engine.ts +360 -0
  120. package/src/modules/timeline/markup-generator.ts +63 -0
  121. package/src/modules/timeline/parser.ts +275 -0
  122. package/src/modules/workflow/templates.ts +124 -0
  123. package/src/modules/writing/platform-rewrite.ts +190 -0
  124. package/src/modules/writing/title-hashtag.ts +385 -0
  125. package/src/runtime/context.test.ts +97 -0
  126. package/src/runtime/context.ts +129 -0
  127. package/src/runtime/events.test.ts +83 -0
  128. package/src/runtime/events.ts +104 -0
  129. package/src/runtime/hooks.ts +174 -0
  130. package/src/runtime/tool-runner.test.ts +204 -0
  131. package/src/runtime/tool-runner.ts +282 -0
  132. package/src/runtime/workflow-engine.test.ts +455 -0
  133. package/src/runtime/workflow-engine.ts +391 -0
  134. package/src/server/index.ts +409 -0
  135. package/src/server/start.ts +39 -0
  136. package/src/storage/local-store.test.ts +304 -0
  137. package/src/storage/local-store.ts +704 -0
  138. package/src/storage/pipeline-store.test.ts +363 -0
  139. package/src/storage/pipeline-store.ts +698 -0
  140. package/src/tools/asset.ts +96 -0
  141. package/src/tools/content-save.ts +276 -0
  142. package/src/tools/cover-review.ts +221 -0
  143. package/src/tools/humanize.ts +54 -0
  144. package/src/tools/init.ts +133 -0
  145. package/src/tools/intel.ts +92 -0
  146. package/src/tools/memory.ts +76 -0
  147. package/src/tools/pipeline-ops.ts +109 -0
  148. package/src/tools/pipeline.ts +168 -0
  149. package/src/tools/pre-publish.ts +232 -0
  150. package/src/tools/publish.ts +183 -0
  151. package/src/tools/registry.ts +198 -0
  152. package/src/tools/research.ts +304 -0
  153. package/src/tools/review.ts +305 -0
  154. package/src/tools/rewrite.ts +165 -0
  155. package/src/tools/status.ts +30 -0
  156. package/src/tools/timeline.ts +234 -0
  157. package/src/tools/topic-create.ts +50 -0
  158. package/src/types/providers.ts +69 -0
  159. package/src/types/timeline.test.ts +147 -0
  160. package/src/types/timeline.ts +83 -0
  161. package/src/utils/retry.test.ts +97 -0
  162. package/src/utils/retry.ts +85 -0
  163. package/templates/AGENTS.md +99 -0
  164. package/templates/SOUL.md +31 -0
  165. package/templates/TOOLS.md +76 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Free Research Engine — topic discovery using only public web search
3
+ *
4
+ * No crawlers, no third-party APIs. Pure web_search + style calibration + viral scoring.
5
+ *
6
+ * PRD §5: "Free 版选题(纯公开搜索)"
7
+ */
8
+ import { loadProfile, type CreatorProfile } from "../profile/creator-profile.js";
9
+
10
+ // --- Types ---
11
+
12
+ export interface TopicCandidate {
13
+ title: string;
14
+ description: string;
15
+ tags: string[];
16
+ source: string;
17
+ /** 0-100 viral potential score */
18
+ viralScore: number;
19
+ /** Score breakdown */
20
+ scoreBreakdown: {
21
+ titleAppeal: number;
22
+ topicHeat: number;
23
+ profileFit: number;
24
+ };
25
+ /** Why this topic was suggested */
26
+ reasoning: string;
27
+ }
28
+
29
+ export interface FreeResearchResult {
30
+ ok: boolean;
31
+ keyword: string;
32
+ industry: string;
33
+ candidates: TopicCandidate[];
34
+ /** Search queries that were used */
35
+ searchQueries: string[];
36
+ /** Style filters applied */
37
+ filtersApplied: string[];
38
+ summary: string;
39
+ }
40
+
41
+ export interface SearchResult {
42
+ title: string;
43
+ snippet: string;
44
+ url: string;
45
+ }
46
+
47
+ /**
48
+ * Build search queries for topic discovery.
49
+ * Returns 3-5 queries tailored to the user's industry and keyword.
50
+ */
51
+ export function buildSearchQueries(
52
+ keyword: string,
53
+ industry: string,
54
+ platforms: string[],
55
+ ): string[] {
56
+ const queries: string[] = [];
57
+ const currentMonth = new Date().toLocaleDateString("zh-CN", { year: "numeric", month: "long" });
58
+
59
+ // Core queries
60
+ queries.push(`${industry} ${keyword} 内容选题`);
61
+ queries.push(`${industry} 热门话题 ${currentMonth}`);
62
+ queries.push(`${keyword} 爆款内容 分析`);
63
+
64
+ // Platform-specific
65
+ const platformNames: Record<string, string> = {
66
+ xhs: "小红书",
67
+ xiaohongshu: "小红书",
68
+ douyin: "抖音",
69
+ wechat_mp: "公众号",
70
+ bilibili: "B站",
71
+ };
72
+ const primaryPlatform = platforms[0];
73
+ if (primaryPlatform && platformNames[primaryPlatform]) {
74
+ queries.push(`${keyword} ${platformNames[primaryPlatform]} 爆款`);
75
+ }
76
+
77
+ // Trend query
78
+ queries.push(`${keyword} 最新趋势 ${new Date().getFullYear()}`);
79
+
80
+ return queries;
81
+ }
82
+
83
+ /**
84
+ * Score a topic candidate for viral potential.
85
+ *
86
+ * Dimensions (each 0-33, total normalized to 0-100):
87
+ * - Title appeal: length, specificity, emotional triggers, numbers
88
+ * - Topic heat: keyword relevance, timeliness signals
89
+ * - Profile fit: alignment with user's industry, audience, writing rules
90
+ */
91
+ export function scoreCandidate(
92
+ candidate: { title: string; description: string; tags: string[] },
93
+ profile: CreatorProfile | null,
94
+ keyword: string,
95
+ ): { viralScore: number; breakdown: TopicCandidate["scoreBreakdown"]; reasoning: string } {
96
+ let titleAppeal = 15;
97
+ let topicHeat = 15;
98
+ let profileFit = 15;
99
+ const reasons: string[] = [];
100
+
101
+ // --- Title Appeal ---
102
+ const titleLen = candidate.title.length;
103
+ // Optimal title length: 10-20 chars
104
+ if (titleLen >= 10 && titleLen <= 20) {
105
+ titleAppeal += 5;
106
+ } else if (titleLen > 25) {
107
+ titleAppeal -= 3;
108
+ }
109
+ // Numbers in title
110
+ if (/\d/.test(candidate.title)) {
111
+ titleAppeal += 5;
112
+ reasons.push("标题含数字,吸引力+");
113
+ }
114
+ // Emotional triggers
115
+ if (/别再|千万|后悔|真相|没想到|居然|竟然|绝了|必看|干货|避坑/.test(candidate.title)) {
116
+ titleAppeal += 5;
117
+ reasons.push("标题有情绪触发词");
118
+ }
119
+ // Specificity (not generic)
120
+ if (/推荐|分享|介绍|总结/.test(candidate.title) && !/\d/.test(candidate.title)) {
121
+ titleAppeal -= 5;
122
+ reasons.push("标题偏泛,建议加具体角度");
123
+ }
124
+ // Question format
125
+ if (/[??]/.test(candidate.title)) {
126
+ titleAppeal += 3;
127
+ }
128
+ titleAppeal = clamp(titleAppeal, 0, 33);
129
+
130
+ // --- Topic Heat ---
131
+ // Keyword match
132
+ if (candidate.title.includes(keyword) || candidate.description.includes(keyword)) {
133
+ topicHeat += 5;
134
+ }
135
+ // Timeliness signals
136
+ const year = String(new Date().getFullYear());
137
+ if (candidate.description.includes(year) || candidate.description.includes("最新")) {
138
+ topicHeat += 5;
139
+ reasons.push("话题有时效性");
140
+ }
141
+ // Tags relevance
142
+ if (candidate.tags.length >= 3) {
143
+ topicHeat += 3;
144
+ }
145
+ // Description quality (has data/evidence)
146
+ if (/\d+[%%万亿]/.test(candidate.description)) {
147
+ topicHeat += 5;
148
+ reasons.push("描述引用了数据");
149
+ }
150
+ topicHeat = clamp(topicHeat, 0, 33);
151
+
152
+ // --- Profile Fit ---
153
+ if (profile) {
154
+ // Industry match
155
+ if (profile.industry && (candidate.title.includes(profile.industry) || candidate.tags.some((t) => t.includes(profile.industry)))) {
156
+ profileFit += 5;
157
+ reasons.push("与用户行业匹配");
158
+ }
159
+ // Audience match
160
+ if (profile.audiencePersona) {
161
+ const painPoints = profile.audiencePersona.painPoints || [];
162
+ for (const pain of painPoints) {
163
+ if (candidate.title.includes(pain) || candidate.description.includes(pain)) {
164
+ profileFit += 5;
165
+ reasons.push(`命中受众痛点: ${pain}`);
166
+ break;
167
+ }
168
+ }
169
+ }
170
+ // Style boundary check (never list)
171
+ const neverTopics = profile.styleBoundaries?.never || [];
172
+ for (const never of neverTopics) {
173
+ if (candidate.title.includes(never) || candidate.description.includes(never)) {
174
+ profileFit -= 10;
175
+ reasons.push(`触碰风格禁区: ${never}`);
176
+ break;
177
+ }
178
+ }
179
+ }
180
+ profileFit = clamp(profileFit, 0, 34);
181
+
182
+ const viralScore = clamp(titleAppeal + topicHeat + profileFit, 0, 100);
183
+ const reasoning = reasons.length > 0 ? reasons.join(";") : "综合评估";
184
+
185
+ return {
186
+ viralScore,
187
+ breakdown: { titleAppeal, topicHeat, profileFit },
188
+ reasoning,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Filter candidates against the user's style profile.
194
+ * Removes topics that conflict with writing rules or style boundaries.
195
+ */
196
+ export function filterByStyle(
197
+ candidates: TopicCandidate[],
198
+ profile: CreatorProfile | null,
199
+ ): { filtered: TopicCandidate[]; filtersApplied: string[] } {
200
+ if (!profile) return { filtered: candidates, filtersApplied: [] };
201
+
202
+ const filtersApplied: string[] = [];
203
+ let filtered = [...candidates];
204
+
205
+ // Filter by style boundaries (never list)
206
+ const neverTopics = profile.styleBoundaries?.never || [];
207
+ if (neverTopics.length > 0) {
208
+ const before = filtered.length;
209
+ filtered = filtered.filter((c) => {
210
+ return !neverTopics.some(
211
+ (n) => c.title.includes(n) || c.description.includes(n),
212
+ );
213
+ });
214
+ if (filtered.length < before) {
215
+ filtersApplied.push(`排除了 ${before - filtered.length} 个触碰风格禁区的选题`);
216
+ }
217
+ }
218
+
219
+ // Filter low profile-fit scores
220
+ const lowFitBefore = filtered.length;
221
+ filtered = filtered.filter((c) => c.scoreBreakdown.profileFit >= 5);
222
+ if (filtered.length < lowFitBefore) {
223
+ filtersApplied.push(`排除了 ${lowFitBefore - filtered.length} 个与用户定位不匹配的选题`);
224
+ }
225
+
226
+ return { filtered, filtersApplied };
227
+ }
228
+
229
+ /**
230
+ * Process raw search results into scored topic candidates.
231
+ *
232
+ * This is the core function that the research skill/tool calls after
233
+ * performing web searches. It takes raw search results and produces
234
+ * scored, filtered topic candidates.
235
+ */
236
+ export async function processSearchResults(
237
+ searchResults: SearchResult[],
238
+ keyword: string,
239
+ profile: CreatorProfile | null,
240
+ topicCount: number = 5,
241
+ ): Promise<{ candidates: TopicCandidate[]; filtersApplied: string[] }> {
242
+ // Deduplicate by title similarity
243
+ const seen = new Set<string>();
244
+ const unique = searchResults.filter((r) => {
245
+ const key = r.title.slice(0, 15);
246
+ if (seen.has(key)) return false;
247
+ seen.add(key);
248
+ return true;
249
+ });
250
+
251
+ // Convert to candidates and score
252
+ let candidates: TopicCandidate[] = unique.map((r) => {
253
+ // Extract a concise title (≤20 chars)
254
+ let title = r.title.replace(/[-_|—–].*$/, "").trim();
255
+ if (title.length > 20) title = title.slice(0, 20);
256
+
257
+ // Extract tags from snippet
258
+ const tags = extractTags(r.snippet, keyword);
259
+
260
+ const { viralScore, breakdown, reasoning } = scoreCandidate(
261
+ { title, description: r.snippet, tags },
262
+ profile,
263
+ keyword,
264
+ );
265
+
266
+ return {
267
+ title,
268
+ description: r.snippet.slice(0, 200),
269
+ tags,
270
+ source: `web_search: ${r.url}`,
271
+ viralScore,
272
+ scoreBreakdown: breakdown,
273
+ reasoning,
274
+ };
275
+ });
276
+
277
+ // Style filter
278
+ const { filtered, filtersApplied } = filterByStyle(candidates, profile);
279
+ candidates = filtered;
280
+
281
+ // Sort by viral score descending
282
+ candidates.sort((a, b) => b.viralScore - a.viralScore);
283
+
284
+ // Return top N
285
+ return {
286
+ candidates: candidates.slice(0, topicCount),
287
+ filtersApplied,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Main entry: run the free research engine.
293
+ *
294
+ * Note: This function does NOT perform web searches itself — it expects
295
+ * the caller (skill or tool) to provide search results. This keeps the
296
+ * module pure and testable.
297
+ */
298
+ export async function runFreeResearch(opts: {
299
+ keyword: string;
300
+ searchResults: SearchResult[];
301
+ topicCount?: number;
302
+ dataDir?: string;
303
+ }): Promise<FreeResearchResult> {
304
+ const { keyword, searchResults, topicCount = 5, dataDir } = opts;
305
+ const profile = await loadProfile(dataDir);
306
+ const industry = profile?.industry || "通用";
307
+
308
+ const queries = buildSearchQueries(keyword, industry, profile?.platforms || []);
309
+ const { candidates, filtersApplied } = await processSearchResults(
310
+ searchResults,
311
+ keyword,
312
+ profile,
313
+ topicCount,
314
+ );
315
+
316
+ const summary =
317
+ candidates.length > 0
318
+ ? `找到 ${candidates.length} 个选题候选(最高分 ${candidates[0].viralScore})`
319
+ : "未找到合适的选题候选,建议换个关键词或方向";
320
+
321
+ return {
322
+ ok: true,
323
+ keyword,
324
+ industry,
325
+ candidates,
326
+ searchQueries: queries,
327
+ filtersApplied,
328
+ summary,
329
+ };
330
+ }
331
+
332
+ // --- Helpers ---
333
+
334
+ function clamp(v: number, min: number, max: number): number {
335
+ return Math.max(min, Math.min(max, v));
336
+ }
337
+
338
+ function extractTags(text: string, keyword: string): string[] {
339
+ const tags = new Set<string>();
340
+ tags.add(keyword);
341
+
342
+ // Extract hashtag-like patterns
343
+ const hashMatches = text.match(/#[\u4e00-\u9fffA-Za-z0-9]+/g);
344
+ if (hashMatches) {
345
+ for (const m of hashMatches.slice(0, 4)) {
346
+ tags.add(m.replace("#", ""));
347
+ }
348
+ }
349
+
350
+ // Extract quoted terms
351
+ const quoteMatches = text.match(/[「」""]/g) ? text.match(/[「「]([^」」]+)[」」]/g) : null;
352
+ if (quoteMatches) {
353
+ for (const m of quoteMatches.slice(0, 2)) {
354
+ const clean = m.replace(/[「」""]/g, "");
355
+ if (clean.length <= 8) tags.add(clean);
356
+ }
357
+ }
358
+
359
+ return Array.from(tags).slice(0, 5);
360
+ }
@@ -0,0 +1,63 @@
1
+ import type { VideoPreset } from "../../types/timeline.js";
2
+
3
+ const CARD_TEMPLATES = `
4
+ Available card templates:
5
+ - comparison-table: attrs: title, rows (format: name:pros:cons,name:pros:cons)
6
+ - key-points: attrs: items (comma-separated)
7
+ - flow-chart: attrs: steps (comma-separated)
8
+ - data-chart: attrs: title, items (format: label:value,label:value)
9
+ `.trim();
10
+
11
+ const MARKUP_SYNTAX = `
12
+ Markup syntax:
13
+ - [card:TEMPLATE_NAME attr1="value1" attr2="value2"] — insert a visual card
14
+ - [broll:PROMPT_DESCRIPTION] — insert a B-roll visual segment
15
+ Optional: add span=N to link the broll to N preceding narration segments
16
+ `.trim();
17
+
18
+ const PRESET_GUIDANCE: Record<VideoPreset, string> = {
19
+ "knowledge-explainer": `
20
+ Preset guidance (knowledge-explainer):
21
+ - Target ~60% card visuals and ~40% broll transitions
22
+ - Use cards when comparing, listing, or highlighting key points
23
+ - Use broll for topic transitions and visual variety
24
+ - broll prompts should be specific: describe the content, style, and atmosphere
25
+ - Alternate between cards and broll to maintain viewer engagement
26
+ `.trim(),
27
+
28
+ tutorial: `
29
+ Preset guidance (tutorial):
30
+ - Use numbered step cards (flow-chart, key-points) to structure the tutorial
31
+ - Use broll between steps for transitions and breathing room
32
+ - Mark screen recording placeholders when describing software operations
33
+ (e.g. [broll:screen recording — opening settings panel])
34
+ - Each major step should have a corresponding card summarizing the action
35
+ `.trim(),
36
+ };
37
+
38
+ const BASE_INSTRUCTIONS = `
39
+ You are a video script markup assistant. Your job is to read the script below
40
+ and insert [card:...] and [broll:...] tags at appropriate positions.
41
+
42
+ Rules:
43
+ 1. Do NOT modify the script text itself — only insert markup tags on new lines
44
+ 2. Place markup tags AFTER the narration line they relate to
45
+ 3. Every card must use one of the available templates with proper attributes
46
+ 4. broll prompts must be vivid and specific enough for AI image generation
47
+ 5. Return the complete script with markup tags inserted
48
+ `.trim();
49
+
50
+ export function buildMarkupPrompt(
51
+ script: string,
52
+ preset: VideoPreset
53
+ ): string {
54
+ const sections = [
55
+ BASE_INSTRUCTIONS,
56
+ MARKUP_SYNTAX,
57
+ CARD_TEMPLATES,
58
+ PRESET_GUIDANCE[preset],
59
+ `---\nScript:\n${script}`,
60
+ ];
61
+
62
+ return sections.join("\n\n");
63
+ }
@@ -0,0 +1,275 @@
1
+ import type {
2
+ Timeline,
3
+ TTSSegment,
4
+ VisualSegment,
5
+ VisualType,
6
+ CardTemplate,
7
+ VideoPreset,
8
+ AspectRatio,
9
+ SegmentStatus,
10
+ } from "../../types/timeline.js";
11
+
12
+ export interface ParseOptions {
13
+ contentId: string;
14
+ preset: VideoPreset;
15
+ aspectRatio: AspectRatio;
16
+ }
17
+
18
+ interface ParsedCard {
19
+ type: "card";
20
+ template: string;
21
+ attrs: Record<string, string>;
22
+ }
23
+
24
+ interface ParsedBroll {
25
+ type: "broll";
26
+ prompt: string;
27
+ span: number;
28
+ }
29
+
30
+ type ParsedMarkup = ParsedCard | ParsedBroll;
31
+
32
+ const CARD_RE = /^\[card:([^\]]+)\]$/;
33
+ const BROLL_RE = /^\[broll:([^\]]+)\]$/;
34
+ const ATTR_RE = /(\w+)="([^"]*)"/g;
35
+ const BARE_ATTR_RE = /(\w+)=(\S+)/g;
36
+
37
+ function estimateDuration(text: string): number {
38
+ let chineseCount = 0;
39
+ let nonChineseCount = 0;
40
+
41
+ for (const char of text) {
42
+ if (/[\u4e00-\u9fff\u3400-\u4dbf]/.test(char)) {
43
+ chineseCount++;
44
+ } else if (/[a-zA-Z0-9]/.test(char)) {
45
+ nonChineseCount++;
46
+ }
47
+ }
48
+
49
+ const chineseSec = chineseCount / 4;
50
+ const nonChineseSec = nonChineseCount / 15;
51
+ return Math.max(chineseSec + nonChineseSec, 0.5);
52
+ }
53
+
54
+ function padId(prefix: string, n: number): string {
55
+ return `${prefix}-${String(n).padStart(3, "0")}`;
56
+ }
57
+
58
+ function parseCardContent(raw: string): ParsedCard {
59
+ const parts = raw.trim();
60
+ const firstSpace = parts.indexOf(" ");
61
+
62
+ let template: string;
63
+ let rest: string;
64
+
65
+ if (firstSpace === -1) {
66
+ template = parts;
67
+ rest = "";
68
+ } else {
69
+ template = parts.slice(0, firstSpace);
70
+ rest = parts.slice(firstSpace + 1);
71
+ }
72
+
73
+ const attrs: Record<string, string> = {};
74
+ let match: RegExpExecArray | null;
75
+
76
+ // quoted attrs first
77
+ ATTR_RE.lastIndex = 0;
78
+ while ((match = ATTR_RE.exec(rest)) !== null) {
79
+ attrs[match[1]] = match[2];
80
+ }
81
+
82
+ return { type: "card", template, attrs };
83
+ }
84
+
85
+ function parseBrollContent(raw: string): ParsedBroll {
86
+ const trimmed = raw.trim();
87
+ let span = 1;
88
+
89
+ // extract bare attrs like span=2
90
+ let prompt = trimmed;
91
+ const spanMatch = /\bspan=(\d+)\b/.exec(trimmed);
92
+ if (spanMatch) {
93
+ span = parseInt(spanMatch[1], 10);
94
+ prompt = trimmed.slice(0, spanMatch.index).trim();
95
+ const after = trimmed.slice(spanMatch.index + spanMatch[0].length).trim();
96
+ if (after) prompt = prompt ? `${prompt} ${after}` : after;
97
+ }
98
+
99
+ return { type: "broll", prompt, span };
100
+ }
101
+
102
+ function parseCardData(attrs: Record<string, string>): Record<string, unknown> {
103
+ const data: Record<string, unknown> = {};
104
+
105
+ if (attrs.title) {
106
+ data.title = attrs.title;
107
+ }
108
+
109
+ if (attrs.rows) {
110
+ data.rows = attrs.rows.split(",").map((row) => {
111
+ const parts = row.split(":");
112
+ return { name: parts[0], pros: parts[1] ?? "", cons: parts[2] ?? "" };
113
+ });
114
+ }
115
+
116
+ if (attrs.items) {
117
+ data.items = attrs.items.split(",");
118
+ }
119
+
120
+ if (attrs.steps) {
121
+ data.steps = attrs.steps.split(",");
122
+ }
123
+
124
+ return data;
125
+ }
126
+
127
+ export function parseMarkedScript(
128
+ script: string,
129
+ options: ParseOptions
130
+ ): Timeline {
131
+ const lines = script.split("\n");
132
+ const ttsSegments: TTSSegment[] = [];
133
+ const visualSegments: VisualSegment[] = [];
134
+
135
+ // Accumulate text lines and pair them with following markup
136
+ let textBuffer: string[] = [];
137
+ let ttsCounter = 0;
138
+ let visCounter = 0;
139
+ let currentStart = 0;
140
+
141
+ // Track which tts+layer combos are taken
142
+ const layerMap = new Map<string, Set<number>>();
143
+
144
+ function flushText(): string | null {
145
+ if (textBuffer.length === 0) return null;
146
+ const text = textBuffer.join("\n");
147
+ textBuffer = [];
148
+ return text;
149
+ }
150
+
151
+ function createTts(text: string): TTSSegment {
152
+ ttsCounter++;
153
+ const duration = estimateDuration(text);
154
+ const seg: TTSSegment = {
155
+ id: padId("tts", ttsCounter),
156
+ text,
157
+ estimatedDuration: parseFloat(duration.toFixed(2)),
158
+ start: parseFloat(currentStart.toFixed(2)),
159
+ asset: null,
160
+ status: "pending" as SegmentStatus,
161
+ };
162
+ currentStart += duration;
163
+ return seg;
164
+ }
165
+
166
+ function pickLayer(ttsIds: string[], preferredLayer: number): number {
167
+ for (const ttsId of ttsIds) {
168
+ const taken = layerMap.get(ttsId);
169
+ if (taken?.has(preferredLayer)) {
170
+ return preferredLayer + 1;
171
+ }
172
+ }
173
+ return preferredLayer;
174
+ }
175
+
176
+ function markLayer(ttsIds: string[], layer: number): void {
177
+ for (const ttsId of ttsIds) {
178
+ if (!layerMap.has(ttsId)) layerMap.set(ttsId, new Set());
179
+ layerMap.get(ttsId)!.add(layer);
180
+ }
181
+ }
182
+
183
+ for (const line of lines) {
184
+ const trimmed = line.trim();
185
+
186
+ if (trimmed === "") continue;
187
+
188
+ const cardMatch = CARD_RE.exec(trimmed);
189
+ const brollMatch = BROLL_RE.exec(trimmed);
190
+
191
+ if (cardMatch) {
192
+ // Flush preceding text as TTS
193
+ const text = flushText();
194
+ if (text) {
195
+ ttsSegments.push(createTts(text));
196
+ }
197
+
198
+ const parsed = parseCardContent(cardMatch[1]);
199
+ const lastTtsId =
200
+ ttsSegments.length > 0
201
+ ? ttsSegments[ttsSegments.length - 1].id
202
+ : null;
203
+ const linkedTts = lastTtsId ? [lastTtsId] : [];
204
+
205
+ const layer = pickLayer(linkedTts, 0);
206
+ markLayer(linkedTts, layer);
207
+
208
+ visCounter++;
209
+ visualSegments.push({
210
+ id: padId("vis", visCounter),
211
+ layer,
212
+ type: "card" as VisualType,
213
+ template: parsed.template as CardTemplate,
214
+ data: parseCardData(parsed.attrs),
215
+ linkedTts,
216
+ opacity: 0.85,
217
+ asset: null,
218
+ status: "pending" as SegmentStatus,
219
+ });
220
+ } else if (brollMatch) {
221
+ // Flush preceding text as TTS
222
+ const text = flushText();
223
+ if (text) {
224
+ ttsSegments.push(createTts(text));
225
+ }
226
+
227
+ const parsed = parseBrollContent(brollMatch[1]);
228
+ const span = Math.min(parsed.span, ttsSegments.length);
229
+ const linkedTts = ttsSegments
230
+ .slice(-span)
231
+ .map((s) => s.id);
232
+
233
+ const layer = pickLayer(linkedTts, 0);
234
+ markLayer(linkedTts, layer);
235
+
236
+ visCounter++;
237
+ visualSegments.push({
238
+ id: padId("vis", visCounter),
239
+ layer,
240
+ type: "broll" as VisualType,
241
+ prompt: parsed.prompt,
242
+ linkedTts,
243
+ asset: null,
244
+ status: "pending" as SegmentStatus,
245
+ });
246
+ } else {
247
+ textBuffer.push(trimmed);
248
+ }
249
+ }
250
+
251
+ // Flush remaining text
252
+ const remaining = flushText();
253
+ if (remaining) {
254
+ ttsSegments.push(createTts(remaining));
255
+ }
256
+
257
+ return {
258
+ version: "2.0",
259
+ contentId: options.contentId,
260
+ preset: options.preset,
261
+ aspectRatio: options.aspectRatio,
262
+ subtitle: {
263
+ template: "modern-outline",
264
+ position: "bottom",
265
+ },
266
+ tracks: {
267
+ tts: ttsSegments,
268
+ visual: visualSegments,
269
+ subtitle: {
270
+ asset: null,
271
+ status: "pending",
272
+ },
273
+ },
274
+ };
275
+ }