plotlink-ows 1.2.94 → 1.2.96

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.
@@ -0,0 +1,260 @@
1
+ import type { WalletInfo } from "../../lib/ows/types";
2
+ import { getBaseAddress, listAgentWallets } from "../../lib/ows/wallet";
3
+ import { db } from "../db";
4
+
5
+ const ACTIVE_WALLET_SETTING_KEY = "activeOwsWallet.v1";
6
+ const PLOTLINK_WALLET_PREFIX = "plotlink-writer";
7
+
8
+ export interface StoredWalletSelection {
9
+ walletId?: string;
10
+ name?: string;
11
+ address?: string;
12
+ source: "ows";
13
+ label?: string;
14
+ }
15
+
16
+ export interface WalletChoice {
17
+ walletId?: string;
18
+ name: string;
19
+ address?: string;
20
+ normalizedAddress?: string;
21
+ source: "ows";
22
+ label: string;
23
+ recognized: boolean;
24
+ active: boolean;
25
+ }
26
+
27
+ export interface ActiveWallet {
28
+ wallet: WalletInfo;
29
+ walletId?: string;
30
+ name: string;
31
+ address: string;
32
+ normalizedAddress: string;
33
+ source: "ows";
34
+ label: string;
35
+ }
36
+
37
+ export interface PublicActiveWallet {
38
+ walletId?: string;
39
+ name: string;
40
+ address: string;
41
+ normalizedAddress: string;
42
+ source: "ows";
43
+ label: string;
44
+ }
45
+
46
+ export interface ActiveWalletResolution {
47
+ activeWallet: ActiveWallet | null;
48
+ wallets: WalletChoice[];
49
+ selectionRequired: boolean;
50
+ error?: string;
51
+ }
52
+
53
+ function normalizeAddress(address: string | undefined): string | undefined {
54
+ const trimmed = address?.trim();
55
+ return trimmed ? trimmed.toLowerCase() : undefined;
56
+ }
57
+
58
+ function getWalletId(wallet: WalletInfo): string | undefined {
59
+ const maybeId = (wallet as WalletInfo & { id?: unknown }).id;
60
+ return typeof maybeId === "string" && maybeId.trim() ? maybeId : undefined;
61
+ }
62
+
63
+ function toWalletChoice(wallet: WalletInfo, activeSelection?: StoredWalletSelection): WalletChoice {
64
+ const address = getBaseAddress(wallet);
65
+ const normalizedAddress = normalizeAddress(address);
66
+ const walletId = getWalletId(wallet);
67
+ const recognized = wallet.name.startsWith(PLOTLINK_WALLET_PREFIX);
68
+ return {
69
+ walletId,
70
+ name: wallet.name,
71
+ address: normalizedAddress,
72
+ normalizedAddress,
73
+ source: "ows",
74
+ label: recognized ? "PlotLink writer wallet" : "OWS wallet",
75
+ recognized,
76
+ active: matchesSelection(wallet, address, activeSelection),
77
+ };
78
+ }
79
+
80
+ function matchesSelection(wallet: WalletInfo, address: string | undefined, selection: StoredWalletSelection | null | undefined): boolean {
81
+ if (!selection) return false;
82
+ const walletId = getWalletId(wallet);
83
+ const normalizedAddress = normalizeAddress(address);
84
+ const selectedAddress = normalizeAddress(selection.address);
85
+ if (selection.walletId && walletId && selection.walletId === walletId) return true;
86
+ if (selectedAddress && normalizedAddress && selectedAddress === normalizedAddress) return true;
87
+ if (selection.name && selection.name === wallet.name) return true;
88
+ return false;
89
+ }
90
+
91
+ function storedSelectionFor(wallet: WalletInfo): StoredWalletSelection {
92
+ const address = normalizeAddress(getBaseAddress(wallet));
93
+ return {
94
+ walletId: getWalletId(wallet),
95
+ name: wallet.name,
96
+ address,
97
+ source: "ows",
98
+ label: wallet.name.startsWith(PLOTLINK_WALLET_PREFIX) ? "PlotLink writer wallet" : "OWS wallet",
99
+ };
100
+ }
101
+
102
+ async function readStoredSelection(): Promise<StoredWalletSelection | null> {
103
+ try {
104
+ const row = await db.setting.findUnique({ where: { key: ACTIVE_WALLET_SETTING_KEY } });
105
+ if (!row?.value) return null;
106
+ const parsed = JSON.parse(row.value) as Partial<StoredWalletSelection>;
107
+ if (parsed.source !== "ows") return null;
108
+ return {
109
+ walletId: typeof parsed.walletId === "string" ? parsed.walletId : undefined,
110
+ name: typeof parsed.name === "string" ? parsed.name : undefined,
111
+ address: normalizeAddress(parsed.address),
112
+ source: "ows",
113
+ label: typeof parsed.label === "string" ? parsed.label : undefined,
114
+ };
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ async function writeStoredSelection(selection: StoredWalletSelection): Promise<void> {
121
+ try {
122
+ await db.setting.upsert({
123
+ where: { key: ACTIVE_WALLET_SETTING_KEY },
124
+ create: { key: ACTIVE_WALLET_SETTING_KEY, value: JSON.stringify(selection) },
125
+ update: { value: JSON.stringify(selection) },
126
+ });
127
+ } catch {
128
+ // The app can still operate in legacy single-wallet mode if persistence is
129
+ // temporarily unavailable; signing never depends on this write succeeding.
130
+ }
131
+ }
132
+
133
+ function findSelectedWallet(wallets: WalletInfo[], selection: StoredWalletSelection | null): WalletInfo | null {
134
+ if (!selection) return null;
135
+ return wallets.find((wallet) => matchesSelection(wallet, getBaseAddress(wallet), selection)) ?? null;
136
+ }
137
+
138
+ function toActiveWallet(wallet: WalletInfo): ActiveWallet | null {
139
+ const address = normalizeAddress(getBaseAddress(wallet));
140
+ if (!address) return null;
141
+ return {
142
+ wallet,
143
+ walletId: getWalletId(wallet),
144
+ name: wallet.name,
145
+ address,
146
+ normalizedAddress: address,
147
+ source: "ows",
148
+ label: wallet.name.startsWith(PLOTLINK_WALLET_PREFIX) ? "PlotLink writer wallet" : "OWS wallet",
149
+ };
150
+ }
151
+
152
+ export async function listWalletChoices(): Promise<WalletChoice[]> {
153
+ const wallets = listAgentWallets();
154
+ const selection = await readStoredSelection();
155
+ return wallets.map((wallet) => toWalletChoice(wallet, selection));
156
+ }
157
+
158
+ export async function resolveActiveWallet(): Promise<ActiveWalletResolution> {
159
+ const wallets = listAgentWallets();
160
+ const selection = await readStoredSelection();
161
+ const storedWallet = findSelectedWallet(wallets, selection);
162
+ const activeFromStored = storedWallet ? toActiveWallet(storedWallet) : null;
163
+ if (activeFromStored) {
164
+ return {
165
+ activeWallet: activeFromStored,
166
+ wallets: wallets.map((wallet) => toWalletChoice(wallet, storedSelectionFor(storedWallet))),
167
+ selectionRequired: false,
168
+ };
169
+ }
170
+
171
+ const evmWallets = wallets.filter((wallet) => Boolean(getBaseAddress(wallet)));
172
+ const recognizedWallets = evmWallets.filter((wallet) => wallet.name.startsWith(PLOTLINK_WALLET_PREFIX));
173
+ const autoSelected = recognizedWallets.length === 1
174
+ ? recognizedWallets[0]
175
+ : recognizedWallets.length === 0 && evmWallets.length === 1
176
+ ? evmWallets[0]
177
+ : null;
178
+
179
+ if (autoSelected) {
180
+ const stored = storedSelectionFor(autoSelected);
181
+ await writeStoredSelection(stored);
182
+ return {
183
+ activeWallet: toActiveWallet(autoSelected),
184
+ wallets: wallets.map((wallet) => toWalletChoice(wallet, stored)),
185
+ selectionRequired: false,
186
+ };
187
+ }
188
+
189
+ const choices = wallets.map((wallet) => toWalletChoice(wallet, null));
190
+ const hasSelectableWallets = evmWallets.length > 0;
191
+ return {
192
+ activeWallet: null,
193
+ wallets: choices,
194
+ selectionRequired: hasSelectableWallets,
195
+ error: hasSelectableWallets
196
+ ? "Multiple OWS wallets found. Select an active wallet before publishing or signing."
197
+ : "No OWS wallet found",
198
+ };
199
+ }
200
+
201
+ export async function selectActiveWallet(input: { walletId?: string; name?: string; address?: string }): Promise<ActiveWalletResolution> {
202
+ const wallets = listAgentWallets();
203
+ const normalizedInputAddress = normalizeAddress(input.address);
204
+ const selected = wallets.find((wallet) => {
205
+ const walletId = getWalletId(wallet);
206
+ const address = normalizeAddress(getBaseAddress(wallet));
207
+ if (input.walletId && walletId && walletId === input.walletId) return true;
208
+ if (normalizedInputAddress && address && address === normalizedInputAddress) return true;
209
+ if (input.name && wallet.name === input.name) return true;
210
+ return false;
211
+ });
212
+
213
+ if (!selected) {
214
+ return {
215
+ activeWallet: null,
216
+ wallets: wallets.map((wallet) => toWalletChoice(wallet, null)),
217
+ selectionRequired: true,
218
+ error: "Selected OWS wallet was not found",
219
+ };
220
+ }
221
+
222
+ const activeWallet = toActiveWallet(selected);
223
+ if (!activeWallet) {
224
+ return {
225
+ activeWallet: null,
226
+ wallets: wallets.map((wallet) => toWalletChoice(wallet, null)),
227
+ selectionRequired: true,
228
+ error: "Selected OWS wallet has no EVM address",
229
+ };
230
+ }
231
+
232
+ const stored = storedSelectionFor(selected);
233
+ await writeStoredSelection(stored);
234
+ return {
235
+ activeWallet,
236
+ wallets: wallets.map((wallet) => toWalletChoice(wallet, stored)),
237
+ selectionRequired: false,
238
+ };
239
+ }
240
+
241
+ export function nextPlotlinkWalletName(wallets: WalletInfo[]): string {
242
+ const names = new Set(wallets.map((wallet) => wallet.name));
243
+ if (!names.has(PLOTLINK_WALLET_PREFIX)) return PLOTLINK_WALLET_PREFIX;
244
+ for (let index = 2; index < 1000; index += 1) {
245
+ const name = `${PLOTLINK_WALLET_PREFIX}-${index}`;
246
+ if (!names.has(name)) return name;
247
+ }
248
+ return `${PLOTLINK_WALLET_PREFIX}-${Date.now()}`;
249
+ }
250
+
251
+ export function toPublicActiveWallet(wallet: ActiveWallet): PublicActiveWallet {
252
+ return {
253
+ walletId: wallet.walletId,
254
+ name: wallet.name,
255
+ address: wallet.address,
256
+ normalizedAddress: wallet.normalizedAddress,
257
+ source: wallet.source,
258
+ label: wallet.label,
259
+ };
260
+ }
@@ -168,7 +168,7 @@ function coachForEpisode(ep: EpisodeProgress, undetectedClean: number): CartoonC
168
168
  }
169
169
 
170
170
  // 2) Lettering — place speech bubbles & captions in the cut workspace.
171
- if (c.withText < c.needClean) {
171
+ if (c.withText < c.total) {
172
172
  return ui("Clean images ready", "Review cuts and start lettering", "open-lettering", file);
173
173
  }
174
174
 
@@ -492,7 +492,7 @@ export interface CartoonCutProgress {
492
492
  needClean: number;
493
493
  /** Of `needClean`, how many have a clean image recorded. */
494
494
  withClean: number;
495
- /** Of the clean-image cuts, how many have text overlays placed. */
495
+ /** Cuts with lettering overlays placed. Image cuts still require clean art first; text panels are first-class lettering targets. */
496
496
  withText: number;
497
497
  /** Cuts (any kind) with an exported final image. */
498
498
  exported: number;
@@ -517,8 +517,8 @@ export function summarizeCutProgress(cuts: Cut[]): CartoonCutProgress {
517
517
  let uploaded = 0;
518
518
  for (const cut of cuts) {
519
519
  // Image cuts need a clean image → lettering; text/interstitial panels (#350)
520
- // do not (they're text on a styled background). Every panel still exports +
521
- // uploads a final image, so those are counted for both kinds.
520
+ // do not (they're text on a styled background). Text panels still require
521
+ // lettering overlays before the shared workflow can advance to export (#488).
522
522
  if (!isTextPanel(cut)) {
523
523
  needClean++;
524
524
  // A PNG clean image is a draft intermediate, not a finished clean asset
@@ -531,6 +531,8 @@ export function summarizeCutProgress(cuts: Cut[]): CartoonCutProgress {
531
531
  // every cut-list render now (#414), so a bad persisted cut must not crash it.
532
532
  if ((cut.overlays?.length ?? 0) > 0) withText++;
533
533
  }
534
+ } else if ((cut.overlays?.length ?? 0) > 0) {
535
+ withText++;
534
536
  }
535
537
  if (cut.finalImagePath && cut.exportedAt) exported++;
536
538
  if (cut.uploadedUrl) uploaded++;
@@ -583,12 +585,12 @@ export function cartoonChecklist(input: { cuts: Cut[]; published?: boolean }): C
583
585
  const p = summarizeCutProgress(cuts);
584
586
  if (p.total === 0) return { steps: [], nextStep: null };
585
587
 
586
- // Clean + letter gate only IMAGE cuts (needClean); export + upload gate EVERY
587
- // cut including text panels (total). For an all-image story needClean === total
588
- // so this is unchanged from before (#350).
588
+ // Clean gates only IMAGE cuts (needClean); lettering/export/upload gate EVERY
589
+ // cut including text panels. Text panels need no clean art, but they are still
590
+ // editable lettering targets before export (#488).
589
591
  const planDone = p.total > 0;
590
592
  const cleanDone = planDone && p.withClean === p.needClean;
591
- const letterDone = cleanDone && p.withText === p.needClean;
593
+ const letterDone = cleanDone && p.withText === p.total;
592
594
  const exportDone = letterDone && p.exported === p.total;
593
595
  const uploadDone = exportDone && p.uploaded === p.total;
594
596
  const publishDone = uploadDone && published;
@@ -604,13 +606,13 @@ export function cartoonChecklist(input: { cuts: Cut[]; published?: boolean }): C
604
606
  const order: CartoonStepKey[] = ["plan", "clean", "letter", "export", "upload", "publish"];
605
607
  const currentIdx = order.findIndex((k) => !complete[k]);
606
608
 
607
- // Clean/letter count image cuts (needClean); export/upload count every cut
609
+ // Clean counts image cuts (needClean); lettering/export/upload count every cut
608
610
  // (total). An all-text-panel episode has needClean === 0 → "no image cuts".
609
611
  const imageDetail = (done: number) => (p.needClean > 0 ? fraction(done, p.needClean) : "no image cuts");
610
612
  const detail: Record<CartoonStepKey, string | null> = {
611
613
  plan: fraction(p.total, p.total),
612
614
  clean: imageDetail(p.withClean),
613
- letter: imageDetail(p.withText),
615
+ letter: fraction(p.withText, p.total),
614
616
  export: fraction(p.exported, p.total),
615
617
  upload: fraction(p.uploaded, p.total),
616
618
  publish: null,
@@ -702,7 +704,7 @@ export function previewFooterGuidance(ctx: PreviewFooterContext): string | null
702
704
  if (p.withClean < p.needClean) {
703
705
  return "Genesis has a cut plan — generate the clean images for its cuts next.";
704
706
  }
705
- if (p.withText < p.needClean) {
707
+ if (p.withText < p.total) {
706
708
  return "Genesis clean art is ready — review the cuts and add speech bubbles & captions next.";
707
709
  }
708
710
  if (p.exported < p.total) {
package/app/lib/cuts.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { hasVisibleSpeechTail, CARTOON_BUBBLE_RENDERER_VERSION, type Overlay } from "./overlays";
3
+ import {
4
+ hasVisibleSpeechTail,
5
+ CARTOON_BUBBLE_RENDERER_VERSION,
6
+ type Overlay,
7
+ } from "./overlays";
4
8
 
5
- export const SHOT_TYPES = ["wide", "medium", "close-up", "extreme-close-up"] as const;
9
+ export const SHOT_TYPES = [
10
+ "wide",
11
+ "medium",
12
+ "close-up",
13
+ "extreme-close-up",
14
+ ] as const;
6
15
  export type ShotType = (typeof SHOT_TYPES)[number];
7
16
 
8
17
  export interface CutDialogue {
@@ -19,6 +28,19 @@ export interface CutDialogue {
19
28
  */
20
29
  export type CutKind = "image" | "text";
21
30
 
31
+ /**
32
+ * AI draft lettering state (#494). `generated` means OWS created a first-pass
33
+ * overlay set from the cut script and it has not been user-tuned yet. `edited`
34
+ * means the writer has since adjusted or replaced that draft in the editor.
35
+ */
36
+ export interface CutAiDraft {
37
+ status: "generated" | "edited";
38
+ /** Signature of the generated overlay set, for edit-detection on save. */
39
+ baseSig?: string;
40
+ generatedAt?: string;
41
+ updatedAt?: string;
42
+ }
43
+
22
44
  export interface Cut {
23
45
  id: number;
24
46
  shotType: ShotType;
@@ -41,6 +63,8 @@ export interface Cut {
41
63
  finalRendererVersion?: number;
42
64
  /** Panel kind (#350). Absent ⇒ "image" (backward-compatible). */
43
65
  kind?: CutKind;
66
+ /** AI draft lettering state (#494). Optional and backward-compatible. */
67
+ aiDraft?: CutAiDraft | null;
44
68
  /** Text-panel background color (CSS color), e.g. "#101820". Optional (#350). */
45
69
  background?: string;
46
70
  /** Text-panel aspect ratio hint, e.g. "4:5". Optional (#350). */
@@ -89,7 +113,11 @@ export function cutNextAction(
89
113
  if (cut.cleanImagePath || isTextPanel(cut)) {
90
114
  return { key: "letter", label: "Letter this cut", opensEditor: true };
91
115
  }
92
- return { key: "add-art", label: "Add clean art for this cut", opensEditor: false };
116
+ return {
117
+ key: "add-art",
118
+ label: "Add clean art for this cut",
119
+ opensEditor: false,
120
+ };
93
121
  }
94
122
 
95
123
  /**
@@ -115,7 +143,9 @@ export function staleTailedCutIds(
115
143
  cutsFile: Pick<CutsFile, "cuts">,
116
144
  currentVersion: number = CARTOON_BUBBLE_RENDERER_VERSION,
117
145
  ): number[] {
118
- return cutsFile.cuts.filter((c) => isStaleTailedExport(c, currentVersion)).map((c) => c.id);
146
+ return cutsFile.cuts
147
+ .filter((c) => isStaleTailedExport(c, currentVersion))
148
+ .map((c) => c.id);
119
149
  }
120
150
 
121
151
  /** Base canvas width for a text panel sized from its aspect ratio (#351). */
@@ -127,14 +157,19 @@ export const TEXT_PANEL_BASE_WIDTH = 800;
127
157
  * panel letters and exports at the SAME shape. Returns null for a missing or
128
158
  * malformed ratio; callers fall back to 800×600.
129
159
  */
130
- export function textPanelDimensions(aspectRatio: string | undefined): { width: number; height: number } | null {
160
+ export function textPanelDimensions(
161
+ aspectRatio: string | undefined,
162
+ ): { width: number; height: number } | null {
131
163
  if (!aspectRatio) return null;
132
164
  const m = aspectRatio.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
133
165
  if (!m) return null;
134
166
  const w = parseFloat(m[1]);
135
167
  const h = parseFloat(m[2]);
136
168
  if (!(w > 0) || !(h > 0)) return null;
137
- return { width: TEXT_PANEL_BASE_WIDTH, height: Math.round((TEXT_PANEL_BASE_WIDTH * h) / w) };
169
+ return {
170
+ width: TEXT_PANEL_BASE_WIDTH,
171
+ height: Math.round((TEXT_PANEL_BASE_WIDTH * h) / w),
172
+ };
138
173
  }
139
174
 
140
175
  export interface CutsFile {
@@ -170,7 +205,9 @@ export function createDefaultCut(id: number, _plotFile: string): Cut {
170
205
  }
171
206
 
172
207
  export function createCutsFile(plotFile: string, cutCount = 1): CutsFile {
173
- const cuts = Array.from({ length: cutCount }, (_, i) => createDefaultCut(i + 1, plotFile));
208
+ const cuts = Array.from({ length: cutCount }, (_, i) =>
209
+ createDefaultCut(i + 1, plotFile),
210
+ );
174
211
  return { version: 1, plotFile, cuts };
175
212
  }
176
213
 
@@ -178,7 +215,10 @@ function cutsFilePath(storyDir: string, plotFile: string): string {
178
215
  return path.join(storyDir, `${plotFile}.cuts.json`);
179
216
  }
180
217
 
181
- export function readCutsFile(storyDir: string, plotFile: string): CutsFile | null {
218
+ export function readCutsFile(
219
+ storyDir: string,
220
+ plotFile: string,
221
+ ): CutsFile | null {
182
222
  const filePath = cutsFilePath(storyDir, plotFile);
183
223
  if (!fs.existsSync(filePath)) return null;
184
224
 
@@ -186,7 +226,9 @@ export function readCutsFile(storyDir: string, plotFile: string): CutsFile | nul
186
226
  try {
187
227
  raw = fs.readFileSync(filePath, "utf-8");
188
228
  } catch (err) {
189
- throw new Error(`Cannot read ${plotFile}.cuts.json: ${(err as Error).message}`);
229
+ throw new Error(
230
+ `Cannot read ${plotFile}.cuts.json: ${(err as Error).message}`,
231
+ );
190
232
  }
191
233
 
192
234
  let data: unknown;
@@ -204,12 +246,19 @@ export function readCutsFile(storyDir: string, plotFile: string): CutsFile | nul
204
246
  return data as CutsFile;
205
247
  }
206
248
 
207
- export function writeCutsFile(storyDir: string, plotFile: string, cutsFile: CutsFile): void {
249
+ export function writeCutsFile(
250
+ storyDir: string,
251
+ plotFile: string,
252
+ cutsFile: CutsFile,
253
+ ): void {
208
254
  const filePath = cutsFilePath(storyDir, plotFile);
209
255
  fs.writeFileSync(filePath, JSON.stringify(cutsFile, null, 2) + "\n");
210
256
  }
211
257
 
212
- export function validateCutsFile(data: unknown): { valid: boolean; error?: string } {
258
+ export function validateCutsFile(data: unknown): {
259
+ valid: boolean;
260
+ error?: string;
261
+ } {
213
262
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
214
263
  return { valid: false, error: "Must be a JSON object" };
215
264
  }
@@ -254,7 +303,10 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
254
303
  }
255
304
  for (let j = 0; j < (cut.characters as unknown[]).length; j++) {
256
305
  if (typeof (cut.characters as unknown[])[j] !== "string") {
257
- return { valid: false, error: `Cut ${i} characters[${j}] must be a string` };
306
+ return {
307
+ valid: false,
308
+ error: `Cut ${i} characters[${j}] must be a string`,
309
+ };
258
310
  }
259
311
  }
260
312
  if (!Array.isArray(cut.dialogue)) {
@@ -262,8 +314,16 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
262
314
  }
263
315
  for (let j = 0; j < (cut.dialogue as unknown[]).length; j++) {
264
316
  const d = (cut.dialogue as Record<string, unknown>[])[j];
265
- if (typeof d !== "object" || d === null || typeof d.speaker !== "string" || typeof d.text !== "string") {
266
- return { valid: false, error: `Cut ${i} dialogue[${j}] must have speaker and text strings` };
317
+ if (
318
+ typeof d !== "object" ||
319
+ d === null ||
320
+ typeof d.speaker !== "string" ||
321
+ typeof d.text !== "string"
322
+ ) {
323
+ return {
324
+ valid: false,
325
+ error: `Cut ${i} dialogue[${j}] must have speaker and text strings`,
326
+ };
267
327
  }
268
328
  }
269
329
  if (typeof cut.narration !== "string") {
@@ -272,10 +332,19 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
272
332
  if (typeof cut.sfx !== "string") {
273
333
  return { valid: false, error: `Cut ${i} missing sfx` };
274
334
  }
275
- const nullableStrings = ["cleanImagePath", "finalImagePath", "exportedAt", "uploadedCid", "uploadedUrl"] as const;
335
+ const nullableStrings = [
336
+ "cleanImagePath",
337
+ "finalImagePath",
338
+ "exportedAt",
339
+ "uploadedCid",
340
+ "uploadedUrl",
341
+ ] as const;
276
342
  for (const field of nullableStrings) {
277
343
  if (cut[field] !== null && typeof cut[field] !== "string") {
278
- return { valid: false, error: `Cut ${i} ${field} must be a string or null` };
344
+ return {
345
+ valid: false,
346
+ error: `Cut ${i} ${field} must be a string or null`,
347
+ };
279
348
  }
280
349
  }
281
350
  if (cut.overlays !== undefined && !Array.isArray(cut.overlays)) {
@@ -285,6 +354,48 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
285
354
  if (cut.kind !== undefined && cut.kind !== "image" && cut.kind !== "text") {
286
355
  return { valid: false, error: `Cut ${i} kind must be "image" or "text"` };
287
356
  }
357
+ if (cut.aiDraft !== undefined && cut.aiDraft !== null) {
358
+ if (typeof cut.aiDraft !== "object") {
359
+ return {
360
+ valid: false,
361
+ error: `Cut ${i} aiDraft must be an object or null`,
362
+ };
363
+ }
364
+ const aiDraft = cut.aiDraft as Record<string, unknown>;
365
+ if (aiDraft.status !== "generated" && aiDraft.status !== "edited") {
366
+ return {
367
+ valid: false,
368
+ error: `Cut ${i} aiDraft.status must be "generated" or "edited"`,
369
+ };
370
+ }
371
+ if (
372
+ aiDraft.baseSig !== undefined &&
373
+ typeof aiDraft.baseSig !== "string"
374
+ ) {
375
+ return {
376
+ valid: false,
377
+ error: `Cut ${i} aiDraft.baseSig must be a string`,
378
+ };
379
+ }
380
+ if (
381
+ aiDraft.generatedAt !== undefined &&
382
+ typeof aiDraft.generatedAt !== "string"
383
+ ) {
384
+ return {
385
+ valid: false,
386
+ error: `Cut ${i} aiDraft.generatedAt must be a string`,
387
+ };
388
+ }
389
+ if (
390
+ aiDraft.updatedAt !== undefined &&
391
+ typeof aiDraft.updatedAt !== "string"
392
+ ) {
393
+ return {
394
+ valid: false,
395
+ error: `Cut ${i} aiDraft.updatedAt must be a string`,
396
+ };
397
+ }
398
+ }
288
399
  if (cut.background !== undefined && typeof cut.background !== "string") {
289
400
  return { valid: false, error: `Cut ${i} background must be a string` };
290
401
  }
@@ -293,8 +404,14 @@ export function validateCutsFile(data: unknown): { valid: boolean; error?: strin
293
404
  }
294
405
  // Bubble-renderer version stamp (#381) — optional, backward-compatible
295
406
  // (absent ⇒ pre-versioning final image).
296
- if (cut.finalRendererVersion !== undefined && typeof cut.finalRendererVersion !== "number") {
297
- return { valid: false, error: `Cut ${i} finalRendererVersion must be a number` };
407
+ if (
408
+ cut.finalRendererVersion !== undefined &&
409
+ typeof cut.finalRendererVersion !== "number"
410
+ ) {
411
+ return {
412
+ valid: false,
413
+ error: `Cut ${i} finalRendererVersion must be a number`,
414
+ };
298
415
  }
299
416
  }
300
417