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.
- 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 +10 -3
- 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 +209 -28
- 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 +1017 -144
- 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-BFw-v-OZ.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,320 @@
|
|
|
1
|
+
import {
|
|
2
|
+
speechTailPoints,
|
|
3
|
+
balloonOutline,
|
|
4
|
+
validateOverlaysForExport,
|
|
5
|
+
bubbleLayoutOptionsForOverlay,
|
|
6
|
+
balloonRadiusForOverlay,
|
|
7
|
+
type TailPoints,
|
|
8
|
+
} from "@app-lib/overlays";
|
|
9
|
+
import { textPanelDimensions } from "@app-lib/cuts";
|
|
10
|
+
// Re-exported so existing importers/tests can keep getting it from export-cut.
|
|
11
|
+
export { textPanelDimensions } from "@app-lib/cuts";
|
|
12
|
+
import { layoutBubbleText } from "@app-lib/bubble-text";
|
|
13
|
+
import { compressCanvasToBlob, MAX_IMAGE_BYTES } from "../lib/image-compress";
|
|
14
|
+
|
|
15
|
+
interface Overlay {
|
|
16
|
+
id: string;
|
|
17
|
+
type: "speech" | "narration" | "sfx";
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
text: string;
|
|
23
|
+
speaker?: string;
|
|
24
|
+
tailAnchor?: { x: number; y: number };
|
|
25
|
+
textStyle?: {
|
|
26
|
+
mode?: "auto" | "manual";
|
|
27
|
+
fontScale?: number;
|
|
28
|
+
fontWeight?: 400 | 700;
|
|
29
|
+
lineHeightFactor?: number;
|
|
30
|
+
speakerScale?: number;
|
|
31
|
+
};
|
|
32
|
+
bubbleStyle?: {
|
|
33
|
+
paddingX?: number;
|
|
34
|
+
paddingY?: number;
|
|
35
|
+
cornerRadius?: number;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Re-exported for the existing export-size validation + tests; the compression
|
|
40
|
+
// policy now lives in the shared image-compress module so the lettering export
|
|
41
|
+
// and the Codex-image import path (#301) stay in lockstep.
|
|
42
|
+
const MAX_SIZE = MAX_IMAGE_BYTES;
|
|
43
|
+
|
|
44
|
+
export async function ensureFontsReady(families: string[]): Promise<{ ready: boolean; missing: string[] }> {
|
|
45
|
+
if (typeof document === "undefined" || !document.fonts || typeof document.fonts.load !== "function") {
|
|
46
|
+
return { ready: true, missing: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const missing: string[] = [];
|
|
50
|
+
for (const family of families) {
|
|
51
|
+
try {
|
|
52
|
+
const loaded = await document.fonts.load(`16px "${family}"`);
|
|
53
|
+
// load() resolves with the FontFace[] that matched. An empty array means
|
|
54
|
+
// the family was never registered (e.g. CDN CSS blocked), so check() may
|
|
55
|
+
// only be matching a system fallback — treat as missing.
|
|
56
|
+
if (!loaded || loaded.length === 0) {
|
|
57
|
+
missing.push(family);
|
|
58
|
+
} else if (!document.fonts.check(`16px "${family}"`)) {
|
|
59
|
+
missing.push(family);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
missing.push(family);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { ready: missing.length === 0, missing };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadImage(url: string): Promise<HTMLImageElement> {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const img = new Image();
|
|
72
|
+
img.crossOrigin = "anonymous";
|
|
73
|
+
img.onload = () => resolve(img);
|
|
74
|
+
img.onerror = () => reject(new Error("Failed to load image"));
|
|
75
|
+
img.src = url;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Webtoon balloon styling (#363). A near-opaque bubble with a strong, clean
|
|
80
|
+
// near-black outline reads as a comic speech balloon rather than a faint UI box
|
|
81
|
+
// (the old rgba(0,0,0,0.3) hairline). Narration is an intentional parchment
|
|
82
|
+
// card with a softer outline; both stroke weights scale with the panel so the
|
|
83
|
+
// look holds at any export resolution.
|
|
84
|
+
const SPEECH_FILL = "rgba(255, 255, 255, 0.95)";
|
|
85
|
+
const SPEECH_STROKE = "#1a1a1a";
|
|
86
|
+
const NARRATION_FILL = "rgba(244, 239, 230, 0.94)";
|
|
87
|
+
const NARRATION_STROKE = "rgba(26, 26, 26, 0.55)";
|
|
88
|
+
|
|
89
|
+
// Outline weight as a fraction of the rendered panel height, so a balloon keeps
|
|
90
|
+
// the same visual line thickness whether exported small or large (#363).
|
|
91
|
+
function balloonStrokeWidth(renderHeight: number): number {
|
|
92
|
+
return Math.max(2, renderHeight * 0.004);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Trace a speech balloon — rounded-rect body plus its pointer tail — as ONE
|
|
96
|
+
// continuous outline, from the SHARED balloonOutline geometry (#341, formerly a
|
|
97
|
+
// duplicate of #317's tracer). Because the editor-preview SVG path and this
|
|
98
|
+
// export canvas now build from the identical command list, the tail is always a
|
|
99
|
+
// detour in the body's perimeter (never a separate stroked shape) and the
|
|
100
|
+
// exported balloon matches the preview with no internal body/tail seam. `tail`
|
|
101
|
+
// is null for a bubble with no (or inside-the-bubble) tail → a rounded rect.
|
|
102
|
+
function traceBalloonPath(
|
|
103
|
+
ctx: CanvasRenderingContext2D,
|
|
104
|
+
ox: number,
|
|
105
|
+
oy: number,
|
|
106
|
+
ow: number,
|
|
107
|
+
oh: number,
|
|
108
|
+
tail: TailPoints | null,
|
|
109
|
+
radius?: number,
|
|
110
|
+
) {
|
|
111
|
+
ctx.beginPath();
|
|
112
|
+
for (const c of balloonOutline(ox, oy, ow, oh, tail, radius)) {
|
|
113
|
+
if (c.k === "M") ctx.moveTo(c.x, c.y);
|
|
114
|
+
else if (c.k === "L") ctx.lineTo(c.x, c.y);
|
|
115
|
+
else ctx.arcTo(c.cornerX, c.cornerY, c.x, c.y, c.r);
|
|
116
|
+
}
|
|
117
|
+
ctx.closePath();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function renderOverlays(
|
|
121
|
+
ctx: CanvasRenderingContext2D,
|
|
122
|
+
overlays: Overlay[],
|
|
123
|
+
width: number,
|
|
124
|
+
height: number,
|
|
125
|
+
bodyFont: string,
|
|
126
|
+
displayFont: string,
|
|
127
|
+
) {
|
|
128
|
+
for (const overlay of overlays) {
|
|
129
|
+
const ox = overlay.x * width;
|
|
130
|
+
const oy = overlay.y * height;
|
|
131
|
+
const ow = overlay.width * width;
|
|
132
|
+
const oh = overlay.height * height;
|
|
133
|
+
|
|
134
|
+
const strokeW = balloonStrokeWidth(height);
|
|
135
|
+
if (overlay.type === "speech") {
|
|
136
|
+
// Trace the body and its tail as a single outline so the exported balloon
|
|
137
|
+
// has no internal seam between them (#317): one fill, one stroke, with the
|
|
138
|
+
// tail forming part of the balloon's outline instead of a shape laid over
|
|
139
|
+
// a fully-stroked body border. A rounded line join keeps the tail/corner
|
|
140
|
+
// junctions soft and organic (#363).
|
|
141
|
+
const radius = balloonRadiusForOverlay(overlay, ow, oh);
|
|
142
|
+
const tail = overlay.tailAnchor ? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius) : null;
|
|
143
|
+
traceBalloonPath(ctx, ox, oy, ow, oh, tail, radius);
|
|
144
|
+
ctx.fillStyle = SPEECH_FILL;
|
|
145
|
+
ctx.fill();
|
|
146
|
+
ctx.strokeStyle = SPEECH_STROKE;
|
|
147
|
+
ctx.lineWidth = strokeW;
|
|
148
|
+
ctx.lineJoin = "round";
|
|
149
|
+
ctx.stroke();
|
|
150
|
+
} else if (overlay.type === "narration") {
|
|
151
|
+
// Narration stays rectangular but reads as an intentional webtoon caption
|
|
152
|
+
// card: gently rounded corners + a confident (if softer-than-speech)
|
|
153
|
+
// outline, instead of a hairline box (#363).
|
|
154
|
+
const nr = Math.min(ow, oh) * 0.12;
|
|
155
|
+
ctx.beginPath();
|
|
156
|
+
ctx.roundRect(ox, oy, ow, oh, nr);
|
|
157
|
+
ctx.fillStyle = NARRATION_FILL;
|
|
158
|
+
ctx.fill();
|
|
159
|
+
ctx.strokeStyle = NARRATION_STROKE;
|
|
160
|
+
ctx.lineWidth = Math.max(1.5, strokeW * 0.75);
|
|
161
|
+
ctx.lineJoin = "round";
|
|
162
|
+
ctx.stroke();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const font = overlay.type === "sfx" ? displayFont : bodyFont;
|
|
166
|
+
const hasSpeaker = overlay.type !== "sfx" && !!overlay.speaker;
|
|
167
|
+
// Measure with the actual draw font so wrapping matches what is rendered.
|
|
168
|
+
const measure = (text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
|
|
169
|
+
ctx.font = `${fontWeight} ${fontSize}px ${font}`;
|
|
170
|
+
return ctx.measureText(text).width;
|
|
171
|
+
};
|
|
172
|
+
const layout = layoutBubbleText(
|
|
173
|
+
measure,
|
|
174
|
+
overlay.text,
|
|
175
|
+
ow,
|
|
176
|
+
oh,
|
|
177
|
+
bubbleLayoutOptionsForOverlay(overlay, height, ow, oh),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
ctx.textAlign = "center";
|
|
181
|
+
ctx.textBaseline = "middle";
|
|
182
|
+
const cx = ox + ow / 2;
|
|
183
|
+
const speakerStrip = hasSpeaker ? layout.speakerFontSize * 1.2 : 0;
|
|
184
|
+
|
|
185
|
+
// Draw the speaker label on its own strip at the top of the bubble.
|
|
186
|
+
if (hasSpeaker) {
|
|
187
|
+
ctx.fillStyle = "#3a3a3a";
|
|
188
|
+
ctx.font = `700 ${layout.speakerFontSize}px ${font}`;
|
|
189
|
+
ctx.fillText(overlay.speaker as string, cx, oy + speakerStrip / 2 + oh * 0.04, ow - 6);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Lay out the wrapped body lines, vertically centered in the remaining box.
|
|
193
|
+
const bodyTop = oy + speakerStrip;
|
|
194
|
+
const bodyH = oh - speakerStrip;
|
|
195
|
+
const totalTextH = layout.lines.length * layout.lineHeight;
|
|
196
|
+
let lineY = bodyTop + bodyH / 2 - totalTextH / 2 + layout.lineHeight / 2;
|
|
197
|
+
|
|
198
|
+
ctx.font = `${overlay.textStyle?.fontWeight ?? 400} ${layout.fontSize}px ${font}`;
|
|
199
|
+
for (const line of layout.lines) {
|
|
200
|
+
if (overlay.type === "sfx") {
|
|
201
|
+
ctx.fillStyle = "#000";
|
|
202
|
+
ctx.strokeStyle = "#fff";
|
|
203
|
+
ctx.lineWidth = 3;
|
|
204
|
+
ctx.strokeText(line, cx, lineY);
|
|
205
|
+
ctx.fillText(line, cx, lineY);
|
|
206
|
+
} else {
|
|
207
|
+
ctx.fillStyle = "#1a1a1a";
|
|
208
|
+
ctx.fillText(line, cx, lineY);
|
|
209
|
+
}
|
|
210
|
+
lineY += layout.lineHeight;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface CutTextContent {
|
|
216
|
+
narration?: string;
|
|
217
|
+
dialogue?: { speaker: string; text: string }[];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderCutText(
|
|
221
|
+
ctx: CanvasRenderingContext2D,
|
|
222
|
+
content: CutTextContent,
|
|
223
|
+
width: number,
|
|
224
|
+
height: number,
|
|
225
|
+
font: string,
|
|
226
|
+
) {
|
|
227
|
+
const fontSize = Math.max(14, Math.min(height * 0.05, 28));
|
|
228
|
+
ctx.font = `${fontSize}px ${font}`;
|
|
229
|
+
ctx.fillStyle = "#1a1a1a";
|
|
230
|
+
ctx.textAlign = "center";
|
|
231
|
+
ctx.textBaseline = "middle";
|
|
232
|
+
|
|
233
|
+
const lines: string[] = [];
|
|
234
|
+
if (content.dialogue) {
|
|
235
|
+
for (const d of content.dialogue) {
|
|
236
|
+
lines.push(`${d.speaker}: ${d.text}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (content.narration) {
|
|
240
|
+
lines.push(content.narration);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const lineHeight = fontSize * 1.6;
|
|
244
|
+
const startY = height / 2 - ((lines.length - 1) * lineHeight) / 2;
|
|
245
|
+
for (let i = 0; i < lines.length; i++) {
|
|
246
|
+
ctx.fillText(lines[i], width / 2, startY + i * lineHeight, width - 40);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Style for a text/interstitial panel exported without a clean image (#351). */
|
|
251
|
+
export interface TextPanelStyle {
|
|
252
|
+
/** CSS background color for the panel canvas. Defaults to white. */
|
|
253
|
+
background?: string;
|
|
254
|
+
/** Aspect ratio "W:H" (e.g. "4:5") sizing the canvas. Defaults to 800×600. */
|
|
255
|
+
aspectRatio?: string;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function exportCut(
|
|
259
|
+
cleanImageUrl: string | null,
|
|
260
|
+
overlays: Overlay[],
|
|
261
|
+
bodyFontFamily: string,
|
|
262
|
+
displayFontFamily: string,
|
|
263
|
+
cutText?: CutTextContent,
|
|
264
|
+
textPanel?: TextPanelStyle,
|
|
265
|
+
): Promise<Blob> {
|
|
266
|
+
// Refuse to export an image whose overlays have invalid geometry — otherwise
|
|
267
|
+
// malformed (e.g. semantic-position) overlays render nothing and we silently
|
|
268
|
+
// produce an unlettered final (#309).
|
|
269
|
+
const overlayCheck = validateOverlaysForExport(overlays);
|
|
270
|
+
if (!overlayCheck.valid) {
|
|
271
|
+
throw new Error(overlayCheck.error ?? "Overlay geometry is invalid");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let width = 800;
|
|
275
|
+
let height = 600;
|
|
276
|
+
let img: HTMLImageElement | null = null;
|
|
277
|
+
|
|
278
|
+
if (cleanImageUrl) {
|
|
279
|
+
img = await loadImage(cleanImageUrl);
|
|
280
|
+
width = img.naturalWidth;
|
|
281
|
+
height = img.naturalHeight;
|
|
282
|
+
} else if (textPanel) {
|
|
283
|
+
// Text/interstitial panel: no clean image — render text on a styled canvas
|
|
284
|
+
// sized by the panel's aspect ratio (#351).
|
|
285
|
+
const dims = textPanelDimensions(textPanel.aspectRatio);
|
|
286
|
+
if (dims) {
|
|
287
|
+
width = dims.width;
|
|
288
|
+
height = dims.height;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const canvas = document.createElement("canvas");
|
|
293
|
+
canvas.width = width;
|
|
294
|
+
canvas.height = height;
|
|
295
|
+
const ctx = canvas.getContext("2d")!;
|
|
296
|
+
|
|
297
|
+
if (img) {
|
|
298
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
299
|
+
} else {
|
|
300
|
+
ctx.fillStyle = textPanel?.background || "#ffffff";
|
|
301
|
+
ctx.fillRect(0, 0, width, height);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
renderOverlays(ctx, overlays, width, height, bodyFontFamily, displayFontFamily);
|
|
305
|
+
|
|
306
|
+
if (cutText && overlays.length === 0 && !img) {
|
|
307
|
+
renderCutText(ctx, cutText, width, height, bodyFontFamily);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return compressCanvasToBlob(canvas);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function validateExportSize(blob: Blob): { valid: boolean; error?: string } {
|
|
314
|
+
if (blob.size > MAX_SIZE) {
|
|
315
|
+
return { valid: false, error: `Image is ${(blob.size / 1024).toFixed(0)}KB, exceeds 1MB limit` };
|
|
316
|
+
}
|
|
317
|
+
return { valid: true };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export { MAX_SIZE };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-BAZGwVwj.js";const z=w;async function G(e){if(typeof document>"u"||!document.fonts||typeof document.fonts.load!="function")return{ready:!0,missing:[]};const n=[];for(const s of e)try{const t=await document.fonts.load(`16px "${s}"`);!t||t.length===0?n.push(s):document.fonts.check(`16px "${s}"`)||n.push(s)}catch{n.push(s)}return{ready:n.length===0,missing:n}}function C(e){return new Promise((n,s)=>{const t=new Image;t.crossOrigin="anonymous",t.onload=()=>n(t),t.onerror=()=>s(new Error("Failed to load image")),t.src=e})}const W="rgba(255, 255, 255, 0.95)",_="#1a1a1a",L="rgba(244, 239, 230, 0.94)",N="rgba(26, 26, 26, 0.55)";function Y(e){return Math.max(2,e*.004)}function K(e,n,s,t,c,d,f){e.beginPath();for(const o of F(n,s,t,c,d,f))o.k==="M"?e.moveTo(o.x,o.y):o.k==="L"?e.lineTo(o.x,o.y):e.arcTo(o.cornerX,o.cornerY,o.x,o.y,o.r);e.closePath()}function P(e,n,s,t,c,d){var f;for(const o of n){const l=o.x*s,i=o.y*t,r=o.width*s,a=o.height*t,y=Y(t);if(o.type==="speech"){const u=R(o,r,a),k=o.tailAnchor?H(l,i,r,a,o.tailAnchor,u):null;K(e,l,i,r,a,k,u),e.fillStyle=W,e.fill(),e.strokeStyle=_,e.lineWidth=y,e.lineJoin="round",e.stroke()}else if(o.type==="narration"){const u=Math.min(r,a)*.12;e.beginPath(),e.roundRect(l,i,r,a,u),e.fillStyle=L,e.fill(),e.strokeStyle=N,e.lineWidth=Math.max(1.5,y*.75),e.lineJoin="round",e.stroke()}const g=o.type==="sfx"?d:c,T=o.type!=="sfx"&&!!o.speaker,h=I((u,k,E=400)=>(e.font=`${E} ${k}px ${g}`,e.measureText(u).width),o.text,r,a,B(o,t,r,a));e.textAlign="center",e.textBaseline="middle";const p=l+r/2,S=T?h.speakerFontSize*1.2:0;T&&(e.fillStyle="#3a3a3a",e.font=`700 ${h.speakerFontSize}px ${g}`,e.fillText(o.speaker,p,i+S/2+a*.04,r-6));const v=i+S,b=a-S,$=h.lines.length*h.lineHeight;let m=v+b/2-$/2+h.lineHeight/2;e.font=`${((f=o.textStyle)==null?void 0:f.fontWeight)??400} ${h.fontSize}px ${g}`;for(const u of h.lines)o.type==="sfx"?(e.fillStyle="#000",e.strokeStyle="#fff",e.lineWidth=3,e.strokeText(u,p,m),e.fillText(u,p,m)):(e.fillStyle="#1a1a1a",e.fillText(u,p,m)),m+=h.lineHeight}}function X(e,n,s,t,c){const d=Math.max(14,Math.min(t*.05,28));e.font=`${d}px ${c}`,e.fillStyle="#1a1a1a",e.textAlign="center",e.textBaseline="middle";const f=[];if(n.dialogue)for(const i of n.dialogue)f.push(`${i.speaker}: ${i.text}`);n.narration&&f.push(n.narration);const o=d*1.6,l=t/2-(f.length-1)*o/2;for(let i=0;i<f.length;i++)e.fillText(f[i],s/2,l+i*o,s-40)}async function Z(e,n,s,t,c,d){const f=O(n);if(!f.valid)throw new Error(f.error??"Overlay geometry is invalid");let o=800,l=600,i=null;if(e)i=await C(e),o=i.naturalWidth,l=i.naturalHeight;else if(d){const y=A(d.aspectRatio);y&&(o=y.width,l=y.height)}const r=document.createElement("canvas");r.width=o,r.height=l;const a=r.getContext("2d");return i?a.drawImage(i,0,0,o,l):(a.fillStyle=(d==null?void 0:d.background)||"#ffffff",a.fillRect(0,0,o,l)),P(a,n,o,l,s,t),c&&n.length===0&&!i&&X(a,c,o,l,s),M(r)}function j(e){return e.size>z?{valid:!1,error:`Image is ${(e.size/1024).toFixed(0)}KB, exceeds 1MB limit`}:{valid:!0}}export{z as MAX_SIZE,G as ensureFontsReady,Z as exportCut,P as renderOverlays,A as textPanelDimensions,j as validateExportSize};
|