@hugpy/video-intelligence-ui 0.1.0

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.
Files changed (177) hide show
  1. package/LICENSE +41 -0
  2. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  3. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  4. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  5. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  6. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  7. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  8. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  9. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  10. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  11. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  12. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  13. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  14. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  15. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  16. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  17. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  18. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  19. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  20. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  21. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  22. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  23. package/assets/sampels/scenes/man.mp4 +0 -0
  24. package/assets/sampels/scenes/woman.mp4 +0 -0
  25. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  26. package/assets/sampels/studio_movies/caverns/movie.json +67 -0
  27. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  28. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  29. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
  30. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
  31. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  32. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
  33. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  34. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  35. package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
  36. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  37. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  38. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
  39. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
  40. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  41. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  42. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
  43. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
  44. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  45. package/assets/sampels/videos/aurora.mp4 +0 -0
  46. package/assets/sampels/videos/dark_night.mp4 +0 -0
  47. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  48. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  49. package/entry.tsx +74 -0
  50. package/index.html +53 -0
  51. package/package.json +42 -0
  52. package/src/assets/hugpy-mark.png +0 -0
  53. package/src/config.ts +676 -0
  54. package/src/demo/DemoBanner.tsx +54 -0
  55. package/src/demo/assets/demo-audio.mp3 +0 -0
  56. package/src/demo/assets/demo-clip.mp4 +0 -0
  57. package/src/demo/assets/demo-crop.mp4 +0 -0
  58. package/src/demo/assets/demo-poster.jpg +0 -0
  59. package/src/demo/assets/frame-1.jpg +0 -0
  60. package/src/demo/assets/frame-2.jpg +0 -0
  61. package/src/demo/assets/frame-3.jpg +0 -0
  62. package/src/demo/assets/frame-4.jpg +0 -0
  63. package/src/demo/assets/frame-5.jpg +0 -0
  64. package/src/demo/assets/frame-6.jpg +0 -0
  65. package/src/demo/assets/gen-1.jpg +0 -0
  66. package/src/demo/assets/gen-2.jpg +0 -0
  67. package/src/demo/assets.d.ts +14 -0
  68. package/src/demo/demo.css +64 -0
  69. package/src/demo/demoFetch.ts +305 -0
  70. package/src/demo/fixtures.ts +814 -0
  71. package/src/demo/index.ts +43 -0
  72. package/src/demo/mode.ts +81 -0
  73. package/src/demo/promptAssist.ts +181 -0
  74. package/src/demo/seed.ts +97 -0
  75. package/src/nav/BrandMark.css +66 -0
  76. package/src/nav/BrandMark.tsx +40 -0
  77. package/src/nav/Navbar.css +13 -0
  78. package/src/nav/Navbar.tsx +68 -0
  79. package/src/regions/SpatialRegionEditor.tsx +375 -0
  80. package/src/regions/TemporalRegionEditor.tsx +485 -0
  81. package/src/regions/types.ts +85 -0
  82. package/src/session.ts +85 -0
  83. package/src/share.ts +77 -0
  84. package/src/stations/ActivePanel.tsx +55 -0
  85. package/src/stations/ActiveProcessesStation.tsx +272 -0
  86. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
  87. package/src/stations/AddFileBar.tsx +59 -0
  88. package/src/stations/AudioCropStation.tsx +157 -0
  89. package/src/stations/ComfyPanel.tsx +210 -0
  90. package/src/stations/ComfyStation.tsx +196 -0
  91. package/src/stations/CondensedKnobStrip.tsx +245 -0
  92. package/src/stations/ConsolePanel.tsx +82 -0
  93. package/src/stations/FrameExtractStation.tsx +148 -0
  94. package/src/stations/GenIdentityBar.tsx +412 -0
  95. package/src/stations/GenerateStation.tsx +5603 -0
  96. package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
  97. package/src/stations/GoalComposer.tsx +139 -0
  98. package/src/stations/ImageCropStation.tsx +153 -0
  99. package/src/stations/KnobInfo.tsx +64 -0
  100. package/src/stations/PlaceholderStation.tsx +26 -0
  101. package/src/stations/SectionTabs.tsx +276 -0
  102. package/src/stations/SharePanel.tsx +298 -0
  103. package/src/stations/SidebarRegistry.tsx +105 -0
  104. package/src/stations/StationShell.tsx +96 -0
  105. package/src/stations/StudioClipsStation.tsx +28 -0
  106. package/src/stations/WorkbenchDrawer.tsx +66 -0
  107. package/src/stations/WorkbenchSidebar.tsx +1224 -0
  108. package/src/stations/WorkbenchStation.tsx +91 -0
  109. package/src/stations/comfyEmbed.ts +79 -0
  110. package/src/stations/drawerShell.tsx +419 -0
  111. package/src/stations/ops/AudioCropCore.tsx +218 -0
  112. package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
  113. package/src/stations/ops/FrameExtractCore.tsx +695 -0
  114. package/src/stations/ops/ImageCropCore.tsx +164 -0
  115. package/src/stations/ops/VideoCropCore.tsx +193 -0
  116. package/src/stations/ops/useVideoCropJobs.ts +224 -0
  117. package/src/stations/pollCaps.ts +87 -0
  118. package/src/stations/registry.ts +191 -0
  119. package/src/stations/studio/AngleRingEditor.tsx +159 -0
  120. package/src/stations/studio/IdentitiesStation.tsx +2455 -0
  121. package/src/stations/studio/IdentityProfileControls.tsx +262 -0
  122. package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
  123. package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
  124. package/src/stations/studio/StudioGenerateMode.tsx +29 -0
  125. package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
  126. package/src/stations/studio/StudioItemCard.tsx +334 -0
  127. package/src/stations/studio/StudioLibraryTab.tsx +506 -0
  128. package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
  129. package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
  130. package/src/stations/studio/StudioMovieSessions.tsx +553 -0
  131. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
  132. package/src/stations/studio/StudioPlane.tsx +302 -0
  133. package/src/stations/studio/StudioSplicedRow.tsx +476 -0
  134. package/src/stations/studio/StudioViewer.tsx +339 -0
  135. package/src/stations/studio/TurntableViewer.tsx +220 -0
  136. package/src/stations/studio/movieTimeline.ts +198 -0
  137. package/src/stations/studio/promptCard.tsx +498 -0
  138. package/src/stations/studio/studioItem.ts +87 -0
  139. package/src/stations/studio/studioShared.tsx +821 -0
  140. package/src/stations/studio/useIdentityProfiles.ts +575 -0
  141. package/src/stations/studio/useReconstruction.ts +283 -0
  142. package/src/stations/types.ts +43 -0
  143. package/src/stations/useAudioCropJobs.ts +202 -0
  144. package/src/stations/useAudioExtractJob.ts +144 -0
  145. package/src/stations/useCropJobs.ts +206 -0
  146. package/src/stations/useFrameJobs.ts +140 -0
  147. package/src/stations/useGenerateJob.ts +151 -0
  148. package/src/stations/useGenerateMovieJob.ts +177 -0
  149. package/src/stations/useGenerateRowJobs.ts +184 -0
  150. package/src/stations/useGenerateSceneJob.ts +156 -0
  151. package/src/stations/useGenerateSceneRowJobs.ts +282 -0
  152. package/src/style/app.css +4974 -0
  153. package/src/transport/client.ts +262 -0
  154. package/src/transport/telemetry.ts +97 -0
  155. package/src/video/DropReceptacle.tsx +122 -0
  156. package/src/video/PromptAssistButtons.tsx +118 -0
  157. package/src/video/composerBridge.ts +66 -0
  158. package/src/video/contract.ts +599 -0
  159. package/src/video/jobTracker.ts +702 -0
  160. package/src/video/mediaLibrary.ts +328 -0
  161. package/src/video/renderCompat.ts +512 -0
  162. package/src/video/sessionForm.ts +189 -0
  163. package/src/video/spreadAssist.ts +392 -0
  164. package/src/video/studioBridge.ts +82 -0
  165. package/src/video/useMediaJobs.ts +279 -0
  166. package/src/video/useModels.ts +413 -0
  167. package/src/video/useMoviePresets.ts +97 -0
  168. package/src/video/useMovieSessions.ts +292 -0
  169. package/src/video/usePresets.ts +167 -0
  170. package/src/video/useProjects.ts +87 -0
  171. package/src/video/usePromptAssist.ts +394 -0
  172. package/src/video/useRenderPresets.ts +221 -0
  173. package/src/video/useStudioAssistLog.ts +266 -0
  174. package/src/video/useStudioPresets.ts +128 -0
  175. package/src/video/useWorkerCalls.ts +165 -0
  176. package/tsconfig.json +17 -0
  177. package/vite.config.ts +33 -0
@@ -0,0 +1,599 @@
1
+ // The frozen hugpy video-intelligence backend contract, in one typed module.
2
+ // Mirrors hugpy_video_intelligence_map.md — a backend is being built to this in
3
+ // parallel, so these shapes are the source of truth the UI serialises to and
4
+ // parses from. zod schemas validate the two responses the UI actually reads
5
+ // (ingest → MediaRef, job poll → JobRecord) so contract drift surfaces as a clean
6
+ // message instead of an undefined-property crash under strictNullChecks-off.
7
+ import { z } from "zod";
8
+ import type { SpatialRegion, TemporalRegion } from "../regions/types";
9
+
10
+ // A nullable numeric MediaRef field. The contract says "numbers may be null"; we
11
+ // also tolerate the key being absent, and preserve extra unknown keys via
12
+ // passthrough so a MediaRef round-trips untouched back into a crop request.
13
+ const nullableNumber = z.number().nullable().optional();
14
+
15
+ export const mediaRefSchema = z
16
+ .object({
17
+ asset_id: z.string(),
18
+ kind: z.string(),
19
+ uri: z.string(),
20
+ mime: z.string(),
21
+ /** For an image, the native pixel dims — the space CropSpec regions live in. */
22
+ width: nullableNumber,
23
+ height: nullableNumber,
24
+ duration_s: nullableNumber,
25
+ fps_native: nullableNumber,
26
+ sample_rate: nullableNumber,
27
+ channels: nullableNumber,
28
+ })
29
+ .passthrough();
30
+
31
+ /** A resolved media asset. Native `width`/`height` anchor the spatial pixel space. */
32
+ export type MediaRef = z.infer<typeof mediaRefSchema>;
33
+
34
+ /** POST /uploads → the server file handle used to ingest. */
35
+ export const uploadResultSchema = z
36
+ .object({
37
+ path: z.string(),
38
+ name: z.string(),
39
+ size: z.number(),
40
+ })
41
+ .passthrough();
42
+ export type UploadResult = z.infer<typeof uploadResultSchema>;
43
+
44
+ /** POST /video/jobs/crop request body. Exactly one axis is set per station today. */
45
+ export interface CropJobRequest {
46
+ source: MediaRef;
47
+ spatial: SpatialRegion | null;
48
+ temporal: TemporalRegion | null;
49
+ }
50
+
51
+ /** POST /video/jobs/crop → the enqueued job's id. */
52
+ export const enqueueResultSchema = z
53
+ .object({ job_id: z.string() })
54
+ .passthrough();
55
+ export type EnqueueResult = z.infer<typeof enqueueResultSchema>;
56
+
57
+ /** The frame image encodings the frame-extract job accepts. */
58
+ export const FRAME_FORMATS = ["jpg", "png", "webp"] as const;
59
+ export type FrameFormat = (typeof FRAME_FORMATS)[number];
60
+
61
+ /**
62
+ * POST /video/jobs/frame_extract request body. ONE job produces MANY frame
63
+ * MediaRefs (polled back via jobRecordSchema → result.outputs, already an array).
64
+ * `window` null = the whole video; `max_frames` null = uncapped. `quality`
65
+ * semantics differ by fmt (jpg/webp 1..100, png 0..9) — the station surfaces the
66
+ * valid range per selected fmt rather than silently clamping.
67
+ */
68
+ export interface FrameExtractRequest {
69
+ source: MediaRef;
70
+ fps: number;
71
+ quality: number;
72
+ fmt: FrameFormat;
73
+ window: TemporalRegion | null;
74
+ max_frames: number | null;
75
+ }
76
+
77
+ /** The audio encodings the audio-extract job can emit. Default (station) is wav. */
78
+ export const AUDIO_FORMATS = ["wav", "mp3", "m4a"] as const;
79
+ export type AudioFormat = (typeof AUDIO_FORMATS)[number];
80
+
81
+ /**
82
+ * POST /video/jobs/audio_extract request body. Pulls the audio track out of a
83
+ * video `source` into ONE standalone audio MediaRef (polled back via
84
+ * jobRecordSchema → result.outputs[0]). `fmt` is the output container/codec; the
85
+ * station defaults it to "wav" (lossless and trivially decodable for the
86
+ * waveform). Mirrors FrameExtractRequest's explicit shape — no silent defaults on
87
+ * the wire. The temporal crop that follows reuses the EXISTING crop endpoint
88
+ * (CropJobRequest with spatial: null + a temporal window).
89
+ */
90
+ export interface AudioExtractRequest {
91
+ source: MediaRef;
92
+ fmt: AudioFormat;
93
+ }
94
+
95
+ /**
96
+ * POST /video/jobs/generate_image — one ordered multimodal prompt part.
97
+ * The prompt is an ORDERED list: text segments, images from the library (picked
98
+ * frames or prior generations), and optional raw video parts the backend chain
99
+ * resolves into frames (uniform-N sampling). Order is preserved by insertion.
100
+ */
101
+ export type GenPromptPart =
102
+ | { kind: "text"; text: string }
103
+ | { kind: "image"; media: MediaRef }
104
+ | { kind: "video"; media: MediaRef };
105
+
106
+ /**
107
+ * POST /video/jobs/generate_image request body. `parts` is the ordered prompt;
108
+ * `model_id` comes from the shared image-model dropdown (task text-to-image).
109
+ * All knobs are explicit numbers the station renders and the user can edit;
110
+ * `seed` null = random, `negative` null = none. The station guards that at least
111
+ * one non-empty text part exists before enabling Run (a prompt with no text part
112
+ * fails backend-side with "no_prompt"). Polled back via jobRecordSchema →
113
+ * result.outputs[0] = the generated image MediaRef.
114
+ */
115
+ export interface GenerateImageRequest {
116
+ parts: GenPromptPart[];
117
+ model_id: string;
118
+ width: number;
119
+ height: number;
120
+ steps: number;
121
+ guidance: number;
122
+ seed: number | null;
123
+ negative: string | null;
124
+ /**
125
+ * Optional auto-archive project NAME. When set, the backend files the output
126
+ * under assets/<project> and echoes the resolved project on result.project.
127
+ * Blank/omitted = an auto-named folder.
128
+ */
129
+ project?: string;
130
+ }
131
+
132
+ /**
133
+ * ONE row of the image TESTER (Generate station, Image mode).
134
+ *
135
+ * The point of the tester is COMPARISON: settings belong to a prompt, not to the
136
+ * station, so you can sit two prompts side by side with different steps/guidance/
137
+ * seed/model and see the difference. Each row therefore carries its OWN complete
138
+ * knob set — a row is a whole `GenerateImageRequest` waiting to happen, and "Run
139
+ * all" fires one job per row.
140
+ *
141
+ * Knobs are held as STRINGS because these are live text inputs (blank seed =
142
+ * random, blank negative = none); they are parsed into a `GenerateImageRequest`
143
+ * only at submit, exactly as the base composer already does. A row that fails to
144
+ * parse is skipped rather than submitted with silently coerced numbers.
145
+ *
146
+ * `media` is deliberately NOT per-row: rows inherit the base composer's image /
147
+ * video / identity parts, so a tester run holds the reference material fixed and
148
+ * varies only what you are testing. Give a row its own text and its own knobs;
149
+ * everything else stays comparable by construction.
150
+ */
151
+ export interface ImageTestRow {
152
+ /** Stable client-side id (React key + jobs-by-row map key). Never sent. */
153
+ id: string;
154
+ /** This row's prompt text. Combined with the base composer's media parts. */
155
+ text: string;
156
+ model_id: string;
157
+ width: string;
158
+ height: string;
159
+ steps: string;
160
+ guidance: string;
161
+ /** Blank = random. */
162
+ seed: string;
163
+ /** Blank = none. */
164
+ negative: string;
165
+ /**
166
+ * PER-SETTING CARRY TICKS (operator ask 2026-08-04, k63) — which of this row's
167
+ * settings are carried into the next component seeded FROM this one ("+ Add
168
+ * row" / "+ Row per model" / "+ Row per preset"). An unticked setting is NOT
169
+ * copied: the new row falls back to the mode default instead.
170
+ *
171
+ * CLIENT-SIDE ONLY. Like `id`, this is editor bookkeeping — it is never
172
+ * serialized into a GenerateImageRequest and never reaches the backend (see
173
+ * runnableTestRows in GenerateStation.tsx, which reads only the knob fields).
174
+ */
175
+ carry: CarryTicks;
176
+ }
177
+
178
+ /**
179
+ * The settings a carry tick can cover. `size` is width+height as ONE tick — they
180
+ * are edited as one "size W × H" control, so they carry together or not at all.
181
+ */
182
+ export type CarrySettingKey =
183
+ | "model"
184
+ | "size"
185
+ | "steps"
186
+ | "guidance"
187
+ | "seed"
188
+ | "negative";
189
+
190
+ /** Tick state for every carryable setting. Client-side only — never sent. */
191
+ export type CarryTicks = Record<CarrySettingKey, boolean>;
192
+
193
+ /**
194
+ * POST /video/jobs/generate_scene request body (Generate station, Scene mode).
195
+ * ONE ordered multimodal prompt → N consecutive image frames forming a scene +
196
+ * (when `assemble`) one assembled mp4 clip. Shares the prompt/model/knob shape of
197
+ * GenerateImageRequest and adds the scene-only knobs: `n_frames` (REQUIRED,
198
+ * explicit; backend caps at 24 → HTTP 400 "frame_cap_exceeded"), `fps` (REQUIRED,
199
+ * explicit), `assemble` (build the mp4 from the frames), and `motion` (an
200
+ * optional per-frame progression template that may contain {i}/{n}). Polled back
201
+ * via jobRecordSchema → result.outputs = N image refs, then (if assemble) ONE
202
+ * video ref LAST — the SAME status seam generate_image uses.
203
+ *
204
+ * Image-to-image conditioning: the FIRST image part in `parts` is the scene's
205
+ * start frame. `strength` is the img2img denoising strength (0..1, null = backend
206
+ * default of 0.45); lower keeps more of the start frame. `chain` (default true)
207
+ * conditions each frame on the previous one (sequential img2img). Until the fleet
208
+ * ships an img2img-capable worker a chained request may fail retryably with
209
+ * "image-to-image not available on the fleet" — surfaced via the normal job-error
210
+ * path.
211
+ */
212
+ export interface GenerateSceneRequest {
213
+ parts: GenPromptPart[];
214
+ model_id: string;
215
+ width: number;
216
+ height: number;
217
+ steps: number;
218
+ guidance: number;
219
+ n_frames: number;
220
+ fps: number;
221
+ assemble: boolean;
222
+ strength: number | null;
223
+ chain: boolean;
224
+ seed: number | null;
225
+ motion: string | null;
226
+ negative: string | null;
227
+ /**
228
+ * Optional auto-archive project NAME. When set, the backend files the scene's
229
+ * frames + clip under assets/<project> and echoes it on result.project.
230
+ * Blank/omitted = an auto-named folder.
231
+ */
232
+ project?: string;
233
+ }
234
+
235
+ /**
236
+ * ONE goal row of a Movie's GOAL TIMELINE (Generate station, Movie mode). The
237
+ * timeline is an ORDERED, CONTIGUOUS list of goals that tile the whole clip:
238
+ * `start_frame`..`end_frame` is HALF-OPEN ([start,end)), goal[0] starts at 0, and
239
+ * each goal begins exactly where the previous one ends (no gaps/overlaps) so the
240
+ * union is [0,total). `prompt` describes what that interval should depict; `ref`
241
+ * is an optional per-goal reference/start image the backend conditions on.
242
+ */
243
+ export const movieGoalSchema = z
244
+ .object({
245
+ start_frame: z.number(),
246
+ end_frame: z.number(),
247
+ prompt: z.string(),
248
+ ref: mediaRefSchema.optional(),
249
+ // Per-goal PROMPT-COMPONENT overrides (k92): each goal IS a prompt component
250
+ // and may carry its OWN generation knobs, mirroring Scene's per-part settings.
251
+ // ALL optional — an omitted field means the backend INHERITS the movie-level
252
+ // (GenerateMovieRequest) value, so a movie with no overrides is byte-for-byte
253
+ // the old payload. `motion` is goal-only (the movie request has no shared one).
254
+ model_id: z.string().optional(),
255
+ width: z.number().optional(),
256
+ height: z.number().optional(),
257
+ steps: z.number().optional(),
258
+ guidance: z.number().optional(),
259
+ seed: z.number().nullable().optional(),
260
+ negative: z.string().nullable().optional(),
261
+ strength: z.number().nullable().optional(),
262
+ chain: z.boolean().optional(),
263
+ motion: z.string().nullable().optional(),
264
+ })
265
+ .passthrough();
266
+ export type MovieGoal = z.infer<typeof movieGoalSchema>;
267
+
268
+ /**
269
+ * POST /video/jobs/generate_movie request body (Generate station, Movie mode). A
270
+ * GOAL TIMELINE (`goals`, contiguous, tiling [0,total)) drives an N-segment movie:
271
+ * each goal becomes one segment the backend renders into consecutive frames, then
272
+ * (when `assemble`) an mp4 clip is built LAST. The scene-template knobs
273
+ * (`model_id`/dims/`steps`/`guidance`/`fps`/`assemble` + optional
274
+ * `seed`/`negative`/`strength`/`chain`/`project`) are REUSED verbatim from Scene
275
+ * mode. The director knobs are OPT-IN: with `vision_enabled` the backend scores
276
+ * each segment with a judge VLM (`judge_model_id`) and re-rolls a segment until it
277
+ * clears `score_threshold` (0-100) or hits `max_attempts_per_segment` /
278
+ * `time_budget_s`. Polled back via jobRecordSchema → a NESTED movie progress
279
+ * (movieProgressSchema) while running, then result.outputs = segment frames then
280
+ * the movie.mp4 LAST (+ result.movie metadata).
281
+ */
282
+ export interface GenerateMovieRequest {
283
+ goals: MovieGoal[];
284
+ model_id: string;
285
+ width: number;
286
+ height: number;
287
+ steps: number;
288
+ guidance: number;
289
+ fps: number;
290
+ assemble: boolean;
291
+ /** Scene-template optionals (reused from GenerateSceneRequest). */
292
+ seed?: number | null;
293
+ negative?: string | null;
294
+ strength?: number | null;
295
+ chain?: boolean;
296
+ project?: string;
297
+ /** Director knobs — opt-in vision scoring / re-roll loop. */
298
+ vision_enabled?: boolean;
299
+ score_threshold?: number;
300
+ max_attempts_per_segment?: number;
301
+ judge_model_id?: string;
302
+ time_budget_s?: number;
303
+ }
304
+
305
+ /**
306
+ * ONE goal of a curated MOVIE TEMPLATE's shot list — a lighter cousin of
307
+ * movieGoalSchema (no per-goal `ref`; a template ships prompt-only goals). The
308
+ * timeline is still the contiguous [start,end) tiling the Movie editor loads.
309
+ */
310
+ export const moviePresetGoalSchema = z
311
+ .object({
312
+ start_frame: z.number(),
313
+ end_frame: z.number(),
314
+ prompt: z.string(),
315
+ })
316
+ .passthrough();
317
+ export type MoviePresetGoal = z.infer<typeof moviePresetGoalSchema>;
318
+
319
+ /**
320
+ * A curated MOVIE TEMPLATE (Generate station, Movie tab). GET /movie/presets → a
321
+ * BARE ARRAY of these (NOT the {presets:[…]} envelope video presets use), and
322
+ * POST /movie/presets/<id>/apply echoes the SAME object. Unlike a video preset
323
+ * (knob-only "ideal default load"), a movie preset carries the WHOLE shot list —
324
+ * picking one drops a ready `goals` timeline + the shared knobs (model/dims/steps/
325
+ * guidance/fps/chain) + opt-in director defaults into the editor, ready to run or
326
+ * tweak. Tolerant/passthrough: numeric knobs are nullable+optional (a template may
327
+ * omit any — the editor keeps its current value), only id/name/model_key/goals are
328
+ * asserted.
329
+ */
330
+ export const moviePresetSchema = z
331
+ .object({
332
+ id: z.string(),
333
+ name: z.string(),
334
+ description: z.string().nullable().optional(),
335
+ model_key: z.string(),
336
+ width: z.number().nullable().optional(),
337
+ height: z.number().nullable().optional(),
338
+ steps: z.number().nullable().optional(),
339
+ guidance: z.number().nullable().optional(),
340
+ fps: z.number().nullable().optional(),
341
+ chain: z.boolean().nullable().optional(),
342
+ strength: z.number().nullable().optional(),
343
+ negative: z.string().nullable().optional(),
344
+ goals: z.array(moviePresetGoalSchema),
345
+ vision_enabled: z.boolean().nullable().optional(),
346
+ score_threshold: z.number().nullable().optional(),
347
+ })
348
+ .passthrough();
349
+ export type MoviePreset = z.infer<typeof moviePresetSchema>;
350
+
351
+ export const JOB_STATUSES = [
352
+ "queued",
353
+ "claimed",
354
+ "running",
355
+ "cancelling", // cancel requested; a running scene honors it between frames
356
+ "cancelled",
357
+ "done",
358
+ "failed",
359
+ ] as const;
360
+ export type JobStatus = (typeof JOB_STATUSES)[number] | null;
361
+
362
+ export const jobErrorSchema = z
363
+ .object({
364
+ code: z.string(),
365
+ message: z.string(),
366
+ retryable: z.boolean(),
367
+ })
368
+ .passthrough();
369
+ export type JobError = z.infer<typeof jobErrorSchema>;
370
+
371
+ /**
372
+ * The auto-archive project a terminal generation was saved into. Present on a
373
+ * `done` result for generate_image/generate_scene: `name` is the user-supplied
374
+ * label (null when the backend auto-named), `dir` is the on-disk folder the
375
+ * frames + clip were written to (the "Saved to …" surface), `uuid` a stable id.
376
+ */
377
+ export const jobProjectSchema = z
378
+ .object({
379
+ name: z.string().nullable(),
380
+ uuid: z.string(),
381
+ dir: z.string(),
382
+ })
383
+ .passthrough();
384
+ export type JobProject = z.infer<typeof jobProjectSchema>;
385
+
386
+ /**
387
+ * The terminal `movie` metadata block a generate_movie run echoes on its result —
388
+ * the per-segment ledger (chosen take, attempts, scores, why) + the assembled
389
+ * clip + overall stats. Tolerant/passthrough: the UI renders the movie mostly off
390
+ * result.outputs (frames + movie.mp4 last) + result.project, so every field here
391
+ * is optional — this schema exists so the block round-trips + is inspectable.
392
+ */
393
+ export const movieResultSchema = z
394
+ .object({
395
+ goals: z.array(movieGoalSchema).optional(),
396
+ drift: z.unknown().optional(),
397
+ vision_enabled: z.boolean().optional(),
398
+ score_threshold: z.number().nullable().optional(),
399
+ n_frames_total: z.number().optional(),
400
+ segments: z
401
+ .array(
402
+ z
403
+ .object({
404
+ index: z.number(),
405
+ goal: z.unknown().optional(),
406
+ prompt: z.string().optional(),
407
+ seed: z.number().nullable().optional(),
408
+ attempts: z.number().optional(),
409
+ // scores carries null for UNSCORED takes (vision off) — must allow null
410
+ scores: z.array(z.number().nullable()).optional(),
411
+ chosen_take: z.number().nullable().optional(),
412
+ status: z.string().optional(),
413
+ why: z.string().nullable().optional(),
414
+ // backend writes mp4 as a relative FILENAME string (e.g. "segment_00/
415
+ // video.mp4"), not a MediaRef — accept either, tolerantly.
416
+ mp4: z.union([z.string(), mediaRefSchema]).nullable().optional(),
417
+ })
418
+ .passthrough(),
419
+ )
420
+ .optional(),
421
+ // manifest movie is the FILENAME string "movie.mp4" (the playable clip rides
422
+ // in result.outputs as a MediaRef) — accept string or MediaRef.
423
+ movie: z.union([z.string(), mediaRefSchema]).nullable().optional(),
424
+ })
425
+ .passthrough();
426
+ export type MovieResult = z.infer<typeof movieResultSchema>;
427
+
428
+ export const jobResultSchema = z
429
+ .object({
430
+ ok: z.boolean(),
431
+ outputs: z.array(mediaRefSchema),
432
+ error: jobErrorSchema.nullable(),
433
+ /** Auto-archive destination (generation kinds); absent/null otherwise. */
434
+ project: jobProjectSchema.nullable().optional(),
435
+ /** Movie-mode ledger (generate_movie only); absent/null otherwise. */
436
+ movie: movieResultSchema.nullable().optional(),
437
+ })
438
+ .passthrough();
439
+ export type JobResult = z.infer<typeof jobResultSchema>;
440
+
441
+ /**
442
+ * Live per-run progress a job reports WHILE running (null/absent at terminal).
443
+ * `done`/`total` drive the "image {done}/{total}" readout; `stage`+`label`
444
+ * describe the current step; `frames` are completed outputs so far (SAME MediaRef
445
+ * shape as result.outputs — rendered with the existing frame renderer so a scene
446
+ * gallery fills in live); `started_at` (epoch seconds) anchors the elapsed clock;
447
+ * `eta_s` is the backend's remaining-time estimate when it has one.
448
+ */
449
+ export const jobProgressSchema = z
450
+ .object({
451
+ done: z.number(),
452
+ total: z.number(),
453
+ stage: z.string(),
454
+ label: z.string().optional(),
455
+ model: z.string().optional(),
456
+ frames: z.array(mediaRefSchema).optional(),
457
+ started_at: z.number().optional(),
458
+ eta_s: z.number().nullable().optional(),
459
+ })
460
+ .passthrough();
461
+ export type JobProgress = z.infer<typeof jobProgressSchema>;
462
+
463
+ /**
464
+ * One segment's live state inside a movie's NESTED progress. `index` is the goal's
465
+ * position; `goal` echoes the source goal (tolerated as unknown — the UI derives a
466
+ * label from `prompt`/`index`); `attempt` is the 1-based re-roll count; `score` is
467
+ * the latest judge score (0-100, null before scoring / when vision is off);
468
+ * `status` walks pending→generating→scoring→done (skipped/resumed for the vision
469
+ * loop); `frames` are the take's frames so far (SAME MediaRef shape as outputs).
470
+ */
471
+ export const movieSegmentProgressSchema = z
472
+ .object({
473
+ index: z.number(),
474
+ goal: z.unknown().optional(),
475
+ prompt: z.string().optional(),
476
+ attempt: z.number().optional(),
477
+ score: z.number().nullable().optional(),
478
+ status: z.string().optional(),
479
+ frames: z.array(mediaRefSchema).optional(),
480
+ })
481
+ .passthrough();
482
+ export type MovieSegmentProgress = z.infer<typeof movieSegmentProgressSchema>;
483
+
484
+ /**
485
+ * The NESTED progress a generate_movie job reports WHILE running — the key shape
486
+ * difference from a flat scene job. `segment_done`/`segment_total` drive the
487
+ * overall "segment d/t" line; `segments[]` is the per-segment strip; `current` is
488
+ * a flat JobProgress-shaped view of the ACTIVE segment's frame render (fed VERBATIM
489
+ * to the shipped progressReadout + live-frame grid). `stage` names the movie-level
490
+ * step (loading/generating/scoring/assembling/archiving).
491
+ */
492
+ export const movieProgressSchema = z
493
+ .object({
494
+ stage: z.string(),
495
+ segment_done: z.number(),
496
+ segment_total: z.number(),
497
+ segments: z.array(movieSegmentProgressSchema),
498
+ current: jobProgressSchema.nullable().optional(),
499
+ started_at: z.number().optional(),
500
+ eta_s: z.number().nullable().optional(),
501
+ })
502
+ .passthrough();
503
+ export type MovieProgress = z.infer<typeof movieProgressSchema>;
504
+
505
+ /** Either progress shape a poll may carry (flat scene/image OR nested movie). */
506
+ export type AnyProgress = JobProgress | MovieProgress;
507
+
508
+ /** GET /video/jobs/<id> → the current status + terminal result of one job. */
509
+ export const jobRecordSchema = z
510
+ .object({
511
+ job_id: z.string(),
512
+ status: z.enum(JOB_STATUSES).nullable(),
513
+ result: jobResultSchema.nullable(),
514
+ /**
515
+ * Live progress while running; null/absent at terminal. A UNION of the flat
516
+ * JobProgress (image/scene) and the nested MovieProgress (movie) — discriminated
517
+ * by the presence of `segments`. A movie blob carries no top-level done/total so
518
+ * jobProgressSchema rejects it, and a flat blob carries no segment_* so
519
+ * movieProgressSchema rejects it — the union is unambiguous either order. Both
520
+ * members passthrough, and `.nullable().optional()` keeps applyPoll's
521
+ * kind-agnostic `progress: jr.progress ?? null` line working unchanged.
522
+ */
523
+ progress: z
524
+ .union([jobProgressSchema, movieProgressSchema])
525
+ .nullable()
526
+ .optional(),
527
+ })
528
+ .passthrough();
529
+ export type JobRecord = z.infer<typeof jobRecordSchema>;
530
+
531
+ /** Narrow a poll's progress union to the nested movie shape (presence of segments). */
532
+ export function isMovieProgress(p: AnyProgress): p is MovieProgress {
533
+ return (
534
+ typeof p === "object" &&
535
+ p != null &&
536
+ "segments" in p &&
537
+ Array.isArray((p as { segments?: unknown }).segments)
538
+ );
539
+ }
540
+
541
+ /** Terminal states — polling stops here. */
542
+ export function isTerminal(status: JobStatus): boolean {
543
+ return status === "done" || status === "failed" || status === "cancelled";
544
+ }
545
+
546
+ /**
547
+ * Flatten an ordered multimodal prompt's TEXT parts into one human-readable
548
+ * summary string (media parts are dropped). Used to annotate a generation's
549
+ * library outputs with the prompt that produced them (session-library provenance)
550
+ * and to re-stage that prompt into the composer for Replicate / Continue. Purely
551
+ * client-side derivation — the wire contract is untouched.
552
+ */
553
+ export function promptSummary(parts: GenPromptPart[]): string {
554
+ return parts
555
+ .filter(
556
+ (p): p is Extract<GenPromptPart, { kind: "text" }> => p.kind === "text",
557
+ )
558
+ .map((p) => p.text.trim())
559
+ .filter((t) => t !== "")
560
+ .join(" ")
561
+ .trim();
562
+ }
563
+
564
+ export const MAX_SOURCE_IMAGES = 12;
565
+ export const MAX_CANONICAL_IMAGES = 4;
566
+
567
+ /**
568
+ * A discrete angular view in an angle-ring reconstruction.
569
+ */
570
+ export const identityAngleViewSchema = z.object({
571
+ view_id: z.string(),
572
+ azimuth_deg: z.number(),
573
+ elevation_deg: z.number().optional(),
574
+ image_uri: z.string().nullable().optional(),
575
+ status: z.enum(["pending", "generating", "generated", "approved", "rejected", "failed"]).optional(),
576
+ version: z.number().optional(),
577
+ seed: z.number().nullable().optional(),
578
+ error: z.string().nullable().optional(),
579
+ }).passthrough();
580
+
581
+ export type IdentityAngleView = z.infer<typeof identityAngleViewSchema>;
582
+
583
+ export const identityReconstructionMeshSchema = z.object({
584
+ status: z.enum(["none", "queued", "running", "completed", "failed"]),
585
+ glb_uri: z.string().optional(),
586
+ error: z.string().optional(),
587
+ }).passthrough();
588
+
589
+ export const identityReconstructionSchema = z.object({
590
+ recon_id: z.string(),
591
+ mode: z.enum(["sheet", "turntable", "angle-ring"]).optional(),
592
+ views: z.array(z.any()).optional(), // Lenient: accepts both legacy strings & new view objects
593
+ mesh: identityReconstructionMeshSchema.optional(),
594
+ created_at: z.number().optional(),
595
+ degrees_per_frame: z.number().optional(),
596
+ frame_count: z.number().optional(),
597
+ }).passthrough();
598
+
599
+ export type IdentityReconstruction = z.infer<typeof identityReconstructionSchema>;