@hugpy/video-intelligence-ui 0.1.0 → 0.3.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 (108) hide show
  1. package/package.json +1 -3
  2. package/src/config.ts +36 -0
  3. package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
  4. package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
  5. package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
  6. package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
  7. package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
  8. package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
  9. package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
  10. package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
  11. package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
  12. package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
  13. package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
  14. package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
  15. package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
  16. package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
  17. package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
  18. package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
  19. package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
  20. package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
  21. package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
  22. package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
  23. package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
  24. package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
  25. package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
  26. package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
  27. package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
  28. package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
  29. package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
  30. package/src/demo/assets/generate2/s0.png +0 -0
  31. package/src/demo/assets/generate2/s1.png +0 -0
  32. package/src/demo/assets/generate2/s10.png +0 -0
  33. package/src/demo/assets/generate2/s11.png +0 -0
  34. package/src/demo/assets/generate2/s12.png +0 -0
  35. package/src/demo/assets/generate2/s13.png +0 -0
  36. package/src/demo/assets/generate2/s14.png +0 -0
  37. package/src/demo/assets/generate2/s15.png +0 -0
  38. package/src/demo/assets/generate2/s16.png +0 -0
  39. package/src/demo/assets/generate2/s17.png +0 -0
  40. package/src/demo/assets/generate2/s18.png +0 -0
  41. package/src/demo/assets/generate2/s19.png +0 -0
  42. package/src/demo/assets/generate2/s2.png +0 -0
  43. package/src/demo/assets/generate2/s20.png +0 -0
  44. package/src/demo/assets/generate2/s21.mp4 +0 -0
  45. package/src/demo/assets/generate2/s22.mp4 +0 -0
  46. package/src/demo/assets/generate2/s23.mp4 +0 -0
  47. package/src/demo/assets/generate2/s24.mp4 +0 -0
  48. package/src/demo/assets/generate2/s25.mp4 +0 -0
  49. package/src/demo/assets/generate2/s26.mp4 +0 -0
  50. package/src/demo/assets/generate2/s27.mp4 +0 -0
  51. package/src/demo/assets/generate2/s28.mp4 +0 -0
  52. package/src/demo/assets/generate2/s29.mp4 +0 -0
  53. package/src/demo/assets/generate2/s3.png +0 -0
  54. package/src/demo/assets/generate2/s30.mp4 +0 -0
  55. package/src/demo/assets/generate2/s4.png +0 -0
  56. package/src/demo/assets/generate2/s5.png +0 -0
  57. package/src/demo/assets/generate2/s6.png +0 -0
  58. package/src/demo/assets/generate2/s7.png +0 -0
  59. package/src/demo/assets/generate2/s8.png +0 -0
  60. package/src/demo/assets/generate2/s9.png +0 -0
  61. package/src/demo/demoFetch.ts +126 -3
  62. package/src/demo/fixtures.ts +72 -4
  63. package/src/demo/promptAssist.ts +25 -0
  64. package/src/demo/sampleData.ts +960 -0
  65. package/src/demo/sampleSets.ts +172 -0
  66. package/src/stations/ActiveProcessesStation.tsx +18 -3
  67. package/src/stations/GenerateStation.tsx +1031 -364
  68. package/src/stations/GoalComposer.tsx +9 -1
  69. package/src/stations/ScriptFirstStation.tsx +1081 -0
  70. package/src/stations/ToksPanel.tsx +188 -0
  71. package/src/stations/WorkbenchDrawer.tsx +4 -3
  72. package/src/stations/WorkbenchSidebar.tsx +60 -12
  73. package/src/stations/oracle/DagPanel.tsx +391 -0
  74. package/src/stations/oracle/OracleStation.tsx +304 -0
  75. package/src/stations/oracle/SpatialTab.tsx +185 -0
  76. package/src/stations/oracle/StewardPanel.tsx +165 -0
  77. package/src/stations/oracle/oracle.css +467 -0
  78. package/src/stations/oracle/oracleShared.tsx +160 -0
  79. package/src/stations/oracle/oracleTypes.ts +304 -0
  80. package/src/stations/oracle/useOracle.ts +560 -0
  81. package/src/stations/registry.ts +37 -0
  82. package/src/stations/scriptFirst.css +350 -0
  83. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  84. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  85. package/src/stations/studio/StudioGenerateTab.tsx +162 -6
  86. package/src/stations/studio/StudioMovieComposer.tsx +763 -318
  87. package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
  88. package/src/stations/studio/StudioPlane.tsx +35 -3
  89. package/src/stations/studio/StudioViewer.tsx +116 -14
  90. package/src/stations/studio/coordinationBadge.tsx +206 -0
  91. package/src/stations/studio/identityUpload.ts +66 -0
  92. package/src/stations/studio/promptCard.tsx +362 -59
  93. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  94. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  95. package/src/stations/useScriptFirstRun.ts +596 -0
  96. package/src/style/app.css +176 -0
  97. package/src/video/LengthRow.tsx +189 -0
  98. package/src/video/LibraryGroups.tsx +81 -0
  99. package/src/video/UnnamedWarn.tsx +146 -0
  100. package/src/video/coordinationReport.ts +266 -0
  101. package/src/video/jobClock.ts +104 -0
  102. package/src/video/spreadAssist.ts +26 -0
  103. package/src/video/useMediaJobs.ts +26 -0
  104. package/src/video/useOperatorHistory.ts +105 -0
  105. package/src/video/useToks.ts +193 -0
  106. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
  107. package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
  108. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +0 -417
package/src/style/app.css CHANGED
@@ -4808,6 +4808,76 @@ button.vi-studio-src-thumb {
4808
4808
  box-shadow: none;
4809
4809
  }
4810
4810
 
4811
+ /* ═══ TOK/S OVERVIEW (live inference throughput) ══════════════════════════════
4812
+ Reuses the assistlog dot/badge/chip/row language; only the layout that is
4813
+ genuinely new to this panel gets rules here. */
4814
+ .vi-toks-header {
4815
+ display: flex;
4816
+ align-items: baseline;
4817
+ justify-content: space-between;
4818
+ gap: 0.75rem;
4819
+ }
4820
+ .vi-toks-cadence {
4821
+ display: inline-flex;
4822
+ align-items: center;
4823
+ gap: 0.4rem;
4824
+ font-family: var(--vi-mono);
4825
+ font-size: 0.78rem;
4826
+ }
4827
+ .vi-toks-heading {
4828
+ margin: 1rem 0 0.4rem;
4829
+ font-size: 0.82rem;
4830
+ text-transform: uppercase;
4831
+ letter-spacing: 0.04em;
4832
+ color: var(--vi-text-dim);
4833
+ }
4834
+ .vi-toks-row {
4835
+ display: flex;
4836
+ align-items: center;
4837
+ flex-wrap: wrap;
4838
+ gap: 0.5rem;
4839
+ font-size: 0.82rem;
4840
+ }
4841
+ .vi-toks-tok {
4842
+ font-family: var(--vi-mono);
4843
+ font-weight: 600;
4844
+ color: var(--vi-text);
4845
+ }
4846
+ .vi-toks-model {
4847
+ font-family: var(--vi-mono);
4848
+ color: var(--vi-text);
4849
+ overflow: hidden;
4850
+ text-overflow: ellipsis;
4851
+ white-space: nowrap;
4852
+ max-width: 16ch;
4853
+ }
4854
+ .vi-toks-worker { max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4855
+ .vi-toks-ago { margin-left: auto; }
4856
+ .vi-toks-rank { min-width: 2.2ch; }
4857
+
4858
+ /* Per-worker strip: a wrapping row of compact cards. */
4859
+ .vi-toks-strip {
4860
+ display: flex;
4861
+ flex-wrap: wrap;
4862
+ gap: 0.5rem;
4863
+ }
4864
+ .vi-toks-worker-card {
4865
+ border: 1px solid var(--vi-border);
4866
+ border-radius: 6px;
4867
+ background: var(--vi-surface);
4868
+ padding: 0.4rem 0.6rem;
4869
+ min-width: 9rem;
4870
+ }
4871
+ .vi-toks-worker-name { font-size: 0.8rem; font-weight: 600; }
4872
+ .vi-toks-worker-nums { margin-top: 0.15rem; }
4873
+ .vi-toks-worker-model {
4874
+ margin-top: 0.15rem;
4875
+ font-family: var(--vi-mono);
4876
+ overflow: hidden;
4877
+ text-overflow: ellipsis;
4878
+ white-space: nowrap;
4879
+ }
4880
+
4811
4881
  /* Cross-model tester button — deliberately distinct from ghost so it reads as
4812
4882
  its own affordance on every generate tab (operator: "not seeing the tester"). */
4813
4883
  .vi-btn.vi-btn-tester {
@@ -4972,3 +5042,109 @@ button.vi-studio-src-thumb {
4972
5042
  .vi-about-body p {
4973
5043
  margin: 0;
4974
5044
  }
5045
+
5046
+ /* Session-library grouped sections (LibraryGroups.tsx) */
5047
+ .vi-lib-group { margin: 0.35rem 0; }
5048
+ .vi-lib-group-head { display: flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; }
5049
+ .vi-lib-count { opacity: 0.65; font-size: 0.8em; }
5050
+
5051
+ /* PROMPT ASSIST one-row consolidation (operator 2026-08-13): the model select
5052
+ must never push Enhance/Generate to a second row — it shrinks with an
5053
+ ellipsized closed face (the OPEN dropdown list always shows full names,
5054
+ that is native <select> behavior). Buttons never shrink. */
5055
+ .vi-prompt-assist > select.vi-select-sm {
5056
+ flex: 0 1 auto;
5057
+ min-width: 8rem;
5058
+ max-width: 24rem;
5059
+ overflow: hidden;
5060
+ text-overflow: ellipsis;
5061
+ white-space: nowrap;
5062
+ }
5063
+ .vi-prompt-assist > button { flex: 0 0 auto; }
5064
+
5065
+ /* ── k93 — Scene group prompt-assist toolbar + part-card expansions ─────────
5066
+ Three-line toolbar ([model] / [range][select all][negatives][std.] /
5067
+ [generate][enhance][+ video]), the inline attach / pretext panels, the
5068
+ pull-frame row and the per-card assist row. All on the existing .vi-btn /
5069
+ .vi-knob language; only the new pieces get rules. */
5070
+ .vi-sr-only {
5071
+ position: absolute !important;
5072
+ width: 1px;
5073
+ height: 1px;
5074
+ padding: 0;
5075
+ margin: -1px;
5076
+ overflow: hidden;
5077
+ clip: rect(0, 0, 0, 0);
5078
+ white-space: nowrap;
5079
+ border: 0;
5080
+ }
5081
+ .vi-prompt-toolbar-group {
5082
+ flex-direction: column;
5083
+ align-items: stretch;
5084
+ gap: 0.45rem;
5085
+ }
5086
+ .vi-prompt-toolbar-line {
5087
+ display: flex;
5088
+ flex-wrap: wrap;
5089
+ align-items: center;
5090
+ gap: 0.6rem;
5091
+ }
5092
+ .vi-prompt-toolbar-range {
5093
+ display: inline-flex;
5094
+ align-items: center;
5095
+ gap: 0.3rem;
5096
+ }
5097
+ .vi-prompt-toolbar-range-input {
5098
+ width: 3.6rem;
5099
+ padding: 0.15rem 0.35rem;
5100
+ }
5101
+ .vi-prompt-toolbar-neg,
5102
+ .vi-prompt-toolbar-std {
5103
+ display: inline-flex;
5104
+ align-items: center;
5105
+ gap: 0.35rem;
5106
+ }
5107
+ /* The on/off switch button — accent when on. */
5108
+ .vi-toggle {
5109
+ min-width: 2.6rem;
5110
+ font-family: var(--vi-mono);
5111
+ font-size: 0.7rem;
5112
+ text-transform: uppercase;
5113
+ letter-spacing: 0.04em;
5114
+ }
5115
+ .vi-toggle-on,
5116
+ .vi-btn-on {
5117
+ color: var(--vi-accent);
5118
+ border-color: var(--vi-accent);
5119
+ }
5120
+ /* An attach / pretext / identity panel expanded inline under its button row. */
5121
+ .vi-gen-inline-attach {
5122
+ margin: 0.35rem 0 0.5rem;
5123
+ padding: 0.5rem 0.6rem;
5124
+ border: 1px dashed var(--vi-border);
5125
+ border-radius: 6px;
5126
+ display: flex;
5127
+ flex-direction: column;
5128
+ gap: 0.5rem;
5129
+ }
5130
+ .vi-gen-part-media-block {
5131
+ display: flex;
5132
+ flex-direction: column;
5133
+ gap: 0.4rem;
5134
+ }
5135
+ .vi-gen-part-pullframe {
5136
+ display: flex;
5137
+ flex-wrap: wrap;
5138
+ align-items: center;
5139
+ gap: 0.4rem;
5140
+ }
5141
+ .vi-gen-pull-input {
5142
+ width: 4.5rem;
5143
+ padding: 0.15rem 0.35rem;
5144
+ }
5145
+ .vi-gen-part-assist-row {
5146
+ display: flex;
5147
+ flex-wrap: wrap;
5148
+ align-items: center;
5149
+ gap: 0.4rem 0.75rem;
5150
+ }
@@ -0,0 +1,189 @@
1
+ // LENGTH ROW (operator 2026-08-13, linkage reworked 2026-08-27): one dedicated
2
+ // row for clip length — fps | length-frames | length-seconds — any TWO set the
3
+ // THIRD. The derived field is the one LEAST-RECENTLY touched (and starts as
4
+ // FRAMES = fps × seconds); it renders dimmed but stays editable — typing in it
5
+ // makes IT authoritative and demotes the stalest of the other two. Seconds
6
+ // never leaves the browser: the payload stays fps + frames; the server still
7
+ // clamps to the model ceiling and snaps to the 4k+1 cadence (the hint says so).
8
+ // ONE component for every frames-based surface (clip, cinema movie-level,
9
+ // cinema per-goal — where fps is movie-level and renders fixed/grey, so
10
+ // frames<->seconds derive against it).
11
+ import { useEffect, useRef, useState } from "react";
12
+
13
+ type Field = "fps" | "frames" | "seconds";
14
+
15
+ export function LengthRow({
16
+ idPrefix,
17
+ fps,
18
+ onFps,
19
+ fpsFixedReason,
20
+ frames,
21
+ onFrames,
22
+ defaultFrames = 81,
23
+ framesPlaceholder = "model default (81)",
24
+ }: {
25
+ idPrefix: string;
26
+ /** Current fps (host state, a number). */
27
+ fps: number;
28
+ /** Omit to render fps FIXED (grey) — e.g. cinema per-goal, where fps is movie-level. */
29
+ onFps?: (n: number) => void;
30
+ /** Tooltip explaining WHY fps is fixed, when it is. */
31
+ fpsFixedReason?: string;
32
+ /** Frames as the host stores it: a string, "" = model default. */
33
+ frames: string;
34
+ onFrames: (v: string) => void;
35
+ defaultFrames?: number;
36
+ framesPlaceholder?: string;
37
+ }) {
38
+ const [secondsText, setSecondsText] = useState("");
39
+ // Last-two-touched ring; the missing one is DERIVED. Starts seconds+fps, so
40
+ // FRAMES opens as the derived display (frames = fps × seconds, e.g. 24 × 3.4)
41
+ // and an fps edit keeps the duration by recomputing frames — while the first
42
+ // direct frames edit demotes seconds (fps stays put), matching "the derived
43
+ // slot is whichever the user adjusted longest ago".
44
+ const touchRef = useRef<Field[]>(["seconds", "fps"]);
45
+ const touch = (f: Field) => {
46
+ const t = touchRef.current.filter((x) => x !== f);
47
+ t.push(f);
48
+ touchRef.current = t.slice(-2);
49
+ };
50
+ const fields: Field[] = ["fps", "frames", "seconds"];
51
+ // The derived slot is the field touched LONGEST ago. When fps is fixed it can
52
+ // never be the derived (writable) target: fall back to the STALER of
53
+ // frames/seconds — touchRef is ordered oldest→newest, so that's [0].
54
+ const deriveNow = (): Field => {
55
+ const missing = fields.find((f) => !touchRef.current.includes(f)) ?? "seconds";
56
+ if (!onFps && missing === "fps") return touchRef.current[0];
57
+ return missing;
58
+ };
59
+ const derived = deriveNow();
60
+
61
+ const framesNum = frames.trim() === "" ? null : Number(frames);
62
+ const effFrames = framesNum != null && Number.isFinite(framesNum) ? framesNum : defaultFrames;
63
+ const secondsNum = secondsText.trim() === "" ? null : Number(secondsText);
64
+
65
+ const secondsDisplay =
66
+ derived === "seconds"
67
+ ? fps > 0
68
+ ? (effFrames / fps).toFixed(1)
69
+ : ""
70
+ : secondsText;
71
+ const framesDisplay = derived === "frames" && frames.trim() === "" ? String(effFrames) : frames;
72
+
73
+ const dim = (f: Field) =>
74
+ derived === f ? { opacity: 0.55 } : undefined;
75
+ const derivedTitle =
76
+ "Derived from the other two — type here to make this the authoritative value.";
77
+
78
+ // Handlers recompute the derived slot AFTER recording the touch — the
79
+ // render-time `derived` is one edit behind by definition (touchRef is a ref),
80
+ // and using it here is what used to make a seconds edit silently rewrite fps.
81
+ const selfFpsRef = useRef(false);
82
+ const changeFps = (v: string) => {
83
+ const n = Number(v);
84
+ if (!onFps || !Number.isFinite(n) || n < 1) return;
85
+ selfFpsRef.current = true;
86
+ onFps(Math.round(n));
87
+ touch("fps");
88
+ if (deriveNow() === "frames") {
89
+ // frames = fps × seconds; when the user never typed seconds, derive it
90
+ // from what the row currently shows so the duration they see is kept.
91
+ const s = secondsNum != null && secondsNum > 0 ? secondsNum : fps > 0 ? effFrames / fps : null;
92
+ if (s != null && s > 0) onFrames(String(Math.max(1, Math.round(s * Math.round(n)))));
93
+ }
94
+ };
95
+ const changeFrames = (v: string) => {
96
+ onFrames(v);
97
+ touch("frames");
98
+ const n = Number(v);
99
+ if (!Number.isFinite(n) || n <= 0) return;
100
+ if (deriveNow() === "fps" && onFps && secondsNum != null && secondsNum > 0) {
101
+ onFps(Math.max(1, Math.round(n / secondsNum)));
102
+ }
103
+ // derived "seconds" needs no write — its display recomputes from frames/fps.
104
+ };
105
+ const changeSeconds = (v: string) => {
106
+ setSecondsText(v);
107
+ touch("seconds");
108
+ const n = Number(v);
109
+ if (!Number.isFinite(n) || n <= 0) return;
110
+ if (deriveNow() === "fps" && onFps && framesNum != null && framesNum > 0) {
111
+ onFps(Math.max(1, Math.round(framesNum / n)));
112
+ } else if (fps > 0) {
113
+ onFrames(String(Math.max(1, Math.round(n * fps))));
114
+ }
115
+ };
116
+ // fps can also change OUTSIDE this row (the sidebar fps knob shares the same
117
+ // host state). Treat that exactly like typing in the fps cell here: fps is
118
+ // now the freshest value, and if frames is the derived slot it follows
119
+ // fps × seconds so the duration the user chose is preserved.
120
+ const lastFpsRef = useRef(fps);
121
+ useEffect(() => {
122
+ if (fps === lastFpsRef.current) return;
123
+ lastFpsRef.current = fps;
124
+ if (selfFpsRef.current) {
125
+ selfFpsRef.current = false;
126
+ return;
127
+ }
128
+ touch("fps");
129
+ if (deriveNow() === "frames" && secondsNum != null && secondsNum > 0 && fps > 0) {
130
+ onFrames(String(Math.max(1, Math.round(secondsNum * fps))));
131
+ }
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ }, [fps]);
134
+
135
+ return (
136
+ <div className="vi-knob vi-length-row" style={{ flexBasis: "100%" }}>
137
+ <label>length</label>
138
+ <div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
139
+ <span className="vi-length-cell">
140
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-fps`}>fps</label>
141
+ <input
142
+ id={`${idPrefix}-fps`}
143
+ className="vi-knob-input"
144
+ style={{ maxWidth: "4.5rem", ...(onFps ? dim("fps") : { opacity: 0.55 }) }}
145
+ type="number"
146
+ min={1}
147
+ disabled={!onFps}
148
+ value={fps}
149
+ title={!onFps ? fpsFixedReason : derived === "fps" ? derivedTitle : undefined}
150
+ onChange={(e) => changeFps(e.target.value)}
151
+ />
152
+ </span>
153
+ <span className="vi-length-cell">
154
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-frames`}>frames</label>
155
+ <input
156
+ id={`${idPrefix}-frames`}
157
+ className="vi-knob-input"
158
+ style={{ maxWidth: "7rem", ...dim("frames") }}
159
+ type="number"
160
+ min={1}
161
+ value={framesDisplay}
162
+ placeholder={framesPlaceholder}
163
+ title={derived === "frames" ? derivedTitle : undefined}
164
+ onChange={(e) => changeFrames(e.target.value)}
165
+ />
166
+ </span>
167
+ <span className="vi-length-cell">
168
+ <label className="vi-knob-mini-label" htmlFor={`${idPrefix}-seconds`}>seconds</label>
169
+ <input
170
+ id={`${idPrefix}-seconds`}
171
+ className="vi-knob-input"
172
+ style={{ maxWidth: "6rem", ...dim("seconds") }}
173
+ type="number"
174
+ min={0.1}
175
+ step={0.1}
176
+ value={secondsDisplay}
177
+ placeholder={fps > 0 ? (defaultFrames / fps).toFixed(1) : ""}
178
+ title={derived === "seconds" ? derivedTitle : undefined}
179
+ onChange={(e) => changeSeconds(e.target.value)}
180
+ />
181
+ </span>
182
+ </div>
183
+ <span className="vi-knob-hint">
184
+ any two set the third — the dimmed one is derived (last touched wins) ·
185
+ server clamps to the model ceiling ({defaultFrames}) and snaps to 4k+1
186
+ </span>
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,81 @@
1
+ // SESSION LIBRARY GROUPS (operator ask 2026-08-13): the library pickers render
2
+ // as TYPE-CATEGORIZED, COLLAPSIBLE sections instead of one flat grid — inputs
3
+ // (uploads/frames/send-to-studio), generated images, scene outputs, movie
4
+ // outputs, studio clips. ONE component for every library surface (the house
5
+ // one-reusable-component rule); the call site supplies the item renderer so
6
+ // pick semantics stay exactly what they were. Groups with no members render
7
+ // nothing; `defaultOpen` seeds which sections start expanded (the current
8
+ // section rides in via a `key` remount on mode change).
9
+ import { useRef, useState, type ReactNode } from "react";
10
+ import type { LibraryItem, LibraryGenKind } from "./mediaLibrary";
11
+
12
+ const GROUPS: ReadonlyArray<{
13
+ id: string;
14
+ label: string;
15
+ kinds: ReadonlyArray<LibraryGenKind | null>;
16
+ }> = [
17
+ { id: "inputs", label: "📤 Inputs — uploads · frames · sent to studio", kinds: [null] },
18
+ { id: "images", label: "🖼 Generated images", kinds: ["generate_image"] },
19
+ { id: "scenes", label: "🎬 Scene outputs", kinds: ["generate_scene"] },
20
+ { id: "movies", label: "🎞 Movie outputs", kinds: ["generate_movie"] },
21
+ { id: "clips", label: "📹 Studio clips", kinds: ["studio_i2v"] },
22
+ ];
23
+
24
+ export function LibraryGroups({
25
+ items,
26
+ defaultOpen,
27
+ seed,
28
+ renderItem,
29
+ }: {
30
+ items: LibraryItem[];
31
+ /** Group ids expanded on first render — e.g. ["inputs", "images"]. */
32
+ defaultOpen: readonly string[];
33
+ /** Re-seed `defaultOpen` when this changes (the active section) — the scoped
34
+ * tsconfig rejects `key` on locally-declared components, so remount-by-key
35
+ * is not available here; this is the React render-phase reset pattern. */
36
+ seed?: string;
37
+ renderItem: (it: LibraryItem) => ReactNode;
38
+ }) {
39
+ const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen));
40
+ const seedRef = useRef(seed);
41
+ if (seedRef.current !== seed) {
42
+ seedRef.current = seed;
43
+ setOpen(new Set(defaultOpen));
44
+ }
45
+ const toggle = (id: string) =>
46
+ setOpen((prev) => {
47
+ const next = new Set(prev);
48
+ if (next.has(id)) next.delete(id);
49
+ else next.add(id);
50
+ return next;
51
+ });
52
+ return (
53
+ <>
54
+ {GROUPS.map((g) => {
55
+ const members = items.filter((it) =>
56
+ g.kinds.includes((it.genKind ?? null) as LibraryGenKind | null),
57
+ );
58
+ if (members.length === 0) return null;
59
+ const isOpen = open.has(g.id);
60
+ return (
61
+ <div key={g.id} className="vi-lib-group">
62
+ <button
63
+ type="button"
64
+ className="vi-timeline-toggle vi-lib-group-head"
65
+ aria-expanded={isOpen}
66
+ onClick={() => toggle(g.id)}
67
+ >
68
+ <span className="vi-timeline-caret" aria-hidden>
69
+ {isOpen ? "▾" : "▸"}
70
+ </span>{" "}
71
+ {g.label} <span className="vi-lib-count">({members.length})</span>
72
+ </button>
73
+ {isOpen ? (
74
+ <div className="vi-gen-picker-grid">{members.map(renderItem)}</div>
75
+ ) : null}
76
+ </div>
77
+ );
78
+ })}
79
+ </>
80
+ );
81
+ }
@@ -0,0 +1,146 @@
1
+ // UNNAMED-GENERATION WARNING (operator 2026-08-13): naming applies to EVERY
2
+ // generate section, and the warning is an EXPLICIT per-section OPTION,
3
+ // DEFAULT OFF, remembered per user (localStorage) — a 2-second image render
4
+ // must never suffer a 30-second warning unless its operator asked for one.
5
+ // ONE hook + toggle + panel for every section (house one-component rule);
6
+ // Cinema, Clip and the Generate station all mount these.
7
+ import { useEffect, useRef, useState, type ReactNode } from "react";
8
+
9
+ const PREF_PREFIX = "vi.warnUnnamed.v1";
10
+
11
+ /** localStorage-backed per-section preference; DEFAULT OFF. */
12
+ export function useWarnUnnamedPref(section: string): [boolean, (v: boolean) => void] {
13
+ const key = `${PREF_PREFIX}:${section}`;
14
+ const [on, setOn] = useState<boolean>(() => {
15
+ try {
16
+ return localStorage.getItem(key) === "1";
17
+ } catch {
18
+ return false;
19
+ }
20
+ });
21
+ const set = (v: boolean) => {
22
+ setOn(v);
23
+ try {
24
+ localStorage.setItem(key, v ? "1" : "0");
25
+ } catch {
26
+ /* best-effort — the in-memory state still applies this session */
27
+ }
28
+ };
29
+ return [on, set];
30
+ }
31
+
32
+ /** The explicit option — one checkbox, same copy in every section. */
33
+ export function WarnUnnamedToggle({
34
+ checked,
35
+ onChange,
36
+ }: {
37
+ checked: boolean;
38
+ onChange: (v: boolean) => void;
39
+ }) {
40
+ return (
41
+ <label
42
+ className="vi-knob-hint"
43
+ style={{ display: "inline-flex", gap: "0.35rem", alignItems: "center" }}
44
+ title="When on, generating without a name pauses with an inline name box for 30 seconds, then continues unnamed. Off = never interrupts. Remembered per section."
45
+ >
46
+ <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
47
+ warn when unnamed
48
+ </label>
49
+ );
50
+ }
51
+
52
+ /** First clause of a prompt as a derived name (≤7 words / 48 chars). */
53
+ export function deriveNameFrom(prompt: string): string {
54
+ return prompt
55
+ .split(/[.,;\n]/)[0]
56
+ .trim()
57
+ .split(/\s+/)
58
+ .slice(0, 7)
59
+ .join(" ")
60
+ .slice(0, 48);
61
+ }
62
+
63
+ /**
64
+ * The warning panel: inline name box + "Name it" + skip-with-countdown.
65
+ * MOUNTING arms the expiry (default 30s); unmounting disarms it; onProceed
66
+ * fires exactly once (undefined name = continue unnamed).
67
+ */
68
+ export function UnnamedWarnPanel({
69
+ noun,
70
+ onProceed,
71
+ onDismiss,
72
+ seconds = 30,
73
+ }: {
74
+ noun: string;
75
+ onProceed: (name?: string) => void;
76
+ onDismiss?: () => void;
77
+ seconds?: number;
78
+ }) {
79
+ const [draft, setDraft] = useState("");
80
+ const [left, setLeft] = useState(seconds);
81
+ const draftRef = useRef("");
82
+ draftRef.current = draft;
83
+ const firedRef = useRef(false);
84
+ const proceedRef = useRef(onProceed);
85
+ proceedRef.current = onProceed;
86
+ const fire = (name?: string) => {
87
+ if (firedRef.current) return;
88
+ firedRef.current = true;
89
+ proceedRef.current(name);
90
+ };
91
+ const fireRef = useRef(fire);
92
+ fireRef.current = fire;
93
+ useEffect(() => {
94
+ const id = window.setInterval(() => {
95
+ setLeft((l) => {
96
+ if (l <= 1) {
97
+ window.clearInterval(id);
98
+ fireRef.current(draftRef.current.trim() || undefined);
99
+ return 0;
100
+ }
101
+ return l - 1;
102
+ });
103
+ }, 1000);
104
+ return () => window.clearInterval(id);
105
+ }, []);
106
+ return (
107
+ <div
108
+ className="vi-studio-required-callout"
109
+ role="alert"
110
+ style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap", marginBottom: "0.6rem" }}
111
+ >
112
+ <span>
113
+ This {noun} has no name — unnamed work lists as “untitled” beside a bare id
114
+ and is hard to tell apart later.
115
+ </span>
116
+ <input
117
+ type="text"
118
+ className="vi-knob-input"
119
+ style={{ flex: "1 1 12rem", minWidth: "10rem" }}
120
+ placeholder="name it here — or wait and it continues unnamed"
121
+ value={draft}
122
+ autoFocus
123
+ onChange={(e) => setDraft(e.target.value)}
124
+ onKeyDown={(e) => {
125
+ if (e.key === "Enter" && draft.trim()) fire(draft.trim());
126
+ }}
127
+ />
128
+ <button type="button" className="vi-btn vi-btn-sm" disabled={!draft.trim()} onClick={() => fire(draft.trim())}>
129
+ Name it &amp; generate
130
+ </button>
131
+ <button
132
+ type="button"
133
+ className="vi-btn vi-btn-sm vi-btn-ghost"
134
+ title="Continues automatically when the timer runs out."
135
+ onClick={() => fire(undefined)}
136
+ >
137
+ Generate without a name ({left}s)
138
+ </button>
139
+ {onDismiss && (
140
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" title="Cancel this submit." onClick={onDismiss}>
141
+ ✕
142
+ </button>
143
+ )}
144
+ </div>
145
+ );
146
+ }