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.
- package/README.md +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +8 -1
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +203 -22
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +951 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-DxATSk7X.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- 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 });
|