@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,716 @@
|
|
|
1
|
+
// CharacterGroupsPanel — the char360 REVIEW + CURATE surface (dev/CHARACTER-GROUPS-PLAN.md
|
|
2
|
+
// slice S2). A sibling of ExtractFromVideoPanel inside the Frames station's
|
|
3
|
+
// "Character tools" group: where ExtractFromVideoPanel commits char→profile mapping
|
|
4
|
+
// SERVER-SIDE at submit time (target:"create"/"<slug>"), THIS panel runs the S1
|
|
5
|
+
// "review" extraction (target:"review", enqueue-only) which returns the per-character
|
|
6
|
+
// grouped manifest WITHOUT writing anything, then lets the user curate the groups
|
|
7
|
+
// (remove / move / merge / rename / select-to-proceed) and only THEN commits the
|
|
8
|
+
// chosen groups to identity profiles via the S3 from-groups route.
|
|
9
|
+
//
|
|
10
|
+
// WHY A SIBLING, NOT A SECOND useIdentityProfiles() MOUNT — the same reason
|
|
11
|
+
// GenIdentityBar is a pure prop-driven sibling: useIdentityProfiles() has no
|
|
12
|
+
// cross-instance cache, so mounting it twice fires duplicate GET
|
|
13
|
+
// /video/identity-profiles and holds two independently-stale lists. This panel is
|
|
14
|
+
// PURE — it takes the ONE station hook's `extractFromVideo` + the loaded `source`
|
|
15
|
+
// as props (FrameExtractCore owns the single hook instance).
|
|
16
|
+
//
|
|
17
|
+
// CURATION STATE IS CLIENT-HELD — keyed off the S1 manifest, mutated locally
|
|
18
|
+
// (remove/move/merge). Nothing is written until "Create identities from selected
|
|
19
|
+
// groups" POSTs the from-groups body. Reversible by design (re-run review to reset).
|
|
20
|
+
import { useEffect, useRef, useState } from "react";
|
|
21
|
+
import {
|
|
22
|
+
mediaBytesUrl,
|
|
23
|
+
videoJobUrl,
|
|
24
|
+
hugpyConfig,
|
|
25
|
+
identityGenerateUrl,
|
|
26
|
+
identityMeshStatusUrl,
|
|
27
|
+
} from "../../config";
|
|
28
|
+
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
29
|
+
import type { MediaRef } from "../../video/contract";
|
|
30
|
+
import type { IdentityProfilesState } from "../studio/useIdentityProfiles";
|
|
31
|
+
import { MeshViewer } from "../studio/IdentitiesStation";
|
|
32
|
+
|
|
33
|
+
// ── S1 review manifest shapes (tolerant reads — the poll result is cast, exactly
|
|
34
|
+
// as ExtractFromVideoPanel casts its job result; strictNullChecks is OFF in this
|
|
35
|
+
// scoped tsconfig so these are flat, nullable-friendly shapes). ─────────────────
|
|
36
|
+
interface ReviewView {
|
|
37
|
+
url: string; // media HANDLE (jailed absolute path) — render via mediaBytesUrl(url)
|
|
38
|
+
yaw: number | null;
|
|
39
|
+
bin: number | null;
|
|
40
|
+
score: number | null;
|
|
41
|
+
}
|
|
42
|
+
interface ReviewGroup {
|
|
43
|
+
char: string;
|
|
44
|
+
face_centroid: number[] | null;
|
|
45
|
+
views: ReviewView[];
|
|
46
|
+
}
|
|
47
|
+
interface ReviewManifest {
|
|
48
|
+
n_characters: number;
|
|
49
|
+
groups: ReviewGroup[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── client-held (editable) curation model ───────────────────────────────────────
|
|
53
|
+
interface GroupState {
|
|
54
|
+
/** Stable local id (survives rename; merges keep the target id). */
|
|
55
|
+
id: string;
|
|
56
|
+
/** Editable display name — defaults to the manifest `char` id. */
|
|
57
|
+
name: string;
|
|
58
|
+
/** The original char360 char id (immutable provenance, shown as a subtitle). */
|
|
59
|
+
char: string;
|
|
60
|
+
views: ReviewView[];
|
|
61
|
+
/** Whether this group advances to the commit step. */
|
|
62
|
+
proceed: boolean;
|
|
63
|
+
/** Per-group commit outcome (set after a from-groups POST), or null. */
|
|
64
|
+
slug: string | null;
|
|
65
|
+
error: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Response of the S3 from-groups route: { results: [{ name, ok, slug?, error? }] }.
|
|
69
|
+
interface CommitResult {
|
|
70
|
+
name?: string;
|
|
71
|
+
ok?: boolean;
|
|
72
|
+
slug?: string;
|
|
73
|
+
error?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── S4 per-group 3D job state (dev/CHARACTER-GROUPS-PLAN.md) — keyed by the
|
|
77
|
+
// committed profile `slug`. Owned INTERNALLY by this panel so "Generate 3D"
|
|
78
|
+
// works whether or not a caller passes `onGenerate3D` (that prop stays a pure
|
|
79
|
+
// fire-and-forget notify hook, called once when a job starts, never driving
|
|
80
|
+
// state here). Mirrors IdentitiesStation's `generateFullIdentity` one-click
|
|
81
|
+
// flow verbatim: POST identityGenerateUrl(slug) with a bare {} body (the
|
|
82
|
+
// happy path — mesh + 360 turntable + auto-promote), then poll
|
|
83
|
+
// identityMeshStatusUrl(slug, reconId) every ~5s (bounded ~20min) for the
|
|
84
|
+
// SAME status vocabulary ("none"|"queued"|"running"|"done"|"error"|
|
|
85
|
+
// "cancelled") and the SAME terminal field, `glb_path` (snake_case) —
|
|
86
|
+
// wrapped with mediaBytesUrl for MeshViewer, exactly as Studio does.
|
|
87
|
+
type Mesh3DPhase = "idle" | "running" | "done" | "error";
|
|
88
|
+
interface Mesh3DState {
|
|
89
|
+
phase: Mesh3DPhase;
|
|
90
|
+
status: string | null; // last raw poll status / stage text for display
|
|
91
|
+
glbUrl: string | null;
|
|
92
|
+
error: string | null;
|
|
93
|
+
}
|
|
94
|
+
const IDLE_3D: Mesh3DState = { phase: "idle", status: null, glbUrl: null, error: null };
|
|
95
|
+
|
|
96
|
+
/** Composite selection key — a view lives in exactly one group at a time, but a
|
|
97
|
+
* handle could (in principle) repeat across groups in the raw manifest, so the
|
|
98
|
+
* group id is part of the key. U+241F is a control-picture separator that can't
|
|
99
|
+
* appear in a group id or a path. */
|
|
100
|
+
function selKey(groupId: string, url: string): string {
|
|
101
|
+
return `${groupId}␟${url}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface CharacterGroupsPanelProps {
|
|
105
|
+
/** The loaded source video (kind==="video") — review runs against THIS ref. */
|
|
106
|
+
source: MediaRef | null;
|
|
107
|
+
/** The ONE station hook's enqueue verb (shared — never a second hook mount). */
|
|
108
|
+
extractFromVideo: IdentityProfilesState["extractFromVideo"];
|
|
109
|
+
/** S4 (dev/CHARACTER-GROUPS-PLAN.md): the panel now owns the actual mesh-gen
|
|
110
|
+
* flow internally (POST identityGenerateUrl(slug) → poll
|
|
111
|
+
* identityMeshStatusUrl → MeshViewer), so this button works with or without
|
|
112
|
+
* a caller-supplied hook. This prop is now an OPTIONAL fire-and-forget
|
|
113
|
+
* notify — called once when a group's "Generate 3D" job starts, for a
|
|
114
|
+
* caller that wants to observe it (e.g. cross-station touch tracking). It
|
|
115
|
+
* never drives this panel's own state. */
|
|
116
|
+
onGenerate3D?: (slug: string) => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function CharacterGroupsPanel({
|
|
120
|
+
source,
|
|
121
|
+
extractFromVideo,
|
|
122
|
+
onGenerate3D,
|
|
123
|
+
}: CharacterGroupsPanelProps) {
|
|
124
|
+
const [busy, setBusy] = useState(false);
|
|
125
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
126
|
+
const [error, setError] = useState<string | null>(null);
|
|
127
|
+
const [groups, setGroups] = useState<GroupState[]>([]);
|
|
128
|
+
const [reviewed, setReviewed] = useState(false);
|
|
129
|
+
// Multi-select ACROSS groups — composite selKey(groupId, url) entries.
|
|
130
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
131
|
+
const [committing, setCommitting] = useState(false);
|
|
132
|
+
const [commitError, setCommitError] = useState<string | null>(null);
|
|
133
|
+
// S4 — per-slug 3D job state + its own active/timer refs (one poll loop per
|
|
134
|
+
// slug can run concurrently, mirrors the group-level state being a map too).
|
|
135
|
+
const [mesh3D, setMesh3D] = useState<Record<string, Mesh3DState>>({});
|
|
136
|
+
const meshActive = useRef<Record<string, boolean>>({});
|
|
137
|
+
const meshTimer = useRef<Record<string, number>>({});
|
|
138
|
+
|
|
139
|
+
const active = useRef(false);
|
|
140
|
+
const timer = useRef<number | null>(null);
|
|
141
|
+
const idSeq = useRef(0);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
return () => {
|
|
145
|
+
active.current = false;
|
|
146
|
+
if (timer.current != null) window.clearTimeout(timer.current);
|
|
147
|
+
for (const slug of Object.keys(meshActive.current)) meshActive.current[slug] = false;
|
|
148
|
+
for (const slug of Object.keys(meshTimer.current)) window.clearTimeout(meshTimer.current[slug]);
|
|
149
|
+
};
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
const sourceIsVideo = source != null && source.kind === "video";
|
|
153
|
+
const canReview = sourceIsVideo && !busy;
|
|
154
|
+
|
|
155
|
+
function nextId(): string {
|
|
156
|
+
idSeq.current += 1;
|
|
157
|
+
return `cg${idSeq.current}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Build the editable model from the S1 manifest. Fresh ids each review so a
|
|
161
|
+
// re-run cleanly replaces the prior curation (reversible reset).
|
|
162
|
+
function loadManifest(manifest: ReviewManifest) {
|
|
163
|
+
const built: GroupState[] = (manifest.groups || []).map((g) => ({
|
|
164
|
+
id: nextId(),
|
|
165
|
+
name: g.char,
|
|
166
|
+
char: g.char,
|
|
167
|
+
views: (g.views || []).filter((v) => v && v.url),
|
|
168
|
+
proceed: false,
|
|
169
|
+
slug: null,
|
|
170
|
+
error: null,
|
|
171
|
+
}));
|
|
172
|
+
setGroups(built);
|
|
173
|
+
setSelected(new Set());
|
|
174
|
+
setReviewed(true);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function runReview() {
|
|
178
|
+
if (!source || busy) return;
|
|
179
|
+
setBusy(true);
|
|
180
|
+
setError(null);
|
|
181
|
+
setCommitError(null);
|
|
182
|
+
setStatus("Reviewing… (queued)");
|
|
183
|
+
active.current = true;
|
|
184
|
+
|
|
185
|
+
// target:"review" — the S1 enqueue-only, non-committing branch.
|
|
186
|
+
const enq = await extractFromVideo(source, "review");
|
|
187
|
+
if (!active.current) return;
|
|
188
|
+
if (!enq.ok || !enq.jobId) {
|
|
189
|
+
active.current = false;
|
|
190
|
+
setBusy(false);
|
|
191
|
+
setStatus(null);
|
|
192
|
+
setError(enq.message || "Could not start the character review.");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const jobId = enq.jobId;
|
|
197
|
+
const deadline = Date.now() + 20 * 60 * 1000; // ~20 min bound, mirrors ExtractFromVideoPanel
|
|
198
|
+
const pollOnce = async () => {
|
|
199
|
+
if (!active.current) return;
|
|
200
|
+
if (Date.now() > deadline) {
|
|
201
|
+
active.current = false;
|
|
202
|
+
setBusy(false);
|
|
203
|
+
setStatus(null);
|
|
204
|
+
setError("Timed out waiting for the character review.");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const res = await request<unknown>(videoJobUrl(jobId), {
|
|
208
|
+
meta: { specKey: "studio", operation: "identity.groups.review.status" },
|
|
209
|
+
});
|
|
210
|
+
if (!active.current) return;
|
|
211
|
+
if (res.ok) {
|
|
212
|
+
const job = okValue(res) as {
|
|
213
|
+
progress?: number | null;
|
|
214
|
+
result?: {
|
|
215
|
+
ok?: boolean;
|
|
216
|
+
groups?: ReviewManifest;
|
|
217
|
+
error?: { code?: string; message?: string; retryable?: boolean };
|
|
218
|
+
} | null;
|
|
219
|
+
stage?: string | null;
|
|
220
|
+
message?: string | null;
|
|
221
|
+
};
|
|
222
|
+
const result = job.result;
|
|
223
|
+
if (result && typeof result.ok === "boolean") {
|
|
224
|
+
active.current = false;
|
|
225
|
+
setBusy(false);
|
|
226
|
+
if (result.ok) {
|
|
227
|
+
setStatus("done");
|
|
228
|
+
if (result.groups && Array.isArray(result.groups.groups)) {
|
|
229
|
+
loadManifest(result.groups);
|
|
230
|
+
} else {
|
|
231
|
+
setError("Review finished but returned no character groups.");
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
setStatus(null);
|
|
235
|
+
setError((result.error && result.error.message) || "Character review failed.");
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const pct = typeof job.progress === "number" ? ` (${Math.round(job.progress * 100)}%)` : "";
|
|
240
|
+
const stage = job.stage || job.message;
|
|
241
|
+
setStatus(`Reviewing…${pct}${stage ? ` — ${stage}` : ""}`);
|
|
242
|
+
}
|
|
243
|
+
// queued/running/transient-poll-failure → keep polling.
|
|
244
|
+
timer.current = window.setTimeout(() => void pollOnce(), 5000);
|
|
245
|
+
};
|
|
246
|
+
timer.current = window.setTimeout(() => void pollOnce(), 5000);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── selection helpers ─────────────────────────────────────────────────────
|
|
250
|
+
function toggleView(groupId: string, url: string) {
|
|
251
|
+
const key = selKey(groupId, url);
|
|
252
|
+
setSelected((prev) => {
|
|
253
|
+
const next = new Set(prev);
|
|
254
|
+
if (next.has(key)) next.delete(key);
|
|
255
|
+
else next.add(key);
|
|
256
|
+
return next;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function clearSelection() {
|
|
260
|
+
setSelected(new Set());
|
|
261
|
+
}
|
|
262
|
+
const selectedCount = selected.size;
|
|
263
|
+
|
|
264
|
+
// ── curation ops (all client-held) ────────────────────────────────────────
|
|
265
|
+
// Remove every selected view from its group.
|
|
266
|
+
function removeSelected() {
|
|
267
|
+
if (selectedCount === 0) return;
|
|
268
|
+
setGroups((prev) =>
|
|
269
|
+
prev.map((g) => ({
|
|
270
|
+
...g,
|
|
271
|
+
views: g.views.filter((v) => !selected.has(selKey(g.id, v.url))),
|
|
272
|
+
})),
|
|
273
|
+
);
|
|
274
|
+
clearSelection();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Move every selected view into `destId` (append; skip views already there).
|
|
278
|
+
function moveSelected(destId: string) {
|
|
279
|
+
if (selectedCount === 0 || !destId) return;
|
|
280
|
+
setGroups((prev) => {
|
|
281
|
+
// Collect the moving views (from any source group), preserving order.
|
|
282
|
+
const moving: ReviewView[] = [];
|
|
283
|
+
for (const g of prev) {
|
|
284
|
+
for (const v of g.views) {
|
|
285
|
+
if (g.id !== destId && selected.has(selKey(g.id, v.url))) moving.push(v);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (moving.length === 0) return prev;
|
|
289
|
+
return prev.map((g) => {
|
|
290
|
+
if (g.id === destId) {
|
|
291
|
+
const have = new Set(g.views.map((v) => v.url));
|
|
292
|
+
const added = moving.filter((v) => !have.has(v.url));
|
|
293
|
+
return { ...g, views: [...g.views, ...added] };
|
|
294
|
+
}
|
|
295
|
+
// Drop the moved views from their source group.
|
|
296
|
+
return {
|
|
297
|
+
...g,
|
|
298
|
+
views: g.views.filter((v) => !selected.has(selKey(g.id, v.url))),
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
clearSelection();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Merge `srcId` INTO `destId` — dest keeps its name/id and gains src's views
|
|
306
|
+
// (deduped by handle); src is dropped.
|
|
307
|
+
function mergeInto(srcId: string, destId: string) {
|
|
308
|
+
if (!srcId || !destId || srcId === destId) return;
|
|
309
|
+
setGroups((prev) => {
|
|
310
|
+
const src = prev.find((g) => g.id === srcId);
|
|
311
|
+
if (!src) return prev;
|
|
312
|
+
return prev
|
|
313
|
+
.map((g) => {
|
|
314
|
+
if (g.id !== destId) return g;
|
|
315
|
+
const have = new Set(g.views.map((v) => v.url));
|
|
316
|
+
const added = src.views.filter((v) => !have.has(v.url));
|
|
317
|
+
return { ...g, views: [...g.views, ...added] };
|
|
318
|
+
})
|
|
319
|
+
.filter((g) => g.id !== srcId);
|
|
320
|
+
});
|
|
321
|
+
// Drop any selection that pointed at the vanished source group.
|
|
322
|
+
setSelected((prev) => {
|
|
323
|
+
const next = new Set<string>();
|
|
324
|
+
for (const k of prev) if (!k.startsWith(`${srcId}␟`)) next.add(k);
|
|
325
|
+
return next;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renameGroup(id: string, name: string) {
|
|
330
|
+
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name } : g)));
|
|
331
|
+
}
|
|
332
|
+
function toggleProceed(id: string) {
|
|
333
|
+
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, proceed: !g.proceed } : g)));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── commit (S3 from-groups) ────────────────────────────────────────────────
|
|
337
|
+
const proceedGroups = groups.filter((g) => g.proceed && g.views.length > 0);
|
|
338
|
+
const canCommit = proceedGroups.length > 0 && !committing;
|
|
339
|
+
|
|
340
|
+
async function commit() {
|
|
341
|
+
if (!canCommit) return;
|
|
342
|
+
setCommitting(true);
|
|
343
|
+
setCommitError(null);
|
|
344
|
+
// Ordered payload — results come back positionally aligned to THIS order.
|
|
345
|
+
const ordered = proceedGroups;
|
|
346
|
+
const body = {
|
|
347
|
+
groups: ordered.map((g) => ({
|
|
348
|
+
name: g.name.trim() || g.char,
|
|
349
|
+
reference_images: g.views.map((v) => v.url),
|
|
350
|
+
})),
|
|
351
|
+
};
|
|
352
|
+
const res = await request<unknown>(hugpyConfig.identityProfilesFromGroupsUrl, {
|
|
353
|
+
method: "POST",
|
|
354
|
+
body: JSON.stringify(body),
|
|
355
|
+
headers: { "Content-Type": "application/json" },
|
|
356
|
+
meta: { specKey: "studio", operation: "identity.groups.commit" },
|
|
357
|
+
});
|
|
358
|
+
setCommitting(false);
|
|
359
|
+
if (!res.ok) {
|
|
360
|
+
setCommitError(describeAppError(errorOf(res)));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const payload = okValue(res) as { results?: CommitResult[] };
|
|
364
|
+
const results = Array.isArray(payload.results) ? payload.results : [];
|
|
365
|
+
// Zip results back onto the submitted groups by index (positional contract).
|
|
366
|
+
setGroups((prev) =>
|
|
367
|
+
prev.map((g) => {
|
|
368
|
+
const idx = ordered.findIndex((o) => o.id === g.id);
|
|
369
|
+
if (idx < 0) return g;
|
|
370
|
+
const r = results[idx];
|
|
371
|
+
if (!r) return { ...g, error: "No result returned for this group." };
|
|
372
|
+
if (r.ok && r.slug) return { ...g, slug: r.slug, error: null };
|
|
373
|
+
return { ...g, slug: null, error: r.error || "Commit failed for this group." };
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── S4: per-group 3D generation (dev/CHARACTER-GROUPS-PLAN.md) ────────────
|
|
379
|
+
// Reuses the ALREADY-EXISTING mesh-from-profile backend — POST
|
|
380
|
+
// identityGenerateUrl(slug) with a bare {} body (the documented happy path:
|
|
381
|
+
// Hunyuan3D mesh + 360° turntable + auto-promoted canonical, off the
|
|
382
|
+
// profile's own reference images, no prior reconstruction needed) →
|
|
383
|
+
// {job_id, recon_id}. Then poll identityMeshStatusUrl(slug, recon_id) — the
|
|
384
|
+
// SAME retrieval Studio's generateFullIdentity uses — until the mesh state
|
|
385
|
+
// block reports status:"done" with a glb_path (or a terminal error).
|
|
386
|
+
function setMesh(slug: string, patch: Partial<Mesh3DState>) {
|
|
387
|
+
setMesh3D((prev) => ({ ...prev, [slug]: { ...(prev[slug] || IDLE_3D), ...patch } }));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function generate3D(slug: string) {
|
|
391
|
+
if (!slug) return;
|
|
392
|
+
if (meshActive.current[slug]) return; // already running for this slug
|
|
393
|
+
if (onGenerate3D) onGenerate3D(slug); // optional fire-and-forget notify hook
|
|
394
|
+
meshActive.current[slug] = true;
|
|
395
|
+
setMesh(slug, { phase: "running", status: "Generating… (queued)", glbUrl: null, error: null });
|
|
396
|
+
|
|
397
|
+
const res = await request<unknown>(identityGenerateUrl(slug), {
|
|
398
|
+
method: "POST",
|
|
399
|
+
body: JSON.stringify({}),
|
|
400
|
+
headers: { "Content-Type": "application/json" },
|
|
401
|
+
meta: { specKey: "studio", operation: "identity.groups.generate3d" },
|
|
402
|
+
});
|
|
403
|
+
if (!meshActive.current[slug]) return;
|
|
404
|
+
if (!res.ok) {
|
|
405
|
+
meshActive.current[slug] = false;
|
|
406
|
+
setMesh(slug, { phase: "error", status: null, error: describeAppError(errorOf(res)) });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const enq = okValue(res) as { job_id?: string; recon_id?: string };
|
|
410
|
+
const reconId = enq.recon_id;
|
|
411
|
+
if (!reconId) {
|
|
412
|
+
meshActive.current[slug] = false;
|
|
413
|
+
setMesh(slug, { phase: "error", status: null, error: "Malformed generate response." });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const deadline = Date.now() + 20 * 60 * 1000; // ~20 min bound, mirrors Studio's generate poll
|
|
418
|
+
const pollOnce = async () => {
|
|
419
|
+
if (!meshActive.current[slug]) return;
|
|
420
|
+
if (Date.now() > deadline) {
|
|
421
|
+
meshActive.current[slug] = false;
|
|
422
|
+
setMesh(slug, { phase: "error", status: null, error: "Timed out waiting for the 3D build." });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const sres = await request<unknown>(identityMeshStatusUrl(slug, reconId), {
|
|
426
|
+
meta: { specKey: "studio", operation: "identity.groups.mesh.status" },
|
|
427
|
+
});
|
|
428
|
+
if (!meshActive.current[slug]) return;
|
|
429
|
+
if (sres.ok) {
|
|
430
|
+
const ms = okValue(sres) as { status?: string; error?: string | null; glb_path?: string | null };
|
|
431
|
+
const st = ms.status ?? "queued";
|
|
432
|
+
if (st === "done") {
|
|
433
|
+
meshActive.current[slug] = false;
|
|
434
|
+
if (ms.glb_path) {
|
|
435
|
+
setMesh(slug, { phase: "done", status: "done", glbUrl: mediaBytesUrl(ms.glb_path), error: null });
|
|
436
|
+
} else {
|
|
437
|
+
setMesh(slug, { phase: "error", status: null, error: "Build finished but returned no model." });
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (st === "error" || st === "cancelled") {
|
|
442
|
+
meshActive.current[slug] = false;
|
|
443
|
+
setMesh(slug, { phase: "error", status: null, error: ms.error || `Build ${st}.` });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
setMesh(slug, { status: `Generating… (${st})` });
|
|
447
|
+
}
|
|
448
|
+
// queued/running/transient-poll-failure → keep polling.
|
|
449
|
+
meshTimer.current[slug] = window.setTimeout(() => void pollOnce(), 5000);
|
|
450
|
+
};
|
|
451
|
+
meshTimer.current[slug] = window.setTimeout(() => void pollOnce(), 5000);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── render ─────────────────────────────────────────────────────────────────
|
|
455
|
+
return (
|
|
456
|
+
<section
|
|
457
|
+
className="vi-comfy-bar"
|
|
458
|
+
aria-label="Review and curate character groups"
|
|
459
|
+
style={{ marginTop: "0.8rem", flexDirection: "column", alignItems: "stretch" }}
|
|
460
|
+
>
|
|
461
|
+
<div>
|
|
462
|
+
<span className="vi-comfy-label">Character groups — review & curate</span>
|
|
463
|
+
<p className="vi-comfy-hint" style={{ margin: "0.15rem 0 0.4rem" }}>
|
|
464
|
+
Run a NON-committing char360 review over the loaded video: detected characters
|
|
465
|
+
come back as editable groups. Remove, move, or merge images, rename groups, mark
|
|
466
|
+
which ones to proceed, then create identity profiles from the ones you keep.
|
|
467
|
+
</p>
|
|
468
|
+
</div>
|
|
469
|
+
|
|
470
|
+
<div className="vi-comfy-bar-actions vi-run-bar" style={{ flexWrap: "wrap" }}>
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
className="vi-btn vi-btn-accent"
|
|
474
|
+
disabled={!canReview}
|
|
475
|
+
onClick={() => void runReview()}
|
|
476
|
+
>
|
|
477
|
+
{busy ? "Reviewing…" : reviewed ? "Re-run review" : "Review characters"}
|
|
478
|
+
</button>
|
|
479
|
+
{!sourceIsVideo && (
|
|
480
|
+
<span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
|
|
481
|
+
Load a video above to review its characters.
|
|
482
|
+
</span>
|
|
483
|
+
)}
|
|
484
|
+
{status && (
|
|
485
|
+
<span className="vi-comfy-hint" role="status">
|
|
486
|
+
{status}
|
|
487
|
+
</span>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
{error && (
|
|
491
|
+
<p className="vi-error" role="alert" style={{ margin: 0 }}>
|
|
492
|
+
{error}
|
|
493
|
+
</p>
|
|
494
|
+
)}
|
|
495
|
+
|
|
496
|
+
{reviewed && groups.length === 0 && !busy && (
|
|
497
|
+
<p className="vi-comfy-hint">No characters were detected in this video.</p>
|
|
498
|
+
)}
|
|
499
|
+
|
|
500
|
+
{groups.length > 0 && (
|
|
501
|
+
<>
|
|
502
|
+
{/* Cross-group selection toolbar. */}
|
|
503
|
+
<div className="vi-frames-toolbar" style={{ flexWrap: "wrap" }}>
|
|
504
|
+
<span className="vi-frames-summary">
|
|
505
|
+
{groups.length} group{groups.length === 1 ? "" : "s"} · {selectedCount} image
|
|
506
|
+
{selectedCount === 1 ? "" : "s"} selected
|
|
507
|
+
</span>
|
|
508
|
+
<div className="vi-frames-actions" style={{ flexWrap: "wrap" }}>
|
|
509
|
+
<button
|
|
510
|
+
type="button"
|
|
511
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
512
|
+
onClick={clearSelection}
|
|
513
|
+
disabled={selectedCount === 0}
|
|
514
|
+
>
|
|
515
|
+
Clear selection
|
|
516
|
+
</button>
|
|
517
|
+
<button
|
|
518
|
+
type="button"
|
|
519
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
520
|
+
onClick={removeSelected}
|
|
521
|
+
disabled={selectedCount === 0}
|
|
522
|
+
title="Remove the selected images from their groups"
|
|
523
|
+
>
|
|
524
|
+
Remove selected
|
|
525
|
+
</button>
|
|
526
|
+
<label className="vi-comfy-field" style={{ flexBasis: "auto" }}>
|
|
527
|
+
<span className="vi-comfy-label">Move selected to</span>
|
|
528
|
+
<select
|
|
529
|
+
className="vi-knob-input"
|
|
530
|
+
value=""
|
|
531
|
+
disabled={selectedCount === 0}
|
|
532
|
+
onChange={(e) => {
|
|
533
|
+
if (e.target.value) moveSelected(e.target.value);
|
|
534
|
+
e.target.value = "";
|
|
535
|
+
}}
|
|
536
|
+
>
|
|
537
|
+
<option value="">choose group…</option>
|
|
538
|
+
{groups.map((g) => (
|
|
539
|
+
<option key={g.id} value={g.id}>
|
|
540
|
+
{g.name || g.char}
|
|
541
|
+
</option>
|
|
542
|
+
))}
|
|
543
|
+
</select>
|
|
544
|
+
</label>
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
|
|
548
|
+
{/* One card per group. */}
|
|
549
|
+
{groups.map((g) => {
|
|
550
|
+
const mergeTargets = groups.filter((o) => o.id !== g.id);
|
|
551
|
+
return (
|
|
552
|
+
<div key={g.id} className="vi-character-tools" style={{ marginTop: "0.5rem" }}>
|
|
553
|
+
<div
|
|
554
|
+
className="vi-character-tools-head"
|
|
555
|
+
style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: "0.6rem" }}
|
|
556
|
+
>
|
|
557
|
+
<label className="vi-comfy-field" style={{ flexBasis: "14rem" }}>
|
|
558
|
+
<span className="vi-comfy-label">Group name</span>
|
|
559
|
+
<input
|
|
560
|
+
type="text"
|
|
561
|
+
className="vi-knob-input"
|
|
562
|
+
value={g.name}
|
|
563
|
+
placeholder={g.char}
|
|
564
|
+
onChange={(e) => renameGroup(g.id, e.target.value)}
|
|
565
|
+
/>
|
|
566
|
+
</label>
|
|
567
|
+
<span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
|
|
568
|
+
char360 id: {g.char} · {g.views.length} image{g.views.length === 1 ? "" : "s"}
|
|
569
|
+
</span>
|
|
570
|
+
<label className="vi-knob-check" style={{ marginLeft: "auto" }}>
|
|
571
|
+
<input
|
|
572
|
+
type="checkbox"
|
|
573
|
+
checked={g.proceed}
|
|
574
|
+
onChange={() => toggleProceed(g.id)}
|
|
575
|
+
/>
|
|
576
|
+
proceed to commit
|
|
577
|
+
</label>
|
|
578
|
+
{mergeTargets.length > 0 && (
|
|
579
|
+
<label className="vi-comfy-field" style={{ flexBasis: "auto" }}>
|
|
580
|
+
<span className="vi-comfy-label">Merge into</span>
|
|
581
|
+
<select
|
|
582
|
+
className="vi-knob-input"
|
|
583
|
+
value=""
|
|
584
|
+
onChange={(e) => {
|
|
585
|
+
if (e.target.value) mergeInto(g.id, e.target.value);
|
|
586
|
+
e.target.value = "";
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
<option value="">choose group…</option>
|
|
590
|
+
{mergeTargets.map((o) => (
|
|
591
|
+
<option key={o.id} value={o.id}>
|
|
592
|
+
{o.name || o.char}
|
|
593
|
+
</option>
|
|
594
|
+
))}
|
|
595
|
+
</select>
|
|
596
|
+
</label>
|
|
597
|
+
)}
|
|
598
|
+
</div>
|
|
599
|
+
|
|
600
|
+
{g.views.length === 0 ? (
|
|
601
|
+
<p className="vi-comfy-hint">No images left in this group.</p>
|
|
602
|
+
) : (
|
|
603
|
+
<div className="vi-frames-grid">
|
|
604
|
+
{g.views.map((v) => {
|
|
605
|
+
const picked = selected.has(selKey(g.id, v.url));
|
|
606
|
+
const parts: string[] = [];
|
|
607
|
+
if (v.yaw != null) parts.push(`yaw ${Math.round(v.yaw)}°`);
|
|
608
|
+
if (v.bin != null) parts.push(`bin ${v.bin}`);
|
|
609
|
+
if (v.score != null) parts.push(`score ${v.score.toFixed(2)}`);
|
|
610
|
+
return (
|
|
611
|
+
<button
|
|
612
|
+
type="button"
|
|
613
|
+
key={v.url}
|
|
614
|
+
className={picked ? "vi-frame-cell vi-frame-cell-selected" : "vi-frame-cell"}
|
|
615
|
+
onClick={() => toggleView(g.id, v.url)}
|
|
616
|
+
aria-pressed={picked}
|
|
617
|
+
title={`${v.url}${parts.length ? ` — ${parts.join(" · ")}` : ""}`}
|
|
618
|
+
>
|
|
619
|
+
<img
|
|
620
|
+
className="vi-frame-img"
|
|
621
|
+
src={mediaBytesUrl(v.url)}
|
|
622
|
+
alt={parts.join(" ") || "character view"}
|
|
623
|
+
loading="lazy"
|
|
624
|
+
/>
|
|
625
|
+
{parts.length > 0 && <span className="vi-frame-tag">{parts[0]}</span>}
|
|
626
|
+
{picked && <span className="vi-frame-check">✓</span>}
|
|
627
|
+
</button>
|
|
628
|
+
);
|
|
629
|
+
})}
|
|
630
|
+
</div>
|
|
631
|
+
)}
|
|
632
|
+
|
|
633
|
+
{/* Per-group commit outcome + the S4 "Generate 3D" flow (self-contained —
|
|
634
|
+
see generate3D() above; onGenerate3D, if passed, is fired as an
|
|
635
|
+
optional notify hook only). */}
|
|
636
|
+
{g.slug && (() => {
|
|
637
|
+
const m3 = mesh3D[g.slug] || IDLE_3D;
|
|
638
|
+
const running = m3.phase === "running";
|
|
639
|
+
return (
|
|
640
|
+
<>
|
|
641
|
+
<div className="vi-frames-actions" style={{ flexWrap: "wrap", alignItems: "center" }}>
|
|
642
|
+
<span className="vi-comfy-hint" style={{ color: "var(--vi-accent)" }}>
|
|
643
|
+
✓ created identity “{g.name || g.char}” ({g.slug})
|
|
644
|
+
</span>
|
|
645
|
+
<button
|
|
646
|
+
type="button"
|
|
647
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
648
|
+
disabled={running}
|
|
649
|
+
onClick={() => void generate3D(g.slug as string)}
|
|
650
|
+
title={`Generate a 3D model for “${g.name || g.char}” (Hunyuan3D mesh + 360° turntable — takes several minutes)`}
|
|
651
|
+
>
|
|
652
|
+
{running
|
|
653
|
+
? "Generating 3D…"
|
|
654
|
+
: m3.phase === "done"
|
|
655
|
+
? "Regenerate 3D"
|
|
656
|
+
: "Generate 3D"}
|
|
657
|
+
</button>
|
|
658
|
+
{m3.status && (m3.phase === "running") && (
|
|
659
|
+
<span className="vi-comfy-hint" role="status">
|
|
660
|
+
{m3.status}
|
|
661
|
+
</span>
|
|
662
|
+
)}
|
|
663
|
+
</div>
|
|
664
|
+
{m3.error && (
|
|
665
|
+
<p className="vi-error" role="alert" style={{ margin: 0 }}>
|
|
666
|
+
{m3.error}
|
|
667
|
+
</p>
|
|
668
|
+
)}
|
|
669
|
+
{m3.phase === "done" && m3.glbUrl && (
|
|
670
|
+
<div style={{ marginTop: "0.4rem" }}>
|
|
671
|
+
<MeshViewer url={m3.glbUrl} height={320} />
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
</>
|
|
675
|
+
);
|
|
676
|
+
})()}
|
|
677
|
+
{g.error && (
|
|
678
|
+
<p className="vi-error" role="alert" style={{ margin: 0 }}>
|
|
679
|
+
{g.error}
|
|
680
|
+
</p>
|
|
681
|
+
)}
|
|
682
|
+
</div>
|
|
683
|
+
);
|
|
684
|
+
})}
|
|
685
|
+
|
|
686
|
+
{/* Commit bar. */}
|
|
687
|
+
<div className="vi-comfy-bar-actions vi-run-bar" style={{ flexWrap: "wrap" }}>
|
|
688
|
+
<button
|
|
689
|
+
type="button"
|
|
690
|
+
className="vi-btn vi-btn-accent"
|
|
691
|
+
disabled={!canCommit}
|
|
692
|
+
onClick={() => void commit()}
|
|
693
|
+
title="Create one identity profile per selected group from its kept images"
|
|
694
|
+
>
|
|
695
|
+
{committing
|
|
696
|
+
? "Creating…"
|
|
697
|
+
: `Create identities from selected groups${
|
|
698
|
+
proceedGroups.length ? ` (${proceedGroups.length})` : ""
|
|
699
|
+
}`}
|
|
700
|
+
</button>
|
|
701
|
+
{proceedGroups.length === 0 && (
|
|
702
|
+
<span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
|
|
703
|
+
Mark at least one non-empty group “proceed to commit”.
|
|
704
|
+
</span>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
707
|
+
{commitError && (
|
|
708
|
+
<p className="vi-error" role="alert" style={{ margin: 0 }}>
|
|
709
|
+
{commitError}
|
|
710
|
+
</p>
|
|
711
|
+
)}
|
|
712
|
+
</>
|
|
713
|
+
)}
|
|
714
|
+
</section>
|
|
715
|
+
);
|
|
716
|
+
}
|