File size: 8,658 Bytes
dfd58db
 
 
 
 
 
7008665
 
dfd58db
 
 
 
7008665
 
dfd58db
 
ed2b0a4
dfd58db
 
ed2b0a4
 
 
dfd58db
 
7008665
dfd58db
 
7008665
 
dfd58db
 
 
 
7008665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfd58db
 
 
 
 
 
 
 
 
ed2b0a4
28b36fa
ed2b0a4
 
28b36fa
ed2b0a4
 
 
28b36fa
ed2b0a4
 
28b36fa
ed2b0a4
 
 
 
 
 
dfd58db
 
ed2b0a4
dfd58db
 
 
ed2b0a4
dfd58db
 
 
 
 
ed2b0a4
dfd58db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7008665
dfd58db
7008665
dfd58db
7008665
dfd58db
 
 
1971f1d
dfd58db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0157e15
 
 
 
 
 
dfd58db
 
 
 
 
 
 
 
 
ed2b0a4
 
 
 
 
dfd58db
 
 
 
7008665
dfd58db
 
 
ed2b0a4
dfd58db
 
ed2b0a4
 
 
 
 
 
 
 
 
 
 
 
 
 
11e61ce
 
 
 
ed2b0a4
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
"""ICML 2026 Open Reproductions β€” Certificate Generator
=======================================================
1. Sign in with Hugging Face (OAuth).
2. Eligibility is checked against the frozen challenge verdicts: every
   participant with at least one claim judged `verified` qualifies
   (baked into eligible.json at challenge close β€” 267 participants).
3. The display name is editable; the certificate renders to PNG with headless
   Chromium (installed via packages.txt) β€” no external renderer dependency.
"""

import json
import os
import shutil
import subprocess
import tempfile
import urllib.parse
from html import escape

import gradio as gr
import uvicorn
from fastapi import FastAPI
from fastapi.responses import RedirectResponse

DISCUSSIONS_URL = "https://huggingface.co/spaces/ICML-2026-agent-repro/challenge/discussions"
CANVAS = (2000, 1414)  # must match the body size in certificate_template.html

_HERE = os.path.dirname(__file__)
with open(os.path.join(_HERE, "certificate_template.html"), encoding="utf-8") as f:
    TEMPLATE = f.read()
with open(os.path.join(_HERE, "eligible.json"), encoding="utf-8") as f:
    ELIGIBLE = json.load(f)


def find_chromium():
    candidates = [
        "chromium", "chromium-browser", "google-chrome", "google-chrome-stable",
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",  # local dev
    ]
    for c in candidates:
        path = shutil.which(c) or (c if os.path.exists(c) else None)
        if path:
            return path
    raise RuntimeError("No Chromium binary found β€” is `chromium` in packages.txt?")


def render_html(html: str) -> str:
    out = os.path.join(tempfile.mkdtemp(), "certificate.png")
    with tempfile.NamedTemporaryFile("w", delete=False, suffix=".html", encoding="utf-8") as f:
        f.write(html)
        src = f.name
    try:
        subprocess.run(
            [find_chromium(), "--headless=new", "--no-sandbox", "--disable-gpu",
             "--hide-scrollbars", "--force-device-scale-factor=1",
             f"--window-size={CANVAS[0]},{CANVAS[1]}", f"--screenshot={out}",
             f"file://{src}"],
            check=True, capture_output=True, timeout=120,
        )
    finally:
        try:
            os.unlink(src)
        except OSError:
            pass
    return out


def plural(n, word):
    return f"{n} {word}" + ("" if n == 1 else "s")


def stats_line(rec):
    return (f"{plural(rec['verified_claims'], 'claim')} verified across "
            f"{plural(rec['verified_papers'], 'paper')}")


def auth_control(profile: gr.OAuthProfile | None):
    """Render OAuth controls that work inside the sandboxed Space iframe."""
    if profile is None:
        href = "/oauth-login"
        target = "_blank"
        label = "πŸ€— Sign in with Hugging Face"
    else:
        href = "/logout?_target_url=/"
        target = "_self"
        label = f"Logout (@{escape(profile.username)})"
    return (
        f'<a href="{href}" target="{target}" rel="noopener" role="button" '
        'style="display:inline-block;background:#111827;color:#fff;font-weight:700;'
        'padding:10px 18px;border-radius:8px;text-decoration:none">'
        f"{label}</a>"
    )


def on_load(profile: gr.OAuthProfile | None):
    if profile is None:
        return (auth_control(profile), gr.update(visible=False),
                "### πŸ‘‹ Sign in with Hugging Face to check your eligibility.", "")
    rec = ELIGIBLE.get(profile.username.lower())
    if rec is None:
        return (auth_control(profile), gr.update(visible=False),
                f"### Sorry @{profile.username} β€” no verified logbook found.\n"
                "Certificates go to participants with at least one claim judged "
                "**verified** by the Logbook Judge before the Aug 2 deadline. "
                f"If you believe this is an error, please open a thread in the "
                f"[challenge discussions]({DISCUSSIONS_URL}).", "")
    return (auth_control(profile), gr.update(visible=True),
            f"### πŸŽ‰ Congratulations @{profile.username} β€” you qualify!\n"
            f"Your record: **{stats_line(rec)}** ({plural(rec['logbooks'], 'logbook')} judged). "
            "Adjust how your name should appear, then generate your certificate.",
            profile.name or profile.username)


def linkedin_url(name):
    p = {
        "startTask": "CERTIFICATION_NAME",
        "name": "ICML 2026 Open Reproductions β€” Certificate of Participation",
        "organizationName": "Hugging Face",
        "issueYear": "2026", "issueMonth": "8",
        "certUrl": "https://huggingface.co/spaces/ICML-2026-agent-repro/certificate-generator",
    }
    return "https://www.linkedin.com/profile/add?" + urllib.parse.urlencode(p)


def create_certificate(display_name, profile: gr.OAuthProfile | None):
    if profile is None:
        return None, "❌ Please sign in first.", gr.update(visible=False)
    rec = ELIGIBLE.get(profile.username.lower())
    if rec is None:
        return None, "❌ No verified logbook found for your account.", gr.update(visible=False)
    name = (display_name or "").strip() or profile.name or profile.username
    html = TEMPLATE.replace("{participant_name}", name).replace("{stats_line}", stats_line(rec))
    try:
        image_path = render_html(html)
    except Exception as e:
        return None, f"❌ Rendering failed: {e}. Please try again in a minute.", gr.update(visible=False)
    btn = (f'<a href="{linkedin_url(name)}" target="_blank" '
           f'style="display:inline-block;background:#0a66c2;color:#fff;font-weight:700;'
           f'padding:10px 22px;border-radius:8px;text-decoration:none">Add to LinkedIn profile β†’</a>')
    return image_path, "πŸŽ‰ Your certificate is ready β€” download it or add it to your LinkedIn profile below.", gr.update(value=btn, visible=True)


THEME = gr.themes.Base(
    primary_hue=gr.themes.colors.orange,
    neutral_hue=gr.themes.colors.stone,
)

CSS = """
.gradio-container {
  background-color: #fdfcf9 !important;
  background-image:
    linear-gradient(rgba(31,41,55,.045) 1px, transparent 1px),
    linear-gradient(90deg, rgba(31,41,55,.045) 1px, transparent 1px);
  background-size: 26px 26px;
}
.dark .gradio-container {
  background-color: #17181c !important;
  background-image:
    linear-gradient(rgba(253,252,249,.04) 1px, transparent 1px),
    linear-gradient(90deg, rgba(253,252,249,.04) 1px, transparent 1px);
}
#hero h1 { font-family: ui-serif, "Iowan Old Style", Georgia, serif; }
"""

with gr.Blocks(title="ICML 2026 Open Reproductions β€” Certificates", theme=THEME, css=CSS) as demo:
    gr.Markdown("# πŸŽ“ ICML 2026 Open Reproductions β€” Certificate of Participation", elem_id="hero")
    gr.Markdown(
        "Every participant with **at least one claim judged `verified`** in the challenge "
        "(July 15 – August 2, 2026) can generate their certificate here."
    )
    # This hidden component enables Gradio's OAuth routes. The visible control above
    # uses a top-level navigation so browsers do not block the OAuth session cookie
    # as a third-party cookie inside the Hugging Face Space iframe.
    gr.LoginButton(visible=False)
    auth = gr.HTML()
    status = gr.Markdown()
    with gr.Column(visible=False) as main_box:
        name_box = gr.Textbox(label="Name on the certificate")
        gen_btn = gr.Button("Generate my certificate πŸŽ“", variant="primary")
        cert_image = gr.Image(label="Your certificate", type="filepath", interactive=False)
        note = gr.Markdown()
        linkedin = gr.HTML(visible=False)

    demo.load(on_load, inputs=None, outputs=[auth, main_box, status, name_box])
    gen_btn.click(create_certificate, inputs=[name_box], outputs=[cert_image, note, linkedin])


app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)


@app.get("/oauth-login", include_in_schema=False)
def oauth_login():
    """Start OAuth after removing session state left by an interrupted login."""
    response = RedirectResponse("/login/huggingface?_target_url=/", status_code=303)
    response.delete_cookie(
        "session", path="/", secure=True, httponly=True, samesite="none"
    )
    return response


# Gradio 5.49.1's Spaces SSR proxy corrupts frontend asset URLs when a Blocks
# app is mounted at the root path. OAuth still needs the explicit FastAPI mount,
# so disable SSR here until the upstream root-mount routing bug is fixed.
app = gr.mount_gradio_app(app, demo, path="/", ssr_mode=False)


if __name__ == "__main__":
    uvicorn.run(
        app,
        host=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
        port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
    )