@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,59 @@
|
|
|
1
|
+
// Compact file-intake bar — the single presentation for picking a source in
|
|
2
|
+
// every station. NO drag-drop dropbox: it is just a "Choose …" button (a label
|
|
3
|
+
// wrapping a hidden file input) plus the caller-supplied status / filename /
|
|
4
|
+
// meta line, and an optional Clear affordance. The button still drives the
|
|
5
|
+
// station's existing onPick/upload path — this is presentation only.
|
|
6
|
+
import type { ChangeEvent, ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
export function AddFileBar({
|
|
9
|
+
accept,
|
|
10
|
+
onFile,
|
|
11
|
+
buttonLabel,
|
|
12
|
+
busy = false,
|
|
13
|
+
onClear,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
accept: string;
|
|
17
|
+
onFile: (file: File) => void;
|
|
18
|
+
buttonLabel: string;
|
|
19
|
+
busy?: boolean;
|
|
20
|
+
/** Optional clear/reset affordance (shown only when provided). */
|
|
21
|
+
onClear?: () => void;
|
|
22
|
+
/** Status / filename / meta rendered inline after the button. */
|
|
23
|
+
children?: ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
function onChange(e: ChangeEvent<HTMLInputElement>) {
|
|
26
|
+
const file = e.target.files?.[0];
|
|
27
|
+
if (file) onFile(file);
|
|
28
|
+
// Reset so re-picking the SAME file re-fires onChange.
|
|
29
|
+
e.target.value = "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="vi-addbar">
|
|
34
|
+
<label className="vi-btn vi-btn-accent vi-file-label">
|
|
35
|
+
{buttonLabel}
|
|
36
|
+
<input
|
|
37
|
+
type="file"
|
|
38
|
+
accept={accept}
|
|
39
|
+
className="vi-file-input"
|
|
40
|
+
onChange={onChange}
|
|
41
|
+
disabled={busy}
|
|
42
|
+
/>
|
|
43
|
+
</label>
|
|
44
|
+
{children}
|
|
45
|
+
{onClear && (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
49
|
+
onClick={onClear}
|
|
50
|
+
disabled={busy}
|
|
51
|
+
>
|
|
52
|
+
Clear
|
|
53
|
+
</button>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default AddFileBar;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Audio Crop station (Phase 5) — the temporal sibling of the Image Crop station.
|
|
2
|
+
// Flow:
|
|
3
|
+
// file (audio OR video) → POST /uploads (multipart) → {path}
|
|
4
|
+
// → POST /video/ingest {path} → MediaRef
|
|
5
|
+
// → AudioCropCore: (video→audio extract) + TemporalRegionEditor + enqueue
|
|
6
|
+
// + result grid
|
|
7
|
+
// Every hop goes through request<T>() + config URLs; errors surface via
|
|
8
|
+
// describeAppError.
|
|
9
|
+
//
|
|
10
|
+
// Studio slice 2a: the extract step + region editor + enqueue hook + result grid
|
|
11
|
+
// were extracted VERBATIM into stations/ops/AudioCropCore (the reusable "Components
|
|
12
|
+
// view minus upload"), including the video→audio extract path. This station now owns
|
|
13
|
+
// only the upload/ingest lifecycle + the persistent add-file bar; it renders
|
|
14
|
+
// <AudioCropCore/> in the Components slot. Behaviour is unchanged.
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import type { StationSpec } from "./types";
|
|
17
|
+
import { SectionTabs } from "./SectionTabs";
|
|
18
|
+
import { AddFileBar } from "./AddFileBar";
|
|
19
|
+
import { AudioCropCore } from "./ops/AudioCropCore";
|
|
20
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
21
|
+
import { hugpyConfig } from "../config";
|
|
22
|
+
import { getSessionId } from "../session";
|
|
23
|
+
import {
|
|
24
|
+
mediaRefSchema,
|
|
25
|
+
uploadResultSchema,
|
|
26
|
+
type MediaRef,
|
|
27
|
+
} from "../video/contract";
|
|
28
|
+
|
|
29
|
+
type Phase = "idle" | "uploading" | "ingesting" | "ready";
|
|
30
|
+
|
|
31
|
+
const PHASE_LABEL: Record<Phase, string> = {
|
|
32
|
+
idle: "",
|
|
33
|
+
uploading: "Uploading…",
|
|
34
|
+
ingesting: "Ingesting…",
|
|
35
|
+
ready: "",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function AudioCropStation({ spec }: { spec: StationSpec }) {
|
|
39
|
+
const [source, setSource] = useState<MediaRef | null>(null);
|
|
40
|
+
const [fileName, setFileName] = useState<string | null>(null);
|
|
41
|
+
const [phase, setPhase] = useState<Phase>("idle");
|
|
42
|
+
const [error, setError] = useState<string | null>(null);
|
|
43
|
+
|
|
44
|
+
const busy = phase === "uploading" || phase === "ingesting";
|
|
45
|
+
|
|
46
|
+
// Clear the loaded source back to the empty state. AudioCropCore resets its queued
|
|
47
|
+
// clips + extract + tracker rows when the source reference changes (here → null).
|
|
48
|
+
function clearSource() {
|
|
49
|
+
setSource(null);
|
|
50
|
+
setFileName(null);
|
|
51
|
+
setError(null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function onPick(file: File) {
|
|
55
|
+
setError(null);
|
|
56
|
+
setFileName(file.name);
|
|
57
|
+
setSource(null);
|
|
58
|
+
setPhase("uploading");
|
|
59
|
+
|
|
60
|
+
// (a) multipart upload — field `file` + field `sid`. No Content-Type header:
|
|
61
|
+
// the browser sets the multipart boundary itself.
|
|
62
|
+
const fd = new FormData();
|
|
63
|
+
fd.append("file", file);
|
|
64
|
+
fd.append("sid", getSessionId());
|
|
65
|
+
const up = await request<unknown>(hugpyConfig.uploadUrl, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
body: fd,
|
|
68
|
+
meta: { specKey: "audio-crop", operation: "upload" },
|
|
69
|
+
});
|
|
70
|
+
if (!up.ok) {
|
|
71
|
+
setError(describeAppError(errorOf(up)));
|
|
72
|
+
setPhase("idle");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const upParsed = uploadResultSchema.safeParse(okValue(up));
|
|
76
|
+
if (!upParsed.success) {
|
|
77
|
+
setError("Malformed upload response.");
|
|
78
|
+
setPhase("idle");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// (b) ingest the server path into a typed MediaRef.
|
|
83
|
+
setPhase("ingesting");
|
|
84
|
+
const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: JSON.stringify({ path: upParsed.data.path }),
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
meta: { specKey: "audio-crop", operation: "ingest" },
|
|
89
|
+
});
|
|
90
|
+
if (!ing.ok) {
|
|
91
|
+
setError(describeAppError(errorOf(ing)));
|
|
92
|
+
setPhase("idle");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const media = mediaRefSchema.safeParse(okValue(ing));
|
|
96
|
+
if (!media.success) {
|
|
97
|
+
setError("Malformed ingest response.");
|
|
98
|
+
setPhase("idle");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (media.data.kind !== "audio" && media.data.kind !== "video") {
|
|
102
|
+
setError(
|
|
103
|
+
`Ingested asset is "${media.data.kind}", not audio or video — use the Image Crop station for that.`,
|
|
104
|
+
);
|
|
105
|
+
setPhase("idle");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
setSource(media.data);
|
|
109
|
+
setPhase("ready");
|
|
110
|
+
// A video's audio track is pulled out by AudioCropCore once the source lands.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const isVideo = source?.kind === "video";
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<section className="station-card station-wide">
|
|
117
|
+
{/* Same shared section framing as Frames/Generate, but with NO Options
|
|
118
|
+
panel — the crop params live inline in the Components view below the
|
|
119
|
+
waveform, so no [Components|Options] toggle / rail renders. */}
|
|
120
|
+
<SectionTabs
|
|
121
|
+
ariaLabel="Audio crop station"
|
|
122
|
+
addFile={
|
|
123
|
+
<>
|
|
124
|
+
{/* ONE compact add control — a Choose button + filename, no dropbox.
|
|
125
|
+
Accepts audio OR video; onPick routes a video through the
|
|
126
|
+
audio_extract chain before cropping. */}
|
|
127
|
+
<AddFileBar
|
|
128
|
+
accept="audio/*,video/*"
|
|
129
|
+
onFile={onPick}
|
|
130
|
+
busy={busy}
|
|
131
|
+
buttonLabel={source ? "Choose another file" : "Choose audio or video"}
|
|
132
|
+
onClear={source && !busy ? clearSource : undefined}
|
|
133
|
+
>
|
|
134
|
+
{busy && <span className="vi-crop-status">{PHASE_LABEL[phase]}</span>}
|
|
135
|
+
{!busy && source && (
|
|
136
|
+
<>
|
|
137
|
+
{fileName && (
|
|
138
|
+
<span className="vi-addbar-name" title={fileName}>
|
|
139
|
+
{fileName}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
<span className="vi-crop-meta">
|
|
143
|
+
<code>{source.mime}</code>
|
|
144
|
+
{source.duration_s != null ? ` · ${source.duration_s}s` : ""}
|
|
145
|
+
{isVideo ? " · video (extracting audio)" : ""}
|
|
146
|
+
</span>
|
|
147
|
+
</>
|
|
148
|
+
)}
|
|
149
|
+
</AddFileBar>
|
|
150
|
+
{error && <p className="vi-error" role="alert">{error}</p>}
|
|
151
|
+
</>
|
|
152
|
+
}
|
|
153
|
+
components={<AudioCropCore source={source} multiRegion />}
|
|
154
|
+
/>
|
|
155
|
+
</section>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// ComfyPanel — ComfyUI in the shared expand-into-the-screen drawer, reachable
|
|
2
|
+
// from EVERY station without spending a slot in the studio tab strip (operator
|
|
3
|
+
// ask 2026-08-04: "have comfy UI as an overlay the same as console is in the
|
|
4
|
+
// left column").
|
|
5
|
+
//
|
|
6
|
+
// Since 2026-08-06 this file contributes ONE TAB to the single unified drawer
|
|
7
|
+
// (drawerShell.tsx → WorkbenchDrawer.tsx) rather than rendering a drawer of its
|
|
8
|
+
// own — so "the same as console" is now literally the same window. (Until
|
|
9
|
+
// 2026-08-04 that shell was copied into this file verbatim; then it was shared;
|
|
10
|
+
// now there is one instance of it.) Only the endpoint bar, the message strips
|
|
11
|
+
// and the frame below are Comfy-specific.
|
|
12
|
+
//
|
|
13
|
+
// ── WHERE IT DELIBERATELY DIFFERS FROM ConsolePanel (read before editing) ────
|
|
14
|
+
// ConsolePanel frames FIRST-PARTY, SAME-ORIGIN, FIXED-href content ("/console"),
|
|
15
|
+
// and is deliberately UNSANDBOXED because a sandbox without `allow-modals` would
|
|
16
|
+
// silently break the console's confirm()/alert() flows. Its header comment
|
|
17
|
+
// explicitly contrasts itself with "ComfyStation's user-supplied URL".
|
|
18
|
+
//
|
|
19
|
+
// This panel is that contrasting case. The ComfyUI endpoint is USER-EDITABLE and
|
|
20
|
+
// PERSISTED, i.e. attacker-influenceable and foreign-origin. So:
|
|
21
|
+
// * every value passes sanitizeEmbedUrl() (http/https only) before it can ever
|
|
22
|
+
// reach iframe src — typed input AND the value rehydrated from storage;
|
|
23
|
+
// * the frame KEEPS its sandbox (COMFY_SANDBOX).
|
|
24
|
+
// Do NOT "simplify" this by copying ConsolePanel's unsandboxed iframe: that
|
|
25
|
+
// would hand a user-supplied foreign origin full same-origin-ish reign over this
|
|
26
|
+
// page. The shared gate lives in comfyEmbed.ts precisely so the two cannot drift.
|
|
27
|
+
// Sharing ONE drawer window with the console tab does not blur this: the two are
|
|
28
|
+
// distinct <iframe> elements rendered side by side (one hidden), each with its
|
|
29
|
+
// own sandbox attribute — never a single frame whose src is swapped.
|
|
30
|
+
//
|
|
31
|
+
// ── WHY THE FRAME MAY STILL BE BLANK (not this component's bug) ─────────────
|
|
32
|
+
// The video UI is HTTPS, so an http:// or loopback endpoint is blocked as mixed
|
|
33
|
+
// content with NO error, and any endpoint must also permit framing from this
|
|
34
|
+
// origin (`frame-ancestors`, no `X-Frame-Options: DENY`). Both cases are warned
|
|
35
|
+
// about inline, and "Open in new tab" always works regardless.
|
|
36
|
+
import { useState, type FormEvent } from "react";
|
|
37
|
+
import { hugpyConfig } from "../config";
|
|
38
|
+
import type { DrawerTab } from "./drawerShell";
|
|
39
|
+
import {
|
|
40
|
+
sanitizeEmbedUrl,
|
|
41
|
+
embedRisk,
|
|
42
|
+
COMFY_SANDBOX,
|
|
43
|
+
COMFY_URL_KEY,
|
|
44
|
+
} from "./comfyEmbed";
|
|
45
|
+
|
|
46
|
+
// The endpoint URL is session-persisted under the SAME key the station used, so
|
|
47
|
+
// an operator who already configured ComfyUI keeps their endpoint.
|
|
48
|
+
function readPersistedUrl(): string {
|
|
49
|
+
try {
|
|
50
|
+
const stored = sessionStorage.getItem(COMFY_URL_KEY);
|
|
51
|
+
if (stored != null) return stored;
|
|
52
|
+
} catch {
|
|
53
|
+
/* storage unavailable — fall back to config default */
|
|
54
|
+
}
|
|
55
|
+
return hugpyConfig.comfyUrl;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function persistUrl(value: string): void {
|
|
59
|
+
try {
|
|
60
|
+
sessionStorage.setItem(COMFY_URL_KEY, value);
|
|
61
|
+
} catch {
|
|
62
|
+
/* best-effort — the in-memory state still drives this session */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ComfyUI's tab in the unified drawer.
|
|
68
|
+
*
|
|
69
|
+
* A HOOK, not a plain descriptor, because this tab owns state (the endpoint
|
|
70
|
+
* field and the committed value). Hoisting that state to the always-mounted
|
|
71
|
+
* drawer host — rather than to the frame's own body — is what lets a half-typed
|
|
72
|
+
* endpoint survive a switch to the Console tab and back.
|
|
73
|
+
*/
|
|
74
|
+
export function useComfyDrawerTab(): DrawerTab<"comfy"> {
|
|
75
|
+
const [url, setUrl] = useState<string>(() => readPersistedUrl());
|
|
76
|
+
const [loaded, setLoaded] = useState<string>(() => readPersistedUrl());
|
|
77
|
+
|
|
78
|
+
// Diagnose the CURRENT text field (immediate feedback while typing).
|
|
79
|
+
const inputSanitized = sanitizeEmbedUrl(url);
|
|
80
|
+
const schemeRejected = url.trim() !== "" && inputSanitized == null;
|
|
81
|
+
// The iframe only ever sees a sanitized value; null → the empty state.
|
|
82
|
+
const embedSrc = sanitizeEmbedUrl(loaded);
|
|
83
|
+
const risk = embedRisk(loaded);
|
|
84
|
+
const openTarget = embedSrc ?? inputSanitized;
|
|
85
|
+
|
|
86
|
+
function onChangeUrl(next: string) {
|
|
87
|
+
setUrl(next);
|
|
88
|
+
persistUrl(next);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function onSubmit(e: FormEvent) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
setLoaded(url); // commit; embedSrc re-derives through sanitizeEmbedUrl()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
id: "comfy",
|
|
98
|
+
label: "ComfyUI",
|
|
99
|
+
hint: "Open ComfyUI alongside this station — no tab switch",
|
|
100
|
+
toggleClassName: "vi-comfy-toggle",
|
|
101
|
+
toggleLabel: "▤ ComfyUI",
|
|
102
|
+
ariaLabel: "ComfyUI",
|
|
103
|
+
panelClassName: "vi-comfy-panel",
|
|
104
|
+
// Stays in the DOM while another tab shows. This one matters most: a
|
|
105
|
+
// ComfyUI workspace is a long-lived, unsaved graph — reloading the frame
|
|
106
|
+
// every time the operator glanced at Active would lose work.
|
|
107
|
+
keepMounted: true,
|
|
108
|
+
headerExtra: (
|
|
109
|
+
/* The endpoint is configurable here because it always was — the station
|
|
110
|
+
owned this field, and dropping it would strand anyone whose ComfyUI is
|
|
111
|
+
not at the configured default. It rides in the shared header, so it only
|
|
112
|
+
appears while THIS tab is selected. */
|
|
113
|
+
<form className="vi-comfy-panel-url" onSubmit={onSubmit}>
|
|
114
|
+
<label className="vi-comfy-panel-url-label" htmlFor="vi-comfy-panel-url">
|
|
115
|
+
Endpoint
|
|
116
|
+
</label>
|
|
117
|
+
<input
|
|
118
|
+
id="vi-comfy-panel-url"
|
|
119
|
+
className="vi-knob-input vi-comfy-input"
|
|
120
|
+
type="url"
|
|
121
|
+
inputMode="url"
|
|
122
|
+
spellCheck={false}
|
|
123
|
+
autoComplete="off"
|
|
124
|
+
placeholder="https://comfy.hugpy.ai"
|
|
125
|
+
value={url}
|
|
126
|
+
onChange={(e) => onChangeUrl(e.target.value)}
|
|
127
|
+
aria-invalid={schemeRejected || undefined}
|
|
128
|
+
/>
|
|
129
|
+
<button
|
|
130
|
+
type="submit"
|
|
131
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
132
|
+
disabled={inputSanitized == null}
|
|
133
|
+
>
|
|
134
|
+
Load
|
|
135
|
+
</button>
|
|
136
|
+
</form>
|
|
137
|
+
),
|
|
138
|
+
headerActions: openTarget ? (
|
|
139
|
+
<a
|
|
140
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
141
|
+
href={openTarget}
|
|
142
|
+
target="_blank"
|
|
143
|
+
rel="noreferrer"
|
|
144
|
+
title="Open ComfyUI in its own tab"
|
|
145
|
+
>
|
|
146
|
+
Open in new tab ↗
|
|
147
|
+
</a>
|
|
148
|
+
) : (
|
|
149
|
+
<span
|
|
150
|
+
className="vi-btn vi-btn-sm vi-btn-ghost vi-comfy-open-disabled"
|
|
151
|
+
aria-disabled="true"
|
|
152
|
+
>
|
|
153
|
+
Open in new tab ↗
|
|
154
|
+
</span>
|
|
155
|
+
),
|
|
156
|
+
body: (
|
|
157
|
+
<>
|
|
158
|
+
{/* Scheme rejected: loud, since this is the security gate — the value
|
|
159
|
+
never reaches the frame. */}
|
|
160
|
+
{schemeRejected && (
|
|
161
|
+
<p className="vi-error vi-comfy-panel-msg" role="alert">
|
|
162
|
+
That URL isn’t an http:// or https:// address, so it won’t be
|
|
163
|
+
embedded. Enter a full HTTPS URL (e.g.{" "}
|
|
164
|
+
<code>https://comfy.hugpy.ai</code>).
|
|
165
|
+
</p>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{risk && (
|
|
169
|
+
<p className="vi-comfy-warn vi-comfy-panel-msg" role="alert">
|
|
170
|
+
{risk === "http" ? (
|
|
171
|
+
<>
|
|
172
|
+
This is an <code>http://</code> endpoint. This page is HTTPS, so
|
|
173
|
+
the browser blocks the frame as mixed content — it stays blank
|
|
174
|
+
with no error. Use an HTTPS endpoint such as{" "}
|
|
175
|
+
<code>https://comfy.hugpy.ai</code>, or open it in a new tab.
|
|
176
|
+
</>
|
|
177
|
+
) : (
|
|
178
|
+
<>
|
|
179
|
+
This points at a <code>localhost</code> / loopback host, which the
|
|
180
|
+
browser can’t reach from an HTTPS page. Expose ComfyUI through an
|
|
181
|
+
HTTPS proxy (e.g. <code>https://comfy.hugpy.ai</code>) instead.
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
</p>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{embedSrc ? (
|
|
188
|
+
<iframe
|
|
189
|
+
className="vi-console-iframe"
|
|
190
|
+
src={embedSrc}
|
|
191
|
+
title="ComfyUI"
|
|
192
|
+
// SANDBOXED on purpose — user-supplied, foreign origin. See the
|
|
193
|
+
// header note; do not drop this to match ConsolePanel.
|
|
194
|
+
sandbox={COMFY_SANDBOX}
|
|
195
|
+
allow="clipboard-read; clipboard-write; fullscreen"
|
|
196
|
+
/>
|
|
197
|
+
) : (
|
|
198
|
+
<div className="vi-comfy-empty vi-comfy-panel-empty">
|
|
199
|
+
<p className="vi-comfy-empty-title">No endpoint loaded</p>
|
|
200
|
+
<p className="vi-comfy-empty-sub">
|
|
201
|
+
Enter your ComfyUI endpoint above and press <strong>Load</strong> to
|
|
202
|
+
embed it. It must be an HTTPS URL (e.g.{" "}
|
|
203
|
+
<code>https://comfy.hugpy.ai</code>).
|
|
204
|
+
</p>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</>
|
|
208
|
+
),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ComfyUI viewer station — a modular "viewer" station that EMBEDS an external
|
|
2
|
+
// ComfyUI endpoint in an <iframe>, with a user-editable, session-persisted URL.
|
|
3
|
+
// Sibling to the studio stations (Generate / Frames / crops), registered the
|
|
4
|
+
// same way (registry.ts, group:"studio").
|
|
5
|
+
//
|
|
6
|
+
// ── MIXED-CONTENT CONSTRAINT (read before touching the iframe src) ──────────
|
|
7
|
+
// The video UI is served over HTTPS (dev.hugpy.ai/video, llm.hugpy.ai/app/video).
|
|
8
|
+
// A page loaded over https:// CANNOT embed an http:// (or http://127.0.0.1:8188)
|
|
9
|
+
// resource — the browser silently blocks the mixed-content subframe and the user
|
|
10
|
+
// sees a blank frame with NO error. So the endpoint MUST be reached over HTTPS
|
|
11
|
+
// (e.g. https://comfy.hugpy.ai). This component detects http:// / localhost /
|
|
12
|
+
// 127.0.0.1 targets and warns that an HTTPS page will refuse to embed them.
|
|
13
|
+
//
|
|
14
|
+
// ── FRAME-ANCESTORS (proxy-side) ────────────────────────────────────────────
|
|
15
|
+
// Even over HTTPS, the endpoint must permit framing FROM the video-UI origin:
|
|
16
|
+
// the comfy.hugpy.ai proxy has to send `Content-Security-Policy: frame-ancestors`
|
|
17
|
+
// listing the video-UI origin (and must NOT send `X-Frame-Options: DENY`).
|
|
18
|
+
// ComfyUI itself has no auth — keep TLS + any auth at the proxy, not in this arm.
|
|
19
|
+
//
|
|
20
|
+
// ── SECURITY (scheme allowlist) ─────────────────────────────────────────────
|
|
21
|
+
// The URL is user-editable AND persisted, so a hostile/typo value could carry a
|
|
22
|
+
// `javascript:` / `data:` / `blob:` / `file:` scheme. We NEVER hand an unsanitized
|
|
23
|
+
// value to iframe src: sanitizeEmbedUrl() parses with `new URL()` and returns the
|
|
24
|
+
// href ONLY for http:/https:, else null — enforced on typed input AND on the
|
|
25
|
+
// value rehydrated from sessionStorage.
|
|
26
|
+
//
|
|
27
|
+
// That gate (and the sandbox token list, and the storage key) now live in
|
|
28
|
+
// ./comfyEmbed so this station and the ComfyPanel overlay share EXACTLY one
|
|
29
|
+
// implementation. A second, subtly-weaker copy is how a `javascript:` URL
|
|
30
|
+
// eventually reaches an iframe — do not re-inline them here.
|
|
31
|
+
//
|
|
32
|
+
// ── THIS STATION IS NO LONGER IN THE TAB STRIP (2026-08-04) ────────────────
|
|
33
|
+
// ComfyUI is reached from the sidebar overlay (ComfyPanel) now. The station is
|
|
34
|
+
// kept, `navHidden`, so an existing /comfy bookmark still resolves instead of
|
|
35
|
+
// bouncing to the default tab.
|
|
36
|
+
import { useState, type FormEvent } from "react";
|
|
37
|
+
import type { StationSpec } from "./types";
|
|
38
|
+
import { hugpyConfig } from "../config";
|
|
39
|
+
import {
|
|
40
|
+
sanitizeEmbedUrl,
|
|
41
|
+
embedRisk,
|
|
42
|
+
COMFY_SANDBOX,
|
|
43
|
+
COMFY_URL_KEY as URL_KEY,
|
|
44
|
+
} from "./comfyEmbed";
|
|
45
|
+
|
|
46
|
+
export { sanitizeEmbedUrl } from "./comfyEmbed";
|
|
47
|
+
|
|
48
|
+
// Read the persisted URL, falling back to the configured default (which itself
|
|
49
|
+
// defaults to "" — no secret is baked in). Best-effort: storage may be unavailable.
|
|
50
|
+
function readPersistedUrl(): string {
|
|
51
|
+
try {
|
|
52
|
+
const stored = sessionStorage.getItem(URL_KEY);
|
|
53
|
+
if (stored != null) return stored;
|
|
54
|
+
} catch {
|
|
55
|
+
/* storage unavailable — fall back to config default */
|
|
56
|
+
}
|
|
57
|
+
return hugpyConfig.comfyUrl;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function persistUrl(value: string): void {
|
|
61
|
+
try {
|
|
62
|
+
sessionStorage.setItem(URL_KEY, value);
|
|
63
|
+
} catch {
|
|
64
|
+
/* best-effort — the in-memory state still drives this session */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ComfyStation({ spec }: { spec: StationSpec }) {
|
|
69
|
+
// `url` is the editable text field (persisted every keystroke). `loaded` is the
|
|
70
|
+
// value actually committed to the iframe — updated on submit and initialized to
|
|
71
|
+
// the persisted/default URL so a valid saved endpoint re-embeds on reload.
|
|
72
|
+
const [url, setUrl] = useState<string>(() => readPersistedUrl());
|
|
73
|
+
const [loaded, setLoaded] = useState<string>(() => readPersistedUrl());
|
|
74
|
+
|
|
75
|
+
// Diagnose the CURRENT text field (immediate feedback while typing).
|
|
76
|
+
const inputSanitized = sanitizeEmbedUrl(url);
|
|
77
|
+
const schemeRejected = url.trim() !== "" && inputSanitized == null;
|
|
78
|
+
|
|
79
|
+
// The iframe only ever sees a sanitized value; null → render the empty state.
|
|
80
|
+
const embedSrc = sanitizeEmbedUrl(loaded);
|
|
81
|
+
const risk = embedRisk(loaded);
|
|
82
|
+
// Best target for the always-available "Open in new tab" affordance: the loaded
|
|
83
|
+
// endpoint if valid, else whatever the user has currently typed (if valid).
|
|
84
|
+
const openTarget = embedSrc ?? inputSanitized;
|
|
85
|
+
const pageIsHttps =
|
|
86
|
+
typeof window !== "undefined" && window.location.protocol === "https:";
|
|
87
|
+
|
|
88
|
+
function onChange(next: string) {
|
|
89
|
+
setUrl(next);
|
|
90
|
+
persistUrl(next);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onSubmit(e: FormEvent) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
setLoaded(url); // commit; embedSrc re-derives through sanitizeEmbedUrl()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<section className="station-wide vi-comfy" aria-label="ComfyUI viewer">
|
|
100
|
+
<form className="vi-comfy-bar" onSubmit={onSubmit}>
|
|
101
|
+
<label className="vi-comfy-field">
|
|
102
|
+
<span className="vi-comfy-label">ComfyUI endpoint URL</span>
|
|
103
|
+
<input
|
|
104
|
+
className="vi-knob-input vi-comfy-input"
|
|
105
|
+
type="url"
|
|
106
|
+
inputMode="url"
|
|
107
|
+
spellCheck={false}
|
|
108
|
+
autoComplete="off"
|
|
109
|
+
placeholder="https://comfy.hugpy.ai"
|
|
110
|
+
value={url}
|
|
111
|
+
onChange={(e) => onChange(e.target.value)}
|
|
112
|
+
aria-invalid={schemeRejected || undefined}
|
|
113
|
+
/>
|
|
114
|
+
</label>
|
|
115
|
+
<div className="vi-comfy-bar-actions">
|
|
116
|
+
<button type="submit" className="vi-btn vi-btn-accent" disabled={inputSanitized == null}>
|
|
117
|
+
Load
|
|
118
|
+
</button>
|
|
119
|
+
{/* Always available: lets the user reach ComfyUI directly even when the
|
|
120
|
+
frame is blocked (mixed content) or refuses embedding (X-Frame-Options). */}
|
|
121
|
+
{openTarget ? (
|
|
122
|
+
<a className="vi-btn vi-btn-ghost" href={openTarget} target="_blank" rel="noreferrer">
|
|
123
|
+
Open in new tab ↗
|
|
124
|
+
</a>
|
|
125
|
+
) : (
|
|
126
|
+
<span className="vi-btn vi-btn-ghost vi-comfy-open-disabled" aria-disabled="true">
|
|
127
|
+
Open in new tab ↗
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</form>
|
|
132
|
+
|
|
133
|
+
{/* Scheme rejected (javascript:/data:/blob:/file:/relative/…): explicit and
|
|
134
|
+
loud, since this is the security gate — the value never reaches the frame. */}
|
|
135
|
+
{schemeRejected && (
|
|
136
|
+
<p className="vi-error" role="alert">
|
|
137
|
+
That URL isn’t an http:// or https:// address, so it won’t be embedded. Enter
|
|
138
|
+
a full HTTPS URL (e.g. <code>https://comfy.hugpy.ai</code>).
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{/* Mixed-content / loopback warning: an HTTPS page can’t frame these. */}
|
|
143
|
+
{risk && (
|
|
144
|
+
<p className="vi-comfy-warn" role="alert">
|
|
145
|
+
{risk === "http" ? (
|
|
146
|
+
<>
|
|
147
|
+
This is an <code>http://</code> endpoint.{" "}
|
|
148
|
+
{pageIsHttps
|
|
149
|
+
? "This page is HTTPS, so the browser blocks the frame as mixed content — it stays blank with no error."
|
|
150
|
+
: "On the deployed HTTPS site (dev.hugpy.ai/video, llm.hugpy.ai/app/video) the browser will block this frame as mixed content."}{" "}
|
|
151
|
+
Use an HTTPS endpoint such as <code>https://comfy.hugpy.ai</code>, or open it
|
|
152
|
+
in a new tab.
|
|
153
|
+
</>
|
|
154
|
+
) : (
|
|
155
|
+
<>
|
|
156
|
+
This points at a <code>localhost</code> / loopback host, which the browser
|
|
157
|
+
can’t reach from an HTTPS page and won’t serve over TLS. Expose ComfyUI
|
|
158
|
+
through an HTTPS proxy (e.g. <code>https://comfy.hugpy.ai</code>) instead.
|
|
159
|
+
</>
|
|
160
|
+
)}
|
|
161
|
+
</p>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{embedSrc ? (
|
|
165
|
+
<div className="vi-comfy-frame">
|
|
166
|
+
<iframe
|
|
167
|
+
className="vi-comfy-iframe"
|
|
168
|
+
src={embedSrc}
|
|
169
|
+
title="ComfyUI"
|
|
170
|
+
// Permissive on purpose: ComfyUI is a trusted internal tool that needs
|
|
171
|
+
// scripts, same-origin, forms, downloads and popups to function. Shared
|
|
172
|
+
// with ComfyPanel so the two frames can never diverge on sandboxing.
|
|
173
|
+
sandbox={COMFY_SANDBOX}
|
|
174
|
+
allow="clipboard-read; clipboard-write; fullscreen"
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
) : (
|
|
178
|
+
<div className="vi-comfy-empty">
|
|
179
|
+
<p className="vi-comfy-empty-title">No endpoint loaded</p>
|
|
180
|
+
<p className="vi-comfy-empty-sub">
|
|
181
|
+
Enter your ComfyUI endpoint above and press <strong>Load</strong> to embed it.
|
|
182
|
+
It must be an HTTPS URL (e.g. <code>https://comfy.hugpy.ai</code>).
|
|
183
|
+
</p>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{embedSrc && (
|
|
188
|
+
<p className="vi-comfy-hint">
|
|
189
|
+
If the frame stays blank, the endpoint is likely refusing to be embedded
|
|
190
|
+
(<code>X-Frame-Options</code> / missing <code>frame-ancestors</code>). Open it in
|
|
191
|
+
a new tab instead — the “Open in new tab” link above works regardless.
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
</section>
|
|
195
|
+
);
|
|
196
|
+
}
|