@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.
- package/LICENSE +41 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +67 -0
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/entry.tsx +74 -0
- package/index.html +53 -0
- package/package.json +42 -0
- package/src/assets/hugpy-mark.png +0 -0
- package/src/config.ts +676 -0
- package/src/demo/DemoBanner.tsx +54 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +14 -0
- package/src/demo/demo.css +64 -0
- package/src/demo/demoFetch.ts +305 -0
- package/src/demo/fixtures.ts +814 -0
- package/src/demo/index.ts +43 -0
- package/src/demo/mode.ts +81 -0
- package/src/demo/promptAssist.ts +181 -0
- package/src/demo/seed.ts +97 -0
- package/src/nav/BrandMark.css +66 -0
- package/src/nav/BrandMark.tsx +40 -0
- package/src/nav/Navbar.css +13 -0
- package/src/nav/Navbar.tsx +68 -0
- package/src/regions/SpatialRegionEditor.tsx +375 -0
- package/src/regions/TemporalRegionEditor.tsx +485 -0
- package/src/regions/types.ts +85 -0
- package/src/session.ts +85 -0
- package/src/share.ts +77 -0
- package/src/stations/ActivePanel.tsx +55 -0
- package/src/stations/ActiveProcessesStation.tsx +272 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
- package/src/stations/AddFileBar.tsx +59 -0
- package/src/stations/AudioCropStation.tsx +157 -0
- package/src/stations/ComfyPanel.tsx +210 -0
- package/src/stations/ComfyStation.tsx +196 -0
- package/src/stations/CondensedKnobStrip.tsx +245 -0
- package/src/stations/ConsolePanel.tsx +82 -0
- package/src/stations/FrameExtractStation.tsx +148 -0
- package/src/stations/GenIdentityBar.tsx +412 -0
- package/src/stations/GenerateStation.tsx +5603 -0
- package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
- package/src/stations/GoalComposer.tsx +139 -0
- package/src/stations/ImageCropStation.tsx +153 -0
- package/src/stations/KnobInfo.tsx +64 -0
- package/src/stations/PlaceholderStation.tsx +26 -0
- package/src/stations/SectionTabs.tsx +276 -0
- package/src/stations/SharePanel.tsx +298 -0
- package/src/stations/SidebarRegistry.tsx +105 -0
- package/src/stations/StationShell.tsx +96 -0
- package/src/stations/StudioClipsStation.tsx +28 -0
- package/src/stations/WorkbenchDrawer.tsx +66 -0
- package/src/stations/WorkbenchSidebar.tsx +1224 -0
- package/src/stations/WorkbenchStation.tsx +91 -0
- package/src/stations/comfyEmbed.ts +79 -0
- package/src/stations/drawerShell.tsx +419 -0
- package/src/stations/ops/AudioCropCore.tsx +218 -0
- package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
- package/src/stations/ops/FrameExtractCore.tsx +695 -0
- package/src/stations/ops/ImageCropCore.tsx +164 -0
- package/src/stations/ops/VideoCropCore.tsx +193 -0
- package/src/stations/ops/useVideoCropJobs.ts +224 -0
- package/src/stations/pollCaps.ts +87 -0
- package/src/stations/registry.ts +191 -0
- package/src/stations/studio/AngleRingEditor.tsx +159 -0
- package/src/stations/studio/IdentitiesStation.tsx +2455 -0
- package/src/stations/studio/IdentityProfileControls.tsx +262 -0
- package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
- package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
- package/src/stations/studio/StudioGenerateMode.tsx +29 -0
- package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
- package/src/stations/studio/StudioItemCard.tsx +334 -0
- package/src/stations/studio/StudioLibraryTab.tsx +506 -0
- package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
- package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
- package/src/stations/studio/StudioMovieSessions.tsx +553 -0
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
- package/src/stations/studio/StudioPlane.tsx +302 -0
- package/src/stations/studio/StudioSplicedRow.tsx +476 -0
- package/src/stations/studio/StudioViewer.tsx +339 -0
- package/src/stations/studio/TurntableViewer.tsx +220 -0
- package/src/stations/studio/movieTimeline.ts +198 -0
- package/src/stations/studio/promptCard.tsx +498 -0
- package/src/stations/studio/studioItem.ts +87 -0
- package/src/stations/studio/studioShared.tsx +821 -0
- package/src/stations/studio/useIdentityProfiles.ts +575 -0
- package/src/stations/studio/useReconstruction.ts +283 -0
- package/src/stations/types.ts +43 -0
- package/src/stations/useAudioCropJobs.ts +202 -0
- package/src/stations/useAudioExtractJob.ts +144 -0
- package/src/stations/useCropJobs.ts +206 -0
- package/src/stations/useFrameJobs.ts +140 -0
- package/src/stations/useGenerateJob.ts +151 -0
- package/src/stations/useGenerateMovieJob.ts +177 -0
- package/src/stations/useGenerateRowJobs.ts +184 -0
- package/src/stations/useGenerateSceneJob.ts +156 -0
- package/src/stations/useGenerateSceneRowJobs.ts +282 -0
- package/src/style/app.css +4974 -0
- package/src/transport/client.ts +262 -0
- package/src/transport/telemetry.ts +97 -0
- package/src/video/DropReceptacle.tsx +122 -0
- package/src/video/PromptAssistButtons.tsx +118 -0
- package/src/video/composerBridge.ts +66 -0
- package/src/video/contract.ts +599 -0
- package/src/video/jobTracker.ts +702 -0
- package/src/video/mediaLibrary.ts +328 -0
- package/src/video/renderCompat.ts +512 -0
- package/src/video/sessionForm.ts +189 -0
- package/src/video/spreadAssist.ts +392 -0
- package/src/video/studioBridge.ts +82 -0
- package/src/video/useMediaJobs.ts +279 -0
- package/src/video/useModels.ts +413 -0
- package/src/video/useMoviePresets.ts +97 -0
- package/src/video/useMovieSessions.ts +292 -0
- package/src/video/usePresets.ts +167 -0
- package/src/video/useProjects.ts +87 -0
- package/src/video/usePromptAssist.ts +394 -0
- package/src/video/useRenderPresets.ts +221 -0
- package/src/video/useStudioAssistLog.ts +266 -0
- package/src/video/useStudioPresets.ts +128 -0
- package/src/video/useWorkerCalls.ts +165 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +33 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// The player-forward VIEWER — the .vi-studio-viewer stage (mirrors the scene/video tab
|
|
2
|
+
// preview) that plays the selected clip. It lives at the TOP of the studio plane
|
|
3
|
+
// (StudioPlane), driven by the plane's lifted `selected` + `pinned`.
|
|
4
|
+
//
|
|
5
|
+
// Header-bar controls (operator ask 2026-07-12 — "Can the change be made THROUGH to
|
|
6
|
+
// change the initial scene or video or make it 'new'."). Before this pass the viewer had
|
|
7
|
+
// NO controls of its own: it silently rode the plane's auto-follow effect and, on top of
|
|
8
|
+
// that, silently substituted "any playable clip" whenever `selected` didn't resolve to
|
|
9
|
+
// one — including a DELIBERATELY cleared selection. Combined with an auto-follow effect
|
|
10
|
+
// that (see StudioPlane.tsx) only re-picked when the selected job vanished from the list
|
|
11
|
+
// entirely, the viewer would latch onto the first playable clip of the session and then
|
|
12
|
+
// never visibly change again — exactly the "stuck, unchangeable default" the operator
|
|
13
|
+
// described. Fixed on both ends:
|
|
14
|
+
// • `chosen` below is now a STRICT read of `selected` — no silent substitution. A
|
|
15
|
+
// cleared viewer (`selected === null`) shows the explicit empty state and only the
|
|
16
|
+
// explicit empty state, until the operator picks something or resumes follow.
|
|
17
|
+
// • the plane's auto-follow effect now genuinely re-syncs to the newest playable clip
|
|
18
|
+
// on every list change (see that file's comment) instead of sticking forever.
|
|
19
|
+
// `pinned` is the one semantic switch this header reads and toggles: true once the
|
|
20
|
+
// operator has manually picked or cleared, false while the plane auto-follows.
|
|
21
|
+
//
|
|
22
|
+
// Layout below is INLINE style (not new app.css classes) — this slice is scoped to
|
|
23
|
+
// src/stations/studio/ only, and the file-local ad hoc `style={{...}}` layout is
|
|
24
|
+
// already the established idiom for one-off arrangement in this same directory (see
|
|
25
|
+
// StudioGenerateTab.tsx's picker rows / hero sections). Reused, not-new pieces (the
|
|
26
|
+
// button classes, the .vi-studio-src-row poster/id row, .vi-comfy-empty) still use
|
|
27
|
+
// their existing classes.
|
|
28
|
+
import { useCallback, useEffect, useState } from "react";
|
|
29
|
+
import {
|
|
30
|
+
studioClipUrl,
|
|
31
|
+
studioClipToEditorUrl,
|
|
32
|
+
hugpyConfig,
|
|
33
|
+
} from "../../config";
|
|
34
|
+
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
35
|
+
import { shortId, dims, whenText, type Clip } from "./studioShared";
|
|
36
|
+
import { SourceThumb } from "./StudioGenerateTab";
|
|
37
|
+
|
|
38
|
+
export interface StudioViewerProps {
|
|
39
|
+
clips: Clip[];
|
|
40
|
+
/** The plane's lifted selection — a job id, or null (nothing chosen / explicitly
|
|
41
|
+
* cleared). Read STRICTLY below; there is no "nearest playable" fallback any more. */
|
|
42
|
+
selected: string | null;
|
|
43
|
+
/** True once the operator has taken manual control (a library pick, or "Clear
|
|
44
|
+
* viewer") — the plane's auto-follow effect stands down while this is true. Drives
|
|
45
|
+
* the "↻ follow newest" toggle's shown state. */
|
|
46
|
+
pinned: boolean;
|
|
47
|
+
/** "▤ Play from library" panel pick — hands the chosen job id back to the plane,
|
|
48
|
+
* which selects it AND pins (see StudioPlane.tsx). */
|
|
49
|
+
onPick: (jobId: string) => void;
|
|
50
|
+
/** "✕ Clear viewer" — empties the selection; the plane pins alongside it so the empty
|
|
51
|
+
* state sticks instead of being repopulated by the next poll. */
|
|
52
|
+
onClear: () => void;
|
|
53
|
+
/** "↻ follow newest" toggle — flips `pinned` on the plane. */
|
|
54
|
+
onToggleFollow: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function StudioViewer({
|
|
58
|
+
clips,
|
|
59
|
+
selected,
|
|
60
|
+
pinned,
|
|
61
|
+
onPick,
|
|
62
|
+
onClear,
|
|
63
|
+
onToggleFollow,
|
|
64
|
+
}: StudioViewerProps) {
|
|
65
|
+
// Local to the picker panel only — never touches the plane's lifted selection.
|
|
66
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
67
|
+
const [previewId, setPreviewId] = useState<string | null>(null);
|
|
68
|
+
|
|
69
|
+
// STRICT: only what `selected` names, and only while it is actually playable. No "any
|
|
70
|
+
// playable clip" fallback — that silent substitution is exactly what made a cleared or
|
|
71
|
+
// stuck-on-a-failed-job viewer unable to ever show empty (see the file header note).
|
|
72
|
+
const chosen = selected
|
|
73
|
+
? (clips.find((c) => c.job_id === selected && c.playable) ?? null)
|
|
74
|
+
: null;
|
|
75
|
+
|
|
76
|
+
const playable = clips.filter((c) => c.playable);
|
|
77
|
+
|
|
78
|
+
// ── "Send to Editor" (k12) — CONSOLE-OPERATOR ONLY affordance ───────────────
|
|
79
|
+
// Visibility is AUTH-DECIDED, not guessed (mirrors SharePanel): probe the same
|
|
80
|
+
// operator-gated GET (keysVideoShareUrl) once on mount. null = probing (hide),
|
|
81
|
+
// false = a share guest / anon (hide — the route would 403 anyway), true =
|
|
82
|
+
// operator (show, but only alongside a real playable clip). A second small GET
|
|
83
|
+
// here is consistent with SharePanel's own probe, not a regression.
|
|
84
|
+
const [operator, setOperator] = useState<boolean | null>(null);
|
|
85
|
+
const [sending, setSending] = useState(false);
|
|
86
|
+
const [sentMsg, setSentMsg] = useState<string | null>(null);
|
|
87
|
+
const [sendErr, setSendErr] = useState<string | null>(null);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
let alive = true;
|
|
91
|
+
void (async () => {
|
|
92
|
+
const res = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
|
|
93
|
+
method: "GET",
|
|
94
|
+
meta: { specKey: "studio", operation: "studio.operatorProbe" },
|
|
95
|
+
});
|
|
96
|
+
if (alive) setOperator(res.ok);
|
|
97
|
+
})();
|
|
98
|
+
return () => {
|
|
99
|
+
alive = false;
|
|
100
|
+
};
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
// Clear any transient send message when the shown clip changes, so a "sent"
|
|
104
|
+
// note never lingers over a different clip.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
setSentMsg(null);
|
|
107
|
+
setSendErr(null);
|
|
108
|
+
}, [chosen?.job_id]);
|
|
109
|
+
|
|
110
|
+
const sendToEditor = useCallback(async () => {
|
|
111
|
+
if (!chosen) return;
|
|
112
|
+
setSending(true);
|
|
113
|
+
setSentMsg(null);
|
|
114
|
+
setSendErr(null);
|
|
115
|
+
const res = await request<{ filename?: string; mode?: string }>(
|
|
116
|
+
studioClipToEditorUrl(chosen.job_id),
|
|
117
|
+
{ method: "POST", meta: { specKey: "studio", operation: "studio.clip.toEditor" } },
|
|
118
|
+
);
|
|
119
|
+
setSending(false);
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
setSendErr(`Could not send to the editor — ${describeAppError(errorOf(res))}`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const fn = okValue(res).filename ?? "clip.mp4";
|
|
125
|
+
const msg = `✓ Sent to editor — ${fn}`;
|
|
126
|
+
setSentMsg(msg);
|
|
127
|
+
window.setTimeout(() => setSentMsg((cur) => (cur === msg ? null : cur)), 4000);
|
|
128
|
+
}, [chosen]);
|
|
129
|
+
|
|
130
|
+
function pick(jobId: string) {
|
|
131
|
+
onPick(jobId);
|
|
132
|
+
setPickerOpen(false);
|
|
133
|
+
setPreviewId(null);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<section aria-label="Viewer" className="vi-studio-viewer-wrap">
|
|
138
|
+
{/* ── ALWAYS-VISIBLE header bar: the viewer must never read as a fixed, unmovable
|
|
139
|
+
default. It always names what's showing (or says nothing is), and always offers
|
|
140
|
+
a way to pick something else, clear it, or hand control back to auto-follow. ── */}
|
|
141
|
+
<div
|
|
142
|
+
style={{
|
|
143
|
+
display: "flex",
|
|
144
|
+
flexWrap: "wrap",
|
|
145
|
+
alignItems: "center",
|
|
146
|
+
justifyContent: "space-between",
|
|
147
|
+
gap: "0.6rem",
|
|
148
|
+
marginBottom: "0.5rem",
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<span
|
|
152
|
+
style={{
|
|
153
|
+
fontFamily: "var(--vi-mono)",
|
|
154
|
+
fontSize: "0.85rem",
|
|
155
|
+
color: "var(--vi-text)",
|
|
156
|
+
overflow: "hidden",
|
|
157
|
+
textOverflow: "ellipsis",
|
|
158
|
+
whiteSpace: "nowrap",
|
|
159
|
+
}}
|
|
160
|
+
title={chosen ? chosen.job_id : "Nothing playing"}
|
|
161
|
+
>
|
|
162
|
+
{chosen ? `▶ ${shortId(chosen.job_id)}` : "— nothing playing —"}
|
|
163
|
+
</span>
|
|
164
|
+
<div
|
|
165
|
+
style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}
|
|
166
|
+
role="group"
|
|
167
|
+
aria-label="Viewer controls"
|
|
168
|
+
>
|
|
169
|
+
{/* CONSOLE-OPERATOR ONLY (operator === true) AND only with a real playable
|
|
170
|
+
clip showing: hand this clip to the LAN Filmora inbox. Hidden for a
|
|
171
|
+
share guest / anon (the route would 403 regardless). */}
|
|
172
|
+
{operator === true && chosen && (
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
176
|
+
disabled={sending}
|
|
177
|
+
onClick={() => void sendToEditor()}
|
|
178
|
+
title="Copy this clip, in a Filmora-ready MP4, into your editor inbox on the LAN"
|
|
179
|
+
>
|
|
180
|
+
{sending ? "Sending…" : "⇱ Send to Editor"}
|
|
181
|
+
</button>
|
|
182
|
+
)}
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
186
|
+
aria-expanded={pickerOpen}
|
|
187
|
+
onClick={() => setPickerOpen((v) => !v)}
|
|
188
|
+
title="Pick any playable clip to show in the viewer"
|
|
189
|
+
>
|
|
190
|
+
▤ Play from library
|
|
191
|
+
</button>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
195
|
+
onClick={onClear}
|
|
196
|
+
disabled={!chosen && pinned}
|
|
197
|
+
title="Empty the viewer — shows an explicit empty state instead of a clip"
|
|
198
|
+
>
|
|
199
|
+
✕ Clear viewer
|
|
200
|
+
</button>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className={pinned ? "vi-btn vi-btn-sm vi-btn-ghost" : "vi-btn vi-btn-sm vi-btn-accent"}
|
|
204
|
+
aria-pressed={!pinned}
|
|
205
|
+
onClick={onToggleFollow}
|
|
206
|
+
title={
|
|
207
|
+
pinned
|
|
208
|
+
? "Off — the viewer stays on your pick. Click to resume following the newest playable clip."
|
|
209
|
+
: "On — the viewer follows the newest playable clip as renders complete. Click to freeze it here."
|
|
210
|
+
}
|
|
211
|
+
>
|
|
212
|
+
↻ follow newest · {pinned ? "off" : "on"}
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Send-to-Editor transient feedback (operator only). Success auto-clears
|
|
218
|
+
after a few seconds; a failure stays until the next send or clip change. */}
|
|
219
|
+
{sentMsg && (
|
|
220
|
+
<p
|
|
221
|
+
className="vi-comfy-hint"
|
|
222
|
+
role="status"
|
|
223
|
+
style={{ margin: "0 0 0.5rem", color: "var(--vi-accent)" }}
|
|
224
|
+
>
|
|
225
|
+
{sentMsg}
|
|
226
|
+
</p>
|
|
227
|
+
)}
|
|
228
|
+
{sendErr && (
|
|
229
|
+
<p className="vi-error" role="alert" style={{ margin: "0 0 0.5rem" }}>
|
|
230
|
+
{sendErr}
|
|
231
|
+
</p>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{/* "▤ Play from library" panel — the reachable select-to-viewer affordance. (The
|
|
235
|
+
original StudioLibrarySection had this exact click-to-select logic, but that
|
|
236
|
+
component is unmounted in BOTH plane mounts — see StudioLibraryTab.tsx and the
|
|
237
|
+
StudioPlane.tsx header note. This panel is the live replacement: mounted once,
|
|
238
|
+
inside the ONE shared StudioPlane, so it is reachable identically from the
|
|
239
|
+
standalone Studio station AND the Generate station's studio mode.) Rows reuse
|
|
240
|
+
the studio "source clip" poster/id idiom (SourceThumb + .vi-studio-src-row,
|
|
241
|
+
exported from StudioGenerateTab.tsx) rather than inventing a fifth row shape. */}
|
|
242
|
+
{pickerOpen && (
|
|
243
|
+
<div
|
|
244
|
+
style={{
|
|
245
|
+
marginBottom: "0.6rem",
|
|
246
|
+
padding: "0.6rem",
|
|
247
|
+
border: "1px solid var(--vi-border)",
|
|
248
|
+
borderRadius: "8px",
|
|
249
|
+
background: "var(--vi-surface-raised)",
|
|
250
|
+
}}
|
|
251
|
+
role="listbox"
|
|
252
|
+
aria-label="Play from library"
|
|
253
|
+
>
|
|
254
|
+
{playable.length === 0 ? (
|
|
255
|
+
<p className="vi-comfy-hint">No playable clips yet — generate one below.</p>
|
|
256
|
+
) : (
|
|
257
|
+
<ul
|
|
258
|
+
style={{
|
|
259
|
+
listStyle: "none",
|
|
260
|
+
margin: 0,
|
|
261
|
+
padding: 0,
|
|
262
|
+
maxHeight: "16rem",
|
|
263
|
+
overflowY: "auto",
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
{playable.map((c) => {
|
|
267
|
+
const uri = c.output?.uri;
|
|
268
|
+
const isPreviewing = previewId === c.job_id;
|
|
269
|
+
const isCurrent = c.job_id === selected;
|
|
270
|
+
return (
|
|
271
|
+
<li key={c.job_id} className="vi-studio-src-row">
|
|
272
|
+
{uri ? (
|
|
273
|
+
<SourceThumb
|
|
274
|
+
uri={uri}
|
|
275
|
+
name={shortId(c.job_id)}
|
|
276
|
+
playing={isPreviewing}
|
|
277
|
+
onToggle={() =>
|
|
278
|
+
setPreviewId((cur) => (cur === c.job_id ? null : c.job_id))
|
|
279
|
+
}
|
|
280
|
+
/>
|
|
281
|
+
) : (
|
|
282
|
+
<div className="vi-studio-src-thumb vi-lib-placeholder" aria-hidden>
|
|
283
|
+
<span>clip</span>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
<div className="vi-studio-src-body">
|
|
287
|
+
<div className="vi-studio-src-meta" title={c.job_id}>
|
|
288
|
+
<code>{shortId(c.job_id)}</code>
|
|
289
|
+
{dims(c) ? <span>{dims(c)}</span> : null}
|
|
290
|
+
{whenText(c) ? <span>{whenText(c)}</span> : null}
|
|
291
|
+
{isCurrent ? <span>· current</span> : null}
|
|
292
|
+
</div>
|
|
293
|
+
<div className="vi-studio-src-actions">
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
297
|
+
onClick={() => pick(c.job_id)}
|
|
298
|
+
disabled={isCurrent}
|
|
299
|
+
title="Play this clip in the viewer"
|
|
300
|
+
>
|
|
301
|
+
{isCurrent ? "▸ Playing" : "▸ Play here"}
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</li>
|
|
306
|
+
);
|
|
307
|
+
})}
|
|
308
|
+
</ul>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
{chosen ? (
|
|
314
|
+
<div className="vi-studio-viewer">
|
|
315
|
+
<video
|
|
316
|
+
key={chosen.job_id}
|
|
317
|
+
className="vi-studio-clip-video"
|
|
318
|
+
controls
|
|
319
|
+
autoPlay
|
|
320
|
+
loop
|
|
321
|
+
playsInline
|
|
322
|
+
src={studioClipUrl(chosen.job_id)}
|
|
323
|
+
/>
|
|
324
|
+
</div>
|
|
325
|
+
) : (
|
|
326
|
+
<div className="vi-comfy-empty">
|
|
327
|
+
<p className="vi-comfy-empty-title">
|
|
328
|
+
{pinned ? "Nothing playing — cleared" : "No clip yet"}
|
|
329
|
+
</p>
|
|
330
|
+
<p className="vi-comfy-empty-sub">
|
|
331
|
+
{pinned
|
|
332
|
+
? "Pick from the library above, or turn “follow newest” back on."
|
|
333
|
+
: "Generate a clip below, or pick a playable clip from the library above — it plays here."}
|
|
334
|
+
</p>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
</section>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// TURNTABLE VIEWER — the drag-to-rotate half of the identity 360° orbit turntable.
|
|
2
|
+
//
|
|
3
|
+
// A turntable reconstruction stores ONE orbit clip's frames in its `views[]` array
|
|
4
|
+
// IN ANGULAR ORDER (frame 0 = start angle … frame N ≈ 360°). This component shows
|
|
5
|
+
// exactly ONE frame at a time and scrubs across the ordered frames as the user drags
|
|
6
|
+
// horizontally (pointer events) or moves the range slider — dragging/sliding left↔
|
|
7
|
+
// right maps position across [0..N-1] = rotating the character 0..360°. Every frame
|
|
8
|
+
// is a degree view, so scrubbing IS the rotation.
|
|
9
|
+
//
|
|
10
|
+
// Frames are PRELOADED (a `new Image()` per uri) on mount/whenever the frame set
|
|
11
|
+
// changes, so once warm the on-screen <img> src swap is instant (served from cache)
|
|
12
|
+
// and scrubbing is smooth. Served bytes come through `mediaBytesUrl(uri)`, the same
|
|
13
|
+
// media-bytes seam every other identity thumb uses.
|
|
14
|
+
//
|
|
15
|
+
// Presentational + self-contained: it owns only the current-frame index and drag
|
|
16
|
+
// bookkeeping; the caller (IdentitiesStation) gates rendering this vs the static
|
|
17
|
+
// sheet grid on the reconstruction's `mode`, and keeps its own approve-to-canonical
|
|
18
|
+
// control alongside.
|
|
19
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
20
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
21
|
+
import { mediaBytesUrl } from "../../config";
|
|
22
|
+
|
|
23
|
+
function clampIndex(i: number, n: number): number {
|
|
24
|
+
if (n <= 0) return 0;
|
|
25
|
+
if (i < 0) return 0;
|
|
26
|
+
if (i > n - 1) return n - 1;
|
|
27
|
+
return i;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function TurntableViewer({
|
|
31
|
+
frames,
|
|
32
|
+
degreesPerFrame,
|
|
33
|
+
frameCount,
|
|
34
|
+
}: {
|
|
35
|
+
/** The reconstruction's `views[]` — the orbit clip's frame uris, in angular order. */
|
|
36
|
+
frames: string[];
|
|
37
|
+
/** Optional manifest hint: degrees advanced per frame (else derived from count). */
|
|
38
|
+
degreesPerFrame?: number | null;
|
|
39
|
+
/** Optional manifest hint: authoritative frame count (else `frames.length`). */
|
|
40
|
+
frameCount?: number | null;
|
|
41
|
+
}) {
|
|
42
|
+
const n = frames.length;
|
|
43
|
+
// `frame_count` is a manifest convenience; the frames we can actually SHOW are the
|
|
44
|
+
// uris we were handed, so scrubbing is always bounded by `frames.length`.
|
|
45
|
+
const count = frameCount && frameCount > 0 ? frameCount : n;
|
|
46
|
+
const dpf = useMemo(() => {
|
|
47
|
+
if (degreesPerFrame && degreesPerFrame > 0) return degreesPerFrame;
|
|
48
|
+
return count > 0 ? 360 / count : 0;
|
|
49
|
+
}, [degreesPerFrame, count]);
|
|
50
|
+
|
|
51
|
+
const [index, setIndex] = useState(0);
|
|
52
|
+
const [loaded, setLoaded] = useState(0);
|
|
53
|
+
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
54
|
+
const dragRef = useRef<{ startX: number; startIndex: number } | null>(null);
|
|
55
|
+
|
|
56
|
+
// Keep the index valid if the frame set changes out from under us (e.g. a fresh
|
|
57
|
+
// turntable replaces an older one on this same card).
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
setIndex((i) => clampIndex(i, n));
|
|
60
|
+
}, [n]);
|
|
61
|
+
|
|
62
|
+
// PRELOAD every frame. Hold the Image objects in a ref for the effect's lifetime so
|
|
63
|
+
// the browser keeps them warm (and doesn't GC an in-flight decode); count decoded
|
|
64
|
+
// frames for a small "loading" readout while the orbit warms up.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setLoaded(0);
|
|
67
|
+
if (n === 0) return;
|
|
68
|
+
let live = true;
|
|
69
|
+
const imgs: HTMLImageElement[] = [];
|
|
70
|
+
frames.forEach((uri) => {
|
|
71
|
+
const img = new Image();
|
|
72
|
+
img.onload = () => {
|
|
73
|
+
if (live) setLoaded((c) => c + 1);
|
|
74
|
+
};
|
|
75
|
+
img.onerror = () => {
|
|
76
|
+
// A broken frame still "settles" so the readout can reach N and not hang.
|
|
77
|
+
if (live) setLoaded((c) => c + 1);
|
|
78
|
+
};
|
|
79
|
+
img.src = mediaBytesUrl(uri);
|
|
80
|
+
imgs.push(img);
|
|
81
|
+
});
|
|
82
|
+
return () => {
|
|
83
|
+
live = false;
|
|
84
|
+
imgs.forEach((img) => {
|
|
85
|
+
img.onload = null;
|
|
86
|
+
img.onerror = null;
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
}, [frames, n]);
|
|
90
|
+
|
|
91
|
+
if (n === 0) return null;
|
|
92
|
+
|
|
93
|
+
// Map a horizontal drag across the FULL stage width to the FULL frame range: a drag
|
|
94
|
+
// spanning the whole viewer = a full 0..360° turn. Dragging RIGHT advances frames
|
|
95
|
+
// (the character rotates), dragging LEFT rewinds.
|
|
96
|
+
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
|
|
97
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture?.(e.pointerId);
|
|
98
|
+
dragRef.current = { startX: e.clientX, startIndex: index };
|
|
99
|
+
}
|
|
100
|
+
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
|
|
101
|
+
const drag = dragRef.current;
|
|
102
|
+
if (!drag) return;
|
|
103
|
+
const width = stageRef.current?.clientWidth || 1;
|
|
104
|
+
const delta = e.clientX - drag.startX;
|
|
105
|
+
const framesMoved = Math.round((delta / width) * (count - 1 || 1));
|
|
106
|
+
setIndex(clampIndex(drag.startIndex + framesMoved, n));
|
|
107
|
+
}
|
|
108
|
+
function endDrag(e: ReactPointerEvent<HTMLDivElement>) {
|
|
109
|
+
(e.currentTarget as HTMLDivElement).releasePointerCapture?.(e.pointerId);
|
|
110
|
+
dragRef.current = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const angle = Math.round(index * dpf) % 360;
|
|
114
|
+
const warming = loaded < n;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div style={{ marginTop: "0.4rem" }}>
|
|
118
|
+
<p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>
|
|
119
|
+
360° turntable
|
|
120
|
+
</p>
|
|
121
|
+
<div
|
|
122
|
+
ref={stageRef}
|
|
123
|
+
className="vi-turntable-stage"
|
|
124
|
+
role="slider"
|
|
125
|
+
aria-label="Drag to rotate the character"
|
|
126
|
+
aria-valuemin={0}
|
|
127
|
+
aria-valuemax={n - 1}
|
|
128
|
+
aria-valuenow={index}
|
|
129
|
+
aria-valuetext={`${angle}°`}
|
|
130
|
+
tabIndex={0}
|
|
131
|
+
onPointerDown={onPointerDown}
|
|
132
|
+
onPointerMove={onPointerMove}
|
|
133
|
+
onPointerUp={endDrag}
|
|
134
|
+
onPointerCancel={endDrag}
|
|
135
|
+
onKeyDown={(e) => {
|
|
136
|
+
if (e.key === "ArrowRight") setIndex((i) => clampIndex(i + 1, n));
|
|
137
|
+
else if (e.key === "ArrowLeft") setIndex((i) => clampIndex(i - 1, n));
|
|
138
|
+
}}
|
|
139
|
+
style={{
|
|
140
|
+
position: "relative",
|
|
141
|
+
width: "100%",
|
|
142
|
+
maxWidth: "18rem",
|
|
143
|
+
aspectRatio: "1 / 1",
|
|
144
|
+
borderRadius: "0.5rem",
|
|
145
|
+
overflow: "hidden",
|
|
146
|
+
background: "#000",
|
|
147
|
+
border: "1px solid var(--vi-border)",
|
|
148
|
+
touchAction: "none",
|
|
149
|
+
userSelect: "none",
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
{frames.map((uri, i) => (
|
|
153
|
+
// Render ALL frames stacked, toggling visibility, so the already-decoded
|
|
154
|
+
// frame is simply revealed on scrub (no src swap / reflow flicker at all).
|
|
155
|
+
<img
|
|
156
|
+
key={uri}
|
|
157
|
+
src={mediaBytesUrl(uri)}
|
|
158
|
+
alt={i === index ? `orbit frame ${i + 1} of ${n} (${angle}°)` : ""}
|
|
159
|
+
draggable={false}
|
|
160
|
+
style={{
|
|
161
|
+
position: "absolute",
|
|
162
|
+
inset: 0,
|
|
163
|
+
width: "100%",
|
|
164
|
+
height: "100%",
|
|
165
|
+
objectFit: "cover",
|
|
166
|
+
opacity: i === index ? 1 : 0,
|
|
167
|
+
pointerEvents: "none",
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
))}
|
|
171
|
+
<span
|
|
172
|
+
className="vi-comfy-hint"
|
|
173
|
+
style={{
|
|
174
|
+
position: "absolute",
|
|
175
|
+
top: 4,
|
|
176
|
+
right: 6,
|
|
177
|
+
fontSize: "0.7rem",
|
|
178
|
+
padding: "0 0.3rem",
|
|
179
|
+
borderRadius: "0.3rem",
|
|
180
|
+
background: "rgba(0,0,0,0.55)",
|
|
181
|
+
textShadow: "0 0 3px #000",
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
{angle}°
|
|
185
|
+
</span>
|
|
186
|
+
{warming && (
|
|
187
|
+
<span
|
|
188
|
+
className="vi-comfy-hint"
|
|
189
|
+
style={{
|
|
190
|
+
position: "absolute",
|
|
191
|
+
left: 6,
|
|
192
|
+
bottom: 4,
|
|
193
|
+
fontSize: "0.7rem",
|
|
194
|
+
padding: "0 0.3rem",
|
|
195
|
+
borderRadius: "0.3rem",
|
|
196
|
+
background: "rgba(0,0,0,0.55)",
|
|
197
|
+
opacity: 0.9,
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
preloading {loaded}/{n}…
|
|
201
|
+
</span>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
<input
|
|
205
|
+
type="range"
|
|
206
|
+
className="vi-turntable-scrub"
|
|
207
|
+
min={0}
|
|
208
|
+
max={n - 1}
|
|
209
|
+
step={1}
|
|
210
|
+
value={index}
|
|
211
|
+
aria-label="Rotate the character"
|
|
212
|
+
onChange={(e) => setIndex(clampIndex(Number(e.target.value), n))}
|
|
213
|
+
style={{ width: "100%", maxWidth: "18rem", marginTop: "0.4rem", display: "block", cursor: "ew-resize" }}
|
|
214
|
+
/>
|
|
215
|
+
<p className="vi-comfy-hint" style={{ margin: "0.25rem 0 0", opacity: 0.8 }}>
|
|
216
|
+
{n} frame{n === 1 ? "" : "s"} · ~{dpf ? dpf.toFixed(dpf < 10 ? 1 : 0) : "?"}°/frame · drag or scrub to rotate
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|