plotlink-ows 1.0.33 → 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 +8 -1
  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 +203 -22
  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 +951 -78
  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-DxATSk7X.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,637 @@
1
+ export const OVERLAY_TYPES = ["speech", "narration", "sfx"] as const;
2
+ export type OverlayType = (typeof OVERLAY_TYPES)[number];
3
+
4
+ /**
5
+ * Revision of the cartoon speech-bubble RENDERER (#381). Bumped whenever the
6
+ * exported balloon shape changes in a way that makes older final images stale —
7
+ * notably the seam fixes that made the body+tail a single unified outline
8
+ * (#317/#341), kept the tail mouth off the corners (#361), and rounded the body
9
+ * proportionally (#363). A final image stamped below this version (or unstamped,
10
+ * i.e. pre-versioning) may show the old separate-tail seam and should be
11
+ * re-exported before publish. Bump this when balloonOutline / defaultBalloonRadius
12
+ * / the export bubble draw changes the rendered shape.
13
+ */
14
+ export const CARTOON_BUBBLE_RENDERER_VERSION = 2;
15
+
16
+ export interface OverlayTextStyle {
17
+ /** Default: auto-fit. Manual keeps the chosen font size until changed. */
18
+ mode?: "auto" | "manual";
19
+ /** Font size as a fraction of the rendered panel height. */
20
+ fontScale?: number;
21
+ /** Body text weight: regular or bold. */
22
+ fontWeight?: 400 | 700;
23
+ /** Line advance as a multiple of body font size. */
24
+ lineHeightFactor?: number;
25
+ /** Speaker-label size as a multiple of body font size. */
26
+ speakerScale?: number;
27
+ }
28
+
29
+ export interface OverlayBubbleStyle {
30
+ /** Horizontal padding as a fraction of bubble width. */
31
+ paddingX?: number;
32
+ /** Vertical padding as a fraction of bubble height. */
33
+ paddingY?: number;
34
+ /** Corner roundness as a fraction of the bubble's shorter side. */
35
+ cornerRadius?: number;
36
+ }
37
+
38
+ export interface Overlay {
39
+ id: string;
40
+ type: OverlayType;
41
+ x: number;
42
+ y: number;
43
+ width: number;
44
+ height: number;
45
+ text: string;
46
+ speaker?: string;
47
+ tailAnchor?: { x: number; y: number };
48
+ textStyle?: OverlayTextStyle;
49
+ bubbleStyle?: OverlayBubbleStyle;
50
+ }
51
+
52
+ import { defaultBubbleFontRange, type BubbleLayoutOptions } from "./bubble-text";
53
+
54
+ export function toPixel(norm: number, containerSize: number): number {
55
+ return norm * containerSize;
56
+ }
57
+
58
+ export function toNorm(pixel: number, containerSize: number): number {
59
+ if (containerSize === 0) return 0;
60
+ return pixel / containerSize;
61
+ }
62
+
63
+ export interface Point {
64
+ x: number;
65
+ y: number;
66
+ }
67
+
68
+ export interface TailPoints {
69
+ tip: Point;
70
+ base1: Point;
71
+ base2: Point;
72
+ }
73
+
74
+ function clamp(v: number, min: number, max: number): number {
75
+ return Math.min(max, Math.max(min, v));
76
+ }
77
+
78
+ function clampOptional(v: unknown, min: number, max: number): number | undefined {
79
+ return typeof v === "number" && Number.isFinite(v) ? clamp(v, min, max) : undefined;
80
+ }
81
+
82
+ function normalizeTextStyle(raw: unknown): OverlayTextStyle | undefined {
83
+ if (!raw || typeof raw !== "object") return undefined;
84
+ const r = raw as Record<string, unknown>;
85
+ const mode = r.mode === "manual" ? "manual" : r.mode === "auto" ? "auto" : undefined;
86
+ const fontScale = clampOptional(r.fontScale, 0.015, 0.12);
87
+ const fontWeight = r.fontWeight === 700 ? 700 : r.fontWeight === 400 ? 400 : undefined;
88
+ const lineHeightFactor = clampOptional(r.lineHeightFactor, 0.9, 2);
89
+ const speakerScale = clampOptional(r.speakerScale, 0.5, 1.5);
90
+ if (!mode && fontScale === undefined && fontWeight === undefined && lineHeightFactor === undefined && speakerScale === undefined) return undefined;
91
+ return {
92
+ ...(mode ? { mode } : {}),
93
+ ...(fontScale !== undefined ? { fontScale } : {}),
94
+ ...(fontWeight !== undefined ? { fontWeight } : {}),
95
+ ...(lineHeightFactor !== undefined ? { lineHeightFactor } : {}),
96
+ ...(speakerScale !== undefined ? { speakerScale } : {}),
97
+ };
98
+ }
99
+
100
+ function normalizeBubbleStyle(raw: unknown): OverlayBubbleStyle | undefined {
101
+ if (!raw || typeof raw !== "object") return undefined;
102
+ const r = raw as Record<string, unknown>;
103
+ const paddingX = clampOptional(r.paddingX, 0, 0.25);
104
+ const paddingY = clampOptional(r.paddingY, 0, 0.25);
105
+ const cornerRadius = clampOptional(r.cornerRadius, 0, 0.49);
106
+ if (paddingX === undefined && paddingY === undefined && cornerRadius === undefined) return undefined;
107
+ return { ...(paddingX !== undefined ? { paddingX } : {}), ...(paddingY !== undefined ? { paddingY } : {}), ...(cornerRadius !== undefined ? { cornerRadius } : {}) };
108
+ }
109
+
110
+ export function bubbleLayoutOptionsForOverlay(
111
+ overlay: Pick<Overlay, "type" | "speaker" | "textStyle" | "bubbleStyle">,
112
+ renderHeight: number,
113
+ boxWidth: number,
114
+ boxHeight: number,
115
+ ): BubbleLayoutOptions {
116
+ const { minFontSize, maxFontSize } = defaultBubbleFontRange(renderHeight);
117
+ const textStyle = normalizeTextStyle(overlay.textStyle);
118
+ const bubbleStyle = normalizeBubbleStyle(overlay.bubbleStyle);
119
+ return {
120
+ minFontSize,
121
+ maxFontSize,
122
+ hasSpeaker: overlay.type !== "sfx" && !!overlay.speaker,
123
+ ...(textStyle?.lineHeightFactor !== undefined ? { lineHeightFactor: textStyle.lineHeightFactor } : {}),
124
+ ...(textStyle?.speakerScale !== undefined ? { speakerScale: textStyle.speakerScale } : {}),
125
+ ...(textStyle?.fontWeight !== undefined ? { fontWeight: textStyle.fontWeight } : {}),
126
+ ...(textStyle?.mode === "manual" && textStyle.fontScale !== undefined
127
+ ? { fontSize: Math.max(1, renderHeight * textStyle.fontScale) }
128
+ : {}),
129
+ ...(bubbleStyle?.paddingX !== undefined ? { paddingX: boxWidth * bubbleStyle.paddingX } : {}),
130
+ ...(bubbleStyle?.paddingY !== undefined ? { paddingY: boxHeight * bubbleStyle.paddingY } : {}),
131
+ };
132
+ }
133
+
134
+ export function balloonRadiusForOverlay(
135
+ overlay: Pick<Overlay, "bubbleStyle">,
136
+ ow: number,
137
+ oh: number,
138
+ ): number | undefined {
139
+ const bubbleStyle = normalizeBubbleStyle(overlay.bubbleStyle);
140
+ return bubbleStyle?.cornerRadius !== undefined ? Math.min(ow, oh) * bubbleStyle.cornerRadius : undefined;
141
+ }
142
+
143
+ /**
144
+ * Geometry for a speech-bubble tail, in the same pixel space as the bubble rect.
145
+ *
146
+ * `tailAnchor` is bubble-relative and normalized: x runs 0→1 across the bubble
147
+ * width, y runs 0→1 down the bubble height, and values outside [0,1] place the
148
+ * tip beyond the bubble edges (the default {x:0.5, y:1.2} points straight down
149
+ * to the speaker). Returns the tip plus the two base points where the tail
150
+ * meets the bubble border, or null when the tip falls inside the bubble (no
151
+ * visible tail to draw). Shared by the export canvas and the editor preview so
152
+ * both render the tail identically.
153
+ */
154
+ /**
155
+ * Default corner radius for a speech balloon. The single source of truth for
156
+ * balloon rounding: both balloonOutline (body curve) and speechTailPoints (so
157
+ * the tail mouth stays clear of the rounded corners — see fitTailMouth) use it,
158
+ * so the body and tail agree and preview and export round identically.
159
+ *
160
+ * Proportional to the bubble's shorter side (#363): a soft, webtoon-style
161
+ * rounding that reads as a comic balloon at any export scale. The previous flat
162
+ * 8px cap looked rounded on the small editor preview but nearly rectangular on a
163
+ * large exported panel — the boxiness #363 fixes. Capped strictly below half the
164
+ * shorter side so the four corner arcs never overrun the body.
165
+ */
166
+ export function defaultBalloonRadius(ow: number, oh: number): number {
167
+ const shorter = Math.min(ow, oh);
168
+ return Math.max(0, Math.min(shorter * 0.4, shorter / 2));
169
+ }
170
+
171
+ /**
172
+ * Place a tail mouth of nominal width `baseW`, centered as near `toward` as
173
+ * possible, but kept entirely on the STRAIGHT span of the edge — between the two
174
+ * rounded corners `[start + r, start + size - r]`. If the straight span is
175
+ * narrower than the mouth, the mouth shrinks to fit. This guarantees both base
176
+ * points sit on the flat edge, never inside a corner arc, so the unified balloon
177
+ * outline never back-tracks into a corner (which would render as an internal
178
+ * notch/seam between body and tail — #361). Returns the mouth center and
179
+ * half-width along the edge axis.
180
+ */
181
+ function fitTailMouth(toward: number, start: number, size: number, r: number, baseW: number): { center: number; half: number } {
182
+ const span = Math.max(0, size - 2 * r); // flat edge length between the corners
183
+ const half = Math.max(1, Math.min(baseW, span) / 2);
184
+ const lo = start + r + half;
185
+ const hi = start + size - r - half;
186
+ const center = hi >= lo ? clamp(toward, lo, hi) : start + size / 2;
187
+ return { center, half };
188
+ }
189
+
190
+ export function speechTailPoints(
191
+ ox: number,
192
+ oy: number,
193
+ ow: number,
194
+ oh: number,
195
+ tail: Point,
196
+ radius?: number,
197
+ ): TailPoints | null {
198
+ const cx = ox + ow / 2;
199
+ const cy = oy + oh / 2;
200
+ const tipX = ox + tail.x * ow;
201
+ const tipY = oy + tail.y * oh;
202
+
203
+ // Tip inside the bubble → nothing meaningful to draw.
204
+ if (tipX >= ox && tipX <= ox + ow && tipY >= oy && tipY <= oy + oh) return null;
205
+
206
+ const dx = tipX - cx;
207
+ const dy = tipY - cy;
208
+ const baseW = Math.max(6, Math.min(ow, oh) * 0.3);
209
+ // Match balloonOutline's corner radius so the mouth stays off the corners.
210
+ const r = radius ?? defaultBalloonRadius(ow, oh);
211
+
212
+ // Anchor the base to the edge the tail points toward, perpendicular to the
213
+ // dominant direction, so the triangle reads as a comic speech tail. The mouth
214
+ // is fitted onto the flat part of that edge (fitTailMouth) so a tail aimed
215
+ // near a corner can't push a base point into the rounded corner.
216
+ if (Math.abs(dy) >= Math.abs(dx)) {
217
+ const edgeY = dy >= 0 ? oy + oh : oy;
218
+ const { center, half } = fitTailMouth(tipX, ox, ow, r, baseW);
219
+ return {
220
+ tip: { x: tipX, y: tipY },
221
+ base1: { x: center - half, y: edgeY },
222
+ base2: { x: center + half, y: edgeY },
223
+ };
224
+ }
225
+ const edgeX = dx >= 0 ? ox + ow : ox;
226
+ const { center, half } = fitTailMouth(tipY, oy, oh, r, baseW);
227
+ return {
228
+ tip: { x: tipX, y: tipY },
229
+ base1: { x: edgeX, y: center - half },
230
+ base2: { x: edgeX, y: center + half },
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Whether an overlay renders a VISIBLE speech-bubble tail (#381): a speech
236
+ * overlay with a tailAnchor whose tip falls outside the bubble (so a tail is
237
+ * actually drawn — the case the body/tail seam fixes affect). Evaluated in a
238
+ * unit box since tailAnchor is normalized; a tip inside the bubble draws no tail.
239
+ */
240
+ export function hasVisibleSpeechTail(overlay: Pick<Overlay, "type" | "tailAnchor">): boolean {
241
+ if (overlay.type !== "speech" || !overlay.tailAnchor) return false;
242
+ return speechTailPoints(0, 0, 1, 1, overlay.tailAnchor) !== null;
243
+ }
244
+
245
+ /**
246
+ * One drawing command in a balloon outline. `M`/`L` are move/line to (x,y); `A`
247
+ * is a rounded corner — round the corner whose vertex is (cornerX,cornerY),
248
+ * ending at (x,y), with radius r. The command set maps 1:1 onto both a canvas
249
+ * path (`moveTo`/`lineTo`/`arcTo`) and an SVG path (`M`/`L`/`A`), so the editor
250
+ * preview and the export trace the EXACT same outline (#341).
251
+ */
252
+ export type BalloonCommand =
253
+ | { k: "M"; x: number; y: number }
254
+ | { k: "L"; x: number; y: number }
255
+ | { k: "A"; cornerX: number; cornerY: number; x: number; y: number; r: number };
256
+
257
+ /**
258
+ * The single source of truth for a speech balloon's outline (#341): the
259
+ * rounded-rect body plus its pointer tail as ONE continuous perimeter, with the
260
+ * tail folded into whichever edge it sits on (a detour out to the tip and back),
261
+ * never a separate shape. Both the editor-preview SVG path (balloonPathD) and
262
+ * the export canvas (traceBalloonPath in export-cut) are built from this list,
263
+ * so they cannot diverge and there is no internal body/tail seam in either.
264
+ *
265
+ * `tail` is null for a tailless bubble (no tailAnchor, or a tip inside the
266
+ * bubble) → a plain rounded rectangle. Coordinates are in the caller's pixel
267
+ * space (export uses natural-image px; the preview uses display px).
268
+ */
269
+ export function balloonOutline(
270
+ ox: number,
271
+ oy: number,
272
+ ow: number,
273
+ oh: number,
274
+ tail: TailPoints | null,
275
+ radius?: number,
276
+ ): BalloonCommand[] {
277
+ const r = radius ?? defaultBalloonRadius(ow, oh);
278
+ const right = ox + ow;
279
+ const bottom = oy + oh;
280
+
281
+ // speechTailPoints anchors both base points exactly on one bubble edge, so the
282
+ // edge each comparison identifies is exact (no float fuzz needed).
283
+ const onTop = !!tail && tail.base1.y === oy && tail.base2.y === oy;
284
+ const onRight = !!tail && tail.base1.x === right && tail.base2.x === right;
285
+ const onBottom = !!tail && tail.base1.y === bottom && tail.base2.y === bottom;
286
+ const onLeft = !!tail && tail.base1.x === ox && tail.base2.x === ox;
287
+
288
+ const cmds: BalloonCommand[] = [{ k: "M", x: ox + r, y: oy }];
289
+ // Top edge, traced left→right (base1.x < base2.x).
290
+ if (onTop && tail) {
291
+ cmds.push({ k: "L", x: tail.base1.x, y: oy }, { k: "L", x: tail.tip.x, y: tail.tip.y }, { k: "L", x: tail.base2.x, y: oy });
292
+ }
293
+ cmds.push({ k: "L", x: right - r, y: oy }, { k: "A", cornerX: right, cornerY: oy, x: right, y: oy + r, r });
294
+ // Right edge, traced top→bottom (base1.y < base2.y).
295
+ if (onRight && tail) {
296
+ cmds.push({ k: "L", x: right, y: tail.base1.y }, { k: "L", x: tail.tip.x, y: tail.tip.y }, { k: "L", x: right, y: tail.base2.y });
297
+ }
298
+ cmds.push({ k: "L", x: right, y: bottom - r }, { k: "A", cornerX: right, cornerY: bottom, x: right - r, y: bottom, r });
299
+ // Bottom edge, traced right→left (so base2.x first, then base1.x).
300
+ if (onBottom && tail) {
301
+ cmds.push({ k: "L", x: tail.base2.x, y: bottom }, { k: "L", x: tail.tip.x, y: tail.tip.y }, { k: "L", x: tail.base1.x, y: bottom });
302
+ }
303
+ cmds.push({ k: "L", x: ox + r, y: bottom }, { k: "A", cornerX: ox, cornerY: bottom, x: ox, y: bottom - r, r });
304
+ // Left edge, traced bottom→top (so base2.y first, then base1.y).
305
+ if (onLeft && tail) {
306
+ cmds.push({ k: "L", x: ox, y: tail.base2.y }, { k: "L", x: tail.tip.x, y: tail.tip.y }, { k: "L", x: ox, y: tail.base1.y });
307
+ }
308
+ cmds.push({ k: "L", x: ox, y: oy + r }, { k: "A", cornerX: ox, cornerY: oy, x: ox + r, y: oy, r });
309
+ return cmds;
310
+ }
311
+
312
+ /**
313
+ * SVG path `d` for a speech balloon, built from the shared {@link balloonOutline}
314
+ * (#327, #341). Filling and stroking this single path yields an integrated
315
+ * balloon with no internal body/tail seam; it traces the identical outline the
316
+ * export canvas does.
317
+ */
318
+ export function balloonPathD(
319
+ ox: number,
320
+ oy: number,
321
+ ow: number,
322
+ oh: number,
323
+ tail: TailPoints | null,
324
+ radius?: number,
325
+ ): string {
326
+ const parts = balloonOutline(ox, oy, ow, oh, tail, radius).map((c) =>
327
+ c.k === "A" ? `A ${c.r} ${c.r} 0 0 1 ${c.x} ${c.y}` : `${c.k} ${c.x} ${c.y}`,
328
+ );
329
+ parts.push("Z");
330
+ return parts.join(" ");
331
+ }
332
+
333
+ /**
334
+ * Whether an overlay's BODY rect extends outside the image (#336). Coordinates
335
+ * are normalized 0–1, so anything below 0 or past 1 on either axis is clipped at
336
+ * export. Only the body box is checked — a speech tail intentionally points
337
+ * beyond the bubble edge (its tip is allowed outside). A tiny epsilon avoids
338
+ * flagging boxes that sit exactly on an edge.
339
+ */
340
+ export function isOverlayOutOfBounds(o: Pick<Overlay, "x" | "y" | "width" | "height">): boolean {
341
+ const eps = 1e-6;
342
+ return (
343
+ o.x < -eps ||
344
+ o.y < -eps ||
345
+ o.x + o.width > 1 + eps ||
346
+ o.y + o.height > 1 + eps
347
+ );
348
+ }
349
+
350
+ let counter = 0;
351
+
352
+ export function createOverlay(type: OverlayType, x = 0.1, y = 0.1): Overlay {
353
+ counter++;
354
+ return {
355
+ id: `overlay-${Date.now()}-${counter}`,
356
+ type,
357
+ x,
358
+ y,
359
+ width: type === "sfx" ? 0.15 : 0.25,
360
+ height: type === "sfx" ? 0.08 : 0.12,
361
+ text: "",
362
+ ...(type === "speech" ? { speaker: "", tailAnchor: { x: 0.5, y: 1.2 } } : {}),
363
+ };
364
+ }
365
+
366
+ /**
367
+ * A comfortable starting box for a freshly-added overlay (#452): big enough that
368
+ * an ordinary narration/dialogue line doesn't overflow the instant it's added —
369
+ * unlike `createOverlay`'s minimal box. The lettering editor refines this with
370
+ * font metrics when available; this is the no-measurement fallback and the
371
+ * comfortable width it grows the height from. Clamped so the box stays on the
372
+ * image starting from (x, y).
373
+ */
374
+ export function comfortableOverlaySize(type: OverlayType, x: number, y: number): { width: number; height: number } {
375
+ return {
376
+ width: Math.min(type === "sfx" ? 0.3 : 0.5, Math.max(0.15, 1 - x)),
377
+ height: Math.min(type === "sfx" ? 0.1 : 0.2, Math.max(0.06, 1 - y)),
378
+ };
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Overlay normalization / export validation (#309)
383
+ //
384
+ // Agent-authored cuts.json overlays sometimes carry a semantic `position`
385
+ // string (e.g. "upper-left") and no numeric geometry. Those records counted as
386
+ // overlays but the editor/export expect numeric x/y/width/height, so bubbles did
387
+ // not render and Export produced a silent UNLETTERED image. We normalize what we
388
+ // can (semantic position → geometry) and block export when an overlay still has
389
+ // no usable geometry.
390
+ // ---------------------------------------------------------------------------
391
+
392
+ const POSITION_MARGIN = 0.05;
393
+
394
+ function isFiniteNumber(v: unknown): v is number {
395
+ return typeof v === "number" && Number.isFinite(v);
396
+ }
397
+
398
+ /**
399
+ * Map a semantic position string ("upper-left", "top right", "bottom-center", …)
400
+ * to a normalized top-left anchor for a box of the given width/height. Returns
401
+ * null when no left/right/top/bottom/center keyword is recognized.
402
+ */
403
+ export function anchorFromPosition(
404
+ position: string,
405
+ width: number,
406
+ height: number,
407
+ ): { x: number; y: number } | null {
408
+ const p = position.toLowerCase();
409
+ const left = /\bleft\b/.test(p);
410
+ const right = /\bright\b/.test(p);
411
+ const top = /\b(?:top|upper)\b/.test(p);
412
+ const bottom = /\b(?:bottom|lower)\b/.test(p);
413
+ const center = /\b(?:center|centre|middle)\b/.test(p);
414
+ if (!left && !right && !top && !bottom && !center) return null;
415
+ const x = left
416
+ ? POSITION_MARGIN
417
+ : right
418
+ ? clamp(1 - width - POSITION_MARGIN, 0, 1)
419
+ : clamp((1 - width) / 2, 0, 1);
420
+ const y = top
421
+ ? POSITION_MARGIN
422
+ : bottom
423
+ ? clamp(1 - height - POSITION_MARGIN, 0, 1)
424
+ : clamp((1 - height) / 2, 0, 1);
425
+ return { x, y };
426
+ }
427
+
428
+ let normCounter = 0;
429
+
430
+ /**
431
+ * Coerce a raw overlay record into a valid Overlay, or return null when it
432
+ * cannot be placed. Accepts overlays with numeric geometry OR a recognizable
433
+ * semantic `position`; fills a stable-ish id, default size, and (for speech) a
434
+ * default tailAnchor. Returns null only when there is neither usable numeric
435
+ * x/y nor a recognizable position — reported as invalid so export can block.
436
+ */
437
+ export function normalizeOverlay(raw: unknown): Overlay | null {
438
+ if (!raw || typeof raw !== "object") return null;
439
+ const r = raw as Record<string, unknown>;
440
+ const type: OverlayType = (OVERLAY_TYPES as readonly string[]).includes(r.type as string)
441
+ ? (r.type as OverlayType)
442
+ : "speech";
443
+ const text = typeof r.text === "string" ? r.text : "";
444
+
445
+ let width = isFiniteNumber(r.width) && r.width > 0 ? r.width : type === "sfx" ? 0.15 : 0.4;
446
+ let height = isFiniteNumber(r.height) && r.height > 0 ? r.height : type === "sfx" ? 0.08 : 0.16;
447
+
448
+ let x: number;
449
+ let y: number;
450
+ if (isFiniteNumber(r.x) && isFiniteNumber(r.y)) {
451
+ x = r.x;
452
+ y = r.y;
453
+ } else {
454
+ const anchor = typeof r.position === "string" ? anchorFromPosition(r.position, width, height) : null;
455
+ if (!anchor) return null;
456
+ x = anchor.x;
457
+ y = anchor.y;
458
+ }
459
+
460
+ x = clamp(x, 0, 1);
461
+ y = clamp(y, 0, 1);
462
+ width = clamp(width, 0.02, 1);
463
+ height = clamp(height, 0.02, 1);
464
+
465
+ const id = typeof r.id === "string" && r.id ? r.id : `overlay-norm-${++normCounter}`;
466
+ const overlay: Overlay = { id, type, x, y, width, height, text };
467
+ if (type === "speech") {
468
+ overlay.speaker = typeof r.speaker === "string" ? r.speaker : "";
469
+ const ta = r.tailAnchor as { x?: unknown; y?: unknown } | undefined;
470
+ overlay.tailAnchor =
471
+ ta && isFiniteNumber(ta.x) && isFiniteNumber(ta.y) ? { x: ta.x, y: ta.y } : { x: 0.5, y: 1.2 };
472
+ } else if (typeof r.speaker === "string" && r.speaker) {
473
+ overlay.speaker = r.speaker;
474
+ }
475
+ const textStyle = normalizeTextStyle(r.textStyle);
476
+ const bubbleStyle = normalizeBubbleStyle(r.bubbleStyle);
477
+ if (textStyle) overlay.textStyle = textStyle;
478
+ if (bubbleStyle) overlay.bubbleStyle = bubbleStyle;
479
+ return overlay;
480
+ }
481
+
482
+ export interface NormalizeOverlaysResult {
483
+ overlays: Overlay[];
484
+ /** True when normalization changed the records (repaired, filled, or dropped). */
485
+ changed: boolean;
486
+ /** Records that could not be placed and were dropped from `overlays`. */
487
+ invalid: { index: number; reason: string }[];
488
+ }
489
+
490
+ function isCanonicalOverlay(raw: unknown): boolean {
491
+ if (!raw || typeof raw !== "object") return false;
492
+ const r = raw as Record<string, unknown>;
493
+ return (
494
+ typeof r.id === "string" &&
495
+ !!r.id &&
496
+ (OVERLAY_TYPES as readonly string[]).includes(r.type as string) &&
497
+ isFiniteNumber(r.x) &&
498
+ isFiniteNumber(r.y) &&
499
+ isFiniteNumber(r.width) &&
500
+ isFiniteNumber(r.height) &&
501
+ typeof r.text === "string"
502
+ );
503
+ }
504
+
505
+ /** Normalize an array of raw overlay records (see normalizeOverlay). */
506
+ export function normalizeOverlays(raw: unknown): NormalizeOverlaysResult {
507
+ const arr = Array.isArray(raw) ? raw : [];
508
+ const overlays: Overlay[] = [];
509
+ const invalid: { index: number; reason: string }[] = [];
510
+ let changed = !Array.isArray(raw);
511
+ arr.forEach((o, i) => {
512
+ const norm = normalizeOverlay(o);
513
+ if (!norm) {
514
+ invalid.push({
515
+ index: i,
516
+ reason: "overlay has no numeric x/y/width/height and no recognizable position",
517
+ });
518
+ changed = true;
519
+ return;
520
+ }
521
+ overlays.push(norm);
522
+ if (!isCanonicalOverlay(o)) changed = true;
523
+ });
524
+ return { overlays, changed, invalid };
525
+ }
526
+
527
+ /**
528
+ * Validate overlays immediately before export (#309). Blocks when any overlay
529
+ * lacks finite, positive numeric geometry, so OWS never silently exports an
530
+ * image whose overlays are invisible/unlettered. Returns the first problem.
531
+ */
532
+ export function validateOverlaysForExport(overlays: Overlay[]): { valid: boolean; error?: string } {
533
+ for (let i = 0; i < overlays.length; i++) {
534
+ const o = overlays[i];
535
+ const geomOk =
536
+ isFiniteNumber(o?.x) &&
537
+ isFiniteNumber(o?.y) &&
538
+ isFiniteNumber(o?.width) &&
539
+ isFiniteNumber(o?.height) &&
540
+ o.width > 0 &&
541
+ o.height > 0;
542
+ if (!geomOk) {
543
+ return {
544
+ valid: false,
545
+ error: `Overlay ${i + 1}${o?.type ? ` (${o.type})` : ""} has invalid geometry — repair or re-place it in the lettering editor before export`,
546
+ };
547
+ }
548
+ const textStyle = normalizeTextStyle(o?.textStyle);
549
+ if (o?.textStyle && !textStyle) {
550
+ return {
551
+ valid: false,
552
+ error: `Overlay ${i + 1}${o?.type ? ` (${o.type})` : ""} has invalid typography controls — reset them in the lettering editor before export`,
553
+ };
554
+ }
555
+ const bubbleStyle = normalizeBubbleStyle(o?.bubbleStyle);
556
+ if (o?.bubbleStyle && !bubbleStyle) {
557
+ return {
558
+ valid: false,
559
+ error: `Overlay ${i + 1}${o?.type ? ` (${o.type})` : ""} has invalid bubble controls — reset them in the lettering editor before export`,
560
+ };
561
+ }
562
+ }
563
+ return { valid: true };
564
+ }
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // Overlapping-bubble detection (#318)
568
+ //
569
+ // Pilot QA found cuts where two speech bubbles overlapped, leaving the back
570
+ // bubble's text faintly visible behind the front one — unpolished and hard to
571
+ // read. This is an MVP readability guard (not a layout engine): flag pairs of
572
+ // bubbles whose filled bodies overlap enough to occlude each other, so the
573
+ // editor can warn before export/publish. Only speech and narration bubbles have
574
+ // an opaque fill that hides what's behind them; SFX is transparent stroked text
575
+ // laid over the art, so it is not treated as occluding.
576
+ // ---------------------------------------------------------------------------
577
+
578
+ const OCCLUDING_TYPES: ReadonlySet<OverlayType> = new Set(["speech", "narration"]);
579
+
580
+ /**
581
+ * Minimum overlap, as a fraction of the SMALLER bubble's area, for a pair to be
582
+ * reported. A small nick where two bubbles barely touch is ignored; once an
583
+ * eighth of the smaller bubble is covered the back text starts to be obscured.
584
+ */
585
+ export const OVERLAP_AREA_THRESHOLD = 0.12;
586
+
587
+ export interface OverlapPair {
588
+ /** Indexes into the overlays array (stable, 0-based). */
589
+ indexA: number;
590
+ indexB: number;
591
+ idA: string;
592
+ idB: string;
593
+ /** Intersection area as a fraction of the smaller bubble's area (0–1). */
594
+ ratio: number;
595
+ }
596
+
597
+ function hasFiniteRect(o: Overlay): boolean {
598
+ return (
599
+ isFiniteNumber(o?.x) &&
600
+ isFiniteNumber(o?.y) &&
601
+ isFiniteNumber(o?.width) &&
602
+ isFiniteNumber(o?.height) &&
603
+ o.width > 0 &&
604
+ o.height > 0
605
+ );
606
+ }
607
+
608
+ /**
609
+ * Find pairs of occluding bubbles (speech/narration) that overlap by at least
610
+ * `threshold` of the smaller bubble's area. Pure and geometry-only so it can be
611
+ * reused by the editor warning and any pre-publish preflight. Overlays with
612
+ * non-finite geometry are skipped (those are caught by validateOverlaysForExport).
613
+ */
614
+ export function detectOverlappingOverlays(
615
+ overlays: Overlay[],
616
+ threshold: number = OVERLAP_AREA_THRESHOLD,
617
+ ): OverlapPair[] {
618
+ const pairs: OverlapPair[] = [];
619
+ for (let i = 0; i < overlays.length; i++) {
620
+ const a = overlays[i];
621
+ if (!OCCLUDING_TYPES.has(a?.type) || !hasFiniteRect(a)) continue;
622
+ for (let j = i + 1; j < overlays.length; j++) {
623
+ const b = overlays[j];
624
+ if (!OCCLUDING_TYPES.has(b?.type) || !hasFiniteRect(b)) continue;
625
+ const overlapW = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
626
+ const overlapH = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
627
+ if (overlapW <= 0 || overlapH <= 0) continue;
628
+ const intersection = overlapW * overlapH;
629
+ const minArea = Math.min(a.width * a.height, b.width * b.height);
630
+ const ratio = minArea > 0 ? intersection / minArea : 0;
631
+ if (ratio >= threshold) {
632
+ pairs.push({ indexA: i, indexB: j, idA: a.id, idB: b.id, ratio });
633
+ }
634
+ }
635
+ }
636
+ return pairs;
637
+ }
package/app/lib/paths.ts CHANGED
@@ -10,6 +10,16 @@ export const DATA_DIR = path.join(CONFIG_DIR, "data");
10
10
  export const DB_PATH = path.join(DATA_DIR, "local.db");
11
11
  export const DATABASE_URL = `file:${DB_PATH}`;
12
12
 
13
+ /**
14
+ * Codex's generated-image cache (#403). Built-in image generation drops finished
15
+ * art here as PNGs; OWS reads (never writes) this directory to let the writer
16
+ * import a generated image into a cut without hunting through a hidden folder.
17
+ * Overridable via `CODEX_IMAGES_DIR` for tests / non-default Codex installs. NOT
18
+ * created on import — it belongs to Codex, and a missing dir simply lists empty.
19
+ */
20
+ export const CODEX_IMAGES_DIR =
21
+ process.env.CODEX_IMAGES_DIR || path.join(os.homedir(), ".codex", "generated_images");
22
+
13
23
  // Ensure persistent directories exist on import
14
24
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
15
25
  fs.mkdirSync(STORIES_DIR, { recursive: true });