@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,139 @@
1
+ // Shared GOAL-INPUT composer template (Round 7 addendum 2b/2c).
2
+ //
3
+ // Extracts the Movie mode's goal text-input idiom into ONE reusable, presentational
4
+ // component so the tiers share a single goal-input look: a .vi-knob-styled labeled text
5
+ // field (Movie's knob rhythm) with an OPTIONAL inline, chat-composer-style ATTACH TOOLBAR
6
+ // ("+ image from library" / "+ video") and removable attached-media chips.
7
+ //
8
+ // Presentational by design: it OWNS no picker or MediaRef logic. Each host wires the
9
+ // attach buttons to its OWN existing library pickers / MediaRef flows via callbacks
10
+ // (`attachments[].onClick`) and renders whatever it already tracks as `attached` chips.
11
+ // That keeps adoption per-mode and non-destabilizing — a mode adopts the idiom without
12
+ // surrendering its editor's state model.
13
+ //
14
+ // Adoption status (see the Round 7 report): the STUDIO surface adopts this for its
15
+ // prompt/negative. The image/scene (ordered-parts composer) and movie (goal/timeline)
16
+ // editors already carry richer, working attach models, so they MIRROR the visual rhythm
17
+ // and their full migration onto this component is flagged as a separate slice rather than
18
+ // forced (per the operator's "mirror + flag what would destabilize" guardrail).
19
+ import type { ReactNode } from "react";
20
+ import { mediaBytesUrl } from "../config";
21
+ import type { MediaRef } from "../video/contract";
22
+
23
+ /** One inline attach button (e.g. "+ image from library" / "+ video"). */
24
+ export interface GoalComposerAttach {
25
+ label: string;
26
+ onClick: () => void;
27
+ disabled?: boolean;
28
+ title?: string;
29
+ }
30
+
31
+ /** One attached-media chip (an already-picked MediaRef the host tracks). */
32
+ export interface GoalComposerAttached {
33
+ ref: MediaRef;
34
+ onRemove?: () => void;
35
+ label?: string;
36
+ }
37
+
38
+ export interface GoalComposerProps {
39
+ id?: string;
40
+ label: string;
41
+ value: string;
42
+ onChange: (v: string) => void;
43
+ placeholder?: string;
44
+ rows?: number;
45
+ disabled?: boolean;
46
+ hint?: ReactNode;
47
+ /**
48
+ * Fired when the textarea loses focus. Optional and inert unless wired: the studio
49
+ * movie composer uses it to classify what was typed (scene vs direction) ON BLUR —
50
+ * never per keystroke — so it can pre-arm the right assist button.
51
+ */
52
+ onBlur?: () => void;
53
+ /** Inline chat-composer attach buttons. Omit/empty = a plain composer (no toolbar). */
54
+ attachments?: GoalComposerAttach[];
55
+ /** Attached media shown as removable thumbnail chips above the toolbar. */
56
+ attached?: GoalComposerAttached[];
57
+ }
58
+
59
+ export function GoalComposer({
60
+ id,
61
+ label,
62
+ value,
63
+ onChange,
64
+ placeholder,
65
+ rows = 2,
66
+ disabled = false,
67
+ hint,
68
+ onBlur,
69
+ attachments,
70
+ attached,
71
+ }: GoalComposerProps) {
72
+ const hasToolbar = !!attachments && attachments.length > 0;
73
+ const hasChips = !!attached && attached.length > 0;
74
+ return (
75
+ <div className="vi-knob vi-goal-composer">
76
+ {id ? <label htmlFor={id}>{label}</label> : <label>{label}</label>}
77
+ <div className="vi-goal-composer-field">
78
+ <textarea
79
+ id={id}
80
+ className="vi-knob-input vi-goal-composer-input"
81
+ rows={rows}
82
+ value={value}
83
+ placeholder={placeholder}
84
+ disabled={disabled}
85
+ onChange={(e) => onChange(e.target.value)}
86
+ onBlur={onBlur}
87
+ />
88
+ {hasChips && (
89
+ <div className="vi-goal-composer-chips">
90
+ {attached!.map((a, i) => (
91
+ <span key={`${a.ref.uri}-${i}`} className="vi-goal-composer-chip">
92
+ {a.ref.kind === "image" ? (
93
+ <img
94
+ src={mediaBytesUrl(a.ref.uri)}
95
+ alt={a.label ?? "attachment"}
96
+ className="vi-goal-composer-chip-thumb"
97
+ />
98
+ ) : (
99
+ <span className="vi-goal-composer-chip-kind">{a.ref.kind}</span>
100
+ )}
101
+ <span className="vi-goal-composer-chip-label">
102
+ {a.label ?? a.ref.asset_id.slice(0, 8)}
103
+ </span>
104
+ {a.onRemove && (
105
+ <button
106
+ type="button"
107
+ className="vi-goal-composer-chip-x"
108
+ onClick={a.onRemove}
109
+ aria-label="Remove attachment"
110
+ title="Remove"
111
+ >
112
+ ✕
113
+ </button>
114
+ )}
115
+ </span>
116
+ ))}
117
+ </div>
118
+ )}
119
+ {hasToolbar && (
120
+ <div className="vi-goal-composer-toolbar">
121
+ {attachments!.map((a, i) => (
122
+ <button
123
+ key={`${a.label}-${i}`}
124
+ type="button"
125
+ className="vi-btn vi-btn-sm vi-btn-ghost vi-goal-composer-attach"
126
+ onClick={a.onClick}
127
+ disabled={a.disabled}
128
+ title={a.title}
129
+ >
130
+ {a.label}
131
+ </button>
132
+ ))}
133
+ </div>
134
+ )}
135
+ </div>
136
+ {hint ? <span className="vi-knob-hint">{hint}</span> : null}
137
+ </div>
138
+ );
139
+ }
@@ -0,0 +1,153 @@
1
+ // Image Crop station (Phase 3) — the first real station. Flow:
2
+ // file → POST /uploads (multipart) → {path}
3
+ // → POST /video/ingest {path} → MediaRef (guard kind==="image")
4
+ // → ImageCropCore: SpatialRegionEditor + enqueue + result grid
5
+ // Every hop goes through request<T>() + config URLs; errors surface via
6
+ // describeAppError.
7
+ //
8
+ // Studio slice 2a: the region editor + enqueue hook + result grid were extracted
9
+ // VERBATIM into stations/ops/ImageCropCore (the reusable "Components view minus
10
+ // upload"). This station now owns only the upload/ingest lifecycle + the persistent
11
+ // add-file bar; it renders <ImageCropCore/> in the Components slot with the same
12
+ // `multiRegion` it always used. Behaviour is unchanged.
13
+ import { useState } from "react";
14
+ import type { StationSpec } from "./types";
15
+ import { SectionTabs } from "./SectionTabs";
16
+ import { AddFileBar } from "./AddFileBar";
17
+ import { ImageCropCore } from "./ops/ImageCropCore";
18
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
19
+ import { hugpyConfig } from "../config";
20
+ import { getSessionId } from "../session";
21
+ import {
22
+ mediaRefSchema,
23
+ uploadResultSchema,
24
+ type MediaRef,
25
+ } from "../video/contract";
26
+
27
+ type Phase = "idle" | "uploading" | "ingesting" | "ready";
28
+
29
+ const PHASE_LABEL: Record<Phase, string> = {
30
+ idle: "",
31
+ uploading: "Uploading…",
32
+ ingesting: "Ingesting…",
33
+ ready: "",
34
+ };
35
+
36
+ export function ImageCropStation({ spec }: { spec: StationSpec }) {
37
+ const [source, setSource] = useState<MediaRef | null>(null);
38
+ const [fileName, setFileName] = useState<string | null>(null);
39
+ const [phase, setPhase] = useState<Phase>("idle");
40
+ const [error, setError] = useState<string | null>(null);
41
+
42
+ const busy = phase === "uploading" || phase === "ingesting";
43
+
44
+ // Clear the loaded source back to the empty state. ImageCropCore resets its queued
45
+ // crops + tracker rows when the source reference changes (here → null).
46
+ function clearSource() {
47
+ setSource(null);
48
+ setFileName(null);
49
+ setError(null);
50
+ }
51
+
52
+ async function onPick(file: File) {
53
+ setError(null);
54
+ setFileName(file.name);
55
+ setSource(null);
56
+ setPhase("uploading");
57
+
58
+ // (a) multipart upload — field `file` + field `sid`. No Content-Type header:
59
+ // the browser sets the multipart boundary itself.
60
+ const fd = new FormData();
61
+ fd.append("file", file);
62
+ fd.append("sid", getSessionId());
63
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
64
+ method: "POST",
65
+ body: fd,
66
+ meta: { specKey: "image-crop", operation: "upload" },
67
+ });
68
+ if (!up.ok) {
69
+ setError(describeAppError(errorOf(up)));
70
+ setPhase("idle");
71
+ return;
72
+ }
73
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
74
+ if (!upParsed.success) {
75
+ setError("Malformed upload response.");
76
+ setPhase("idle");
77
+ return;
78
+ }
79
+
80
+ // (b) ingest the server path into a typed MediaRef.
81
+ setPhase("ingesting");
82
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
83
+ method: "POST",
84
+ body: JSON.stringify({ path: upParsed.data.path }),
85
+ headers: { "Content-Type": "application/json" },
86
+ meta: { specKey: "image-crop", operation: "ingest" },
87
+ });
88
+ if (!ing.ok) {
89
+ setError(describeAppError(errorOf(ing)));
90
+ setPhase("idle");
91
+ return;
92
+ }
93
+ const media = mediaRefSchema.safeParse(okValue(ing));
94
+ if (!media.success) {
95
+ setError("Malformed ingest response.");
96
+ setPhase("idle");
97
+ return;
98
+ }
99
+ if (media.data.kind !== "image") {
100
+ setError(
101
+ `Ingested asset is "${media.data.kind}", not an image — use the Audio or Frames station for that.`,
102
+ );
103
+ setPhase("idle");
104
+ return;
105
+ }
106
+ if (!media.data.width || !media.data.height) {
107
+ setError("Ingest returned no pixel dimensions for the image.");
108
+ setPhase("idle");
109
+ return;
110
+ }
111
+ setSource(media.data);
112
+ setPhase("ready");
113
+ }
114
+
115
+ return (
116
+ <section className="station-card station-wide">
117
+ {/* Same shared section framing as Frames/Generate, but with NO Options
118
+ panel — the crop params live inline in the Components view below the
119
+ image, so no [Components|Options] toggle / rail renders. */}
120
+ <SectionTabs
121
+ ariaLabel="Image crop station"
122
+ addFile={
123
+ <>
124
+ {/* ONE compact add control — a Choose button + filename, no dropbox. */}
125
+ <AddFileBar
126
+ accept="image/*"
127
+ onFile={onPick}
128
+ busy={busy}
129
+ buttonLabel={source ? "Choose another image" : "Choose image"}
130
+ onClear={source && !busy ? clearSource : undefined}
131
+ >
132
+ {busy && <span className="vi-crop-status">{PHASE_LABEL[phase]}</span>}
133
+ {!busy && source && (
134
+ <>
135
+ {fileName && (
136
+ <span className="vi-addbar-name" title={fileName}>
137
+ {fileName}
138
+ </span>
139
+ )}
140
+ <span className="vi-crop-meta">
141
+ <code>{source.mime}</code> · {source.width}×{source.height}
142
+ </span>
143
+ </>
144
+ )}
145
+ </AddFileBar>
146
+ {error && <p className="vi-error" role="alert">{error}</p>}
147
+ </>
148
+ }
149
+ components={<ImageCropCore source={source} multiRegion />}
150
+ />
151
+ </section>
152
+ );
153
+ }
@@ -0,0 +1,64 @@
1
+ // Compact "ⓘ" affordance for a knob's verbose explanation.
2
+ //
3
+ // The compacted knobs (width/height/steps/guidance/seed in the Generate rail)
4
+ // used to carry their explanation as permanently-visible copy under the input
5
+ // (`.vi-knob-hint`) — that copy IS the dead space the row-pairing reclaims. This
6
+ // component moves it behind a small info affordance instead of dropping it.
7
+ //
8
+ // ONE pattern covers both desktop and touch (which has no hover), via two
9
+ // INDEPENDENT reveal paths that don't fight each other:
10
+ // - desktop hover / keyboard focus: pure CSS (`:hover` / `:focus-within` in
11
+ // app.css) — no React state involved, so it can't be knocked closed by
12
+ // anything else.
13
+ // - click/tap: toggles `.vi-knob-info-open` via React state, driven ONLY by
14
+ // onClick. This is the mobile-equivalent — touch has no hover, so tapping
15
+ // the ⓘ is how the explanation gets discovered there; tap again to close.
16
+ // (An earlier version also wired onMouseEnter/onMouseLeave into the same
17
+ // state and that raced with the synthetic hover-compat events some touch
18
+ // browsers fire around a tap, closing the popover right after it opened —
19
+ // keeping the two paths independent avoids that.)
20
+ //
21
+ // Closing via tap/Escape also blurs the button. Without that, a tap leaves the
22
+ // button focused, `:focus-within` stays true, and the popover would keep
23
+ // showing even after `open` (and aria-expanded) flip back to false.
24
+ import { useRef, useState, type KeyboardEvent } from "react";
25
+
26
+ export function KnobInfo({ label, text }: { label: string; text: string }) {
27
+ const [open, setOpen] = useState(false);
28
+ const btnRef = useRef<HTMLButtonElement>(null);
29
+
30
+ function close() {
31
+ setOpen(false);
32
+ btnRef.current?.blur();
33
+ }
34
+
35
+ function onKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
36
+ if (e.key === "Escape") close();
37
+ }
38
+
39
+ return (
40
+ <span className={`vi-knob-info${open ? " vi-knob-info-open" : ""}`}>
41
+ <button
42
+ ref={btnRef}
43
+ type="button"
44
+ className="vi-knob-info-btn"
45
+ aria-label={`About ${label}`}
46
+ aria-expanded={open}
47
+ onClick={(e) => {
48
+ // Don't let the click bubble to a document-level handler elsewhere
49
+ // in the tree — there isn't one today, but this keeps the toggle
50
+ // self-contained if one is added later.
51
+ e.stopPropagation();
52
+ if (open) close();
53
+ else setOpen(true);
54
+ }}
55
+ onKeyDown={onKeyDown}
56
+ >
57
+ ⓘ
58
+ </button>
59
+ <span className="vi-knob-info-pop" role="tooltip" aria-hidden={!open}>
60
+ {text}
61
+ </span>
62
+ </span>
63
+ );
64
+ }
@@ -0,0 +1,26 @@
1
+ // Placeholder body for a not-yet-built station. Shows what the station will do
2
+ // and which roadmap phase makes it real, so the shell is honest about being a
3
+ // Phase 0 scaffold — no dead controls pretending to work (the media console's
4
+ // "shows features that don't function" lesson, learned once, applied here).
5
+ import type { StationSpec } from "./types";
6
+
7
+ export function PlaceholderStation({ spec }: { spec: StationSpec }) {
8
+ return (
9
+ <section className="station-card">
10
+ <header>
11
+ <span className="station-phase">{spec.phase} · builds {spec.buildsSpec}</span>
12
+ <h2>{spec.title}</h2>
13
+ <p className="station-blurb">{spec.blurb}</p>
14
+ </header>
15
+ <ul className="station-planned">
16
+ {spec.planned.map((item) => (
17
+ <li key={item}>{item}</li>
18
+ ))}
19
+ </ul>
20
+ <footer className="station-foot">
21
+ Planned — see <code>ROADMAP.md</code> ({spec.phase}) and{" "}
22
+ <code>hugpy_video_intelligence_map.md</code> for the schema and job wiring.
23
+ </footer>
24
+ </section>
25
+ );
26
+ }
@@ -0,0 +1,276 @@
1
+ // Shared responsive section layout for the Frames & Generate stations.
2
+ //
3
+ // A section has three logical parts: the persistent file/media intake bar
4
+ // (`addFile`), the config knobs (`options`), and the working UI (`components`).
5
+ //
6
+ // Mobile (<62rem): a PERSISTENT compact add-file bar on top (never a tab),
7
+ // then a two-state [ Components | Options ] toggle (reusing the .vi-gen-mode
8
+ // segmented-control idiom with real tab semantics — role="tablist"/"tab"/
9
+ // "tabpanel", aria-selected, aria-controls/aria-labelledby, roving tabindex,
10
+ // arrow-key movement, `hidden` on the inactive panel), and ONE panel below
11
+ // showing the selected state (default Components).
12
+ //
13
+ // Desktop (>=62rem): no toggle — the working components on the LEFT (with the
14
+ // compact add-file bar atop that column) and the ~17rem Options rail on the
15
+ // RIGHT, both visible at once.
16
+ //
17
+ // When `options` is omitted (crop stations — their crop params live inline in
18
+ // the Components view), there is NO toggle strip on mobile and NO right rail on
19
+ // desktop: just the persistent add-file bar over the Components content.
20
+ //
21
+ // Slots may be plain nodes or render functions `(api) => node`, where `api`
22
+ // carries `isDesktop`. Pure layout wrapper — it owns no station logic; all
23
+ // per-station state lives in the station component.
24
+ import {
25
+ useEffect,
26
+ useId,
27
+ useState,
28
+ type KeyboardEvent,
29
+ type ReactNode,
30
+ } from "react";
31
+ import { createPortal } from "react-dom";
32
+
33
+ /** The two toggle states shown below the persistent add-file bar. */
34
+ type ToggleId = "components" | "options";
35
+
36
+ export interface SectionApi {
37
+ isDesktop: boolean;
38
+ }
39
+
40
+ type Slot = ReactNode | ((api: SectionApi) => ReactNode);
41
+
42
+ function resolveSlot(slot: Slot | undefined, api: SectionApi): ReactNode {
43
+ if (typeof slot === "function") return (slot as (a: SectionApi) => ReactNode)(api);
44
+ return slot ?? null;
45
+ }
46
+
47
+ function tabElId(base: string, id: ToggleId): string {
48
+ return `${base}-tab-${id}`;
49
+ }
50
+ function panelElId(base: string, id: ToggleId): string {
51
+ return `${base}-panel-${id}`;
52
+ }
53
+
54
+ // matchMedia-backed media query, SSR-safe (defaults to no-match / mobile when
55
+ // window/matchMedia are unavailable). Initial state reads synchronously on the
56
+ // client so the correct layout paints on the first frame (no toggle flash).
57
+ function useMediaQuery(query: string): boolean {
58
+ const read = () =>
59
+ typeof window !== "undefined" && typeof window.matchMedia === "function"
60
+ ? window.matchMedia(query).matches
61
+ : false;
62
+ const [matches, setMatches] = useState<boolean>(read);
63
+ useEffect(() => {
64
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
65
+ return;
66
+ const mql = window.matchMedia(query);
67
+ const onChange = () => setMatches(mql.matches);
68
+ onChange();
69
+ mql.addEventListener("change", onChange);
70
+ return () => mql.removeEventListener("change", onChange);
71
+ }, [query]);
72
+ return matches;
73
+ }
74
+
75
+ export function SectionTabs({
76
+ ariaLabel,
77
+ options,
78
+ optionsLabel = "Options",
79
+ optionsHost,
80
+ addFile,
81
+ components,
82
+ componentsLabel = "Components",
83
+ initialTab = "components",
84
+ }: {
85
+ ariaLabel: string;
86
+ /** Omit when the station has NO config knobs (crop stations) — then no
87
+ [Components|Options] toggle (mobile) and no right rail (desktop) render. */
88
+ options?: Slot;
89
+ optionsLabel?: string;
90
+ /** Round 9 (additive; default OFF): when a DOM node is passed, the options are
91
+ PORTALED into it (the arm shell's shared sidebar "Settings" tab) instead of the
92
+ desktop right rail / mobile [Components|Options] toggle — the work column then goes
93
+ FULL WIDTH (the movie-mirror). Stations that don't pass this (e.g. Frames) keep the
94
+ in-place rail/toggle EXACTLY as before. Null while the Settings tab is closed —
95
+ the options simply don't render until it opens (the knob state lives in the caller). */
96
+ optionsHost?: HTMLElement | null;
97
+ /** The persistent compact add-file bar (never a tab). */
98
+ addFile: Slot;
99
+ components: Slot;
100
+ componentsLabel?: string;
101
+ /** Which toggle state opens active on mobile. Defaults to Components. */
102
+ initialTab?: ToggleId;
103
+ }) {
104
+ const base = useId();
105
+ const isDesktop = useMediaQuery("(min-width: 62rem)");
106
+ const hasOptions = options != null;
107
+ // When the caller OPTS IN to external hosting (passes the optionsHost prop at all —
108
+ // even null while the Settings tab is closed), the options live THERE (portaled), so
109
+ // the in-place layout behaves like the no-options case (full-width work column / no
110
+ // mobile toggle) REGARDLESS of whether the host node exists yet. Keying on "prop was
111
+ // passed" (not "element exists") is what stops the right rail from flickering back
112
+ // every time the Settings tab is closed. Stations that never pass the prop
113
+ // (optionsHost === undefined, e.g. Frames) keep the in-place rail/toggle unchanged.
114
+ const hostOptIn = hasOptions && optionsHost !== undefined;
115
+
116
+ // Toggle order: [ Components | Options ] (Options on the right). Only used
117
+ // when Options exist.
118
+ const order: { id: ToggleId; label: string }[] = [
119
+ { id: "components", label: componentsLabel },
120
+ { id: "options", label: optionsLabel },
121
+ ];
122
+
123
+ const [active, setActive] = useState<ToggleId>(() =>
124
+ order.some((o) => o.id === initialTab) ? initialTab : "components",
125
+ );
126
+
127
+ const api: SectionApi = { isDesktop };
128
+ const optionsNode = hasOptions ? resolveSlot(options, api) : null;
129
+ const addFileNode = resolveSlot(addFile, api);
130
+ const componentsNode = resolveSlot(components, api);
131
+ // Round 9: when a host is supplied, portal the options into it (the shell Settings
132
+ // tab). Renders nothing until the host node exists (Settings tab closed = no target).
133
+ const portalOptions =
134
+ hostOptIn && optionsHost != null && optionsNode != null
135
+ ? createPortal(optionsNode, optionsHost)
136
+ : null;
137
+
138
+ // ---- Desktop ----
139
+ if (isDesktop) {
140
+ // No options (or options portaled to the shell Settings tab): a single full-width
141
+ // work column (add-file bar atop the components) — no empty right rail.
142
+ if (!hasOptions || hostOptIn) {
143
+ return (
144
+ <div className="vi-section-tabs vi-section-tabs--stacked">
145
+ <section
146
+ className="vi-section-region vi-section-region--work"
147
+ aria-label={componentsLabel}
148
+ >
149
+ <div className="vi-section-addfile">{addFileNode}</div>
150
+ {componentsNode}
151
+ </section>
152
+ {portalOptions}
153
+ </div>
154
+ );
155
+ }
156
+ // Options: components (with the add-file bar atop) on the left, the Options
157
+ // rail on the right. No toggle.
158
+ return (
159
+ <div className="vi-section-tabs vi-section-tabs--beside">
160
+ <section
161
+ className="vi-section-region vi-section-region--work"
162
+ aria-label={componentsLabel}
163
+ >
164
+ <div className="vi-section-addfile">{addFileNode}</div>
165
+ {componentsNode}
166
+ </section>
167
+ <section
168
+ className="vi-section-region vi-section-region--options"
169
+ aria-label={optionsLabel}
170
+ >
171
+ {optionsNode}
172
+ </section>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ // ---- Mobile, no options (or options portaled to the shell Settings tab): persistent
178
+ // add-file bar over the components only — no [Components|Options] toggle. ----
179
+ if (!hasOptions || hostOptIn) {
180
+ return (
181
+ <div className="vi-section-tabs vi-section-tabs--mobile">
182
+ <div className="vi-section-addfile">{addFileNode}</div>
183
+ {componentsNode}
184
+ {portalOptions}
185
+ </div>
186
+ );
187
+ }
188
+
189
+ // ---- Mobile, with options: persistent add-file bar, then a
190
+ // [ Components | Options ] toggle over a single panel. ----
191
+ function focusTab(index: number) {
192
+ const t = order[index];
193
+ if (!t) return;
194
+ setActive(t.id);
195
+ document.getElementById(tabElId(base, t.id))?.focus();
196
+ }
197
+
198
+ function onKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
199
+ switch (e.key) {
200
+ case "ArrowRight":
201
+ case "ArrowDown":
202
+ e.preventDefault();
203
+ focusTab((index + 1) % order.length);
204
+ break;
205
+ case "ArrowLeft":
206
+ case "ArrowUp":
207
+ e.preventDefault();
208
+ focusTab((index - 1 + order.length) % order.length);
209
+ break;
210
+ case "Home":
211
+ e.preventDefault();
212
+ focusTab(0);
213
+ break;
214
+ case "End":
215
+ e.preventDefault();
216
+ focusTab(order.length - 1);
217
+ break;
218
+ default:
219
+ break;
220
+ }
221
+ }
222
+
223
+ const nodeFor = (id: ToggleId): ReactNode =>
224
+ id === "options" ? optionsNode : componentsNode;
225
+
226
+ return (
227
+ <div className="vi-section-tabs vi-section-tabs--mobile">
228
+ {/* PERSISTENT add-file bar — always visible, not a tab. */}
229
+ <div className="vi-section-addfile">{addFileNode}</div>
230
+
231
+ {/* Components | Options toggle. */}
232
+ <div
233
+ role="tablist"
234
+ aria-label={ariaLabel}
235
+ className="vi-gen-mode vi-section-tablist"
236
+ >
237
+ {order.map((t, i) => {
238
+ const selected = t.id === active;
239
+ return (
240
+ <button
241
+ key={t.id}
242
+ type="button"
243
+ role="tab"
244
+ id={tabElId(base, t.id)}
245
+ aria-selected={selected}
246
+ aria-controls={panelElId(base, t.id)}
247
+ tabIndex={selected ? 0 : -1}
248
+ className={`vi-gen-mode-btn vi-section-tab${
249
+ selected ? " vi-gen-mode-btn-active" : ""
250
+ }`}
251
+ onClick={() => setActive(t.id)}
252
+ onKeyDown={(e) => onKeyDown(e, i)}
253
+ >
254
+ {t.label}
255
+ </button>
256
+ );
257
+ })}
258
+ </div>
259
+
260
+ {/* Single panel below the toggle. */}
261
+ {order.map((t) => (
262
+ <div
263
+ key={t.id}
264
+ role="tabpanel"
265
+ id={panelElId(base, t.id)}
266
+ aria-labelledby={tabElId(base, t.id)}
267
+ hidden={t.id !== active}
268
+ tabIndex={0}
269
+ className="vi-section-tabpanel"
270
+ >
271
+ {nodeFor(t.id)}
272
+ </div>
273
+ ))}
274
+ </div>
275
+ );
276
+ }