File size: 13,045 Bytes
83e2f75
1d8e163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f163d41
 
 
 
1d8e163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91c3d77
 
 
 
 
 
 
 
 
1d8e163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83e2f75
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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MOZAIC T4 Review</title>
<style>
  :root { --bg: #f7f7f8; --card: #fff; --text: #1d1d1f; --muted: #6b6b70; --line: #e2e2e6;
          --accent: #2f6fde; --ok: #1e8e3e; --warn: #c26b00; --bad: #c5221f; }
  @media (prefers-color-scheme: dark) {
    :root { --bg: #151517; --card: #1f1f22; --text: #ececf0; --muted: #a0a0a8; --line: #33333a;
            --accent: #6d9cff; --ok: #5bc77a; --warn: #f0a640; --bad: #ff6b66; }
  }
  * { box-sizing: border-box; }
  body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, sans-serif; }
  main { max-width: 1200px; margin: 0 auto; padding: 16px; }
  h1 { font-size: 20px; margin: 0 0 12px; }
  .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
  @media (max-width: 760px) { .row { grid-template-columns: 1fr; } }
  video { width: 100%; max-height: 360px; background: #000; border-radius: 8px; }
  .label { font-size: 13px; color: var(--muted); margin-bottom: 6px; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 14px; }
  .pill { padding: 2px 10px; border-radius: 99px; border: 1px solid var(--line); font-size: 13px; }
  .done { color: var(--ok); border-color: var(--ok); }
  h3 { margin: 0 0 6px; font-size: 16px; }
  .fake { color: var(--bad); font-weight: 600; } .real { color: var(--ok); font-weight: 600; }
  ul { margin: 4px 0 10px; padding-left: 18px; }
  .choices { display: flex; gap: 8px; flex-wrap: wrap; }
  .choices label { border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; cursor: pointer; }
  .choices input { margin-right: 6px; }
  .choices label:has(input:checked) { border-color: var(--accent); outline: 1px solid var(--accent); }
  textarea, input[type=number] { width: 100%; padding: 8px; border: 1px solid var(--line); border-radius: 8px;
                                 background: var(--card); color: var(--text); font: inherit; }
  button { padding: 8px 14px; border: 1px solid var(--line); border-radius: 8px; background: var(--card);
           color: var(--text); font: inherit; cursor: pointer; }
  button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
  .nav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 14px; }
  .nav input { width: 90px; }
  #msg { color: var(--warn); min-height: 1.5em; margin-top: 8px; }
  #sync { margin-left: auto; font-size: 13px; color: var(--muted); }
  details { margin-bottom: 14px; }
  .hidden { display: none !important; }
</style>
</head>
<body>
<main>
  <h1>MOZAIC T4 review</h1>

  <div id="login" class="card hidden">
    <p id="loginMsg">Sign in with your Hugging Face account to start.</p>
    <button class="primary" id="signin">Sign in with Hugging Face</button>
  </div>

  <div id="app" class="hidden">
    <details class="card">
      <summary>How to review</summary>
      <p>Watch the clip, and the reference (the same person's real clip) to compare. The facts say
      what is fake; they are correct. Judge only what is written, per part:</p>
      <ul>
        <li><b>accurate</b>: it is there, in the region the facts name (for a REAL part: no fake
          artifact is claimed and none is obvious)</li>
        <li><b>ungrounded</b>: vague, generic, or in the wrong region or moment</li>
        <li><b>invented</b>: describes something that is not in the clip</li>
      </ul>
      <p>Every move saves in this browser; your file goes to Hugging Face at least once a minute.
      Close the tab any time: you resume at your first unfinished clip. Use one tab at a time.</p>
    </details>

    <div class="bar"><span id="header"></span><span id="sync"></span></div>

    <div class="row">
      <div class="card"><div class="label">Clip to review</div><video id="clip" controls></video></div>
      <div class="card" id="refCard"><div class="label">Reference: the pair's real clip</div>
        <video id="ref" controls></video></div>
    </div>

    <div class="row">
      <div class="card" id="part-video"></div>
      <div class="card" id="part-audio"></div>
    </div>

    <div class="card">
      <div class="label">Note (optional)</div>
      <textarea id="note" rows="2"></textarea>
      <div class="nav">
        <button data-go="prev">⬅ Previous</button>
        <button class="primary" data-go="next">Save &amp; next ➡</button>
        <button data-go="todo">Next unfinished ⏭</button>
        <input id="goto" type="number" min="1" placeholder="Clip #">
        <button data-go="goto">Go</button>
        <button id="pushNow">Save to HF now</button>
      </div>
      <div id="msg"></div>
    </div>
  </div>
</main>

<script type="module">
import { oauthLoginUrl, oauthHandleRedirectIfPresent, downloadFile, uploadFile }
  from "https://cdn.jsdelivr.net/npm/@huggingface/hub@2.16.3/+esm";

const LABELS = ["accurate", "ungrounded", "invented"];
const PUSH_EVERY_MS = 60_000;  // at most one commit a minute per reviewer
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}[c]));
const done = (c) => c.review.video && c.review.audio;
const store = {  // localStorage can be blocked: the page still works, it just cannot keep unsent work
  get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } },
  set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
  del(k) { try { localStorage.removeItem(k); } catch {} },
};

const config = await (await fetch("data/config.json")).json();
let auth = store.get("t4:auth"), name = "", clips = [], idx = 0, dirty = false, lastPush = 0, pushing = null;

// ---------------------------------------------------------------- sign in
const redirect = await oauthHandleRedirectIfPresent();
if (redirect) {
  auth = {token: redirect.accessToken, expires: redirect.accessTokenExpiresAt, user: redirect.userInfo.preferred_username};
  store.set("t4:auth", auth);
}
$("signin").onclick = async () => { location.href = await oauthLoginUrl(); };
if (!auth || new Date(auth.expires) <= new Date()) signIn("Sign in with your Hugging Face account to start.");
else start();

function signIn(text) {
  $("loginMsg").textContent = text;
  $("login").classList.remove("hidden");
  $("app").classList.add("hidden");
}

// ---------------------------------------------------------------- load
const file = () => `reviews/golden_${name}.json`;
const repo = () => ({type: "dataset", name: config.dataset});

function merge(...versions) {
  // per clip, the review with the latest time wins (seed, saved on HF, unsent in this browser)
  const base = versions[0].map((c) => ({...c}));
  for (const v of versions.slice(1)) {
    if (!v) continue;
    const byId = Object.fromEntries(v.map((c) => [c.id, c.review]));
    for (const c of base) {
      const r = byId[c.id];
      if (r && (r.time || "") > (c.review.time || "")) c.review = r;
    }
  }
  return base;
}

async function remote() {
  // the reviewer's file on HF; null before their first save
  let blob;
  try { blob = await downloadFile({repo: repo(), path: file(), accessToken: auth.token}); }
  catch (e) { if (e.statusCode === 404 || String(e).includes("status 404")) return null; throw e; }
  return blob ? JSON.parse(await blob.text()) : null;
}

async function start() {
  name = config.reviewers[auth.user];
  if (!name) return signIn(`${auth.user} is not on the reviewer list. Ask for your account to be added.`);
  try {
    const seed = await (await fetch(`data/reviews/golden_${name}.json`)).json();
    const local = store.get(`t4:work:${name}`);
    clips = merge(seed, await remote(), local);
    dirty = Boolean(local);
  } catch (e) {
    if (String(e).includes("401")) return signIn("Your sign-in expired. Sign in again.");
    return signIn("Could not load your file: " + e.message);
  }
  idx = Math.max(0, clips.findIndex((c) => !done(c)));
  $("login").classList.add("hidden");
  $("app").classList.remove("hidden");
  show();
  syncLabel();
  setInterval(() => push(false), 15_000);
}

// ---------------------------------------------------------------- show
function part(c, p) {
  const fake = c.facts[p] !== "REAL";
  const ev = c.evidence[p].map((e) => `<li>${esc(e)}</li>`).join("") || "<li>(none)</li>";
  const radios = LABELS.map((l) => `<label><input type="radio" name="${p}" value="${l}"
      ${c.review[p] === l ? "checked" : ""}>${l}</label>`).join("");
  return `<h3>${p[0].toUpperCase() + p.slice(1)}</h3>
    <div><b>Facts:</b> <span class="${fake ? "fake" : "real"}">${esc(c.facts[p])}</span></div>
    <p><b>Explanation:</b> ${esc(c.explanation[p])}</p>
    <div><b>Evidence</b> <span class="label">(artifact | where | when)</span></div><ul>${ev}</ul>
    <div class="label">${p} label</div><div class="choices">${radios}</div>`;
}

function show() {
  const c = clips[idx];
  $("header").innerHTML = `<b>${esc(name)}</b>
    <span class="pill">clip ${idx + 1} / ${clips.length}</span><span class="pill">${clips.filter(done).length} done</span>
    <span class="pill">${esc(c.label)}</span><span class="pill">${esc(c.generator)}</span>
    <span class="pill">${c.frames}f</span>${done(c) ? '<span class="pill done">✓ reviewed</span>' : ""}`;
  $("clip").src = c.clip;
  $("refCard").classList.toggle("hidden", !c.reference);
  $("ref").src = c.reference || "";
  $("part-video").innerHTML = part(c, "video");
  $("part-audio").innerHTML = part(c, "audio");
  $("note").value = c.review.note || "";
}

// ---------------------------------------------------------------- save
const picked = (p) => document.querySelector(`input[name=${p}]:checked`)?.value || null;

function saveLocal() {
  if (!clips.length) return false;  // not signed in yet
  const c = clips[idx];
  const r = {video: picked("video"), audio: picked("audio"), note: $("note").value.trim()};
  if (r.video === c.review.video && r.audio === c.review.audio && r.note === (c.review.note || "")) return false;
  c.review = {...r, time: new Date().toISOString()};
  store.set(`t4:work:${name}`, clips);
  dirty = true;
  syncLabel();
  return true;
}

function push(now) {
  if (!dirty || pushing || (!now && Date.now() - lastPush < PUSH_EVERY_MS)) return pushing;
  pushing = (async () => {
    try {
      clips = merge(clips, await remote());  // keeps anything saved from another tab
      await uploadFile({repo: repo(), accessToken: auth.token, commitTitle: `review: ${name}`,
                        file: {path: file(), content: new Blob([JSON.stringify(clips, null, 1)])}});
      dirty = false;
      lastPush = Date.now();
      store.del(`t4:work:${name}`);
    } catch (e) {
      const text = String(e);
      if (text.includes("401") || text.includes("not found")) {
        // expired, or the sign-in did not grant the Deep-Fake org: sign in again (work stays in this browser)
        store.del("t4:auth");
        $("msg").innerHTML = '⚠️ Your sign-in cannot write to the dataset. Your work is kept in this browser. ' +
          '<a href="" onclick="location.reload()">Reload and sign in again</a>, and allow access to the organization.';
      } else {
        $("msg").textContent = "⚠️ Could not save to HF (kept in this browser, will retry): " + e.message;
      }
    } finally {
      pushing = null;
      syncLabel();
    }
  })();
  return pushing;
}

function syncLabel() {
  $("sync").textContent = dirty ? "● changes not on HF yet" : lastPush ? `☁️ saved to HF ${new Date(lastPush).toLocaleTimeString()}` : "☁️ up to date";
}

function go(where) {
  let msg = saveLocal() ? "💾 saved" : "";
  if (where === "prev") idx = Math.max(0, idx - 1);
  if (where === "next") {
    if (!done(clips[idx])) msg = "⚠️ saved, but one part has no label yet";
    idx = Math.min(clips.length - 1, idx + 1);
  }
  if (where === "todo") {
    const todo = clips.map((_, i) => i).filter((i) => !done(clips[i]));
    if (!todo.length) msg = "🎉 all clips done";
    else idx = todo.find((i) => i > idx) ?? todo[0];
  }
  if (where === "goto") {
    const n = parseInt($("goto").value, 10);
    if (!(n >= 1 && n <= clips.length)) { $("msg").textContent = `❌ clip number must be 1 to ${clips.length}`; return; }
    idx = n - 1;
  }
  $("msg").textContent = msg;
  show();
  window.scrollTo({top: 0});
  push(false);
}

for (const b of document.querySelectorAll("[data-go]")) b.onclick = () => go(b.dataset.go);
$("pushNow").onclick = async () => { saveLocal(); await push(true); if (!dirty) $("msg").textContent = "☁️ saved to HF"; };
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { saveLocal(); push(true); } });
window.addEventListener("beforeunload", (e) => { if (dirty) e.preventDefault(); });
</script>
</body>
</html>