services2 commited on
Commit
a3c783c
·
verified ·
1 Parent(s): 72bad88

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +33 -7
  2. app.py +479 -0
  3. requirements.txt +2 -0
README.md CHANGED
@@ -1,13 +1,39 @@
1
  ---
2
- title: Presentation Viewer
3
- emoji: 🏃
4
- colorFrom: indigo
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DataHub Live Viewer
3
+ emoji: 📺
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: 4.44.1
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # DataHub Live Viewer
13
+
14
+ Public read-only viewer for DataHub segment text and uploaded media.
15
+
16
+ The app expects an AWS sync process to publish files like this into a Hugging Face Dataset or Space repo:
17
+
18
+ ```text
19
+ live/manifest.json
20
+ live/current/segment.json
21
+ live/current/input.txt
22
+ live/current/details.json
23
+ live/current/media/<uploaded images>
24
+ ```
25
+
26
+ Required environment variables in the Space settings:
27
+
28
+ ```text
29
+ DATA_REPO_ID=yourname/your-datahub-live-dataset
30
+ DATA_REPO_TYPE=dataset
31
+ DATA_PREFIX=live
32
+ ```
33
+
34
+ Optional:
35
+
36
+ ```text
37
+ AUTO_REFRESH_SECONDS=5
38
+ HF_TOKEN=<only if the data repo is private>
39
+ ```
app.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from pathlib import PurePosixPath
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ import gradio as gr
7
+ from huggingface_hub import hf_hub_download
8
+
9
+
10
+ DATA_REPO_ID = os.environ.get("DATA_REPO_ID", "").strip()
11
+ DATA_REPO_TYPE = os.environ.get("DATA_REPO_TYPE", "dataset").strip() or "dataset"
12
+ DATA_PREFIX = os.environ.get("DATA_PREFIX", "live").strip().strip("/")
13
+ DATA_REVISION = os.environ.get("DATA_REVISION", "main").strip() or "main"
14
+ DATA_TOKEN = (
15
+ os.environ.get("DATA_REPO_TOKEN")
16
+ or os.environ.get("HF_TOKEN")
17
+ or os.environ.get("HUGGINGFACE_HUB_TOKEN")
18
+ or None
19
+ )
20
+ AUTO_REFRESH_SECONDS = float(os.environ.get("AUTO_REFRESH_SECONDS", "5") or "5")
21
+
22
+ CURRENT_LABEL = "CURRENT / latest synced segment"
23
+
24
+
25
+ CSS = """
26
+ #topbar {
27
+ border-radius: 14px;
28
+ padding: 12px 16px;
29
+ background: linear-gradient(90deg, rgba(20,32,70,.95), rgba(20,70,65,.85));
30
+ color: white;
31
+ }
32
+ .big-status {
33
+ font-size: 15px;
34
+ }
35
+ .entry-text textarea {
36
+ font-size: 18px !important;
37
+ line-height: 1.35 !important;
38
+ }
39
+ .small-note {
40
+ opacity: .75;
41
+ font-size: 12px;
42
+ }
43
+ """
44
+
45
+
46
+ def repo_path(*parts: str) -> str:
47
+ clean_parts = []
48
+ if DATA_PREFIX:
49
+ clean_parts.append(DATA_PREFIX)
50
+ for part in parts:
51
+ part = str(part or "").strip().strip("/")
52
+ if part:
53
+ clean_parts.append(part)
54
+ return str(PurePosixPath(*clean_parts))
55
+
56
+
57
+ def require_config() -> None:
58
+ if not DATA_REPO_ID:
59
+ raise RuntimeError(
60
+ "Missing DATA_REPO_ID. In the Space settings, set DATA_REPO_ID to the "
61
+ "dataset repo your AWS app is syncing to, for example: krhogan2/datahub-live"
62
+ )
63
+
64
+
65
+ def download_file(path_in_repo: str) -> str:
66
+ require_config()
67
+ return hf_hub_download(
68
+ repo_id=DATA_REPO_ID,
69
+ repo_type=DATA_REPO_TYPE,
70
+ filename=path_in_repo,
71
+ revision=DATA_REVISION,
72
+ token=DATA_TOKEN,
73
+ )
74
+
75
+
76
+ def download_json(path_in_repo: str) -> Dict[str, Any]:
77
+ local_path = download_file(path_in_repo)
78
+ with open(local_path, "r", encoding="utf-8") as fh:
79
+ data = json.load(fh)
80
+ if not isinstance(data, dict):
81
+ raise ValueError(f"{path_in_repo} did not contain a JSON object")
82
+ return data
83
+
84
+
85
+ def load_manifest() -> Dict[str, Any]:
86
+ try:
87
+ return download_json(repo_path("manifest.json"))
88
+ except Exception:
89
+ return {
90
+ "updatedUtc": "",
91
+ "latestSegment": "",
92
+ "currentSegmentJson": "current/segment.json",
93
+ "folders": [],
94
+ }
95
+
96
+
97
+ def folder_choices_from_manifest(manifest: Dict[str, Any]) -> List[str]:
98
+ folders = manifest.get("folders", [])
99
+ if not isinstance(folders, list):
100
+ folders = []
101
+
102
+ choices = [CURRENT_LABEL]
103
+ for folder in folders:
104
+ folder = str(folder or "").strip()
105
+ if folder and folder not in choices:
106
+ choices.append(folder)
107
+
108
+ return choices
109
+
110
+
111
+ def load_segment(folder_choice: Optional[str]) -> Tuple[Dict[str, Any], str, str]:
112
+ manifest = load_manifest()
113
+ latest = str(manifest.get("latestSegment") or "").strip()
114
+
115
+ if not folder_choice or folder_choice == CURRENT_LABEL:
116
+ segment_path = repo_path("current", "segment.json")
117
+ segment = download_json(segment_path)
118
+ segment["_hf_segment_path"] = segment_path
119
+ segment["_hf_segment_base"] = str(PurePosixPath(segment_path).parent)
120
+ return segment, CURRENT_LABEL, ""
121
+
122
+ folder_choice = str(folder_choice).strip()
123
+
124
+ # This supports a future enhanced sync format:
125
+ # live/segments/<folder name>/segment.json
126
+ archive_path = repo_path("segments", folder_choice, "segment.json")
127
+ try:
128
+ segment = download_json(archive_path)
129
+ segment["_hf_segment_path"] = archive_path
130
+ segment["_hf_segment_base"] = str(PurePosixPath(archive_path).parent)
131
+ return segment, folder_choice, ""
132
+ except Exception:
133
+ pass
134
+
135
+ # With the AWS patch we made, only live/current is pushed.
136
+ # So if the selected folder is the latest folder, use current.
137
+ if latest and folder_choice == latest:
138
+ segment_path = repo_path("current", "segment.json")
139
+ segment = download_json(segment_path)
140
+ segment["_hf_segment_path"] = segment_path
141
+ segment["_hf_segment_base"] = str(PurePosixPath(segment_path).parent)
142
+ return segment, folder_choice, ""
143
+
144
+ raise FileNotFoundError(
145
+ f"'{folder_choice}' is listed in the manifest, but its full segment snapshot "
146
+ "is not synced yet. The current AWS patch uploads the latest segment to "
147
+ "live/current/. To browse old folders, add a later AWS sync pass that writes "
148
+ "live/segments/<folder>/segment.json and live/segments/<folder>/media/."
149
+ )
150
+
151
+
152
+ def entries_from_segment(segment: Dict[str, Any]) -> List[Dict[str, Any]]:
153
+ entries = segment.get("entries", [])
154
+ if not isinstance(entries, list):
155
+ return []
156
+ return [entry for entry in entries if isinstance(entry, dict)]
157
+
158
+
159
+ def entry_label(entry: Dict[str, Any]) -> str:
160
+ row = entry.get("row", "")
161
+ name = str(entry.get("name") or "").strip()
162
+ entry_id = str(entry.get("entryId") or "").strip()
163
+
164
+ text = str(entry.get("onScreenText") or "").strip().replace("\n", " ")
165
+ if len(text) > 70:
166
+ text = text[:67] + "..."
167
+
168
+ pieces = [f"Row {row}"]
169
+ if name:
170
+ pieces.append(name)
171
+ if text:
172
+ pieces.append(text)
173
+ if entry_id:
174
+ pieces.append(entry_id[:8])
175
+
176
+ return " | ".join(pieces)
177
+
178
+
179
+ def table_rows(segment: Dict[str, Any]) -> List[List[Any]]:
180
+ rows = []
181
+ for entry in entries_from_segment(segment):
182
+ images = entry.get("images", [])
183
+ if not isinstance(images, list):
184
+ images = []
185
+
186
+ rows.append([
187
+ entry.get("row", ""),
188
+ entry.get("type", ""),
189
+ entry.get("name", ""),
190
+ entry.get("chart", ""),
191
+ entry.get("textStatus", ""),
192
+ len(images),
193
+ entry.get("onScreenText", ""),
194
+ ])
195
+ return rows
196
+
197
+
198
+ def image_paths_for_entry(segment: Dict[str, Any], entry: Dict[str, Any]) -> List[str]:
199
+ base = str(segment.get("_hf_segment_base") or "").strip()
200
+ image_paths = entry.get("imagePaths", [])
201
+
202
+ if not isinstance(image_paths, list) or not image_paths:
203
+ images = entry.get("images", [])
204
+ if isinstance(images, list):
205
+ image_paths = [f"media/{name}" for name in images]
206
+ else:
207
+ image_paths = []
208
+
209
+ local_images = []
210
+ for image_path in image_paths:
211
+ image_path = str(image_path or "").strip().strip("/")
212
+ if not image_path:
213
+ continue
214
+
215
+ full_repo_path = str(PurePosixPath(base) / image_path) if base else repo_path(image_path)
216
+
217
+ try:
218
+ local_images.append(download_file(full_repo_path))
219
+ except Exception:
220
+ continue
221
+
222
+ return local_images
223
+
224
+
225
+ def first_entry_outputs(segment: Dict[str, Any]) -> Tuple[Any, str, List[str]]:
226
+ entries = entries_from_segment(segment)
227
+ if not entries:
228
+ return gr.update(choices=[], value=None), "", []
229
+
230
+ labels = [entry_label(entry) for entry in entries]
231
+ first = entries[0]
232
+ return (
233
+ gr.update(choices=labels, value=labels[0]),
234
+ str(first.get("onScreenText") or ""),
235
+ image_paths_for_entry(segment, first),
236
+ )
237
+
238
+
239
+ def status_markdown(
240
+ segment: Optional[Dict[str, Any]],
241
+ folder_choice: str,
242
+ warning: str = "",
243
+ ) -> str:
244
+ if not segment:
245
+ return (
246
+ "### Not connected yet\n"
247
+ "Set `DATA_REPO_ID` in the Space settings to the dataset repo receiving the AWS sync."
248
+ )
249
+
250
+ folder = segment.get("folder") or folder_choice or "Unknown"
251
+ updated = segment.get("updatedUtc") or "Unknown"
252
+ count = segment.get("entryCount", len(entries_from_segment(segment)))
253
+ repo = DATA_REPO_ID or "DATA_REPO_ID not set"
254
+ path = segment.get("_hf_segment_path", "")
255
+
256
+ warning_text = f"\n\n⚠️ {warning}" if warning else ""
257
+
258
+ return (
259
+ f"### {folder}\n"
260
+ f"**Updated:** {updated} \n"
261
+ f"**Entries:** {count} \n"
262
+ f"**Source repo:** `{repo}` \n"
263
+ f"**Source file:** `{path}`"
264
+ f"{warning_text}"
265
+ )
266
+
267
+
268
+ def error_outputs(message: str):
269
+ return (
270
+ f"### Error\n{message}",
271
+ gr.update(choices=[CURRENT_LABEL], value=CURRENT_LABEL),
272
+ {},
273
+ [],
274
+ gr.update(choices=[], value=None),
275
+ "",
276
+ [],
277
+ json.dumps({"error": message}, indent=2),
278
+ )
279
+
280
+
281
+ def strip_internal_keys(segment: Dict[str, Any]) -> Dict[str, Any]:
282
+ clean = {}
283
+ for key, value in segment.items():
284
+ if not str(key).startswith("_hf_"):
285
+ clean[key] = value
286
+ return clean
287
+
288
+
289
+ def refresh_ui(folder_choice: Optional[str]):
290
+ try:
291
+ manifest = load_manifest()
292
+ choices = folder_choices_from_manifest(manifest)
293
+
294
+ if not folder_choice or folder_choice not in choices:
295
+ folder_choice = CURRENT_LABEL
296
+
297
+ segment, resolved_folder, warning = load_segment(folder_choice)
298
+ row_dropdown, first_text, first_images = first_entry_outputs(segment)
299
+
300
+ return (
301
+ status_markdown(segment, resolved_folder, warning),
302
+ gr.update(choices=choices, value=folder_choice),
303
+ segment,
304
+ table_rows(segment),
305
+ row_dropdown,
306
+ first_text,
307
+ first_images,
308
+ json.dumps(strip_internal_keys(segment), indent=2),
309
+ )
310
+ except Exception as e:
311
+ return error_outputs(str(e))
312
+
313
+
314
+ def load_folder_ui(folder_choice: Optional[str]):
315
+ return refresh_ui(folder_choice)
316
+
317
+
318
+ def show_selected_entry(segment: Dict[str, Any], selected_label: Optional[str]):
319
+ if not segment or not selected_label:
320
+ return "", []
321
+
322
+ for entry in entries_from_segment(segment):
323
+ if entry_label(entry) == selected_label:
324
+ return str(entry.get("onScreenText") or ""), image_paths_for_entry(segment, entry)
325
+
326
+ return "", []
327
+
328
+
329
+ def build_app() -> gr.Blocks:
330
+ with gr.Blocks(title="DataHub Live Viewer", css=CSS) as demo:
331
+ segment_state = gr.State({})
332
+
333
+ gr.HTML(
334
+ """
335
+ <div id="topbar">
336
+ <h1 style="margin:0;">📺 DataHub Live Viewer</h1>
337
+ <div class="big-status">
338
+ Read-only public viewer for on-screen text and Pictures tab uploads.
339
+ </div>
340
+ </div>
341
+ """
342
+ )
343
+
344
+ with gr.Row():
345
+ with gr.Column(scale=1):
346
+ folder_dropdown = gr.Dropdown(
347
+ choices=[CURRENT_LABEL],
348
+ value=CURRENT_LABEL,
349
+ label="DataHub segment folder",
350
+ interactive=True,
351
+ )
352
+ refresh_btn = gr.Button("Refresh now", variant="primary")
353
+ status = gr.Markdown()
354
+
355
+ gr.Markdown(
356
+ """
357
+ <div class="small-note">
358
+ Tip: with the current AWS sync patch, the latest segment appears under
359
+ <code>CURRENT / latest synced segment</code>. Older folders will become clickable
360
+ once the AWS side also syncs <code>live/segments/&lt;folder&gt;/...</code>.
361
+ </div>
362
+ """
363
+ )
364
+
365
+ with gr.Column(scale=2):
366
+ entries_table = gr.Dataframe(
367
+ headers=[
368
+ "Row",
369
+ "Type",
370
+ "Name",
371
+ "Chart",
372
+ "Text Status",
373
+ "Images",
374
+ "On-Screen Text",
375
+ ],
376
+ datatype=["number", "str", "str", "str", "str", "number", "str"],
377
+ interactive=False,
378
+ wrap=True,
379
+ label="All on-screen text",
380
+ )
381
+
382
+ with gr.Row():
383
+ with gr.Column(scale=1):
384
+ row_dropdown = gr.Dropdown(
385
+ choices=[],
386
+ label="Pick row / Pictures tab item",
387
+ interactive=True,
388
+ )
389
+ selected_text = gr.Textbox(
390
+ label="Full on-screen text for selected row",
391
+ lines=12,
392
+ elem_classes=["entry-text"],
393
+ )
394
+
395
+ with gr.Column(scale=1):
396
+ gallery = gr.Gallery(
397
+ label="Uploaded pictures for selected row",
398
+ columns=2,
399
+ height=420,
400
+ show_download_button=True,
401
+ object_fit="contain",
402
+ )
403
+
404
+ with gr.Accordion("Raw current segment.json", open=False):
405
+ raw_json = gr.Code(language="json", label="Raw JSON")
406
+
407
+ demo.load(
408
+ fn=refresh_ui,
409
+ inputs=[folder_dropdown],
410
+ outputs=[
411
+ status,
412
+ folder_dropdown,
413
+ segment_state,
414
+ entries_table,
415
+ row_dropdown,
416
+ selected_text,
417
+ gallery,
418
+ raw_json,
419
+ ],
420
+ )
421
+
422
+ refresh_btn.click(
423
+ fn=refresh_ui,
424
+ inputs=[folder_dropdown],
425
+ outputs=[
426
+ status,
427
+ folder_dropdown,
428
+ segment_state,
429
+ entries_table,
430
+ row_dropdown,
431
+ selected_text,
432
+ gallery,
433
+ raw_json,
434
+ ],
435
+ )
436
+
437
+ folder_dropdown.change(
438
+ fn=load_folder_ui,
439
+ inputs=[folder_dropdown],
440
+ outputs=[
441
+ status,
442
+ folder_dropdown,
443
+ segment_state,
444
+ entries_table,
445
+ row_dropdown,
446
+ selected_text,
447
+ gallery,
448
+ raw_json,
449
+ ],
450
+ )
451
+
452
+ row_dropdown.change(
453
+ fn=show_selected_entry,
454
+ inputs=[segment_state, row_dropdown],
455
+ outputs=[selected_text, gallery],
456
+ )
457
+
458
+ if AUTO_REFRESH_SECONDS > 0:
459
+ timer = gr.Timer(value=AUTO_REFRESH_SECONDS)
460
+ timer.tick(
461
+ fn=refresh_ui,
462
+ inputs=[folder_dropdown],
463
+ outputs=[
464
+ status,
465
+ folder_dropdown,
466
+ segment_state,
467
+ entries_table,
468
+ row_dropdown,
469
+ selected_text,
470
+ gallery,
471
+ raw_json,
472
+ ],
473
+ )
474
+
475
+ return demo
476
+
477
+
478
+ if __name__ == "__main__":
479
+ build_app().launch()
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio>=4.44.0
2
+ huggingface_hub>=0.23.0