akhaliq HF Staff commited on
Commit
d84960f
·
1 Parent(s): db93463

Incorporated modern Gradio Server UI and backend refactoring

Browse files
Files changed (2) hide show
  1. app.py +90 -198
  2. index.html +553 -0
app.py CHANGED
@@ -1,6 +1,3 @@
1
- '''
2
- Gradio App Demo
3
- '''
4
  import os, sys, shutil
5
  import json
6
  import glob
@@ -14,6 +11,7 @@ import numpy as np
14
  import torch
15
  import tempfile
16
  import spaces
 
17
 
18
  # Temp file bug of gradio
19
  BASE_TMP_DIR = os.path.abspath("./gradio_tmp")
@@ -23,7 +21,9 @@ os.environ["TEMP"] = BASE_TMP_DIR
23
  os.environ["TMP"] = BASE_TMP_DIR
24
  os.environ["GRADIO_TEMP_DIR"] = BASE_TMP_DIR
25
  tempfile.tempdir = BASE_TMP_DIR
26
- import gradio as gr
 
 
27
 
28
 
29
  # Import your existing project code
@@ -69,11 +69,10 @@ model, model_args = load_model(checkpoint_path)
69
 
70
 
71
 
72
- ######################## Gallery Prepare ########################
73
 
74
  def escape_html(x):
75
  x = "" if x is None else str(x)
76
-
77
  return (
78
  x.replace("&", "&")
79
  .replace("<", "&lt;")
@@ -83,15 +82,6 @@ def escape_html(x):
83
  )
84
 
85
 
86
- def prepare_gallery(page_paths: List[str]):
87
- gallery_items = []
88
-
89
- for page_idx, page_path in enumerate(page_paths):
90
- gallery_items.append((page_path, f"Page {page_idx}"))
91
-
92
- return gallery_items
93
-
94
-
95
  def prepare_result_table(
96
  pred_ranges: List[List[int]],
97
  pred_intra_labels: List[int],
@@ -156,212 +146,114 @@ def prepare_result_table(
156
  return html
157
 
158
 
159
- def list_sample_videos(asset_dir: str = "__assets__", max_samples: int = 8) -> List[List[str]]:
160
  script_dir = os.path.dirname(os.path.abspath(__file__))
161
  asset_dir = os.path.join(script_dir, asset_dir)
162
 
163
  if not os.path.isdir(asset_dir):
164
  return []
165
 
166
- mp4_paths = []
167
  for name in sorted(os.listdir(asset_dir)):
168
  path = os.path.join(asset_dir, name)
169
  if os.path.isfile(path) and name.lower().endswith(".mp4"):
170
- mp4_paths.append([path])
171
 
172
- print("We have", len(mp4_paths), "number of videos!")
173
- return mp4_paths[:max_samples]
174
 
175
- sample_videos = list_sample_videos("__assets__/", max_samples = 16)
176
 
 
177
 
 
 
 
178
 
 
 
 
179
 
180
- @spaces.GPU(duration=120)
181
- def run_demo(video_file):
 
 
 
 
 
 
 
 
182
 
183
- if video_file is None:
184
- raise gr.Error("Please upload a video first.")
 
 
 
 
 
 
 
 
 
 
 
185
 
186
- video_path = video_file if isinstance(video_file, str) else video_file.name
187
  if not os.path.exists(video_path):
188
- raise gr.Error(f"Video file does not exist: {video_path}")
189
-
190
- # Read the setting
191
- num_context_frames = DEFAULT_NUM_CONTEXT_FRAMES
192
- max_frames_per_img = DEFAULT_MAX_FRAMES_PER_IMG
193
 
 
 
 
 
 
 
194
 
195
- print("Start processing the video", video_path)
196
  pred_ranges, pred_intra_labels, pred_inter_labels, video_np_full, fps = single_video_inference(
197
- video_path = video_path,
198
- model = model,
199
- model_args = model_args,
200
- num_context_frames = int(num_context_frames),
201
- )
202
- print("Finish running the video")
203
 
204
- # Prepare the folder
205
- cur_VIS_DIR = VIS_DIR + "_" + str(time.time())
206
- if os.path.exists(cur_VIS_DIR):
207
- shutil.rmtree(cur_VIS_DIR)
208
- os.makedirs(cur_VIS_DIR)
209
 
210
- # Visualize and store (Must Do!)
211
  page_paths = visualize_concated_frames(
212
- frames = video_np_full,
213
- out_dir = cur_VIS_DIR,
214
- highlight_ranges_closed = pred_ranges,
215
- max_frames_per_img = int(max_frames_per_img),
216
- end_range_exclusive = True,
217
- fps = fps,
218
- start_index = 0,
219
- )
220
-
221
- gallery_paths = page_paths[:MAX_GALLERY_PAGES]
222
-
223
- result_table = prepare_result_table(
224
- pred_ranges = pred_ranges,
225
- pred_intra_labels = pred_intra_labels,
226
- pred_inter_labels = pred_inter_labels,
227
- fps = fps,
228
- )
229
-
230
- print("Visualization pages:", len(page_paths))
231
- print("Shown visualization pages:", len(gallery_paths))
232
- print("Predicted shots:", len(pred_ranges))
233
-
234
- return gr.update(value = prepare_gallery(gallery_paths)), gr.update(value = result_table)
235
-
236
-
237
- def clear_demo_outputs():
238
- return gr.update(value = []), gr.update(value = "")
239
-
240
-
241
-
242
- # -------------------------
243
- # UI Design
244
- # -------------------------
245
- custom_css = """
246
- #visual_gallery img {
247
- object-fit: contain !important;
248
- }
249
-
250
- #visual_gallery .thumbnail-item {
251
- object-fit: contain !important;
252
- }
253
-
254
- #visual_gallery .grid-wrap {
255
- align-items: start !important;
256
- }
257
-
258
- .result-table-wrap {
259
- width: 100%;
260
- max-height: 360px;
261
- overflow: auto;
262
- border: 1px solid #e5e7eb;
263
- border-radius: 10px;
264
- }
265
-
266
- .result-table {
267
- width: 100%;
268
- border-collapse: collapse;
269
- font-size: 14px;
270
- }
271
-
272
- .result-table th {
273
- position: sticky;
274
- top: 0;
275
- background: #f9fafb;
276
- border-bottom: 1px solid #e5e7eb;
277
- padding: 8px 10px;
278
- text-align: left;
279
- white-space: nowrap;
280
- }
281
-
282
- .result-table td {
283
- border-bottom: 1px solid #f1f5f9;
284
- padding: 8px 10px;
285
- white-space: nowrap;
286
- }
287
-
288
- .result-table tr:hover {
289
- background: #f9fafb;
290
- }
291
- """
292
-
293
-
294
-
295
- MARKDOWN = \
296
- """
297
- <div align="center">
298
-
299
- # OmniShotCut: Holistic Relational Shot Boundary Detection with Shot-Query Transformer
300
-
301
- <b>A sensitive and more informative SoTA shot boundary detection model.</b>
302
-
303
- <br>
304
-
305
- <a href="https://arxiv.org/abs/2604.24762">arXiv</a> ·
306
- <a href="https://uva-computer-vision-lab.github.io/OmniShotCut_website/">Project Page</a> ·
307
- <a href="https://github.com/UVA-Computer-Vision-Lab/OmniShotCut">Github</a> ·
308
- <a href="https://huggingface.co/uva-cv-lab/OmniShotCut">Model</a>
309
-
310
- </div>
311
-
312
- ---
313
-
314
- Upload a video and click <b>Run Inference</b>.
315
- """
316
-
317
-
318
- with gr.Blocks(title="OmniShotCut Demo", css = custom_css) as demo:
319
-
320
- # Head title
321
- gr.Markdown(MARKDOWN)
322
-
323
- with gr.Row():
324
- with gr.Column(scale=1):
325
- video_input = gr.Video(label = "Input Video", height = 480)
326
- run_button = gr.Button("Run Inference", variant="primary")
327
-
328
- with gr.Column(scale=1):
329
- gr.Markdown("## Visualization")
330
- gallery = gr.Gallery(
331
- label = None,
332
- columns = 1,
333
- height = 760,
334
- preview = True,
335
- elem_id = "visual_gallery",
336
- object_fit = "contain",
337
- )
338
-
339
- gr.Markdown("## Predicted Shot Results")
340
- result_table = gr.HTML(
341
- value = "",
342
- elem_id = "result_table",
343
- )
344
-
345
-
346
- gr.Markdown("## Sample Videos")
347
- gr.Examples(
348
- examples = sample_videos,
349
- inputs = [video_input],
350
- label = "Choose a sample video",
351
- )
352
-
353
-
354
- run_button.click(
355
- fn = clear_demo_outputs,
356
- inputs = [],
357
- outputs = [gallery, result_table],
358
- ).then(
359
- fn = run_demo,
360
- inputs =[video_input],
361
- outputs = [gallery, result_table],
362
- )
363
-
364
-
365
 
366
  if __name__ == "__main__":
367
- demo.launch(share=True)
 
 
 
 
1
  import os, sys, shutil
2
  import json
3
  import glob
 
11
  import torch
12
  import tempfile
13
  import spaces
14
+ from fastapi.responses import HTMLResponse
15
 
16
  # Temp file bug of gradio
17
  BASE_TMP_DIR = os.path.abspath("./gradio_tmp")
 
21
  os.environ["TMP"] = BASE_TMP_DIR
22
  os.environ["GRADIO_TEMP_DIR"] = BASE_TMP_DIR
23
  tempfile.tempdir = BASE_TMP_DIR
24
+
25
+ from gradio import Server
26
+ from gradio.data_classes import FileData
27
 
28
 
29
  # Import your existing project code
 
69
 
70
 
71
 
72
+ ######################## Utilities ########################
73
 
74
  def escape_html(x):
75
  x = "" if x is None else str(x)
 
76
  return (
77
  x.replace("&", "&amp;")
78
  .replace("<", "&lt;")
 
82
  )
83
 
84
 
 
 
 
 
 
 
 
 
 
85
  def prepare_result_table(
86
  pred_ranges: List[List[int]],
87
  pred_intra_labels: List[int],
 
146
  return html
147
 
148
 
149
+ def list_sample_videos(asset_dir: str = "__assets__", max_samples: int = 8) -> List[dict]:
150
  script_dir = os.path.dirname(os.path.abspath(__file__))
151
  asset_dir = os.path.join(script_dir, asset_dir)
152
 
153
  if not os.path.isdir(asset_dir):
154
  return []
155
 
156
+ samples = []
157
  for name in sorted(os.listdir(asset_dir)):
158
  path = os.path.join(asset_dir, name)
159
  if os.path.isfile(path) and name.lower().endswith(".mp4"):
160
+ samples.append({"path": path, "name": name})
161
 
162
+ return samples[:max_samples]
 
163
 
 
164
 
165
+ from fastapi.staticfiles import StaticFiles
166
 
167
+ # -------------------------
168
+ # Server and API
169
+ # -------------------------
170
 
171
+ app = Server()
172
+ os.makedirs(VIS_DIR, exist_ok=True)
173
+ app.mount("/outputs", StaticFiles(directory=VIS_DIR), name="outputs")
174
 
175
+ @app.api()
176
+ def get_examples() -> List[dict]:
177
+ samples = list_sample_videos("__assets__/", max_samples=16)
178
+ space_id = os.getenv("SPACE_ID")
179
+
180
+ if space_id:
181
+ hub_base = f"https://huggingface.co/spaces/{space_id}/resolve/main/__assets__/"
182
+ return [{"url": f"{hub_base}{s['name']}", "orig_name": s["name"]} for s in samples]
183
+
184
+ return [FileData(path=s["path"], orig_name=s["name"]) for s in samples]
185
 
186
+ @app.api()
187
+ @spaces.GPU(duration=120)
188
+ def run_inference(video_file: dict) -> dict:
189
+ video_path = video_file["path"]
190
+
191
+ # ffmpeg/opencv often need a file extension to correctly parse the container
192
+ if not os.path.splitext(video_path)[1]:
193
+ orig_name = video_file.get("orig_name") or "input.mp4"
194
+ ext = os.path.splitext(orig_name)[1] or ".mp4"
195
+ new_path = video_path + ext
196
+ if not os.path.exists(new_path):
197
+ shutil.copy(video_path, new_path)
198
+ video_path = new_path
199
 
 
200
  if not os.path.exists(video_path):
201
+ return {"error": "Video file not found"}
 
 
 
 
202
 
203
+ # Check if it's a Git LFS pointer instead of a real video
204
+ if os.path.getsize(video_path) < 1000:
205
+ with open(video_path, "rb") as f:
206
+ header = f.read(100)
207
+ if b"version https://git-lfs" in header:
208
+ return {"error": "LFS pointer detected. Please ensure video files are fully downloaded on the Space."}
209
 
210
+ print(f"Start processing: {video_path}")
211
  pred_ranges, pred_intra_labels, pred_inter_labels, video_np_full, fps = single_video_inference(
212
+ video_path=video_path,
213
+ model=model,
214
+ model_args=model_args,
215
+ num_context_frames=DEFAULT_NUM_CONTEXT_FRAMES,
216
+ )
217
+ print("Inference finished")
218
 
219
+ # Prepare visualization directory
220
+ cur_vis_dir = os.path.join(VIS_DIR, f"vis_{int(time.time())}")
221
+ os.makedirs(cur_vis_dir, exist_ok=True)
 
 
222
 
223
+ # Generate visualization frames
224
  page_paths = visualize_concated_frames(
225
+ frames=video_np_full,
226
+ out_dir=cur_vis_dir,
227
+ highlight_ranges_closed=pred_ranges,
228
+ max_frames_per_img=DEFAULT_MAX_FRAMES_PER_IMG,
229
+ end_range_exclusive=True,
230
+ fps=fps,
231
+ start_index=0,
232
+ )
233
+
234
+ gallery_data = []
235
+ for p in page_paths[:MAX_GALLERY_PAGES]:
236
+ rel_path = os.path.relpath(p, VIS_DIR)
237
+ gallery_data.append({"url": f"/outputs/{rel_path}"})
238
+
239
+ result_table_html = prepare_result_table(
240
+ pred_ranges=pred_ranges,
241
+ pred_intra_labels=pred_intra_labels,
242
+ pred_inter_labels=pred_inter_labels,
243
+ fps=fps,
244
+ )
245
+
246
+ return {
247
+ "gallery": gallery_data,
248
+ "table": result_table_html,
249
+ "shot_count": len(pred_ranges)
250
+ }
251
+
252
+ @app.get("/", response_class=HTMLResponse)
253
+ async def homepage():
254
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
255
+ with open(html_path, "r", encoding="utf-8") as f:
256
+ return f.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
  if __name__ == "__main__":
259
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,553 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OmniShotCut Pro | Shot Boundary Detection</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <script src="https://unpkg.com/lucide@latest"></script>
11
+ <style>
12
+ :root {
13
+ --bg-base: #0c0c0e;
14
+ --bg-surface: #141417;
15
+ --bg-elevated: #1c1c21;
16
+ --border: #2a2a2f;
17
+ --primary: #8b5cf6;
18
+ --primary-muted: rgba(139, 92, 246, 0.2);
19
+ --text-main: #efeff1;
20
+ --text-dim: #94949e;
21
+ --accent-green: #10b981;
22
+ --header-height: 48px;
23
+ --sidebar-width: 280px;
24
+ --inspector-width: 320px;
25
+ --timeline-height: 240px;
26
+ }
27
+
28
+ * {
29
+ box-sizing: border-box;
30
+ margin: 0;
31
+ padding: 0;
32
+ scrollbar-width: thin;
33
+ scrollbar-color: var(--border) transparent;
34
+ }
35
+
36
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
37
+ ::-webkit-scrollbar-track { background: transparent; }
38
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
39
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
40
+
41
+ body {
42
+ font-family: 'Inter', sans-serif;
43
+ background-color: var(--bg-base);
44
+ color: var(--text-main);
45
+ height: 100vh;
46
+ display: grid;
47
+ grid-template-rows: var(--header-height) 1fr var(--timeline-height);
48
+ grid-template-columns: var(--sidebar-width) 1fr var(--inspector-width);
49
+ grid-template-areas:
50
+ "header header header"
51
+ "media preview inspector"
52
+ "timeline timeline timeline";
53
+ overflow: hidden;
54
+ }
55
+
56
+ /* --- Header --- */
57
+ header {
58
+ grid-area: header;
59
+ background: var(--bg-surface);
60
+ border-bottom: 1px solid var(--border);
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ padding: 0 1rem;
65
+ z-index: 100;
66
+ }
67
+
68
+ .brand {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 0.75rem;
72
+ font-weight: 700;
73
+ font-size: 0.9rem;
74
+ letter-spacing: -0.01em;
75
+ }
76
+
77
+ .brand svg { color: var(--primary); }
78
+
79
+ .header-actions {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 1rem;
83
+ }
84
+
85
+ /* --- Media Sidebar --- */
86
+ .sidebar {
87
+ grid-area: media;
88
+ background: var(--bg-surface);
89
+ border-right: 1px solid var(--border);
90
+ display: flex;
91
+ flex-direction: column;
92
+ overflow: hidden;
93
+ }
94
+
95
+ .panel-header {
96
+ padding: 0.75rem 1rem;
97
+ font-size: 0.75rem;
98
+ font-weight: 600;
99
+ text-transform: uppercase;
100
+ color: var(--text-dim);
101
+ border-bottom: 1px solid var(--border);
102
+ display: flex;
103
+ justify-content: space-between;
104
+ align-items: center;
105
+ }
106
+
107
+ .media-content {
108
+ flex: 1;
109
+ overflow-y: auto;
110
+ padding: 1rem;
111
+ }
112
+
113
+ .upload-card {
114
+ border: 2px dashed var(--border);
115
+ border-radius: 0.5rem;
116
+ padding: 1.5rem 1rem;
117
+ text-align: center;
118
+ cursor: pointer;
119
+ transition: all 0.2s;
120
+ margin-bottom: 1.5rem;
121
+ }
122
+
123
+ .upload-card:hover {
124
+ border-color: var(--primary);
125
+ background: var(--primary-muted);
126
+ }
127
+
128
+ .upload-card i { margin-bottom: 0.5rem; color: var(--text-dim); }
129
+ .upload-card p { font-size: 0.8rem; font-weight: 500; }
130
+
131
+ .examples-list {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 0.75rem;
135
+ }
136
+
137
+ .example-node {
138
+ background: var(--bg-elevated);
139
+ border: 1px solid var(--border);
140
+ border-radius: 0.4rem;
141
+ overflow: hidden;
142
+ cursor: pointer;
143
+ transition: border-color 0.2s;
144
+ }
145
+
146
+ .example-node:hover { border-color: var(--primary); }
147
+ .example-node video { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; }
148
+ .example-node span { display: block; padding: 0.4rem 0.6rem; font-size: 0.7rem; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
149
+
150
+ /* --- Preview Panel --- */
151
+ .preview {
152
+ grid-area: preview;
153
+ background: #000;
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ position: relative;
158
+ padding: 2rem;
159
+ }
160
+
161
+ .video-container {
162
+ width: 100%;
163
+ max-width: 1000px;
164
+ aspect-ratio: 16/9;
165
+ background: #050505;
166
+ box-shadow: 0 20px 50px rgba(0,0,0,0.5);
167
+ border-radius: 4px;
168
+ overflow: hidden;
169
+ position: relative;
170
+ }
171
+
172
+ #main-video { width: 100%; height: 100%; object-fit: contain; }
173
+
174
+ .analyze-overlay {
175
+ position: absolute;
176
+ bottom: 2rem;
177
+ right: 2rem;
178
+ }
179
+
180
+ .btn-analyze {
181
+ background: var(--primary);
182
+ color: white;
183
+ border: none;
184
+ padding: 0.6rem 1.25rem;
185
+ border-radius: 2rem;
186
+ font-weight: 600;
187
+ font-size: 0.85rem;
188
+ cursor: pointer;
189
+ box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4);
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 0.5rem;
193
+ transition: all 0.2s;
194
+ }
195
+
196
+ .btn-analyze:hover:not(:disabled) { transform: scale(1.05); background: #7c3aed; }
197
+ .btn-analyze:disabled { opacity: 0.5; cursor: not-allowed; }
198
+
199
+ /* --- Inspector Panel --- */
200
+ .inspector {
201
+ grid-area: inspector;
202
+ background: var(--bg-surface);
203
+ border-left: 1px solid var(--border);
204
+ display: flex;
205
+ flex-direction: column;
206
+ overflow: hidden;
207
+ }
208
+
209
+ .inspector-content {
210
+ flex: 1;
211
+ overflow-y: auto;
212
+ padding: 0;
213
+ }
214
+
215
+ /* Result table override */
216
+ .result-table-wrap { width: 100%; }
217
+ .result-table { width: 100%; border-collapse: collapse; font-size: 11px; font-family: 'JetBrains Mono', monospace; }
218
+ .result-table th {
219
+ background: var(--bg-elevated);
220
+ padding: 0.5rem;
221
+ text-align: left;
222
+ color: var(--text-dim);
223
+ font-weight: 500;
224
+ border-bottom: 1px solid var(--border);
225
+ position: sticky; top: 0;
226
+ }
227
+ .result-table td { padding: 0.5rem; border-bottom: 1px solid var(--border); color: var(--text-main); }
228
+ .result-table tr:hover { background: rgba(255,255,255,0.03); }
229
+
230
+ /* --- Timeline Panel --- */
231
+ .timeline {
232
+ grid-area: timeline;
233
+ background: var(--bg-surface);
234
+ border-top: 1px solid var(--border);
235
+ display: flex;
236
+ flex-direction: column;
237
+ overflow: hidden;
238
+ }
239
+
240
+ .timeline-track {
241
+ flex: 1;
242
+ overflow-x: auto;
243
+ overflow-y: hidden;
244
+ padding: 1rem;
245
+ display: flex;
246
+ gap: 4px;
247
+ align-items: center;
248
+ background: var(--bg-base);
249
+ }
250
+
251
+ .timeline-item {
252
+ flex: 0 0 280px;
253
+ aspect-ratio: 16/9;
254
+ background: var(--bg-elevated);
255
+ border: 1px solid var(--border);
256
+ border-radius: 2px;
257
+ overflow: hidden;
258
+ position: relative;
259
+ cursor: pointer;
260
+ transition: transform 0.2s, border-color 0.2s;
261
+ }
262
+
263
+ .timeline-item:hover {
264
+ border-color: var(--primary);
265
+ z-index: 10;
266
+ }
267
+
268
+ .timeline-item img { width: 100%; height: 100%; object-fit: cover; }
269
+ .timeline-item .timestamp {
270
+ position: absolute;
271
+ bottom: 4px;
272
+ left: 4px;
273
+ background: rgba(0,0,0,0.7);
274
+ padding: 2px 4px;
275
+ font-size: 10px;
276
+ font-family: 'JetBrains Mono', monospace;
277
+ border-radius: 2px;
278
+ }
279
+
280
+ /* --- Loader --- */
281
+ .loader-overlay {
282
+ position: fixed;
283
+ inset: 0;
284
+ background: rgba(0,0,0,0.8);
285
+ backdrop-filter: blur(8px);
286
+ z-index: 1000;
287
+ display: none;
288
+ flex-direction: column;
289
+ align-items: center;
290
+ justify-content: center;
291
+ }
292
+
293
+ .spinner {
294
+ width: 48px;
295
+ height: 48px;
296
+ border: 3px solid var(--border);
297
+ border-top-color: var(--primary);
298
+ border-radius: 50%;
299
+ animation: spin 1s linear infinite;
300
+ }
301
+
302
+ @keyframes spin { to { transform: rotate(360deg); } }
303
+
304
+ /* --- Modal --- */
305
+ .modal {
306
+ position: fixed;
307
+ inset: 0;
308
+ background: rgba(0,0,0,0.95);
309
+ z-index: 2000;
310
+ display: none;
311
+ align-items: center;
312
+ justify-content: center;
313
+ padding: 2rem;
314
+ }
315
+
316
+ .modal-content { max-width: 90%; max-height: 90%; object-fit: contain; }
317
+ .close-modal { position: absolute; top: 1rem; right: 1rem; cursor: pointer; color: var(--text-dim); }
318
+
319
+ .empty-state {
320
+ display: flex;
321
+ flex-direction: column;
322
+ align-items: center;
323
+ justify-content: center;
324
+ height: 100%;
325
+ color: var(--text-dim);
326
+ font-size: 0.8rem;
327
+ text-align: center;
328
+ padding: 2rem;
329
+ }
330
+ </style>
331
+ </head>
332
+ <body>
333
+
334
+ <header>
335
+ <div class="brand">
336
+ <i data-lucide="video"></i>
337
+ <span>OMNISHOTCUT <span style="color: var(--primary)">PRO</span></span>
338
+ </div>
339
+ <div class="header-actions">
340
+ <span id="status-tag" style="font-size: 0.7rem; color: var(--accent-green); display: flex; align-items: center; gap: 0.4rem;">
341
+ <span style="width: 6px; height: 6px; background: var(--accent-green); border-radius: 50%;"></span>
342
+ Engine Ready
343
+ </span>
344
+ </div>
345
+ </header>
346
+
347
+ <aside class="sidebar">
348
+ <div class="panel-header">
349
+ <span>Media Library</span>
350
+ <i data-lucide="plus" size="14" style="cursor: pointer;"></i>
351
+ </div>
352
+ <div class="media-content">
353
+ <div class="upload-card" id="drop-zone">
354
+ <i data-lucide="upload-cloud"></i>
355
+ <p>Import Media</p>
356
+ <input type="file" id="video-input" accept="video/*" style="display: none;">
357
+ </div>
358
+
359
+ <div class="panel-header" style="border: none; padding-left: 0;">Examples</div>
360
+ <div class="examples-list" id="examples-grid">
361
+ <!-- Examples load here -->
362
+ </div>
363
+ </div>
364
+ </aside>
365
+
366
+ <main class="preview">
367
+ <div class="video-container">
368
+ <video id="main-video" controls></video>
369
+ <div id="empty-preview" class="empty-state">
370
+ <i data-lucide="film" size="48" style="margin-bottom: 1rem; opacity: 0.2;"></i>
371
+ <p>No media loaded.<br>Import a file to begin analysis.</p>
372
+ </div>
373
+ </div>
374
+
375
+ <div class="analyze-overlay">
376
+ <button id="run-btn" class="btn-analyze" disabled>
377
+ <i data-lucide="zap"></i>
378
+ Analyze Shots
379
+ </button>
380
+ </div>
381
+ </main>
382
+
383
+ <aside class="inspector">
384
+ <div class="panel-header">
385
+ <span>Shot Inspector</span>
386
+ <span id="shot-count" style="background: var(--primary-muted); padding: 1px 6px; border-radius: 4px; color: var(--primary); font-size: 10px;">0 SHOTS</span>
387
+ </div>
388
+ <div class="inspector-content" id="table-container">
389
+ <div class="empty-state">
390
+ <p>Results will appear here after analysis.</p>
391
+ </div>
392
+ </div>
393
+ </aside>
394
+
395
+ <footer class="timeline">
396
+ <div class="panel-header">
397
+ <span>Relational Visualization Track</span>
398
+ <div style="display: flex; gap: 1rem;">
399
+ <span style="font-size: 10px; color: var(--text-dim);">Frames: <span id="frame-info">--</span></span>
400
+ </div>
401
+ </div>
402
+ <div class="timeline-track" id="gallery">
403
+ <!-- Timeline items load here -->
404
+ </div>
405
+ </footer>
406
+
407
+ <div class="loader-overlay" id="loader">
408
+ <div class="spinner"></div>
409
+ <p style="margin-top: 1.5rem; font-weight: 500; letter-spacing: 0.05em; font-size: 0.9rem;">PROCESSING TEMPORAL RELATIONS...</p>
410
+ </div>
411
+
412
+ <div class="modal" id="modal">
413
+ <i data-lucide="x" class="close-modal"></i>
414
+ <img class="modal-content" id="modal-img">
415
+ </div>
416
+
417
+ <script type="module">
418
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
419
+
420
+ // Initialize Icons
421
+ lucide.createIcons();
422
+
423
+ const dropZone = document.getElementById('drop-zone');
424
+ const videoInput = document.getElementById('video-input');
425
+ const mainVideo = document.getElementById('main-video');
426
+ const emptyPreview = document.getElementById('empty-preview');
427
+ const runBtn = document.getElementById('run-btn');
428
+ const loader = document.getElementById('loader');
429
+ const gallery = document.getElementById('gallery');
430
+ const tableContainer = document.getElementById('table-container');
431
+ const shotCountBadge = document.getElementById('shot-count');
432
+ const modal = document.getElementById('modal');
433
+ const modalImg = document.getElementById('modal-img');
434
+ const examplesGrid = document.getElementById('examples-grid');
435
+ const frameInfo = document.getElementById('frame-info');
436
+
437
+ let selectedFile = null;
438
+ let client = null;
439
+
440
+ async function initClient() {
441
+ client = await Client.connect(window.location.origin);
442
+ console.log("OmniShotCut Engine Online");
443
+ loadExamples();
444
+ }
445
+ initClient();
446
+
447
+ dropZone.onclick = () => videoInput.click();
448
+ videoInput.onchange = (e) => handleFile(e.target.files[0]);
449
+
450
+ function handleFile(file) {
451
+ if (!file) return;
452
+ selectedFile = file;
453
+ mainVideo.src = URL.createObjectURL(file);
454
+ mainVideo.style.display = 'block';
455
+ emptyPreview.style.display = 'none';
456
+ runBtn.disabled = false;
457
+ }
458
+
459
+ runBtn.onclick = async () => {
460
+ if (!selectedFile || !client) return;
461
+
462
+ loader.style.display = 'flex';
463
+
464
+ try {
465
+ const result = await client.predict("/run_inference", {
466
+ video_file: handle_file(selectedFile)
467
+ });
468
+ renderResults(result.data[0]);
469
+ } catch (err) {
470
+ console.error(err);
471
+ alert("Analysis engine error. Check console.");
472
+ } finally {
473
+ loader.style.display = 'none';
474
+ }
475
+ };
476
+
477
+ function renderResults(data) {
478
+ if (data.error) {
479
+ alert(data.error);
480
+ return;
481
+ }
482
+
483
+ shotCountBadge.innerText = `${data.shot_count} SHOTS`;
484
+
485
+ // Timeline rendering
486
+ gallery.innerHTML = '';
487
+ data.gallery.forEach((file, idx) => {
488
+ const item = document.createElement('div');
489
+ item.className = 'timeline-item';
490
+ const img = document.createElement('img');
491
+ img.src = file.url;
492
+
493
+ const ts = document.createElement('div');
494
+ ts.className = 'timestamp';
495
+ ts.innerText = `SHOT_${idx.toString().padStart(2, '0')}`;
496
+
497
+ item.appendChild(img);
498
+ item.appendChild(ts);
499
+
500
+ item.onclick = () => {
501
+ modalImg.src = file.url;
502
+ modal.style.display = 'flex';
503
+ };
504
+ gallery.appendChild(item);
505
+ });
506
+
507
+ // Table rendering
508
+ tableContainer.innerHTML = data.table;
509
+ frameInfo.innerText = data.shot_count > 0 ? "Analyzed" : "--";
510
+ }
511
+
512
+ async function loadExamples() {
513
+ try {
514
+ const result = await client.predict("/get_examples", {});
515
+ const examples = result.data[0];
516
+ examplesGrid.innerHTML = '';
517
+
518
+ examples.forEach(ex => {
519
+ const node = document.createElement('div');
520
+ node.className = 'example-node';
521
+
522
+ const video = document.createElement('video');
523
+ video.src = ex.url;
524
+ video.muted = true;
525
+ video.preload = "metadata";
526
+ video.onloadedmetadata = () => video.currentTime = 0.1;
527
+
528
+ const label = document.createElement('span');
529
+ label.innerText = ex.orig_name || 'Clip';
530
+
531
+ node.appendChild(video);
532
+ node.appendChild(label);
533
+
534
+ node.onmouseenter = () => video.play();
535
+ node.onmouseleave = () => { video.pause(); video.currentTime = 0.1; };
536
+
537
+ node.onclick = async () => {
538
+ const response = await fetch(ex.url);
539
+ const blob = await response.blob();
540
+ handleFile(new File([blob], ex.orig_name || 'clip.mp4', { type: 'video/mp4' }));
541
+ };
542
+
543
+ examplesGrid.appendChild(node);
544
+ });
545
+ } catch (e) { console.error("Examples load failed"); }
546
+ }
547
+
548
+ document.querySelector('.close-modal').onclick = () => modal.style.display = 'none';
549
+ modal.onclick = (e) => { if (e.target === modal) modal.style.display = 'none'; };
550
+
551
+ </script>
552
+ </body>
553
+ </html>