plotlink-ows 1.2.94 → 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/app/lib/active-wallet.ts +260 -0
- package/app/lib/cartoon-coach.ts +1 -1
- package/app/lib/cartoon-readiness.ts +12 -10
- package/app/lib/story-progress.ts +2 -3
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +56 -23
- package/app/routes/settings.ts +92 -37
- package/app/routes/wallet.ts +58 -30
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPublishPage.tsx +1 -1
- package/app/web/components/CutListPanel.tsx +124 -86
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/LetteringEditor.tsx +55 -14
- package/app/web/components/PreviewPanel.tsx +2 -1
- package/app/web/components/StoriesPage.tsx +35 -0
- package/app/web/components/StoryProgressPanel.tsx +32 -102
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +63 -35
- package/app/web/dist/assets/{export-cut-nKQ_n2-J.js → export-cut-che5mMWc.js} +1 -1
- 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/package.json +1 -1
- package/app/web/dist/assets/index-BAZGwVwj.js +0 -143
- package/app/web/dist/assets/index-DoXH2OlP.css +0 -32
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef } from "react";
|
|
1
|
+
import { Fragment, useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { LetteringEditor } from "./LetteringEditor";
|
|
3
3
|
import { AssetImage, assetUrl } from "./asset-image";
|
|
4
|
-
import { buildCodexTaskPrompt
|
|
4
|
+
import { buildCodexTaskPrompt } from "@app-lib/cartoon-prompt";
|
|
5
5
|
import type { Cut as LibCut } from "@app-lib/cuts";
|
|
6
6
|
import { isTextPanel, isStaleTailedExport } from "@app-lib/cuts";
|
|
7
7
|
import { withRateLimitRetry, createUploadThrottle, type RetryDeps } from "../lib/upload-retry";
|
|
@@ -141,6 +141,25 @@ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): Boa
|
|
|
141
141
|
return { key: "needs-image", label: "Needs image", tone: "muted" };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
function letteringReviewState(cut: Cut): { label: string; detail: string; tone: BoardTone } {
|
|
145
|
+
if (cut.uploadedCid || cut.uploadedUrl) {
|
|
146
|
+
return { label: "Complete", detail: "Final image uploaded", tone: "green" };
|
|
147
|
+
}
|
|
148
|
+
if (cut.finalImagePath || cut.exportedAt) {
|
|
149
|
+
return { label: "Exported", detail: "Ready to upload", tone: "green" };
|
|
150
|
+
}
|
|
151
|
+
if ((cut.overlays?.length ?? 0) > 0) {
|
|
152
|
+
return { label: "Draft saved", detail: `${cut.overlays.length} overlay${cut.overlays.length === 1 ? "" : "s"} placed`, tone: "amber" };
|
|
153
|
+
}
|
|
154
|
+
if (isTextPanel(cut)) {
|
|
155
|
+
return { label: "Between-scene card", detail: "Open to add narration or title text", tone: "accent" };
|
|
156
|
+
}
|
|
157
|
+
if (cut.cleanImagePath) {
|
|
158
|
+
return { label: "Unlettered", detail: "Clean art ready for bubble placement", tone: "muted" };
|
|
159
|
+
}
|
|
160
|
+
return { label: "Needs artwork", detail: "Add or sync clean art first", tone: "muted" };
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
function CutRow({
|
|
145
164
|
cut,
|
|
146
165
|
storyName,
|
|
@@ -190,10 +209,6 @@ function CutRow({
|
|
|
190
209
|
// generated PNG into this cut without hunting through a hidden folder.
|
|
191
210
|
const [showCodexPicker, setShowCodexPicker] = useState(false);
|
|
192
211
|
const [convertingThis, setConvertingThis] = useState(false);
|
|
193
|
-
// Lettering is a first-class step (#442): an intentional Manual vs AI-draft
|
|
194
|
-
// choice per cut, surfaced on the card (not hidden under Edit).
|
|
195
|
-
const [letteringMode, setLetteringMode] = useState<"manual" | "ai">("manual");
|
|
196
|
-
const [letteringCopied, setLetteringCopied] = useState(false);
|
|
197
212
|
const status = getCutStatus(cut);
|
|
198
213
|
// A recorded cleanImagePath/finalImagePath whose file is missing/invalid (#302):
|
|
199
214
|
// surface it precisely rather than letting the field-based status claim the cut
|
|
@@ -267,18 +282,13 @@ function CutRow({
|
|
|
267
282
|
!isTextPanel(cut) && !!cut.cleanImagePath && !cut.finalImagePath &&
|
|
268
283
|
!cut.uploadedCid && !cut.uploadedUrl && !hasStale && !needsConversion;
|
|
269
284
|
|
|
270
|
-
const copyLetteringPrompt = useCallback(() => {
|
|
271
|
-
navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile));
|
|
272
|
-
setLetteringCopied(true);
|
|
273
|
-
setTimeout(() => setLetteringCopied(false), 2000);
|
|
274
|
-
}, [cut, plotFile]);
|
|
275
|
-
|
|
276
285
|
const primary: { label: string; onClick: () => void; testid: string } | null =
|
|
277
286
|
board.key === "convert" ? { label: convertingThis ? "Converting…" : "Convert image", onClick: handleConvertThis, testid: `card-convert-${cut.id}` }
|
|
278
287
|
: board.key === "review" ? { label: "Review cut", onClick: onOpenEditor, testid: `card-review-${cut.id}` }
|
|
279
288
|
: board.key === "text" ? { label: "Add captions", onClick: onOpenEditor, testid: `card-letter-${cut.id}` }
|
|
280
289
|
: board.key === "needs-image" ? { label: "Add artwork", onClick: onToggle, testid: `card-addart-${cut.id}` }
|
|
281
290
|
: null; // exported / uploaded — the next action is the episode-level upload/publish
|
|
291
|
+
const reviewState = letteringReviewState(cut);
|
|
282
292
|
|
|
283
293
|
return (
|
|
284
294
|
<div
|
|
@@ -303,13 +313,20 @@ function CutRow({
|
|
|
303
313
|
assetPath={thumbPath}
|
|
304
314
|
authFetch={authFetch}
|
|
305
315
|
alt={`Cut ${cut.id} artwork`}
|
|
306
|
-
className="w-full max-h-
|
|
316
|
+
className="w-full max-h-[32rem] object-contain rounded border border-border bg-white"
|
|
307
317
|
/>
|
|
308
318
|
) : (
|
|
309
|
-
<div className="w-full h-
|
|
319
|
+
<div className="w-full min-h-28 rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted" data-testid={`cut-card-noart-${cut.id}`}>
|
|
310
320
|
{isTextPanel(cut) ? "Text panel — no artwork needed" : "No artwork yet"}
|
|
311
321
|
</div>
|
|
312
322
|
)}
|
|
323
|
+
<div
|
|
324
|
+
className={`rounded border border-border/70 bg-surface/50 px-2 py-1.5 text-[11px] ${BOARD_TONE_TEXT[reviewState.tone]}`}
|
|
325
|
+
data-testid={`lettering-review-state-${cut.id}`}
|
|
326
|
+
>
|
|
327
|
+
<span className="font-semibold">{reviewState.label}</span>
|
|
328
|
+
<span className="text-muted"> · {reviewState.detail}</span>
|
|
329
|
+
</div>
|
|
313
330
|
<button
|
|
314
331
|
onClick={onToggle}
|
|
315
332
|
data-testid={`cut-desc-${cut.id}`}
|
|
@@ -317,51 +334,15 @@ function CutRow({
|
|
|
317
334
|
>
|
|
318
335
|
{cut.description || "No description"}
|
|
319
336
|
</button>
|
|
320
|
-
{/* Lettering is a first-class, visible step (#442): an intentional
|
|
321
|
-
Manual vs AI-draft choice on the card, then the matching CTA. */}
|
|
322
|
-
{atLetteringStage && (
|
|
323
|
-
<div className="space-y-1" data-testid={`lettering-${cut.id}`}>
|
|
324
|
-
<div className="text-[10px] font-medium text-muted uppercase tracking-wider">Lettering</div>
|
|
325
|
-
<label className="flex items-center gap-1.5 text-[11px] text-foreground">
|
|
326
|
-
<input
|
|
327
|
-
type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "manual"}
|
|
328
|
-
onChange={() => setLetteringMode("manual")} data-testid={`lettering-mode-manual-${cut.id}`}
|
|
329
|
-
/>
|
|
330
|
-
Manual — I place bubbles myself
|
|
331
|
-
</label>
|
|
332
|
-
<label className="flex items-center gap-1.5 text-[11px] text-foreground">
|
|
333
|
-
<input
|
|
334
|
-
type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "ai"}
|
|
335
|
-
onChange={() => setLetteringMode("ai")} data-testid={`lettering-mode-ai-${cut.id}`}
|
|
336
|
-
/>
|
|
337
|
-
AI draft — ask the agent to place initial bubbles
|
|
338
|
-
</label>
|
|
339
|
-
{letteringMode === "ai" && (
|
|
340
|
-
<p className="text-[10px] text-muted">
|
|
341
|
-
Paste it to your agent, then review the draft bubbles here and export the final cut.
|
|
342
|
-
</p>
|
|
343
|
-
)}
|
|
344
|
-
</div>
|
|
345
|
-
)}
|
|
346
337
|
<div className="flex items-center gap-2 flex-wrap">
|
|
347
338
|
{atLetteringStage ? (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
>
|
|
354
|
-
|
|
355
|
-
</button>
|
|
356
|
-
) : (
|
|
357
|
-
<button
|
|
358
|
-
onClick={copyLetteringPrompt}
|
|
359
|
-
data-testid={`copy-lettering-${cut.id}`}
|
|
360
|
-
className="px-2.5 py-1 text-[11px] font-medium rounded border border-accent/40 text-accent hover:bg-accent/5"
|
|
361
|
-
>
|
|
362
|
-
{letteringCopied ? "Copied!" : "Copy AI lettering prompt"}
|
|
363
|
-
</button>
|
|
364
|
-
)
|
|
339
|
+
<button
|
|
340
|
+
onClick={onOpenEditor}
|
|
341
|
+
data-testid={`add-bubbles-${cut.id}`}
|
|
342
|
+
className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
|
|
343
|
+
>
|
|
344
|
+
{bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
|
|
345
|
+
</button>
|
|
365
346
|
) : primary ? (
|
|
366
347
|
<button
|
|
367
348
|
onClick={primary.onClick}
|
|
@@ -924,10 +905,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
924
905
|
setRepairing(false);
|
|
925
906
|
}, [authFetch, storyName, plotFile, loadCuts, loadDetect]);
|
|
926
907
|
|
|
927
|
-
//
|
|
928
|
-
// add a narration/title card between image cuts without hand-editing cuts.json.
|
|
908
|
+
// Insert a text/interstitial panel to the cut plan (#352/#488) — a one-click way
|
|
909
|
+
// to add a narration/title card between image cuts without hand-editing cuts.json.
|
|
929
910
|
const [addingPanel, setAddingPanel] = useState(false);
|
|
930
|
-
const
|
|
911
|
+
const addTextPanelAt = useCallback(async (insertIndex: number, openEditor = true) => {
|
|
931
912
|
if (!cutsFile) return;
|
|
932
913
|
setAddingPanel(true);
|
|
933
914
|
try {
|
|
@@ -939,14 +920,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
939
920
|
uploadedCid: null, uploadedUrl: null, overlays: [],
|
|
940
921
|
kind: "text" as const, background: "#101820", aspectRatio: "4:5",
|
|
941
922
|
};
|
|
942
|
-
const
|
|
923
|
+
const nextCuts = [...cutsFile.cuts];
|
|
924
|
+
nextCuts.splice(Math.max(0, Math.min(insertIndex, nextCuts.length)), 0, panel);
|
|
925
|
+
const updated = { ...cutsFile, cuts: nextCuts };
|
|
943
926
|
const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
|
|
944
927
|
method: "PUT",
|
|
945
928
|
headers: { "Content-Type": "application/json" },
|
|
946
929
|
body: JSON.stringify(updated),
|
|
947
930
|
});
|
|
948
931
|
if (res.ok) {
|
|
949
|
-
|
|
932
|
+
if (openEditor) setEditingCutId(nextId);
|
|
933
|
+
else setExpandedCut(nextId);
|
|
950
934
|
await loadCuts();
|
|
951
935
|
} else {
|
|
952
936
|
const data = await res.json().catch(() => ({}));
|
|
@@ -957,6 +941,7 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
957
941
|
}
|
|
958
942
|
setAddingPanel(false);
|
|
959
943
|
}, [cutsFile, authFetch, storyName, plotFile, loadCuts]);
|
|
944
|
+
const addTextPanel = useCallback(() => addTextPanelAt(cutsFile?.cuts.length ?? 0, true), [addTextPanelAt, cutsFile]);
|
|
960
945
|
|
|
961
946
|
useEffect(() => {
|
|
962
947
|
loadCuts();
|
|
@@ -1000,6 +985,8 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1000
985
|
plotFile={plotFile}
|
|
1001
986
|
language={language}
|
|
1002
987
|
authFetch={authFetch}
|
|
988
|
+
targetLabel={isTextPanel(editingCut) ? `Between-scene card ${editingCut.id}` : `Cut ${String(editingCut.id).padStart(2, "0")}`}
|
|
989
|
+
returnOnSave
|
|
1003
990
|
onSave={async (overlays: Overlay[]) => {
|
|
1004
991
|
const updated = { ...cutsFile, cuts: cutsFile.cuts.map((c) => c.id === editingCutId ? { ...c, overlays } : c) };
|
|
1005
992
|
const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
|
|
@@ -1268,32 +1255,83 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
|
|
|
1268
1255
|
published={episodeState.published}
|
|
1269
1256
|
/>
|
|
1270
1257
|
|
|
1271
|
-
{/*
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1258
|
+
{/* Full cut review (#488): all clean cuts are shown vertically first, with
|
|
1259
|
+
explicit between-scene slots for narration/title cards. */}
|
|
1260
|
+
<div className="flex-1 min-h-56 overflow-y-auto p-3 space-y-3" data-testid="lettering-review-board">
|
|
1261
|
+
{cutsFile.cuts.map((cut, index) => (
|
|
1262
|
+
<Fragment key={cut.id}>
|
|
1263
|
+
<BetweenSceneSlot
|
|
1264
|
+
index={index}
|
|
1265
|
+
beforeLabel={index === 0 ? "Episode opening" : `After cut ${cutsFile.cuts[index - 1]?.id}`}
|
|
1266
|
+
afterLabel={`Before cut ${cut.id}`}
|
|
1267
|
+
disabled={addingPanel}
|
|
1268
|
+
onAdd={() => addTextPanelAt(index)}
|
|
1269
|
+
/>
|
|
1270
|
+
<CutRow
|
|
1271
|
+
cut={cut}
|
|
1272
|
+
storyName={storyName}
|
|
1273
|
+
plotFile={plotFile}
|
|
1274
|
+
expanded={expandedCut === cut.id}
|
|
1275
|
+
onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)}
|
|
1276
|
+
authFetch={authFetch}
|
|
1277
|
+
onUpdated={() => { loadCuts(); loadDetect(); loadDiagnostics(); }}
|
|
1278
|
+
onOpenEditor={() => setEditingCutId(cut.id)}
|
|
1279
|
+
detectedLocalClean={detected.has(cut.id)}
|
|
1280
|
+
onSyncClean={syncCleanImages}
|
|
1281
|
+
syncing={syncing}
|
|
1282
|
+
staleMessages={staleByCut.get(cut.id) ?? []}
|
|
1283
|
+
onRepairStale={repairStalePaths}
|
|
1284
|
+
repairing={repairing}
|
|
1285
|
+
conversionPng={conversionByCut.get(cut.id) ?? null}
|
|
1286
|
+
onConvert={convertCut}
|
|
1287
|
+
converting={converting}
|
|
1288
|
+
rowRef={(el) => { if (el) rowRefs.current.set(cut.id, el); else rowRefs.current.delete(cut.id); }}
|
|
1289
|
+
/>
|
|
1290
|
+
</Fragment>
|
|
1295
1291
|
))}
|
|
1292
|
+
<BetweenSceneSlot
|
|
1293
|
+
index={cutsFile.cuts.length}
|
|
1294
|
+
beforeLabel={`After cut ${cutsFile.cuts[cutsFile.cuts.length - 1]?.id}`}
|
|
1295
|
+
afterLabel="Episode ending"
|
|
1296
|
+
disabled={addingPanel}
|
|
1297
|
+
onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
|
|
1298
|
+
/>
|
|
1296
1299
|
</div>
|
|
1297
1300
|
</div>
|
|
1298
1301
|
);
|
|
1299
1302
|
}
|
|
1303
|
+
|
|
1304
|
+
function BetweenSceneSlot({
|
|
1305
|
+
index,
|
|
1306
|
+
beforeLabel,
|
|
1307
|
+
afterLabel,
|
|
1308
|
+
disabled,
|
|
1309
|
+
onAdd,
|
|
1310
|
+
}: {
|
|
1311
|
+
index: number;
|
|
1312
|
+
beforeLabel: string;
|
|
1313
|
+
afterLabel: string;
|
|
1314
|
+
disabled: boolean;
|
|
1315
|
+
onAdd: () => void;
|
|
1316
|
+
}) {
|
|
1317
|
+
return (
|
|
1318
|
+
<div
|
|
1319
|
+
className="rounded border border-dashed border-border bg-surface/35 px-3 py-2 text-[11px] text-muted flex items-center gap-3"
|
|
1320
|
+
data-testid={`between-scene-slot-${index}`}
|
|
1321
|
+
>
|
|
1322
|
+
<span className="min-w-0 flex-1">
|
|
1323
|
+
<span className="font-medium text-foreground">Between-scene lettering</span>
|
|
1324
|
+
<span className="block truncate">{beforeLabel} · {afterLabel}</span>
|
|
1325
|
+
</span>
|
|
1326
|
+
<button
|
|
1327
|
+
type="button"
|
|
1328
|
+
onClick={onAdd}
|
|
1329
|
+
disabled={disabled}
|
|
1330
|
+
className="flex-shrink-0 rounded border border-accent/40 px-2.5 py-1 text-[11px] font-medium text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
1331
|
+
data-testid={`add-between-scene-${index}`}
|
|
1332
|
+
>
|
|
1333
|
+
Add card
|
|
1334
|
+
</button>
|
|
1335
|
+
</div>
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
1
|
+
import React, { useCallback, useState, useEffect } from "react";
|
|
2
2
|
|
|
3
3
|
const API_BASE = "http://localhost:7777";
|
|
4
4
|
|
|
5
5
|
interface WalletInfo {
|
|
6
|
+
walletId?: string;
|
|
7
|
+
name?: string;
|
|
6
8
|
address: string;
|
|
7
9
|
ethBalance: string;
|
|
8
10
|
ethFormatted: string;
|
|
@@ -48,16 +50,17 @@ interface DashboardData {
|
|
|
48
50
|
|
|
49
51
|
export function Dashboard({ token }: { token: string }) {
|
|
50
52
|
const [data, setData] = useState<DashboardData | null>(null);
|
|
51
|
-
const authFetch = (url: string, opts?: RequestInit) =>
|
|
52
|
-
fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } })
|
|
53
|
+
const authFetch = useCallback((url: string, opts?: RequestInit) =>
|
|
54
|
+
fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }),
|
|
55
|
+
[token]);
|
|
53
56
|
|
|
54
|
-
const loadDashboard = () => {
|
|
57
|
+
const loadDashboard = useCallback(() => {
|
|
55
58
|
authFetch(`${API_BASE}/api/dashboard`)
|
|
56
59
|
.then((r) => r.json())
|
|
57
60
|
.then(setData);
|
|
58
|
-
};
|
|
61
|
+
}, [authFetch]);
|
|
59
62
|
|
|
60
|
-
useEffect(() => { loadDashboard(); }, []);
|
|
63
|
+
useEffect(() => { loadDashboard(); }, [loadDashboard]);
|
|
61
64
|
|
|
62
65
|
const truncate = (addr: string) => `${addr.slice(0, 6)}...${addr.slice(-4)}`;
|
|
63
66
|
const formatDate = (d: string | undefined | null) => {
|
|
@@ -104,6 +107,12 @@ export function Dashboard({ token }: { token: string }) {
|
|
|
104
107
|
<div className="border-border rounded border p-4">
|
|
105
108
|
<h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Wallet</h3>
|
|
106
109
|
<div className="space-y-1.5">
|
|
110
|
+
{data.wallet.name && (
|
|
111
|
+
<div className="flex justify-between text-xs">
|
|
112
|
+
<span className="text-muted">Active wallet</span>
|
|
113
|
+
<span className="text-foreground truncate pl-3 font-mono text-[10px]">{data.wallet.name}</span>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
107
116
|
<div className="flex justify-between text-xs">
|
|
108
117
|
<span className="text-muted">Address</span>
|
|
109
118
|
<code className="text-foreground font-mono text-[10px]">{truncate(data.wallet.address)}</code>
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
import { layoutBubbleText } from "@app-lib/bubble-text";
|
|
23
23
|
import { cutLetteringChecklist, cutScriptLines, isExportStale, overlaysSignature, type ScriptLine } from "@app-lib/lettering-status";
|
|
24
24
|
import { textPanelDimensions } from "@app-lib/cuts";
|
|
25
|
+
import { buildLetteringPrompt } from "@app-lib/cartoon-prompt";
|
|
26
|
+
import type { Cut as LibCut } from "@app-lib/cuts";
|
|
25
27
|
import { useAuthedAsset } from "./asset-image";
|
|
26
28
|
|
|
27
29
|
function toPixel(norm: number, size: number): number {
|
|
@@ -72,6 +74,10 @@ interface LetteringEditorProps {
|
|
|
72
74
|
onExported?: () => void;
|
|
73
75
|
language?: string;
|
|
74
76
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
77
|
+
/** Focused-editor header label supplied by the review board (#488). */
|
|
78
|
+
targetLabel?: string;
|
|
79
|
+
/** When true, the Save button returns to the review board after persisting. */
|
|
80
|
+
returnOnSave?: boolean;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
const TYPE_LABEL: Record<OverlayType, string> = {
|
|
@@ -106,7 +112,7 @@ function clamp(v: number, min: number, max: number): number {
|
|
|
106
112
|
return Math.min(max, Math.max(min, v));
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch }: LetteringEditorProps) {
|
|
115
|
+
export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch, targetLabel, returnOnSave = false }: LetteringEditorProps) {
|
|
110
116
|
const bodyFont = getDefaultFont(language);
|
|
111
117
|
const displayFont = getDisplayFont();
|
|
112
118
|
const bodyFontFamily = getFontFamily(bodyFont);
|
|
@@ -176,6 +182,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
176
182
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
177
183
|
const [exporting, setExporting] = useState(false);
|
|
178
184
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
185
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
186
|
+
const [aiCopied, setAiCopied] = useState(false);
|
|
179
187
|
const [imageBounds, setImageBounds] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
|
180
188
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
181
189
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
@@ -355,9 +363,21 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
355
363
|
};
|
|
356
364
|
}, [imageBounds, updateOverlay]);
|
|
357
365
|
|
|
358
|
-
const handleSave = useCallback(() => {
|
|
359
|
-
|
|
360
|
-
|
|
366
|
+
const handleSave = useCallback(async () => {
|
|
367
|
+
setSaveError(null);
|
|
368
|
+
try {
|
|
369
|
+
await onSave(overlays);
|
|
370
|
+
if (returnOnSave) onClose();
|
|
371
|
+
} catch (err) {
|
|
372
|
+
setSaveError(err instanceof Error ? err.message : "Failed to save overlays");
|
|
373
|
+
}
|
|
374
|
+
}, [overlays, onSave, onClose, returnOnSave]);
|
|
375
|
+
|
|
376
|
+
const copyAiDraftPrompt = useCallback(() => {
|
|
377
|
+
navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile));
|
|
378
|
+
setAiCopied(true);
|
|
379
|
+
setTimeout(() => setAiCopied(false), 2000);
|
|
380
|
+
}, [cut, plotFile]);
|
|
361
381
|
|
|
362
382
|
const handleExport = useCallback(async () => {
|
|
363
383
|
// Block export when the cut plan contained overlays that could not be placed
|
|
@@ -515,25 +535,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
515
535
|
}
|
|
516
536
|
|
|
517
537
|
return (
|
|
518
|
-
<div className="h-full flex flex-col">
|
|
538
|
+
<div className="h-full flex flex-col" data-testid="focused-lettering-editor">
|
|
519
539
|
{/* Toolbar */}
|
|
520
|
-
<div className="px-
|
|
521
|
-
<div className="
|
|
522
|
-
<
|
|
540
|
+
<div className="px-4 py-3 border-b border-border bg-surface/40 flex items-center justify-between gap-3">
|
|
541
|
+
<div className="min-w-0">
|
|
542
|
+
<div className="flex items-center gap-2">
|
|
543
|
+
<span className="text-[10px] font-bold uppercase tracking-[0.16em] text-accent">Focused lettering editor</span>
|
|
544
|
+
<span className="text-xs font-mono text-muted">{targetLabel ?? `Cut #${cut.id}`}</span>
|
|
545
|
+
</div>
|
|
546
|
+
<p className="mt-0.5 text-[11px] text-muted">
|
|
547
|
+
Place bubbles, captions, SFX, or between-scene card text, then save back to the full cut review.
|
|
548
|
+
</p>
|
|
523
549
|
<span className="text-[10px] text-muted" data-testid="overlay-count">{overlays.length} overlays</span>
|
|
550
|
+
</div>
|
|
551
|
+
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
524
552
|
<div className="flex items-center gap-1 ml-2">
|
|
525
553
|
<button onClick={() => addOverlay("speech")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-speech">Speech</button>
|
|
526
554
|
<button onClick={() => addOverlay("narration")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-narration">Narration</button>
|
|
527
555
|
<button onClick={() => addOverlay("sfx")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-sfx">SFX</button>
|
|
528
556
|
</div>
|
|
529
|
-
</div>
|
|
530
|
-
<div className="flex items-center gap-2">
|
|
531
557
|
{exportError && <span className="text-[10px] text-error">{exportError}</span>}
|
|
558
|
+
{saveError && <span className="text-[10px] text-error">{saveError}</span>}
|
|
532
559
|
<button onClick={handleExport} disabled={exporting} className="px-3 py-1 text-xs border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50" data-testid="export-btn">
|
|
533
560
|
{exporting ? "Exporting..." : "Export"}
|
|
534
561
|
</button>
|
|
535
|
-
<button onClick={handleSave} className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim">Save</button>
|
|
536
|
-
<button onClick={onClose} className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded">
|
|
562
|
+
<button onClick={() => { void handleSave(); }} className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim" data-testid="save-lettering-btn">Save</button>
|
|
563
|
+
<button onClick={onClose} className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded" data-testid="cancel-lettering-btn">Cancel</button>
|
|
537
564
|
</div>
|
|
538
565
|
</div>
|
|
539
566
|
|
|
@@ -635,7 +662,7 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
635
662
|
<div className="flex-1 min-h-0 flex">
|
|
636
663
|
<div
|
|
637
664
|
ref={containerRef}
|
|
638
|
-
className="flex-1 min-w-0 relative overflow-hidden"
|
|
665
|
+
className="flex-1 min-w-0 relative overflow-hidden bg-[#f8f5ef]"
|
|
639
666
|
onClick={handleBackgroundClick}
|
|
640
667
|
data-testid="editor-surface"
|
|
641
668
|
>
|
|
@@ -830,7 +857,21 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
830
857
|
</div>
|
|
831
858
|
|
|
832
859
|
{/* Inspector panel */}
|
|
833
|
-
<div className="w-
|
|
860
|
+
<div className="w-64 border-l border-border p-3 overflow-y-auto flex-shrink-0">
|
|
861
|
+
<div className="mb-3 rounded border border-accent/30 bg-accent/5 p-2 space-y-1.5" data-testid="ai-draft-current-target">
|
|
862
|
+
<p className="text-[10px] font-bold uppercase tracking-[0.14em] text-accent">AI draft assist</p>
|
|
863
|
+
<p className="text-[11px] text-muted">
|
|
864
|
+
Copy a prompt scoped to {targetLabel ?? `cut ${cut.id}`}. Review and edit any drafted bubbles here before saving.
|
|
865
|
+
</p>
|
|
866
|
+
<button
|
|
867
|
+
type="button"
|
|
868
|
+
onClick={copyAiDraftPrompt}
|
|
869
|
+
className="rounded border border-accent/40 px-2 py-1 text-[11px] font-medium text-accent hover:bg-accent/10"
|
|
870
|
+
data-testid="copy-ai-lettering-current"
|
|
871
|
+
>
|
|
872
|
+
{aiCopied ? "Copied!" : "Copy AI draft prompt"}
|
|
873
|
+
</button>
|
|
874
|
+
</div>
|
|
834
875
|
{/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
|
|
835
876
|
SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
|
|
836
877
|
{scriptLines.length > 0 && (
|
|
@@ -1015,6 +1015,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
1015
1015
|
authFetch={authFetch}
|
|
1016
1016
|
refreshKey={cutsRefreshKey}
|
|
1017
1017
|
onAction={handleCoachAction}
|
|
1018
|
+
showEmptyState
|
|
1018
1019
|
/>
|
|
1019
1020
|
)}
|
|
1020
1021
|
|
|
@@ -1334,7 +1335,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
1334
1335
|
<div className="flex items-center flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted" data-testid="cartoon-status-summary">
|
|
1335
1336
|
<span>Cuts: <span className="text-foreground font-medium">{cartoonCutProgress.total}</span></span>
|
|
1336
1337
|
<span>Clean: <span className="text-foreground font-medium">{cartoonCutProgress.withClean}/{cartoonCutProgress.needClean}</span></span>
|
|
1337
|
-
<span>Lettered: <span className="text-foreground font-medium">{cartoonCutProgress.withText}/{cartoonCutProgress.
|
|
1338
|
+
<span>Lettered: <span className="text-foreground font-medium">{cartoonCutProgress.withText}/{cartoonCutProgress.total}</span></span>
|
|
1338
1339
|
<span>Uploaded: <span className="text-foreground font-medium">{cartoonCutProgress.uploaded}/{cartoonCutProgress.total}</span></span>
|
|
1339
1340
|
{onViewProgress && (
|
|
1340
1341
|
<button onClick={onViewProgress} className="ml-auto text-accent hover:underline" data-testid="status-view-progress">
|
|
@@ -7,11 +7,13 @@ import { CartoonWorkflowNav, type CartoonWorkflowTab } from "./CartoonWorkflowNa
|
|
|
7
7
|
import { StoryInfoPage } from "./StoryInfoPage";
|
|
8
8
|
import { EpisodesPage } from "./EpisodesPage";
|
|
9
9
|
import { CartoonPublishPage } from "./CartoonPublishPage";
|
|
10
|
+
import { CartoonNextAction } from "./CartoonNextAction";
|
|
10
11
|
import { LANGUAGES, GENRES } from "../../../lib/genres";
|
|
11
12
|
import { getContentTypeForPublish, resolveSelectedContentType, needsLegacyProviderRepair, attachCoverToStoryline, derivePublishTitle, shouldBlockDuplicatePlotPublish, isRawFilenameTitle, hasExplicitEpisodeTitle, isPreflightBlocked, formatPreflightBlock } from "../lib/publish-helpers";
|
|
12
13
|
import { verifyPublicCartoonTitle, publicTitleWarning } from "../lib/verify-public-title";
|
|
13
14
|
import { isCodexAuthUnclear, CODEX_AUTH_UNCLEAR_MESSAGE, type AgentReadiness } from "@app-lib/agent-readiness";
|
|
14
15
|
import { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
|
|
16
|
+
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
15
17
|
|
|
16
18
|
interface StoriesPageProps {
|
|
17
19
|
token: string;
|
|
@@ -763,6 +765,27 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
763
765
|
}
|
|
764
766
|
}, [selectedStory, handleSelectFile]);
|
|
765
767
|
|
|
768
|
+
const handleWorkflowNextAction = useCallback((action: CoachUiAction, episodeFile: string | null) => {
|
|
769
|
+
const story = selectedStory;
|
|
770
|
+
if (!story) return;
|
|
771
|
+
switch (action) {
|
|
772
|
+
case "view-progress":
|
|
773
|
+
setCartoonView(null);
|
|
774
|
+
setSelectedFile(null);
|
|
775
|
+
break;
|
|
776
|
+
case "publish":
|
|
777
|
+
setCartoonView("publish");
|
|
778
|
+
break;
|
|
779
|
+
case "open-cuts":
|
|
780
|
+
case "open-lettering":
|
|
781
|
+
case "upload":
|
|
782
|
+
case "refresh-assets":
|
|
783
|
+
case "generate-markdown":
|
|
784
|
+
if (episodeFile) handleSelectFile(story, episodeFile);
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}, [selectedStory, handleSelectFile]);
|
|
788
|
+
|
|
766
789
|
// Keep the publish-control seeds in sync after a Story Info save (#439).
|
|
767
790
|
const handleStoryInfoSaved = useCallback((patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
|
|
768
791
|
if (!selectedStory) return;
|
|
@@ -814,6 +837,17 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
814
837
|
onSelect={handleCartoonNav}
|
|
815
838
|
/>
|
|
816
839
|
)}
|
|
840
|
+
{isCartoonStory && selectedStory && cartoonView !== null && (
|
|
841
|
+
<div className="flex-shrink-0 border-b border-border" data-testid="workflow-context-next-action">
|
|
842
|
+
<CartoonNextAction
|
|
843
|
+
storyName={selectedStory}
|
|
844
|
+
authFetch={authFetch}
|
|
845
|
+
refreshKey={cartoonPublishRefresh}
|
|
846
|
+
onCoachAction={handleWorkflowNextAction}
|
|
847
|
+
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
848
|
+
/>
|
|
849
|
+
</div>
|
|
850
|
+
)}
|
|
817
851
|
{isCartoonStory && cartoonView === "story-info" && selectedStory ? (
|
|
818
852
|
<StoryInfoPage storyName={selectedStory} authFetch={authFetch} onSaved={handleStoryInfoSaved} />
|
|
819
853
|
) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
|
|
@@ -836,6 +870,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
836
870
|
storyName={selectedStory}
|
|
837
871
|
authFetch={authFetch}
|
|
838
872
|
onOpenFile={handleSelectFile}
|
|
873
|
+
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
839
874
|
/>
|
|
840
875
|
) : (
|
|
841
876
|
<PreviewPanel
|