plotlink-ows 1.0.32 → 1.2.94

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 (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +10 -3
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +209 -28
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +1017 -144
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -0,0 +1,302 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { hasVisibleSpeechTail, CARTOON_BUBBLE_RENDERER_VERSION, type Overlay } from "./overlays";
4
+
5
+ export const SHOT_TYPES = ["wide", "medium", "close-up", "extreme-close-up"] as const;
6
+ export type ShotType = (typeof SHOT_TYPES)[number];
7
+
8
+ export interface CutDialogue {
9
+ speaker: string;
10
+ text: string;
11
+ }
12
+
13
+ /**
14
+ * Panel kind (#350). An "image" cut is the normal art panel (needs a clean
15
+ * image → lettering → export → upload). A "text" panel is a text/interstitial
16
+ * card (no clean image; text on a styled background, still exported + uploaded
17
+ * as a final image for MVP). The field is OPTIONAL and backward-compatible:
18
+ * a missing `kind` means "image".
19
+ */
20
+ export type CutKind = "image" | "text";
21
+
22
+ export interface Cut {
23
+ id: number;
24
+ shotType: ShotType;
25
+ description: string;
26
+ characters: string[];
27
+ dialogue: CutDialogue[];
28
+ narration: string;
29
+ sfx: string;
30
+ cleanImagePath: string | null;
31
+ finalImagePath: string | null;
32
+ exportedAt: string | null;
33
+ uploadedCid: string | null;
34
+ uploadedUrl: string | null;
35
+ overlays: Overlay[];
36
+ /**
37
+ * Bubble-renderer revision the final image was exported with (#381). Absent on
38
+ * cuts exported before versioning (treated as stale for tailed bubbles). Stamped
39
+ * by the export-final endpoint with CARTOON_BUBBLE_RENDERER_VERSION.
40
+ */
41
+ finalRendererVersion?: number;
42
+ /** Panel kind (#350). Absent ⇒ "image" (backward-compatible). */
43
+ kind?: CutKind;
44
+ /** Text-panel background color (CSS color), e.g. "#101820". Optional (#350). */
45
+ background?: string;
46
+ /** Text-panel aspect ratio hint, e.g. "4:5". Optional (#350). */
47
+ aspectRatio?: string;
48
+ }
49
+
50
+ /** Whether a cut is a text/interstitial panel (#350); missing kind ⇒ image. */
51
+ export function isTextPanel(cut: Pick<Cut, "kind">): boolean {
52
+ return cut.kind === "text";
53
+ }
54
+
55
+ /** Stable key for a cut's single next production step (#371). */
56
+ export type CutActionKey = "add-art" | "letter" | "review";
57
+
58
+ /**
59
+ * The one next production action for a single cut (#371), used to deep-link from
60
+ * the Preview / Cut Inspector straight into that cut's editing step instead of
61
+ * making the writer hunt for it in the Edit tab. Mirrors the per-cut status the
62
+ * Edit tab shows (no clean art → letter → final ready) but in creator-facing
63
+ * language, and reports whether the lettering editor can open directly for it.
64
+ *
65
+ * - "add-art": an image cut with no clean image yet → the writer adds/imports the
66
+ * clean art first, so the CTA focuses the cut's row (there is nothing to letter
67
+ * yet, so the editor does not open).
68
+ * - "letter": a clean image cut, or a text/interstitial panel, that still needs
69
+ * overlays or a final export → open the lettering editor directly.
70
+ * - "review": a final image already exists → open the editor to review/redo it.
71
+ */
72
+ export interface CutNextAction {
73
+ key: CutActionKey;
74
+ /** Creator-facing CTA label — no markdown/schema jargon (#371). */
75
+ label: string;
76
+ /** Whether the lettering editor can be opened directly for this cut. */
77
+ opensEditor: boolean;
78
+ }
79
+
80
+ export function cutNextAction(
81
+ cut: Pick<Cut, "cleanImagePath" | "finalImagePath" | "exportedAt" | "kind">,
82
+ ): CutNextAction {
83
+ const hasFinal = !!cut.finalImagePath || !!cut.exportedAt;
84
+ if (hasFinal) {
85
+ return { key: "review", label: "Review final panel", opensEditor: true };
86
+ }
87
+ // A clean image or a text/interstitial panel is ready to letter; a text panel
88
+ // letters on its background and needs no clean image (#350).
89
+ if (cut.cleanImagePath || isTextPanel(cut)) {
90
+ return { key: "letter", label: "Letter this cut", opensEditor: true };
91
+ }
92
+ return { key: "add-art", label: "Add clean art for this cut", opensEditor: false };
93
+ }
94
+
95
+ /**
96
+ * Whether a cut's exported final image is STALE for #381: it has a final image
97
+ * AND renders at least one visible speech-bubble tail AND was exported by an
98
+ * older bubble renderer (its `finalRendererVersion` is absent — pre-versioning —
99
+ * or below `currentVersion`). Such an image may show the old separate-tail seam
100
+ * and must be re-exported before publish. Tailless cuts are never stale (the
101
+ * seam fixes only affect tailed bubbles), so existing exports aren't churned.
102
+ */
103
+ export function isStaleTailedExport(
104
+ cut: Pick<Cut, "finalImagePath" | "finalRendererVersion" | "overlays">,
105
+ currentVersion: number = CARTOON_BUBBLE_RENDERER_VERSION,
106
+ ): boolean {
107
+ if (!cut.finalImagePath) return false;
108
+ const tailed = (cut.overlays ?? []).some(hasVisibleSpeechTail);
109
+ if (!tailed) return false;
110
+ return (cut.finalRendererVersion ?? 0) < currentVersion;
111
+ }
112
+
113
+ /** Ids of cuts whose final image is a stale tailed export (#381), in order. */
114
+ export function staleTailedCutIds(
115
+ cutsFile: Pick<CutsFile, "cuts">,
116
+ currentVersion: number = CARTOON_BUBBLE_RENDERER_VERSION,
117
+ ): number[] {
118
+ return cutsFile.cuts.filter((c) => isStaleTailedExport(c, currentVersion)).map((c) => c.id);
119
+ }
120
+
121
+ /** Base canvas width for a text panel sized from its aspect ratio (#351). */
122
+ export const TEXT_PANEL_BASE_WIDTH = 800;
123
+
124
+ /**
125
+ * Canvas dimensions for a text panel from an "W:H" aspect ratio (#351) — shared
126
+ * by the lettering editor (so its surface matches) and the export, so a text
127
+ * panel letters and exports at the SAME shape. Returns null for a missing or
128
+ * malformed ratio; callers fall back to 800×600.
129
+ */
130
+ export function textPanelDimensions(aspectRatio: string | undefined): { width: number; height: number } | null {
131
+ if (!aspectRatio) return null;
132
+ const m = aspectRatio.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
133
+ if (!m) return null;
134
+ const w = parseFloat(m[1]);
135
+ const h = parseFloat(m[2]);
136
+ if (!(w > 0) || !(h > 0)) return null;
137
+ return { width: TEXT_PANEL_BASE_WIDTH, height: Math.round((TEXT_PANEL_BASE_WIDTH * h) / w) };
138
+ }
139
+
140
+ export interface CutsFile {
141
+ version: 1;
142
+ plotFile: string;
143
+ /**
144
+ * Optional human-readable episode title (#347). When present it becomes the
145
+ * published chapter title for a cartoon episode whose plot-NN.md has no H1
146
+ * (cartoon publish markdown is image-only by design), so the episode never
147
+ * publishes as the raw "plot-NN" filename. Absent in v1 cut plans — callers
148
+ * fall back to a friendly "Episode NN".
149
+ */
150
+ title?: string;
151
+ cuts: Cut[];
152
+ }
153
+
154
+ export function createDefaultCut(id: number, _plotFile: string): Cut {
155
+ return {
156
+ id,
157
+ shotType: "medium",
158
+ description: "",
159
+ characters: [],
160
+ dialogue: [],
161
+ narration: "",
162
+ sfx: "",
163
+ cleanImagePath: null,
164
+ finalImagePath: null,
165
+ exportedAt: null,
166
+ uploadedCid: null,
167
+ uploadedUrl: null,
168
+ overlays: [],
169
+ };
170
+ }
171
+
172
+ export function createCutsFile(plotFile: string, cutCount = 1): CutsFile {
173
+ const cuts = Array.from({ length: cutCount }, (_, i) => createDefaultCut(i + 1, plotFile));
174
+ return { version: 1, plotFile, cuts };
175
+ }
176
+
177
+ function cutsFilePath(storyDir: string, plotFile: string): string {
178
+ return path.join(storyDir, `${plotFile}.cuts.json`);
179
+ }
180
+
181
+ export function readCutsFile(storyDir: string, plotFile: string): CutsFile | null {
182
+ const filePath = cutsFilePath(storyDir, plotFile);
183
+ if (!fs.existsSync(filePath)) return null;
184
+
185
+ let raw: string;
186
+ try {
187
+ raw = fs.readFileSync(filePath, "utf-8");
188
+ } catch (err) {
189
+ throw new Error(`Cannot read ${plotFile}.cuts.json: ${(err as Error).message}`);
190
+ }
191
+
192
+ let data: unknown;
193
+ try {
194
+ data = JSON.parse(raw);
195
+ } catch {
196
+ throw new Error(`${plotFile}.cuts.json contains invalid JSON`);
197
+ }
198
+
199
+ const validation = validateCutsFile(data);
200
+ if (!validation.valid) {
201
+ throw new Error(`${plotFile}.cuts.json is invalid: ${validation.error}`);
202
+ }
203
+
204
+ return data as CutsFile;
205
+ }
206
+
207
+ export function writeCutsFile(storyDir: string, plotFile: string, cutsFile: CutsFile): void {
208
+ const filePath = cutsFilePath(storyDir, plotFile);
209
+ fs.writeFileSync(filePath, JSON.stringify(cutsFile, null, 2) + "\n");
210
+ }
211
+
212
+ export function validateCutsFile(data: unknown): { valid: boolean; error?: string } {
213
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
214
+ return { valid: false, error: "Must be a JSON object" };
215
+ }
216
+
217
+ const obj = data as Record<string, unknown>;
218
+
219
+ if (obj.version !== 1) {
220
+ return { valid: false, error: "Unsupported version (expected 1)" };
221
+ }
222
+
223
+ if (typeof obj.plotFile !== "string" || !obj.plotFile) {
224
+ return { valid: false, error: "Missing or invalid plotFile" };
225
+ }
226
+
227
+ if (!Array.isArray(obj.cuts)) {
228
+ return { valid: false, error: "cuts must be an array" };
229
+ }
230
+
231
+ // Optional episode title (#347) — string when present.
232
+ if (obj.title !== undefined && typeof obj.title !== "string") {
233
+ return { valid: false, error: "title must be a string" };
234
+ }
235
+
236
+ const validShots = new Set<string>(SHOT_TYPES);
237
+
238
+ for (let i = 0; i < obj.cuts.length; i++) {
239
+ const cut = obj.cuts[i] as Record<string, unknown>;
240
+ if (typeof cut !== "object" || cut === null) {
241
+ return { valid: false, error: `Cut ${i} is not an object` };
242
+ }
243
+ if (typeof cut.id !== "number") {
244
+ return { valid: false, error: `Cut ${i} missing numeric id` };
245
+ }
246
+ if (typeof cut.shotType !== "string" || !validShots.has(cut.shotType)) {
247
+ return { valid: false, error: `Cut ${i} has invalid shotType` };
248
+ }
249
+ if (typeof cut.description !== "string") {
250
+ return { valid: false, error: `Cut ${i} missing description` };
251
+ }
252
+ if (!Array.isArray(cut.characters)) {
253
+ return { valid: false, error: `Cut ${i} characters must be an array` };
254
+ }
255
+ for (let j = 0; j < (cut.characters as unknown[]).length; j++) {
256
+ if (typeof (cut.characters as unknown[])[j] !== "string") {
257
+ return { valid: false, error: `Cut ${i} characters[${j}] must be a string` };
258
+ }
259
+ }
260
+ if (!Array.isArray(cut.dialogue)) {
261
+ return { valid: false, error: `Cut ${i} dialogue must be an array` };
262
+ }
263
+ for (let j = 0; j < (cut.dialogue as unknown[]).length; j++) {
264
+ const d = (cut.dialogue as Record<string, unknown>[])[j];
265
+ if (typeof d !== "object" || d === null || typeof d.speaker !== "string" || typeof d.text !== "string") {
266
+ return { valid: false, error: `Cut ${i} dialogue[${j}] must have speaker and text strings` };
267
+ }
268
+ }
269
+ if (typeof cut.narration !== "string") {
270
+ return { valid: false, error: `Cut ${i} missing narration` };
271
+ }
272
+ if (typeof cut.sfx !== "string") {
273
+ return { valid: false, error: `Cut ${i} missing sfx` };
274
+ }
275
+ const nullableStrings = ["cleanImagePath", "finalImagePath", "exportedAt", "uploadedCid", "uploadedUrl"] as const;
276
+ for (const field of nullableStrings) {
277
+ if (cut[field] !== null && typeof cut[field] !== "string") {
278
+ return { valid: false, error: `Cut ${i} ${field} must be a string or null` };
279
+ }
280
+ }
281
+ if (cut.overlays !== undefined && !Array.isArray(cut.overlays)) {
282
+ return { valid: false, error: `Cut ${i} overlays must be an array` };
283
+ }
284
+ // Text-panel fields (#350) — all optional and backward-compatible.
285
+ if (cut.kind !== undefined && cut.kind !== "image" && cut.kind !== "text") {
286
+ return { valid: false, error: `Cut ${i} kind must be "image" or "text"` };
287
+ }
288
+ if (cut.background !== undefined && typeof cut.background !== "string") {
289
+ return { valid: false, error: `Cut ${i} background must be a string` };
290
+ }
291
+ if (cut.aspectRatio !== undefined && typeof cut.aspectRatio !== "string") {
292
+ return { valid: false, error: `Cut ${i} aspectRatio must be a string` };
293
+ }
294
+ // Bubble-renderer version stamp (#381) — optional, backward-compatible
295
+ // (absent ⇒ pre-versioning final image).
296
+ if (cut.finalRendererVersion !== undefined && typeof cut.finalRendererVersion !== "number") {
297
+ return { valid: false, error: `Cut ${i} finalRendererVersion must be a number` };
298
+ }
299
+ }
300
+
301
+ return { valid: true };
302
+ }
@@ -0,0 +1,109 @@
1
+ export interface FontEntry {
2
+ family: string;
3
+ googleFontsId: string;
4
+ license: string;
5
+ licenseUrl: string;
6
+ category: "body" | "display";
7
+ weights: number[];
8
+ languages: string[];
9
+ }
10
+
11
+ export const FONT_REGISTRY: FontEntry[] = [
12
+ {
13
+ family: "Noto Sans",
14
+ googleFontsId: "Noto+Sans",
15
+ license: "OFL-1.1",
16
+ licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans/about",
17
+ category: "body",
18
+ weights: [400, 500, 700],
19
+ languages: ["English", "Spanish", "French", "Portuguese", "Russian", "Others"],
20
+ },
21
+ {
22
+ family: "Noto Sans KR",
23
+ googleFontsId: "Noto+Sans+KR",
24
+ license: "OFL-1.1",
25
+ licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans+KR/about",
26
+ category: "body",
27
+ weights: [400, 500, 700],
28
+ languages: ["Korean"],
29
+ },
30
+ {
31
+ family: "Noto Sans JP",
32
+ googleFontsId: "Noto+Sans+JP",
33
+ license: "OFL-1.1",
34
+ licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans+JP/about",
35
+ category: "body",
36
+ weights: [400, 500, 700],
37
+ languages: ["Japanese"],
38
+ },
39
+ {
40
+ family: "Noto Sans SC",
41
+ googleFontsId: "Noto+Sans+SC",
42
+ license: "OFL-1.1",
43
+ licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans+SC/about",
44
+ category: "body",
45
+ weights: [400, 500, 700],
46
+ languages: ["Chinese"],
47
+ },
48
+ {
49
+ family: "Noto Sans Devanagari",
50
+ googleFontsId: "Noto+Sans+Devanagari",
51
+ license: "OFL-1.1",
52
+ licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Sans+Devanagari/about",
53
+ category: "body",
54
+ weights: [400, 500, 700],
55
+ languages: ["Hindi"],
56
+ },
57
+ {
58
+ family: "Noto Naskh Arabic",
59
+ googleFontsId: "Noto+Naskh+Arabic",
60
+ license: "OFL-1.1",
61
+ licenseUrl: "https://fonts.google.com/noto/specimen/Noto+Naskh+Arabic/about",
62
+ category: "body",
63
+ weights: [400, 500, 700],
64
+ languages: ["Arabic"],
65
+ },
66
+ {
67
+ family: "Bangers",
68
+ googleFontsId: "Bangers",
69
+ license: "OFL-1.1",
70
+ licenseUrl: "https://fonts.google.com/specimen/Bangers/about",
71
+ category: "display",
72
+ weights: [400],
73
+ languages: [],
74
+ },
75
+ ];
76
+
77
+ export const FONT_FALLBACK_STACK = "system-ui, sans-serif";
78
+
79
+ const defaultFont = FONT_REGISTRY.find((f) => f.family === "Noto Sans")!;
80
+ const displayFont = FONT_REGISTRY.find((f) => f.category === "display")!;
81
+
82
+ export function getDefaultFont(language: string): FontEntry {
83
+ const match = FONT_REGISTRY.find(
84
+ (f) => f.category === "body" && f.languages.includes(language),
85
+ );
86
+ return match || defaultFont;
87
+ }
88
+
89
+ export function getDisplayFont(): FontEntry {
90
+ return displayFont;
91
+ }
92
+
93
+ export function getFontCdnUrl(font: FontEntry): string {
94
+ const weights = font.weights.join(";");
95
+ return `https://fonts.googleapis.com/css2?family=${font.googleFontsId}:wght@${weights}&display=swap`;
96
+ }
97
+
98
+ export function getFontFamily(font: FontEntry): string {
99
+ return `"${font.family}", ${FONT_FALLBACK_STACK}`;
100
+ }
101
+
102
+ export const LANGUAGE_FONT_SAMPLES: Record<string, { text: string; font: string }> = {
103
+ English: { text: "The quick brown fox jumps", font: "Noto Sans" },
104
+ Korean: { text: "한국어 샘플 텍스트", font: "Noto Sans KR" },
105
+ Japanese: { text: "日本語のサンプル", font: "Noto Sans JP" },
106
+ Chinese: { text: "中文示例文本", font: "Noto Sans SC" },
107
+ Hindi: { text: "हिंदी नमूना पाठ", font: "Noto Sans Devanagari" },
108
+ Arabic: { text: "نص عربي نموذجي", font: "Noto Naskh Arabic" },
109
+ };
@@ -27,6 +27,11 @@ All endpoints except auth use \`Authorization: Bearer {token}\` headers.
27
27
  The OWS passphrase is stored in \`~/.plotlink-ows/.env\` as \`OWS_PASSPHRASE\`.
28
28
  For login, the passphrase is hashed with HMAC-SHA256 and compared against the stored hash.
29
29
 
30
+ **Never print secrets into the terminal.** Do not \`echo\`, \`cat\`, or log the
31
+ \`OWS_PASSPHRASE\`, the session token, or any \`Authorization: Bearer\` header / login
32
+ command that contains the passphrase. The app authenticates for you — you never
33
+ need to read or print these.
34
+
30
35
  | Endpoint | Method | Auth | Purpose |
31
36
  |----------|--------|------|---------|
32
37
  | \`/api/auth/status\` | GET | No | Check if passphrase is configured |
@@ -39,11 +44,11 @@ For login, the passphrase is hashed with HMAC-SHA256 and compared against the st
39
44
 
40
45
  | Endpoint | Method | Purpose |
41
46
  |----------|--------|---------|
42
- | \`/api/publish/preflight\` | GET | Check wallet balance, Filebase config |
47
+ | \`/api/publish/preflight\` | GET | Check wallet balance vs. creation fee (uploads go through the PlotLink API) |
43
48
  | \`/api/publish/file\` | POST | Publish story on-chain (SSE stream of progress events) |
44
49
  | \`/api/publish/retry-index\` | POST | Retry indexing for a published file |
45
- | \`/api/publish/upload-cover\` | POST | Upload cover image — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid }\` |
46
- | \`/api/publish/upload-plot-image\` | POST | Upload plot illustration — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid, url }\` |
50
+ | \`/api/publish/upload-cover\` | POST | Upload cover image — FormData \`file\` field, **WebP or JPEG only**, max 1MB → returns \`{ cid }\` |
51
+ | \`/api/publish/upload-plot-image\` | POST | Upload plot illustration — FormData \`file\` field, **WebP or JPEG only**, max 1MB → returns \`{ cid, url }\` |
47
52
  | \`/api/publish/update-storyline\` | POST | Update storyline metadata (coverCid, genre, language, isNsfw) |
48
53
 
49
54
  **Publish flow:** Upload to IPFS → estimate gas → sign with OWS wallet → broadcast → confirm → index on plotlink.xyz (8s delay + 10 retries × 30s). Genesis files call \`createStoryline\`, plot files (\`plot-*.md\`) call \`chainPlot\`. Content limit: 10K chars.
@@ -77,6 +82,7 @@ Both upload-cover and update-storyline sign messages with the OWS wallet.
77
82
  | \`/api/stories/:name/:file\` | GET | Single file content and publish status |
78
83
  | \`/api/stories/:name/:file\` | PUT | Update file content \`{ content }\` |
79
84
  | \`/api/stories/:name/:file/publish-status\` | POST | Record publish result (txHash, storylineId, etc.) |
85
+ | \`/api/stories/:name/metadata\` | POST | Write story metadata \`{ contentType }\` |
80
86
  | \`/api/stories/:name/:file/mark-not-indexed\` | POST | Mark file as not indexed \`{ indexError? }\` |
81
87
 
82
88
  ## Terminal
@@ -110,6 +116,7 @@ Stories live in \`~/.plotlink-ows/stories/{story-name}/\`:
110
116
 
111
117
  \`\`\`
112
118
  stories/{story-name}/
119
+ .story.json # Content type metadata (fiction | cartoon)
113
120
  structure.md # Outline, characters, arc
114
121
  genesis.md # Synopsis hook (~1000 chars)
115
122
  plot-01.md # Chapter 1 (max 10K chars)