import os import re from datetime import datetime import gradio as gr from huggingface_hub import HfApi, hf_hub_download # ========================================================= # CONFIG # ========================================================= REPO_ID = os.getenv("REPO_ID") REPO_TYPE = "dataset" HF_TOKEN = os.getenv("HF_TOKEN") api = HfApi(token=HF_TOKEN) if not REPO_ID: raise RuntimeError("REPO_ID environment variable is not set") if not HF_TOKEN: raise RuntimeError("HF_TOKEN environment variable is not set") LOCAL_CACHE_DIR = "/tmp/story_showcase" os.makedirs(LOCAL_CACHE_DIR, exist_ok=True) # ========================================================= # HELPERS # ========================================================= def format_ts(ts: int) -> str: try: return datetime.fromtimestamp(ts).strftime("%d %b %Y, %I:%M %p") except Exception: return "Unknown date" def natural_story_title(folder_name: str) -> str: m = re.match(r"job_(\d+)_(.+)$", folder_name) if not m: return folder_name.replace("_", " ") return m.group(2).replace("_", " ").strip() def extract_job_ts(folder_name: str) -> int: m = re.match(r"job_(\d+)_", folder_name) return int(m.group(1)) if m else 0 def list_repo_files(): files = [] tree = api.list_repo_tree( repo_id=REPO_ID, repo_type=REPO_TYPE, recursive=True, expand=True, ) for item in tree: path = getattr(item, "path", None) if path and not path.endswith("/"): files.append(path) return files def collect_stories(): repo_files = list_repo_files() stories = {} for path in repo_files: parts = path.split("/") if len(parts) < 2: continue folder = parts[0] if not folder.startswith("job_"): continue if folder not in stories: stories[folder] = { "folder": folder, "title": natural_story_title(folder), "timestamp": extract_job_ts(folder), "markdown_path": None, "video_path": None, "csv_path": None, "image_paths": [], } lower = path.lower() if lower.endswith(".md"): stories[folder]["markdown_path"] = path elif lower.endswith("full_video.mp4"): stories[folder]["video_path"] = path elif lower.endswith("dataset.csv"): stories[folder]["csv_path"] = path elif "/images/" in lower and lower.endswith((".png", ".jpg", ".jpeg", ".webp")): stories[folder]["image_paths"].append(path) for story in stories.values(): story["image_paths"] = sorted( story["image_paths"], key=lambda p: [ int(x) if x.isdigit() else x.lower() for x in re.split(r"(\d+)", p) ] ) return sorted(stories.values(), key=lambda x: x["timestamp"], reverse=True) def download_repo_file(path: str): if not path: return None try: return hf_hub_download( repo_id=REPO_ID, filename=path, repo_type=REPO_TYPE, token=HF_TOKEN, local_dir=LOCAL_CACHE_DIR, ) except Exception as e: print(f"[DOWNLOAD ERROR] {path}: {e}") return None def download_markdown(path: str) -> str: if not path: return "_No story markdown found._" try: local_path = download_repo_file(path) if not local_path: return "_Failed to load markdown._" with open(local_path, "r", encoding="utf-8") as f: return f.read() except Exception as e: return f"_Failed to load markdown: {e}_" # ========================================================= # BUILD UI DATA # ========================================================= def build_story_outputs(): stories = collect_stories() if not stories: return [ gr.update(value="

No stories found

Could not find any job_* folders in the repo.

") ] hero_html = f"""
OhamLab Story Showcase

OhamLab Enabled

Latest generated stories, images, and videos from the dataset repo.

{len(stories)}
Stories
""" outputs = [gr.update(value=hero_html)] max_cards = 20 for idx in range(max_cards): if idx < len(stories): story = stories[idx] story_md = download_markdown(story["markdown_path"]) video_path = download_repo_file(story["video_path"]) if story["video_path"] else None image_paths = [download_repo_file(p) for p in story["image_paths"]] image_paths = [p for p in image_paths if p] title_md = f"## {story['title']}" sub_md = story["folder"] meta_html = ( f"{format_ts(story['timestamp'])}" f"{len(story['image_paths'])} images" f"{'Theme' if story['csv_path'] else 'No Theme'}" ) outputs.extend([ gr.update(visible=True), # card container gr.update(value=title_md), # title gr.update(value=sub_md), # subtitle gr.update(value=meta_html), # meta gr.update(value=video_path, visible=bool(video_path)), # video gr.update(value=story_md), # story markdown gr.update(value=image_paths, visible=bool(image_paths)), # gallery ]) else: outputs.extend([ gr.update(visible=False), gr.update(value=""), gr.update(value=""), gr.update(value=""), gr.update(value=None, visible=False), gr.update(value=""), gr.update(value=[], visible=False), ]) return outputs # ========================================================= # PAGINATION CONFIG # ========================================================= # ========================================================= # PAGINATION CONFIG # ========================================================= STORIES_PER_PAGE = 20 # ========================================================= # CSS # ========================================================= CUSTOM_CSS = """ :root { --bg: #f5f7fb; --panel: #ffffff; --panel-2: #f9fafc; --border: rgba(0,0,0,0.08); --text: #1a1f36; --muted: #6b7280; --accent: #2563eb; --accent-2: #0ea5e9; --shadow: 0 8px 24px rgba(0,0,0,0.06); } html, body { margin: 0 !important; padding: 0 !important; width: 100% !important; overflow-x: hidden !important; } .gradio-container { max-width: 100% !important; width: 100% !important; margin: 0 !important; padding-left: 16px !important; padding-right: 16px !important; } .gradio-container > .main, .gradio-container .main { max-width: 100% !important; width: 100% !important; margin: 0 !important; padding: 0 !important; } body, .gradio-container { background: radial-gradient(circle at top left, rgba(124,156,255,0.16), transparent 25%), radial-gradient(circle at top right, rgba(144,224,239,0.10), transparent 20%), linear-gradient(180deg, #0a0f1d, #0e1422 45%, #0b1020); } .page-wrap { width: 100%; max-width: 100%; padding: 6px 4px 30px 4px; box-sizing: border-box; } .hero-shell { display: grid; grid-template-columns: 1fr auto; gap: 14px; align-items: stretch; width: 100%; margin-bottom: 20px; } .hero { display: flex; align-items: center; justify-content: space-between; gap: 24px; background: white; border: 1px solid var(--border); border-radius: 24px; padding: 28px; box-shadow: var(--shadow); width: 100%; box-sizing: border-box; } .eyebrow { color: var(--accent-2); font-size: 12px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 8px; } .hero h1 { margin: 0; color: var(--text); font-size: 36px; line-height: 1.1; } .hero p { margin: 10px 0 0 0; color: var(--muted); font-size: 16px; } .hero-actions { display: flex; align-items: center; gap: 14px; } .hero-stat { min-width: 170px; text-align: center; background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 20px; padding: 18px; } .stat-num { color: var(--text); font-size: 34px; font-weight: 800; } .stat-label { color: var(--muted); font-size: 13px; } .hero-refresh, .hero-refresh button, .hero-refresh > button { min-width: 60px !important; width: 60px !important; height: 60px !important; border-radius: 20px !important; padding: 0 !important; font-size: 24px !important; } .stories-list { display: flex; flex-direction: column; gap: 22px; width: 100%; } .story-card { background: white; border: 1px solid var(--border); border-radius: 24px; padding: 22px; box-shadow: var(--shadow); backdrop-filter: blur(8px); margin-bottom: 22px; width: 100%; box-sizing: border-box; } .story-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; margin-bottom: 18px; } .story-sub .prose, .story-sub p { margin: 0 !important; color: var(--muted) !important; font-size: 13px !important; word-break: break-word; } .story-meta { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; } .meta-pill { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; color: var(--text); background: rgba(124,156,255,0.12); border: 1px solid rgba(124,156,255,0.25); border-radius: 999px; padding: 8px 12px; font-size: 12px; font-weight: 600; margin-right: 8px; margin-bottom: 8px; } .story-content { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 20px; } .story-text, .story-visuals { background: rgba(255,255,255,0.025); border: 1px solid var(--border); border-radius: 20px; padding: 18px; } .story-text h3, .story-visuals h3 { margin: 0 0 14px 0; color: var(--text); font-size: 18px; } .story-markdown .prose, .story-markdown .prose p, .story-markdown .prose li, .story-markdown .prose h1, .story-markdown .prose h2, .story-markdown .prose h3, .story-markdown .prose h4, .story-markdown .prose h5, .story-markdown .prose h6, .story-markdown .prose blockquote { color: var(--text) !important; } .story-video-wrap video { width: 100% !important; max-height: 580px !important; border-radius: 18px !important; background: #000 !important; border: 1px solid var(--border) !important; } .story-gallery { min-height: 80px; } .story-gallery .grid-wrap, .story-gallery .grid-container, .story-gallery .gallery { border-radius: 16px !important; } .pagination-wrap { display: flex; justify-content: center; align-items: center; gap: 12px; margin: 10px 0 40px 0; flex-wrap: wrap; width: 100%; } .page-indicator { background: white; border: 1px solid var(--border); border-radius: 16px; padding: 10px 16px; box-shadow: var(--shadow); color: var(--text); font-weight: 600; } .page-btn button, .page-btn > button { border-radius: 16px !important; min-width: 120px !important; height: 44px !important; } .empty-state { background: rgba(255,255,255,0.025); border: 1px dashed var(--border); border-radius: 18px; padding: 22px; color: var(--muted); } @media (max-width: 1100px) { .hero-shell { grid-template-columns: 1fr; } .story-content { grid-template-columns: 1fr; } .story-header { flex-direction: column; } .story-meta { justify-content: flex-start; } .hero { flex-direction: column; align-items: flex-start; } .hero-refresh, .hero-refresh button, .hero-refresh > button { width: 100% !important; } } """ # ========================================================= # OUTPUT BUILDER # ========================================================= def build_story_outputs(page=1): stories = collect_stories() total_stories = len(stories) total_pages = max(1, (total_stories + STORIES_PER_PAGE - 1) // STORIES_PER_PAGE) try: page = int(page) except Exception: page = 1 page = max(1, min(page, total_pages)) start_idx = (page - 1) * STORIES_PER_PAGE end_idx = start_idx + STORIES_PER_PAGE page_stories = stories[start_idx:end_idx] if not stories: hero_html = """
OhamLab Story Showcase

OhamLab Enabled

No stories found in the dataset repo.

0
Stories Rendered
""" outputs = [ gr.update(value=hero_html), gr.update(value="Page 1 of 1"), gr.update(interactive=False), gr.update(interactive=False), ] for _ in range(STORIES_PER_PAGE): outputs.extend([ gr.update(visible=False), gr.update(value=""), gr.update(value=""), gr.update(value=""), gr.update(value=None, visible=False), gr.update(value=""), gr.update(value=[], visible=False), ]) return outputs hero_html = f"""
OhamLab Story Showcase

OhamLab Enabled

Latest generated stories, images, and videos from the dataset repo.

{total_stories}
Stories Rendered
""" outputs = [ gr.update(value=hero_html), # Bottom pagination only gr.update(value=f"Page {page} of {total_pages}"), gr.update(interactive=page > 1), gr.update(interactive=page < total_pages), ] for idx in range(STORIES_PER_PAGE): if idx < len(page_stories): story = page_stories[idx] story_md = download_markdown(story["markdown_path"]) video_path = download_repo_file(story["video_path"]) if story["video_path"] else None image_paths = [download_repo_file(p) for p in story["image_paths"]] image_paths = [p for p in image_paths if p] title_md = f"## {story['title']}" sub_md = story["folder"] meta_html = ( f"{format_ts(story['timestamp'])}" f"{len(story['image_paths'])} images" f"{'Theme' if story['csv_path'] else 'No Theme'}" ) outputs.extend([ gr.update(visible=True), gr.update(value=title_md), gr.update(value=sub_md), gr.update(value=meta_html), gr.update(value=video_path, visible=bool(video_path)), gr.update(value=story_md), gr.update(value=image_paths, visible=bool(image_paths)), ]) else: outputs.extend([ gr.update(visible=False), gr.update(value=""), gr.update(value=""), gr.update(value=""), gr.update(value=None, visible=False), gr.update(value=""), gr.update(value=[], visible=False), ]) return outputs def load_first_page(): return [gr.update(value=1)] + build_story_outputs(1) def refresh_to_first_page(): return [gr.update(value=1)] + build_story_outputs(1) def go_prev_page(page): try: page = int(page) except Exception: page = 1 new_page = max(1, page - 1) return [gr.update(value=new_page)] + build_story_outputs(new_page) def go_next_page(page): stories = collect_stories() total_pages = max(1, (len(stories) + STORIES_PER_PAGE - 1) // STORIES_PER_PAGE) try: page = int(page) except Exception: page = 1 new_page = min(total_pages, page + 1) return [gr.update(value=new_page)] + build_story_outputs(new_page) # ========================================================= # UI # ========================================================= with gr.Blocks( title="Story Factory Showcase", theme=gr.themes.Soft(), css=CUSTOM_CSS, ) as demo: page_state = gr.State(1) with gr.Column(elem_classes=["page-wrap"]): with gr.Row(elem_classes=["hero-shell"]): hero_html = gr.HTML() refresh_btn = gr.Button("↻", variant="secondary", elem_classes=["hero-refresh"], scale=0) with gr.Column(elem_classes=["stories-list"]): card_components = [] for _ in range(STORIES_PER_PAGE): with gr.Column(visible=False, elem_classes=["story-card"]) as card: with gr.Row(elem_classes=["story-header"]): with gr.Column(scale=6): title_md = gr.Markdown() sub_md = gr.Markdown(elem_classes=["story-sub"]) with gr.Column(scale=4): meta_html = gr.HTML(elem_classes=["story-meta"]) video_comp = gr.Video(label=None, visible=False, elem_classes=["story-video-wrap"]) with gr.Row(elem_classes=["story-content"]): with gr.Column(elem_classes=["story-text"]): gr.Markdown("### Story") story_md = gr.Markdown(elem_classes=["story-markdown"]) with gr.Column(elem_classes=["story-visuals"]): gr.Markdown("### Images") gallery_comp = gr.Gallery( label=None, columns=4, height="auto", preview=True, visible=False, elem_classes=["story-gallery"], ) card_components.extend([ card, title_md, sub_md, meta_html, video_comp, story_md, gallery_comp, ]) # BOTTOM pagination only with gr.Row(elem_classes=["pagination-wrap"]): prev_btn_bottom = gr.Button("← Previous", variant="secondary", elem_classes=["page-btn"], scale=0) page_info_bottom = gr.Markdown("Page 1 of 1", elem_classes=["page-indicator"]) next_btn_bottom = gr.Button("Next →", variant="secondary", elem_classes=["page-btn"], scale=0) all_outputs = [ hero_html, page_info_bottom, prev_btn_bottom, next_btn_bottom, ] + card_components demo.load( fn=load_first_page, inputs=[], outputs=[page_state] + all_outputs, ) refresh_btn.click( fn=refresh_to_first_page, inputs=[], outputs=[page_state] + all_outputs, ) prev_btn_bottom.click( fn=go_prev_page, inputs=[page_state], outputs=[page_state] + all_outputs, ) next_btn_bottom.click( fn=go_next_page, inputs=[page_state], outputs=[page_state] + all_outputs, ) demo.launch(share=True)