plotlink-ows 1.0.33 → 1.2.95
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/active-wallet.ts +260 -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 +813 -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 +242 -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/dashboard.ts +6 -4
- package/app/routes/publish.ts +259 -45
- package/app/routes/settings.ts +92 -37
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/routes/wallet.ts +58 -30
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonNextAction.tsx +145 -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 +1337 -0
- package/app/web/components/Dashboard.tsx +15 -6
- 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 +1182 -0
- package/app/web/components/PreviewPanel.tsx +952 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +745 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +446 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +156 -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-che5mMWc.js +1 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -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,156 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { CartoonCoach, CoachUiAction } from "@app-lib/cartoon-coach";
|
|
3
|
+
import type { StoryProgress } from "@app-lib/story-progress";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Persistent cartoon workflow coach (#429). Converts the current story/episode
|
|
7
|
+
* state into one stage label + one primary next action — an agent copy-paste
|
|
8
|
+
* prompt or a direct in-app UI action — so a normal writer always knows the next
|
|
9
|
+
* step without reading terminal logs or technical warnings. It never blocks the
|
|
10
|
+
* terminal or advanced controls; it just makes the normal path obvious.
|
|
11
|
+
*
|
|
12
|
+
* Two pieces:
|
|
13
|
+
* - `WorkflowCoachView` is presentational (takes an already-loaded coach) so the
|
|
14
|
+
* progress overview, which already fetches the progress payload, can render it
|
|
15
|
+
* with no extra request.
|
|
16
|
+
* - `WorkflowCoach` is the self-loading container the file views use.
|
|
17
|
+
*
|
|
18
|
+
* Fiction is unaffected: the coach is null for fiction, so the view renders
|
|
19
|
+
* nothing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface WorkflowCoachViewProps {
|
|
23
|
+
coach: CartoonCoach | null | undefined;
|
|
24
|
+
/** Run an app-driven step (the agent steps copy a prompt instead). */
|
|
25
|
+
onAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
/** Show a clear completed state instead of disappearing when no coach exists. */
|
|
28
|
+
showEmptyState?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function WorkflowCoachView({ coach, onAction, className = "", showEmptyState = false }: WorkflowCoachViewProps) {
|
|
32
|
+
// Track the prompt that was copied rather than a bare boolean, so the "Copied!"
|
|
33
|
+
// confirmation derives to false the moment the coach (and its prompt) changes —
|
|
34
|
+
// no reset effect, no stale confirmation under a new stage.
|
|
35
|
+
const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
|
|
36
|
+
const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;
|
|
37
|
+
|
|
38
|
+
if (coach === undefined) return null;
|
|
39
|
+
if (!coach) {
|
|
40
|
+
if (!showEmptyState) return null;
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className={`m-3 rounded-lg border border-green-700/25 bg-green-950/5 px-4 py-3 ${className}`}
|
|
44
|
+
data-testid="workflow-coach"
|
|
45
|
+
data-state="complete"
|
|
46
|
+
>
|
|
47
|
+
<div className="flex items-start gap-3">
|
|
48
|
+
<span className="rounded-full bg-green-700/10 px-2 py-1 text-[10px] font-bold uppercase tracking-[0.16em] text-green-700">
|
|
49
|
+
Complete
|
|
50
|
+
</span>
|
|
51
|
+
<div className="min-w-0 flex-1">
|
|
52
|
+
<p className="text-sm font-semibold text-foreground">No next action available</p>
|
|
53
|
+
<p className="mt-0.5 text-xs text-muted">This workflow has no queued next step right now.</p>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
className={`m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm ${className}`}
|
|
63
|
+
data-testid="workflow-coach"
|
|
64
|
+
data-stage={coach.stageLabel}
|
|
65
|
+
data-action-kind={coach.actionKind}
|
|
66
|
+
data-ui-action={coach.uiAction ?? ""}
|
|
67
|
+
>
|
|
68
|
+
<div className="flex items-center gap-3">
|
|
69
|
+
<div className="min-w-0 flex-1">
|
|
70
|
+
<span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent" data-testid="workflow-coach-stage">
|
|
71
|
+
{coach.stageLabel}
|
|
72
|
+
</span>
|
|
73
|
+
<p className="mt-1 text-sm text-foreground" data-testid="workflow-coach-action">
|
|
74
|
+
<span className="font-semibold">Next: </span>
|
|
75
|
+
<span>{coach.action}</span>
|
|
76
|
+
</p>
|
|
77
|
+
{copied && <p className="mt-1 text-[11px] font-medium text-accent">Prompt copied.</p>}
|
|
78
|
+
</div>
|
|
79
|
+
{coach.actionKind === "agent" && coach.prompt ? (
|
|
80
|
+
<button
|
|
81
|
+
onClick={() => {
|
|
82
|
+
if (!coach.prompt) return;
|
|
83
|
+
const prompt = coach.prompt;
|
|
84
|
+
navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
|
|
85
|
+
}}
|
|
86
|
+
data-testid="workflow-coach-copy"
|
|
87
|
+
className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim"
|
|
88
|
+
>
|
|
89
|
+
Next Action
|
|
90
|
+
</button>
|
|
91
|
+
) : coach.actionKind === "ui" && coach.uiAction ? (
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
|
|
94
|
+
data-testid="workflow-coach-do"
|
|
95
|
+
className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim"
|
|
96
|
+
>
|
|
97
|
+
Next Action
|
|
98
|
+
</button>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface WorkflowCoachProps {
|
|
106
|
+
storyName: string;
|
|
107
|
+
/** The file currently in focus, so the coach speaks about that episode (#429). */
|
|
108
|
+
fileName?: string | null;
|
|
109
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
110
|
+
/** Bumped by the parent to reload after a state change (cut edit / publish). */
|
|
111
|
+
refreshKey?: number;
|
|
112
|
+
onAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
113
|
+
showEmptyState?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Self-loading coach for the file views. Fetches the story progress (scoped to
|
|
118
|
+
* the focused file) and renders the coach bar. The coach is cleared in EVERY
|
|
119
|
+
* load exit path — at the start, on a non-OK response, and on error — so a
|
|
120
|
+
* previous file's coach can never linger under a different file when the new
|
|
121
|
+
* request fails or 404s (the stale-state-on-error class flagged on #420/#427).
|
|
122
|
+
*/
|
|
123
|
+
export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0, onAction, showEmptyState = false }: WorkflowCoachProps) {
|
|
124
|
+
const [coach, setCoach] = useState<CartoonCoach | null | undefined>(undefined);
|
|
125
|
+
|
|
126
|
+
// Reset the coach the instant the target changes (file switch / refresh),
|
|
127
|
+
// during render — React's recommended way to reset state on a changing input.
|
|
128
|
+
// This clears the prior file's coach BEFORE the new load resolves; and because
|
|
129
|
+
// the effect below sets the coach to null on a non-OK response and never sets
|
|
130
|
+
// it on error, it also STAYS cleared when the new load fails or 404s (the
|
|
131
|
+
// stale-state-on-error class flagged on #420/#427).
|
|
132
|
+
//
|
|
133
|
+
// JSON.stringify keeps the key printable and source-safe (#437): it changes
|
|
134
|
+
// whenever any input changes — identical reset semantics — and it escapes the
|
|
135
|
+
// parts so a separator can never collide with the values' own content.
|
|
136
|
+
const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
|
|
137
|
+
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
|
138
|
+
if (loadedKey !== targetKey) {
|
|
139
|
+
setCoach(undefined);
|
|
140
|
+
setLoadedKey(targetKey);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
let cancelled = false;
|
|
145
|
+
const focus = fileName ? `?focus=${encodeURIComponent(fileName)}` : "";
|
|
146
|
+
authFetch(`/api/stories/${storyName}/progress${focus}`)
|
|
147
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
148
|
+
.then((data: (StoryProgress & { coach?: CartoonCoach | null }) | null) => {
|
|
149
|
+
if (!cancelled) setCoach(data?.coach ?? null);
|
|
150
|
+
})
|
|
151
|
+
.catch(() => { /* leave it cleared — the coach is best-effort */ });
|
|
152
|
+
return () => { cancelled = true; };
|
|
153
|
+
}, [storyName, fileName, authFetch, refreshKey]);
|
|
154
|
+
|
|
155
|
+
return <WorkflowCoachView coach={coach} onAction={onAction} showEmptyState={showEmptyState} />;
|
|
156
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
|
|
4
|
+
|
|
5
|
+
/** Resolve a story-relative asset path to its auth-protected API URL. */
|
|
6
|
+
export function assetUrl(storyName: string, assetPath: string): string {
|
|
7
|
+
const relative = assetPath.startsWith("assets/") ? assetPath.slice(7) : assetPath;
|
|
8
|
+
return `/api/stories/${storyName}/asset/${relative}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AssetState {
|
|
12
|
+
/** Same-origin blob object URL safe to use as an <img src>, or null. */
|
|
13
|
+
url: string | null;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load an auth-protected story asset as a blob object URL.
|
|
20
|
+
*
|
|
21
|
+
* Story asset routes sit behind `requireAuth`, but a browser `<img src>`
|
|
22
|
+
* request never carries the `Authorization: Bearer` header that `authFetch`
|
|
23
|
+
* adds, so the raw URL 401s and the image breaks. Instead we fetch the asset
|
|
24
|
+
* via `authFetch`, turn the response into a blob, and hand back an object URL.
|
|
25
|
+
* The object URL is revoked when the asset path changes or the component
|
|
26
|
+
* unmounts so we don't leak blobs across cut selections.
|
|
27
|
+
*/
|
|
28
|
+
export function useAuthedAsset(
|
|
29
|
+
storyName: string,
|
|
30
|
+
assetPath: string | null | undefined,
|
|
31
|
+
authFetch: AuthFetch,
|
|
32
|
+
): AssetState {
|
|
33
|
+
const [state, setState] = useState<AssetState>({
|
|
34
|
+
url: null,
|
|
35
|
+
loading: !!assetPath,
|
|
36
|
+
error: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!assetPath) {
|
|
41
|
+
setState({ url: null, loading: false, error: false });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let objectUrl: string | null = null;
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
setState({ url: null, loading: true, error: false });
|
|
48
|
+
|
|
49
|
+
(async () => {
|
|
50
|
+
try {
|
|
51
|
+
const res = await authFetch(assetUrl(storyName, assetPath));
|
|
52
|
+
if (!res.ok) throw new Error(`asset request failed (${res.status})`);
|
|
53
|
+
const blob = await res.blob();
|
|
54
|
+
if (cancelled) return;
|
|
55
|
+
objectUrl = URL.createObjectURL(blob);
|
|
56
|
+
setState({ url: objectUrl, loading: false, error: false });
|
|
57
|
+
} catch {
|
|
58
|
+
if (!cancelled) setState({ url: null, loading: false, error: true });
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
65
|
+
};
|
|
66
|
+
}, [storyName, assetPath, authFetch]);
|
|
67
|
+
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AssetImageProps {
|
|
72
|
+
storyName: string;
|
|
73
|
+
assetPath: string;
|
|
74
|
+
authFetch: AuthFetch;
|
|
75
|
+
alt: string;
|
|
76
|
+
className?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render an auth-protected story asset as an image, loading it through
|
|
81
|
+
* `useAuthedAsset`. Shows a neutral placeholder while loading and a clear
|
|
82
|
+
* "Image not available" state on failure so a broken auth boundary surfaces
|
|
83
|
+
* instead of a broken-image glyph.
|
|
84
|
+
*/
|
|
85
|
+
export function AssetImage({ storyName, assetPath, authFetch, alt, className }: AssetImageProps) {
|
|
86
|
+
const { url, loading, error } = useAuthedAsset(storyName, assetPath, authFetch);
|
|
87
|
+
|
|
88
|
+
if (error || (!loading && !url)) {
|
|
89
|
+
return (
|
|
90
|
+
<div className="w-full aspect-video bg-surface border border-border rounded flex items-center justify-center">
|
|
91
|
+
<span className="text-xs text-muted">Image not available</span>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!url) {
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
className="w-full aspect-video bg-surface border border-border rounded flex items-center justify-center"
|
|
100
|
+
data-testid="asset-loading"
|
|
101
|
+
>
|
|
102
|
+
<span className="text-xs text-muted">Loading image…</span>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<img
|
|
109
|
+
src={url}
|
|
110
|
+
alt={alt}
|
|
111
|
+
className={className ?? "w-full rounded border border-border"}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
/** Object URL returned by the stubbed `URL.createObjectURL` in jsdom tests. */
|
|
4
|
+
export const MOCK_BLOB_URL = "blob:mock-asset-url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* jsdom doesn't implement the object-URL APIs that `useAuthedAsset` relies on.
|
|
8
|
+
* Stub them so blob-backed asset loading works in component tests, and so a
|
|
9
|
+
* test can assert that an `<img>` points at the object URL rather than the
|
|
10
|
+
* raw auth-protected API route.
|
|
11
|
+
*/
|
|
12
|
+
export function installObjectUrlStub(): void {
|
|
13
|
+
const u = URL as unknown as {
|
|
14
|
+
createObjectURL: (b: Blob) => string;
|
|
15
|
+
revokeObjectURL: (s: string) => void;
|
|
16
|
+
};
|
|
17
|
+
u.createObjectURL = vi.fn(() => MOCK_BLOB_URL);
|
|
18
|
+
u.revokeObjectURL = vi.fn();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `authFetch` double for tests that render asset-loading components. Asset
|
|
23
|
+
* routes (`/asset/`) resolve to an image blob the way the real authenticated
|
|
24
|
+
* route does; every other route resolves to `jsonData`. This mirrors the real
|
|
25
|
+
* world where the Bearer header reaches both the data route and the image —
|
|
26
|
+
* the bug being fixed was a raw `<img src>` that never sent that header.
|
|
27
|
+
*/
|
|
28
|
+
export function makeAssetAuthFetch(jsonData: unknown = {}) {
|
|
29
|
+
return vi.fn((url: string) =>
|
|
30
|
+
Promise.resolve(
|
|
31
|
+
url.includes("/asset/")
|
|
32
|
+
? {
|
|
33
|
+
ok: true,
|
|
34
|
+
status: 200,
|
|
35
|
+
blob: () => Promise.resolve(new Blob(["img-bytes"], { type: "image/webp" })),
|
|
36
|
+
}
|
|
37
|
+
: {
|
|
38
|
+
ok: true,
|
|
39
|
+
status: 200,
|
|
40
|
+
json: () => Promise.resolve(jsonData),
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -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-Dc2TQ3Ij.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};
|