@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.
- package/package.json +1 -3
- package/src/config.ts +36 -0
- package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
- package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
- package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
- package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
- package/src/demo/assets/generate2/s0.png +0 -0
- package/src/demo/assets/generate2/s1.png +0 -0
- package/src/demo/assets/generate2/s10.png +0 -0
- package/src/demo/assets/generate2/s11.png +0 -0
- package/src/demo/assets/generate2/s12.png +0 -0
- package/src/demo/assets/generate2/s13.png +0 -0
- package/src/demo/assets/generate2/s14.png +0 -0
- package/src/demo/assets/generate2/s15.png +0 -0
- package/src/demo/assets/generate2/s16.png +0 -0
- package/src/demo/assets/generate2/s17.png +0 -0
- package/src/demo/assets/generate2/s18.png +0 -0
- package/src/demo/assets/generate2/s19.png +0 -0
- package/src/demo/assets/generate2/s2.png +0 -0
- package/src/demo/assets/generate2/s20.png +0 -0
- package/src/demo/assets/generate2/s21.mp4 +0 -0
- package/src/demo/assets/generate2/s22.mp4 +0 -0
- package/src/demo/assets/generate2/s23.mp4 +0 -0
- package/src/demo/assets/generate2/s24.mp4 +0 -0
- package/src/demo/assets/generate2/s25.mp4 +0 -0
- package/src/demo/assets/generate2/s26.mp4 +0 -0
- package/src/demo/assets/generate2/s27.mp4 +0 -0
- package/src/demo/assets/generate2/s28.mp4 +0 -0
- package/src/demo/assets/generate2/s29.mp4 +0 -0
- package/src/demo/assets/generate2/s3.png +0 -0
- package/src/demo/assets/generate2/s30.mp4 +0 -0
- package/src/demo/assets/generate2/s4.png +0 -0
- package/src/demo/assets/generate2/s5.png +0 -0
- package/src/demo/assets/generate2/s6.png +0 -0
- package/src/demo/assets/generate2/s7.png +0 -0
- package/src/demo/assets/generate2/s8.png +0 -0
- package/src/demo/assets/generate2/s9.png +0 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +72 -4
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +960 -0
- package/src/demo/sampleSets.ts +172 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +1031 -364
- package/src/stations/GoalComposer.tsx +9 -1
- package/src/stations/ScriptFirstStation.tsx +1081 -0
- package/src/stations/ToksPanel.tsx +188 -0
- package/src/stations/WorkbenchDrawer.tsx +4 -3
- package/src/stations/WorkbenchSidebar.tsx +60 -12
- package/src/stations/oracle/DagPanel.tsx +391 -0
- package/src/stations/oracle/OracleStation.tsx +304 -0
- package/src/stations/oracle/SpatialTab.tsx +185 -0
- package/src/stations/oracle/StewardPanel.tsx +165 -0
- package/src/stations/oracle/oracle.css +467 -0
- package/src/stations/oracle/oracleShared.tsx +160 -0
- package/src/stations/oracle/oracleTypes.ts +304 -0
- package/src/stations/oracle/useOracle.ts +560 -0
- package/src/stations/registry.ts +37 -0
- package/src/stations/scriptFirst.css +350 -0
- package/src/stations/studio/IdentitiesStation.tsx +219 -292
- package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
- package/src/stations/studio/StudioGenerateTab.tsx +162 -6
- package/src/stations/studio/StudioMovieComposer.tsx +763 -318
- package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
- package/src/stations/studio/StudioPlane.tsx +35 -3
- package/src/stations/studio/StudioViewer.tsx +116 -14
- package/src/stations/studio/coordinationBadge.tsx +206 -0
- package/src/stations/studio/identityUpload.ts +66 -0
- package/src/stations/studio/promptCard.tsx +362 -59
- package/src/stations/studio/useIdentityCreateJob.ts +184 -0
- package/src/stations/studio/useIdentityProfiles.ts +66 -0
- package/src/stations/useScriptFirstRun.ts +596 -0
- package/src/style/app.css +176 -0
- package/src/video/LengthRow.tsx +189 -0
- package/src/video/LibraryGroups.tsx +81 -0
- package/src/video/UnnamedWarn.tsx +146 -0
- package/src/video/coordinationReport.ts +266 -0
- package/src/video/jobClock.ts +104 -0
- package/src/video/spreadAssist.ts +26 -0
- package/src/video/useMediaJobs.ts +26 -0
- package/src/video/useOperatorHistory.ts +105 -0
- package/src/video/useToks.ts +193 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
- package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
- 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 & 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
|
+
}
|