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,212 @@
1
+ /**
2
+ * Cover Prompt Builder — generates 3 differentiated cover prompt sets
3
+ * from a content topic.
4
+ *
5
+ * Each set produces a different visual style while sharing the same
6
+ * core subject matter and title text.
7
+ */
8
+
9
+ // --- Types ---
10
+
11
+ export type CoverStyle = "cinematic" | "minimalist" | "bold-impact";
12
+
13
+ export interface CoverPromptSet {
14
+ label: "A" | "B" | "C";
15
+ style: CoverStyle;
16
+ /** Full English image generation prompt */
17
+ imagePrompt: string;
18
+ /** Chinese title text for the cover (2-8 chars) */
19
+ titleText: string;
20
+ /** Layout description for reference */
21
+ layoutHint: string;
22
+ }
23
+
24
+ export interface PromptBuilderInput {
25
+ /** Content title */
26
+ title: string;
27
+ /** Content body (first ~200 chars used for context) */
28
+ body: string;
29
+ /** Target platform */
30
+ platform?: string;
31
+ /** Whether personal IP reference photos are available */
32
+ hasReferencePhotos: boolean;
33
+ /** Optional custom title override (user-specified cover title) */
34
+ customTitle?: string;
35
+ }
36
+
37
+ // --- Title extraction ---
38
+
39
+ /**
40
+ * Extract a short, punchy cover title (2-8 Chinese chars) from the content title.
41
+ * Strips filler words and picks the most impactful segment.
42
+ */
43
+ export function extractCoverTitle(title: string, customTitle?: string): string {
44
+ if (customTitle && customTitle.length >= 2 && customTitle.length <= 8) {
45
+ return customTitle;
46
+ }
47
+
48
+ // Remove common filler patterns
49
+ let cleaned = title
50
+ .replace(/[【】《》「」『』""'']/g, "")
51
+ .replace(/[!!??。,,.::;;\s]+/g, " ")
52
+ .trim();
53
+
54
+ // If already short enough, use as-is
55
+ if (cleaned.length <= 8) return cleaned;
56
+
57
+ // Try to find a punchy segment: split by common delimiters
58
+ const segments = cleaned.split(/[,,::!!??||—\-\/、]/);
59
+ const best = segments
60
+ .map((s) => s.trim())
61
+ .filter((s) => s.length >= 2 && s.length <= 8)
62
+ .sort((a, b) => b.length - a.length)[0];
63
+
64
+ if (best) return best;
65
+
66
+ // Fallback: take first 6 chars
67
+ return cleaned.slice(0, 6);
68
+ }
69
+
70
+ // --- Subject analysis ---
71
+
72
+ type SubjectType = "person" | "concept" | "event";
73
+
74
+ function analyzeSubject(title: string, body: string): SubjectType {
75
+ const text = title + " " + body.slice(0, 200);
76
+ const personKeywords = /我|你|他|她|创始人|CEO|老板|博主|达人|明星|网红|自己|个人|人物/;
77
+ const eventKeywords = /事件|新闻|发布|上线|爆|热|突发|刚刚|今天|昨天|最新/;
78
+
79
+ if (personKeywords.test(text)) return "person";
80
+ if (eventKeywords.test(text)) return "event";
81
+ return "concept";
82
+ }
83
+
84
+ // --- Mood analysis ---
85
+
86
+ function analyzeMood(title: string, body: string): string {
87
+ const text = title + " " + body.slice(0, 200);
88
+
89
+ if (/危|险|警|崩|暴|怒|恐|慌|焦虑|失败/.test(text)) return "tense, dramatic";
90
+ if (/赚|钱|财|富|增长|暴涨|翻倍/.test(text)) return "ambitious, golden";
91
+ if (/美|好|幸福|温暖|治愈|舒适|放松/.test(text)) return "warm, soft";
92
+ if (/科技|AI|未来|数字|智能|技术/.test(text)) return "futuristic, cool-toned";
93
+ if (/干货|方法|技巧|攻略|教程|秘诀/.test(text)) return "clean, professional";
94
+ return "cinematic, atmospheric";
95
+ }
96
+
97
+ // --- Style templates ---
98
+
99
+ const STYLE_CONFIGS: Record<CoverStyle, {
100
+ styleDesc: string;
101
+ lightingDesc: string;
102
+ colorDesc: string;
103
+ layoutForPerson: string;
104
+ layoutForConcept: string;
105
+ layoutForEvent: string;
106
+ }> = {
107
+ cinematic: {
108
+ styleDesc: "cinematic movie poster style, photorealistic, film grain texture",
109
+ lightingDesc: "dramatic chiaroscuro lighting with strong shadows and highlights, Rembrandt lighting",
110
+ colorDesc: "deep contrast, desaturated with selective color accent",
111
+ layoutForPerson: "person positioned in the lower 2/3 of the frame, looking slightly off-camera, title text in bold sans-serif at the top 1/3 with dark gradient overlay",
112
+ layoutForConcept: "strong visual metaphor centered in frame, title text overlaid at upper 1/3 with semi-transparent dark band",
113
+ layoutForEvent: "most dramatic moment frozen in time, title text at top with heavy dark vignette ensuring readability",
114
+ },
115
+ minimalist: {
116
+ styleDesc: "minimalist editorial style, clean composition, large negative space",
117
+ lightingDesc: "soft diffused studio lighting, even and clean",
118
+ colorDesc: "muted palette with one bold accent color, high-key or low-key depending on mood",
119
+ layoutForPerson: "person small in frame with vast negative space, title text large and dominant occupying 40% of frame",
120
+ layoutForConcept: "single iconic object or symbol centered, surrounded by clean space, title text as the primary visual element",
121
+ layoutForEvent: "abstract representation of the event, geometric shapes, title text centered and oversized",
122
+ },
123
+ "bold-impact": {
124
+ styleDesc: "bold high-impact visual, saturated colors, dynamic composition",
125
+ lightingDesc: "high-contrast dramatic lighting with neon or colored light accents",
126
+ colorDesc: "vibrant saturated colors, complementary color scheme, eye-catching",
127
+ layoutForPerson: "close-up or medium shot with intense expression, title text integrated into the composition with bold color block behind it",
128
+ layoutForConcept: "explosive or dynamic visual with motion blur or energy effects, title text large and bold with drop shadow",
129
+ layoutForEvent: "action-packed composition with diagonal lines and movement, title text at an angle or with perspective effect",
130
+ },
131
+ };
132
+
133
+ // --- Core prompt builder ---
134
+
135
+ function buildImagePrompt(
136
+ titleText: string,
137
+ subject: SubjectType,
138
+ mood: string,
139
+ style: CoverStyle,
140
+ hasReferencePhotos: boolean,
141
+ ): string {
142
+ const config = STYLE_CONFIGS[style];
143
+ const layout = subject === "person"
144
+ ? config.layoutForPerson
145
+ : subject === "event"
146
+ ? config.layoutForEvent
147
+ : config.layoutForConcept;
148
+
149
+ const parts: string[] = [
150
+ // Core visual
151
+ `Vertical 3:4 portrait orientation cover image.`,
152
+ config.styleDesc + ".",
153
+ `Mood: ${mood}.`,
154
+ config.lightingDesc + ".",
155
+ config.colorDesc + ".",
156
+
157
+ // Layout
158
+ `Composition: ${layout}.`,
159
+
160
+ // Title text on image
161
+ `The image MUST include the Chinese text "${titleText}" as a prominent visual element.`,
162
+ `Text style: bold sans-serif font, large size, high contrast against background, clearly readable.`,
163
+ `Text must be sharp, correctly spelled, and not distorted.`,
164
+
165
+ // Reference photo handling
166
+ ...(hasReferencePhotos && subject === "person"
167
+ ? ["Feature the person from the reference photo as the main subject, maintaining their likeness."]
168
+ : []),
169
+
170
+ // Prohibitions
171
+ "No watermarks, no logos, no URLs.",
172
+ "No white or light solid color backgrounds.",
173
+ "No cartoon or illustration style — photorealistic only.",
174
+ "No blurry, warped, or misspelled text.",
175
+ ];
176
+
177
+ return parts.join(" ");
178
+ }
179
+
180
+ // --- Public API ---
181
+
182
+ /**
183
+ * Generate 3 cover prompt sets (A/B/C) from content metadata.
184
+ */
185
+ export function buildCoverPrompts(input: PromptBuilderInput): CoverPromptSet[] {
186
+ const titleText = extractCoverTitle(input.title, input.customTitle);
187
+ const subject = analyzeSubject(input.title, input.body);
188
+ const mood = analyzeMood(input.title, input.body);
189
+
190
+ const styles: Array<{ label: "A" | "B" | "C"; style: CoverStyle }> = [
191
+ { label: "A", style: "cinematic" },
192
+ { label: "B", style: "minimalist" },
193
+ { label: "C", style: "bold-impact" },
194
+ ];
195
+
196
+ return styles.map(({ label, style }) => {
197
+ const config = STYLE_CONFIGS[style];
198
+ const layout = subject === "person"
199
+ ? config.layoutForPerson
200
+ : subject === "event"
201
+ ? config.layoutForEvent
202
+ : config.layoutForConcept;
203
+
204
+ return {
205
+ label,
206
+ style,
207
+ imagePrompt: buildImagePrompt(titleText, subject, mood, style, input.hasReferencePhotos),
208
+ titleText,
209
+ layoutHint: layout,
210
+ };
211
+ });
212
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock the Gemini adapter and Pro gate before importing
4
+ vi.mock("../../adapters/image/gemini.js", () => ({
5
+ generateImage: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("../pro/gate.js", () => ({
9
+ requirePro: vi.fn(),
10
+ proGateResponse: vi.fn((name: string, alt: string) => ({
11
+ ok: false,
12
+ error: `「${name}」是 Pro 版功能。`,
13
+ upgradeHint: "运行 autocrew upgrade 了解 Pro 版详情。",
14
+ freeAlternative: alt,
15
+ })),
16
+ }));
17
+
18
+ import { generateMultiRatio, type RatioAdaptInput } from "../cover/ratio-adapter.js";
19
+ import { generateImage } from "../../adapters/image/gemini.js";
20
+ import { requirePro } from "../pro/gate.js";
21
+
22
+ const mockGenerateImage = vi.mocked(generateImage);
23
+ const mockRequirePro = vi.mocked(requirePro);
24
+
25
+ const baseInput: RatioAdaptInput = {
26
+ originalPrompt: "Vertical 3:4 portrait orientation cover image. cinematic style.",
27
+ apiKey: "test-key",
28
+ model: "auto",
29
+ outputDir: "/tmp/test-covers",
30
+ baseName: "cover-a",
31
+ dataDir: "/tmp/test-autocrew",
32
+ };
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ });
37
+
38
+ describe("generateMultiRatio", () => {
39
+ it("returns Pro gate response when user is Free", async () => {
40
+ mockRequirePro.mockResolvedValue({
41
+ ok: false,
42
+ error: "Pro required",
43
+ upgradeHint: "upgrade",
44
+ });
45
+
46
+ const result = await generateMultiRatio(baseInput);
47
+ expect("upgradeHint" in result).toBe(true);
48
+ expect(result.ok).toBe(false);
49
+ });
50
+
51
+ it("generates 16:9 and 4:3 when Pro", async () => {
52
+ mockRequirePro.mockResolvedValue(null); // Pro user
53
+ mockGenerateImage.mockResolvedValue({
54
+ ok: true,
55
+ imagePath: "/tmp/test-covers/cover-a-16x9.png",
56
+ model: "gemini-native",
57
+ });
58
+
59
+ const result = await generateMultiRatio(baseInput);
60
+
61
+ // Should have called generateImage twice (16:9 and 4:3)
62
+ expect(mockGenerateImage).toHaveBeenCalledTimes(2);
63
+
64
+ // Check that aspect ratios were passed correctly
65
+ const calls = mockGenerateImage.mock.calls;
66
+ expect(calls[0][0].aspectRatio).toBe("16:9");
67
+ expect(calls[1][0].aspectRatio).toBe("4:3");
68
+ });
69
+
70
+ it("adapts prompt for different ratios", async () => {
71
+ mockRequirePro.mockResolvedValue(null);
72
+ mockGenerateImage.mockResolvedValue({
73
+ ok: true,
74
+ imagePath: "/tmp/test.png",
75
+ model: "gemini-native",
76
+ });
77
+
78
+ await generateMultiRatio(baseInput);
79
+
80
+ const calls = mockGenerateImage.mock.calls;
81
+ // 16:9 prompt should NOT contain "Vertical 3:4"
82
+ expect(calls[0][0].prompt).not.toContain("Vertical 3:4");
83
+ expect(calls[0][0].prompt).toContain("16:9");
84
+ // 4:3 prompt should NOT contain "Vertical 3:4"
85
+ expect(calls[1][0].prompt).not.toContain("Vertical 3:4");
86
+ expect(calls[1][0].prompt).toContain("4:3");
87
+ });
88
+
89
+ it("reports errors for failed generations", async () => {
90
+ mockRequirePro.mockResolvedValue(null);
91
+ mockGenerateImage
92
+ .mockResolvedValueOnce({ ok: true, imagePath: "/tmp/ok.png", model: "gemini-native" })
93
+ .mockResolvedValueOnce({ ok: false, imagePath: "", model: "gemini-native", error: "API error" });
94
+
95
+ const result = await generateMultiRatio(baseInput);
96
+ // Should not be fully ok since one failed
97
+ if (!("upgradeHint" in result)) {
98
+ expect(result.ok).toBe(false);
99
+ expect(result.errors.length).toBe(1);
100
+ expect(result.paths["16:9"]).toBeTruthy();
101
+ expect(result.paths["4:3"]).toBeUndefined();
102
+ }
103
+ });
104
+
105
+ it("passes reference images through", async () => {
106
+ mockRequirePro.mockResolvedValue(null);
107
+ mockGenerateImage.mockResolvedValue({
108
+ ok: true,
109
+ imagePath: "/tmp/test.png",
110
+ model: "gemini-native",
111
+ });
112
+
113
+ await generateMultiRatio({
114
+ ...baseInput,
115
+ referenceImagePaths: ["/tmp/photo.jpg"],
116
+ });
117
+
118
+ for (const call of mockGenerateImage.mock.calls) {
119
+ expect(call[0].referenceImagePaths).toEqual(["/tmp/photo.jpg"]);
120
+ }
121
+ });
122
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Ratio Adapter — generates 16:9 and 4:3 versions from a finalized 3:4 cover.
3
+ *
4
+ * Strategy: re-generate using the original prompt with the new aspect ratio.
5
+ * This produces better results than cropping/stretching.
6
+ *
7
+ * This is a Pro-only feature (gated by pro/gate.ts).
8
+ */
9
+ import { generateImage, type GeminiModel, type AspectRatio } from "../../adapters/image/gemini.js";
10
+ import { requirePro, proGateResponse } from "../pro/gate.js";
11
+ import path from "node:path";
12
+
13
+ export interface RatioAdaptInput {
14
+ /** Original image prompt used for the 3:4 version */
15
+ originalPrompt: string;
16
+ /** Gemini API key */
17
+ apiKey: string;
18
+ /** Model to use */
19
+ model: GeminiModel;
20
+ /** Reference image paths (same as used for 3:4) */
21
+ referenceImagePaths?: string[];
22
+ /** Base directory for saving (e.g. content assets dir) */
23
+ outputDir: string;
24
+ /** Base filename without extension (e.g. "cover-A") */
25
+ baseName: string;
26
+ /** Data dir for Pro gate check */
27
+ dataDir?: string;
28
+ }
29
+
30
+ export interface RatioAdaptResult {
31
+ ok: boolean;
32
+ paths: {
33
+ "16:9"?: string;
34
+ "4:3"?: string;
35
+ };
36
+ errors: string[];
37
+ }
38
+
39
+ /**
40
+ * Adapt a prompt to a different aspect ratio.
41
+ * Replaces "Vertical 3:4 portrait orientation" with the target ratio description.
42
+ */
43
+ function adaptPromptForRatio(prompt: string, ratio: AspectRatio): string {
44
+ const ratioDescriptions: Record<string, string> = {
45
+ "16:9": "Horizontal 16:9 widescreen landscape orientation",
46
+ "4:3": "Square-ish 4:3 landscape orientation",
47
+ };
48
+
49
+ return prompt
50
+ .replace(/Vertical 3:4 portrait orientation/i, ratioDescriptions[ratio] || ratio)
51
+ .replace(/3:4/g, ratio);
52
+ }
53
+
54
+ /**
55
+ * Generate 16:9 and 4:3 versions from a finalized 3:4 cover prompt.
56
+ *
57
+ * Pro-only: returns upgrade hint if user is on Free plan.
58
+ */
59
+ export async function generateMultiRatio(
60
+ input: RatioAdaptInput,
61
+ ): Promise<RatioAdaptResult | { ok: false; error: string; upgradeHint: string; freeAlternative: string }> {
62
+ // Pro gate check
63
+ const gate = await requirePro("多比例封面生成", input.dataDir);
64
+ if (gate) {
65
+ return proGateResponse(
66
+ "多比例封面生成(16:9 + 4:3)",
67
+ "3:4 封面已生成。你可以用图片编辑工具手动裁剪为其他比例。",
68
+ );
69
+ }
70
+
71
+ const ratios: AspectRatio[] = ["16:9", "4:3"];
72
+ const paths: Record<string, string> = {};
73
+ const errors: string[] = [];
74
+
75
+ for (const ratio of ratios) {
76
+ const adaptedPrompt = adaptPromptForRatio(input.originalPrompt, ratio);
77
+ const safeName = ratio.replace(":", "x");
78
+ const outputPath = path.join(input.outputDir, `${input.baseName}-${safeName}`);
79
+
80
+ const result = await generateImage({
81
+ prompt: adaptedPrompt,
82
+ aspectRatio: ratio,
83
+ model: input.model,
84
+ apiKey: input.apiKey,
85
+ referenceImagePaths: input.referenceImagePaths,
86
+ outputPath,
87
+ });
88
+
89
+ if (result.ok) {
90
+ paths[ratio] = result.imagePath;
91
+ } else {
92
+ errors.push(`${ratio}: ${result.error}`);
93
+ }
94
+ }
95
+
96
+ return {
97
+ ok: errors.length === 0,
98
+ paths: {
99
+ "16:9": paths["16:9"],
100
+ "4:3": paths["4:3"],
101
+ },
102
+ errors,
103
+ };
104
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { scanText, _resetCache } from "../filter/sensitive-words.js";
6
+
7
+ let testDir: string;
8
+
9
+ beforeEach(async () => {
10
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), "autocrew-sw-test-"));
11
+ _resetCache();
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await fs.rm(testDir, { recursive: true, force: true });
16
+ _resetCache();
17
+ });
18
+
19
+ describe("scanText", () => {
20
+ it("returns ok:true and empty hits for clean text", async () => {
21
+ const result = await scanText("今天天气不错,出去走走吧。", undefined, testDir);
22
+ expect(result.ok).toBe(true);
23
+ expect(result.hits).toHaveLength(0);
24
+ });
25
+
26
+ it("detects political sensitive words", async () => {
27
+ const result = await scanText("习近平发表讲话", undefined, testDir);
28
+ expect(result.hits.length).toBeGreaterThan(0);
29
+ expect(result.hits[0].category).toBeTruthy();
30
+ });
31
+
32
+ it("hitCount matches hits array length", async () => {
33
+ const result = await scanText("这是一段普通文字", undefined, testDir);
34
+ expect(result.hitCount).toBe(result.hits.length);
35
+ });
36
+
37
+ it("returns a non-empty summary string", async () => {
38
+ const result = await scanText("今天天气不错", undefined, testDir);
39
+ expect(typeof result.summary).toBe("string");
40
+ expect(result.summary.length).toBeGreaterThan(0);
41
+ });
42
+
43
+ it("loads custom words from dataDir", async () => {
44
+ const customPath = path.join(testDir, "sensitive-words", "custom.txt");
45
+ await fs.mkdir(path.dirname(customPath), { recursive: true });
46
+ await fs.writeFile(customPath, "# comment\n自定义敏感词\n", "utf-8");
47
+
48
+ _resetCache();
49
+ const result = await scanText("这里有自定义敏感词出现", undefined, testDir);
50
+ const found = result.hits.some((h) => h.word === "自定义敏感词");
51
+ expect(found).toBe(true);
52
+ });
53
+
54
+ it("handles empty string without error", async () => {
55
+ const result = await scanText("", undefined, testDir);
56
+ expect(result.ok).toBe(true);
57
+ expect(result.hits).toHaveLength(0);
58
+ });
59
+
60
+ it("returns positions array for each hit", async () => {
61
+ const result = await scanText("习近平习近平", undefined, testDir);
62
+ if (result.hits.length > 0) {
63
+ expect(Array.isArray(result.hits[0].positions)).toBe(true);
64
+ expect(result.hits[0].positions.length).toBeGreaterThan(0);
65
+ }
66
+ });
67
+
68
+ it("autoFixedText is string or undefined", async () => {
69
+ const result = await scanText("这是一段文字", undefined, testDir);
70
+ expect(result.autoFixedText === undefined || typeof result.autoFixedText === "string").toBe(true);
71
+ });
72
+ });