lllqaq commited on
Commit
31443df
·
verified ·
1 Parent(s): d95996a

Add VisPhyWorld static comparator Space

Browse files

Upload static comparator app, manifest, and Space metadata.

Files changed (6) hide show
  1. README.md +30 -4
  2. index.html +137 -17
  3. static/app.js +685 -0
  4. static/manifest.json +0 -0
  5. static/style.css +537 -0
  6. style.css +0 -28
README.md CHANGED
@@ -1,10 +1,36 @@
1
  ---
2
  title: VisPhyWorld Comparator
3
- emoji: 👁
4
- colorFrom: yellow
5
- colorTo: green
6
  sdk: static
 
7
  pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: VisPhyWorld Comparator
3
+ emoji: 🎞️
4
+ colorFrom: red
5
+ colorTo: blue
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ datasets:
10
+ - TIGER-Lab/VisPhyBench-Data
11
+ - TIGER-Lab/VisPhyWorld-Sub-Generated-Videos
12
+ tags:
13
+ - visphyworld
14
+ - physics
15
+ - video-comparison
16
+ - benchmark
17
  ---
18
 
19
+ # VisPhyWorld Comparator
20
+
21
+ Interactive sample-level comparator for VisPhyWorld.
22
+
23
+ This Space lets you:
24
+
25
+ - browse the `sub` split sample by sample
26
+ - keep the ground-truth video as the first card
27
+ - compare matching generated videos from all supported engines and models
28
+ - filter by engine and model
29
+ - share the current state via URL parameters
30
+
31
+ Data sources:
32
+
33
+ - Ground-truth videos: [`TIGER-Lab/VisPhyBench-Data`](https://huggingface.co/datasets/TIGER-Lab/VisPhyBench-Data)
34
+ - Generated videos: [`TIGER-Lab/VisPhyWorld-Sub-Generated-Videos`](https://huggingface.co/datasets/TIGER-Lab/VisPhyWorld-Sub-Generated-Videos)
35
+
36
+ The Space is static HTML and reads video assets directly from the public dataset repositories at runtime.
index.html CHANGED
@@ -1,19 +1,139 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
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" />
6
+ <title>VisPhyWorld Comparator</title>
7
+ <link rel="icon" href="data:," />
8
+ <link rel="stylesheet" href="./static/style.css" />
9
+ </head>
10
+ <body>
11
+ <div class="page-shell">
12
+ <header class="hero">
13
+ <div class="hero-copy">
14
+ <div class="eyebrow">VisPhyWorld Demo</div>
15
+ <h1 class="title">Ground Truth vs Generated Video Comparator</h1>
16
+ <p class="subtitle">
17
+ Browse the `sub` split sample by sample. Keep the ground-truth video fixed and compare matching
18
+ generated videos across engines and models.
19
+ </p>
20
+ </div>
21
+ <div class="hero-actions">
22
+ <div class="source-toggle" role="radiogroup" aria-label="Asset source">
23
+ <button type="button" class="source-btn" id="source-local">Local</button>
24
+ <button type="button" class="source-btn" id="source-hub">Hugging Face</button>
25
+ </div>
26
+ <div class="hero-tool-row">
27
+ <button type="button" class="action-btn accent-outline" id="random-sample">Random sample</button>
28
+ <button type="button" class="action-btn accent-outline" id="copy-link">Copy link</button>
29
+ </div>
30
+ <div class="hero-note" id="source-note"></div>
31
+ </div>
32
+ </header>
33
+
34
+ <main class="layout">
35
+ <aside class="control-panel">
36
+ <section class="panel">
37
+ <div class="panel-label">Navigation</div>
38
+ <div class="nav-row">
39
+ <button class="nav-btn" id="prev-sample" title="Previous sample">←</button>
40
+ <button class="nav-btn" id="next-sample" title="Next sample">→</button>
41
+ <div class="counter" id="sample-counter">0/0</div>
42
+ </div>
43
+ <label class="field-label" for="sample-search">Search task</label>
44
+ <input id="sample-search" class="text-input" type="search" placeholder="task10021_014" />
45
+ <label class="field-label" for="sample-select">Task</label>
46
+ <select id="sample-select" class="sample-select" aria-label="Sample selector"></select>
47
+ </section>
48
+
49
+ <section class="panel">
50
+ <div class="panel-label">Sample Metadata</div>
51
+ <div class="chip-row" id="sample-meta"></div>
52
+ <div class="meta-list">
53
+ <div><span class="meta-key">Task</span><span class="meta-value" id="meta-task">-</span></div>
54
+ <div><span class="meta-key">Split</span><span class="meta-value" id="meta-split">-</span></div>
55
+ <div><span class="meta-key">Results</span><span class="meta-value" id="meta-results">-</span></div>
56
+ <div><span class="meta-key">Detection</span><span class="meta-value" id="meta-detection">-</span></div>
57
+ </div>
58
+ </section>
59
+
60
+ <section class="panel">
61
+ <div class="panel-label">Playback</div>
62
+ <div class="button-grid">
63
+ <button type="button" class="action-btn" id="sync-toggle">Sync: On</button>
64
+ <button type="button" class="action-btn" id="mute-toggle">Muted: On</button>
65
+ <button type="button" class="action-btn" id="play-all">Play all</button>
66
+ <button type="button" class="action-btn" id="pause-all">Pause all</button>
67
+ <button type="button" class="action-btn" id="restart-all">Restart</button>
68
+ </div>
69
+ <label class="field-label" for="playback-rate">Playback rate</label>
70
+ <select id="playback-rate" class="sample-select">
71
+ <option value="0.5">0.5x</option>
72
+ <option value="0.75">0.75x</option>
73
+ <option value="1" selected>1.0x</option>
74
+ <option value="1.25">1.25x</option>
75
+ <option value="1.5">1.5x</option>
76
+ </select>
77
+ </section>
78
+
79
+ <section class="panel">
80
+ <div class="panel-label">Filters</div>
81
+ <div class="filter-group">
82
+ <div class="field-label">Presets</div>
83
+ <div class="preset-grid" id="preset-bar"></div>
84
+ <button type="button" class="action-btn wide-btn" id="reset-filters">Reset filters</button>
85
+ </div>
86
+ <div class="filter-group">
87
+ <div class="field-label">Engines</div>
88
+ <div class="pill-row" id="engine-filters"></div>
89
+ </div>
90
+ <div class="filter-group">
91
+ <div class="field-label">Models</div>
92
+ <div class="pill-row" id="model-filters"></div>
93
+ </div>
94
+ </section>
95
+ </aside>
96
+
97
+ <section class="comparison-panel">
98
+ <section class="stage comparator-stage">
99
+ <div class="section-header">
100
+ <div>
101
+ <div class="eyebrow">Task Comparator</div>
102
+ <h2 class="section-title" id="comparison-title">Reference Video</h2>
103
+ </div>
104
+ <div class="header-side">
105
+ <div class="results-summary" id="results-summary">0 visible</div>
106
+ <div class="active-filter-summary" id="active-filter-summary">All engines · all models</div>
107
+ </div>
108
+ </div>
109
+ <div class="results-grid" id="results-grid">
110
+ <article class="video-card gt-card">
111
+ <div class="card-header">
112
+ <div class="badge-stack">
113
+ <span class="badge badge-gt">GT</span>
114
+ </div>
115
+ <span class="card-title" id="gt-title">Ground Truth</span>
116
+ <div class="card-subtitle">Reference video</div>
117
+ </div>
118
+ <video id="gt-video" controls playsinline preload="metadata"></video>
119
+ </article>
120
+ </div>
121
+ <div class="empty-state" id="empty-state" hidden>No generated videos match the current filters.</div>
122
+ </section>
123
+ </section>
124
+ </main>
125
+
126
+ <section class="footer-note">
127
+ <div>
128
+ This Space streams public assets directly from
129
+ <code>TIGER-Lab/VisPhyBench-Data</code> and <code>TIGER-Lab/VisPhyWorld-Sub-Generated-Videos</code>.
130
+ </div>
131
+ <div>
132
+ Shareable state is stored in the URL, including the current sample, filters, and playback options.
133
+ </div>
134
+ </section>
135
+ </div>
136
+
137
+ <script src="./static/app.js"></script>
138
+ </body>
139
  </html>
static/app.js ADDED
@@ -0,0 +1,685 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* global window, document, fetch, navigator */
2
+
3
+ function el(id) {
4
+ const node = document.getElementById(id);
5
+ if (!node) throw new Error(`Missing element: ${id}`);
6
+ return node;
7
+ }
8
+
9
+ function getSearchParams() {
10
+ return new URLSearchParams(window.location.search);
11
+ }
12
+
13
+ function getQueryValue(name) {
14
+ return getSearchParams().get(name);
15
+ }
16
+
17
+ function titleCase(text) {
18
+ return String(text || "")
19
+ .split(/[-_\s]+/)
20
+ .filter(Boolean)
21
+ .map((part) => part[0].toUpperCase() + part.slice(1))
22
+ .join(" ");
23
+ }
24
+
25
+ function clampRate(value) {
26
+ const numeric = Number(value);
27
+ if (!Number.isFinite(numeric) || numeric <= 0) return 1;
28
+ return numeric;
29
+ }
30
+
31
+ function buildAssetUrl(mode, manifest, type, path) {
32
+ if (mode === "hub") {
33
+ const repo =
34
+ type === "gt" ? manifest.repos.gt_dataset : manifest.repos.generated_dataset;
35
+ return `https://huggingface.co/datasets/${repo}/resolve/main/${path}`;
36
+ }
37
+ if (type === "gt") {
38
+ return `/data/${path}`;
39
+ }
40
+ return `/data/sub_generated_videos/${path}`;
41
+ }
42
+
43
+ function isLocalPreviewHost() {
44
+ const host = window.location.hostname || "";
45
+ return host === "localhost" || host === "127.0.0.1";
46
+ }
47
+
48
+ function detectSourceMode() {
49
+ const explicit = getQueryValue("source");
50
+ if (explicit === "hub") return "hub";
51
+ if (explicit === "local" && isLocalPreviewHost()) return "local";
52
+ if (isLocalPreviewHost()) return "local";
53
+ return "hub";
54
+ }
55
+
56
+ function sameValue(a, b, epsilon) {
57
+ return Math.abs(a - b) <= epsilon;
58
+ }
59
+
60
+ function setVideoSource(video, src) {
61
+ if (!src) {
62
+ video.removeAttribute("src");
63
+ video.load();
64
+ return;
65
+ }
66
+ if (video.dataset.currentSrc === src) return;
67
+ video.dataset.currentSrc = src;
68
+ video.src = src;
69
+ video.load();
70
+ }
71
+
72
+ function createButton(label, active, onClick, className) {
73
+ const button = document.createElement("button");
74
+ button.type = "button";
75
+ button.className = className;
76
+ if (active) button.classList.add("active");
77
+ button.textContent = label;
78
+ button.addEventListener("click", onClick);
79
+ return button;
80
+ }
81
+
82
+ function createResultCard(result) {
83
+ const article = document.createElement("article");
84
+ article.className = "video-card result-card";
85
+ article.dataset.engine = result.engine;
86
+ article.dataset.model = result.model;
87
+
88
+ const header = document.createElement("div");
89
+ header.className = "card-header";
90
+
91
+ const badges = document.createElement("div");
92
+ badges.className = "badge-stack";
93
+
94
+ const engineBadge = document.createElement("span");
95
+ engineBadge.className = "badge";
96
+ engineBadge.textContent = result.engine_label;
97
+ badges.appendChild(engineBadge);
98
+
99
+ const modelBadge = document.createElement("span");
100
+ modelBadge.className = "badge badge-secondary";
101
+ modelBadge.textContent = result.model_label;
102
+ badges.appendChild(modelBadge);
103
+
104
+ const title = document.createElement("div");
105
+ title.className = "card-title";
106
+ title.textContent = result.model_label;
107
+
108
+ const subtitle = document.createElement("div");
109
+ subtitle.className = "card-subtitle";
110
+ subtitle.textContent = `${result.engine_label} renderer`;
111
+
112
+ const video = document.createElement("video");
113
+ video.controls = true;
114
+ video.preload = "metadata";
115
+ video.playsInline = true;
116
+ video.dataset.role = "result-video";
117
+
118
+ header.appendChild(badges);
119
+ header.appendChild(title);
120
+ header.appendChild(subtitle);
121
+
122
+ article.appendChild(header);
123
+ article.appendChild(video);
124
+
125
+ return { article, video };
126
+ }
127
+
128
+ function parseSetParam(name, validValues) {
129
+ const raw = getQueryValue(name);
130
+ if (!raw) return null;
131
+ const out = new Set();
132
+ for (const part of raw.split(",")) {
133
+ const item = decodeURIComponent(part).trim();
134
+ if (item && validValues.has(item)) out.add(item);
135
+ }
136
+ return out.size > 0 ? out : null;
137
+ }
138
+
139
+ function setsEqual(left, right) {
140
+ if (left.size !== right.size) return false;
141
+ for (const item of left) {
142
+ if (!right.has(item)) return false;
143
+ }
144
+ return true;
145
+ }
146
+
147
+ async function loadManifest() {
148
+ const response = await fetch("./static/manifest.json", { cache: "no-store" });
149
+ if (!response.ok) throw new Error(`Failed to load manifest: ${response.status}`);
150
+ return response.json();
151
+ }
152
+
153
+ async function copyToClipboard(text) {
154
+ if (navigator.clipboard && navigator.clipboard.writeText) {
155
+ await navigator.clipboard.writeText(text);
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ async function main() {
162
+ const manifest = await loadManifest();
163
+ const samples = Array.isArray(manifest.samples) ? manifest.samples : [];
164
+ if (samples.length === 0) throw new Error("Manifest has no samples");
165
+
166
+ const allModels = [];
167
+ const seenModels = new Set();
168
+ for (const engine of manifest.engine_order) {
169
+ for (const model of manifest.model_order[engine] || []) {
170
+ if (seenModels.has(model)) continue;
171
+ seenModels.add(model);
172
+ allModels.push(model);
173
+ }
174
+ }
175
+
176
+ const engineSet = new Set(manifest.engine_order);
177
+ const modelSet = new Set(allModels);
178
+
179
+ const state = {
180
+ samples,
181
+ sourceMode: detectSourceMode(),
182
+ currentIndex: 0,
183
+ syncEnabled: getQueryValue("sync") !== "0",
184
+ muted: getQueryValue("muted") !== "0",
185
+ propagating: false,
186
+ selectedEngines: parseSetParam("engines", engineSet) || new Set(manifest.engine_order),
187
+ selectedModels: parseSetParam("models", modelSet) || new Set(allModels),
188
+ playbackRate: clampRate(getQueryValue("rate") || "1"),
189
+ };
190
+
191
+ const sampleSelect = el("sample-select");
192
+ const sampleSearch = el("sample-search");
193
+ const sampleCounter = el("sample-counter");
194
+ const sourceLocal = el("source-local");
195
+ const sourceHub = el("source-hub");
196
+ const sourceNote = el("source-note");
197
+
198
+ const metaTask = el("meta-task");
199
+ const metaSplit = el("meta-split");
200
+ const metaResults = el("meta-results");
201
+ const metaDetection = el("meta-detection");
202
+ const sampleMeta = el("sample-meta");
203
+
204
+ const gtVideo = el("gt-video");
205
+ const gtTitle = el("gt-title");
206
+ const comparisonTitle = el("comparison-title");
207
+ const resultsSummary = el("results-summary");
208
+ const activeFilterSummary = el("active-filter-summary");
209
+ const resultsGrid = el("results-grid");
210
+ const gtCard = resultsGrid.querySelector(".gt-card");
211
+ const emptyState = el("empty-state");
212
+
213
+ const engineFilters = el("engine-filters");
214
+ const modelFilters = el("model-filters");
215
+ const presetBar = el("preset-bar");
216
+
217
+ const syncToggle = el("sync-toggle");
218
+ const muteToggle = el("mute-toggle");
219
+ const playbackRate = el("playback-rate");
220
+ const copyLinkButton = el("copy-link");
221
+
222
+ playbackRate.value = String(state.playbackRate);
223
+
224
+ const presetDefinitions = [
225
+ {
226
+ key: "all",
227
+ label: "All",
228
+ engines: manifest.engine_order,
229
+ models: allModels,
230
+ },
231
+ {
232
+ key: "code",
233
+ label: "Code",
234
+ engines: ["threejs", "p5js"],
235
+ models: allModels.filter((model) => model !== "svd-img2vid" && model !== "sora-2" && model !== "veo31"),
236
+ },
237
+ {
238
+ key: "threejs",
239
+ label: "Three.js",
240
+ engines: ["threejs"],
241
+ models: manifest.model_order.threejs || [],
242
+ },
243
+ {
244
+ key: "p5js",
245
+ label: "p5.js",
246
+ engines: ["p5js"],
247
+ models: manifest.model_order.p5js || [],
248
+ },
249
+ {
250
+ key: "video",
251
+ label: "Video",
252
+ engines: ["video"],
253
+ models: manifest.model_order.video || [],
254
+ },
255
+ ];
256
+
257
+ function currentSample() {
258
+ return state.samples[state.currentIndex];
259
+ }
260
+
261
+ function allVisibleVideos() {
262
+ return Array.from(resultsGrid.querySelectorAll("video"));
263
+ }
264
+
265
+ function updateUrlState() {
266
+ const params = new URLSearchParams();
267
+ params.set("sample", currentSample().id);
268
+ params.set("source", state.sourceMode);
269
+ if (!setsEqual(state.selectedEngines, new Set(manifest.engine_order))) {
270
+ params.set("engines", Array.from(state.selectedEngines).join(","));
271
+ }
272
+ if (!setsEqual(state.selectedModels, new Set(allModels))) {
273
+ params.set("models", Array.from(state.selectedModels).join(","));
274
+ }
275
+ if (!state.syncEnabled) params.set("sync", "0");
276
+ if (!state.muted) params.set("muted", "0");
277
+ if (!sameValue(state.playbackRate, 1, 0.001)) {
278
+ params.set("rate", String(state.playbackRate));
279
+ }
280
+ const nextUrl = `${window.location.pathname}?${params.toString()}`;
281
+ window.history.replaceState(null, "", nextUrl);
282
+ }
283
+
284
+ function updateSourceButtons() {
285
+ const localPreview = isLocalPreviewHost();
286
+ sourceLocal.disabled = !localPreview;
287
+ sourceLocal.title = localPreview ? "Read local files under /data" : "Local mode is only available in local preview";
288
+ sourceLocal.classList.toggle("active", state.sourceMode === "local");
289
+ sourceHub.classList.toggle("active", state.sourceMode === "hub");
290
+ sourceNote.textContent =
291
+ state.sourceMode === "local"
292
+ ? "Reading videos from this repo under /data. This is the best mode for local inspection."
293
+ : localPreview
294
+ ? "Reading videos from the public Hugging Face datasets. This matches the deployed Space."
295
+ : "Reading videos from the public Hugging Face datasets used by this Space.";
296
+ }
297
+
298
+ function updateSyncButtons() {
299
+ syncToggle.textContent = `Sync: ${state.syncEnabled ? "On" : "Off"}`;
300
+ muteToggle.textContent = `Muted: ${state.muted ? "On" : "Off"}`;
301
+ syncToggle.classList.toggle("active", state.syncEnabled);
302
+ muteToggle.classList.toggle("active", state.muted);
303
+ }
304
+
305
+ function updateCounter() {
306
+ sampleCounter.textContent = `${state.currentIndex + 1}/${state.samples.length}`;
307
+ }
308
+
309
+ function applyPlaybackSettings() {
310
+ state.playbackRate = clampRate(playbackRate.value || "1");
311
+ for (const video of allVisibleVideos()) {
312
+ video.playbackRate = state.playbackRate;
313
+ video.defaultPlaybackRate = state.playbackRate;
314
+ video.muted = state.muted;
315
+ }
316
+ }
317
+
318
+ function propagate(origin, action) {
319
+ if (!state.syncEnabled || state.propagating) return;
320
+ state.propagating = true;
321
+ const videos = allVisibleVideos();
322
+
323
+ try {
324
+ if (action === "play") {
325
+ for (const video of videos) {
326
+ if (video === origin) continue;
327
+ if (!sameValue(video.currentTime, origin.currentTime, 0.08)) {
328
+ video.currentTime = origin.currentTime;
329
+ }
330
+ video.playbackRate = origin.playbackRate;
331
+ video.play().catch(() => {
332
+ /* ignore autoplay restrictions */
333
+ });
334
+ }
335
+ } else if (action === "pause") {
336
+ for (const video of videos) {
337
+ if (video === origin) continue;
338
+ if (!sameValue(video.currentTime, origin.currentTime, 0.08)) {
339
+ video.currentTime = origin.currentTime;
340
+ }
341
+ video.pause();
342
+ }
343
+ } else if (action === "seek") {
344
+ for (const video of videos) {
345
+ if (video === origin) continue;
346
+ if (!sameValue(video.currentTime, origin.currentTime, 0.1)) {
347
+ video.currentTime = origin.currentTime;
348
+ }
349
+ }
350
+ } else if (action === "rate") {
351
+ for (const video of videos) {
352
+ if (video === origin) continue;
353
+ video.playbackRate = origin.playbackRate;
354
+ video.defaultPlaybackRate = origin.playbackRate;
355
+ }
356
+ }
357
+ } finally {
358
+ window.setTimeout(() => {
359
+ state.propagating = false;
360
+ }, 0);
361
+ }
362
+ }
363
+
364
+ function bindSync(video) {
365
+ video.addEventListener("play", () => propagate(video, "play"));
366
+ video.addEventListener("pause", () => propagate(video, "pause"));
367
+ video.addEventListener("seeked", () => propagate(video, "seek"));
368
+ video.addEventListener("ratechange", () => propagate(video, "rate"));
369
+ }
370
+
371
+ function modelsForEngines(engines) {
372
+ const out = new Set();
373
+ for (const engine of engines) {
374
+ for (const model of manifest.model_order[engine] || []) {
375
+ out.add(model);
376
+ }
377
+ }
378
+ return out;
379
+ }
380
+
381
+ function activePresetKey() {
382
+ for (const preset of presetDefinitions) {
383
+ if (
384
+ setsEqual(state.selectedEngines, new Set(preset.engines)) &&
385
+ setsEqual(state.selectedModels, new Set(preset.models))
386
+ ) {
387
+ return preset.key;
388
+ }
389
+ }
390
+ return null;
391
+ }
392
+
393
+ function describeFilters() {
394
+ const engineSummary =
395
+ state.selectedEngines.size === manifest.engine_order.length
396
+ ? "All engines"
397
+ : `${state.selectedEngines.size} engine${state.selectedEngines.size === 1 ? "" : "s"}`;
398
+ const modelSummary =
399
+ state.selectedModels.size === allModels.length
400
+ ? "all models"
401
+ : `${state.selectedModels.size} model${state.selectedModels.size === 1 ? "" : "s"}`;
402
+ return `${engineSummary} · ${modelSummary}`;
403
+ }
404
+
405
+ function setFilterState(engines, models) {
406
+ state.selectedEngines = new Set(engines);
407
+ state.selectedModels = new Set(models);
408
+ renderFilterButtons();
409
+ renderSample();
410
+ }
411
+
412
+ function renderFilterButtons() {
413
+ const activePreset = activePresetKey();
414
+
415
+ presetBar.innerHTML = "";
416
+ for (const preset of presetDefinitions) {
417
+ const button = createButton(
418
+ preset.label,
419
+ activePreset === preset.key,
420
+ () => setFilterState(preset.engines, preset.models),
421
+ "preset-btn"
422
+ );
423
+ presetBar.appendChild(button);
424
+ }
425
+
426
+ engineFilters.innerHTML = "";
427
+ for (const engine of manifest.engine_order) {
428
+ const button = createButton(
429
+ manifest.engine_labels[engine] || titleCase(engine),
430
+ state.selectedEngines.has(engine),
431
+ () => {
432
+ if (state.selectedEngines.has(engine)) state.selectedEngines.delete(engine);
433
+ else state.selectedEngines.add(engine);
434
+ if (state.selectedEngines.size === 0) state.selectedEngines.add(engine);
435
+ renderFilterButtons();
436
+ renderSample();
437
+ },
438
+ "pill-btn"
439
+ );
440
+ engineFilters.appendChild(button);
441
+ }
442
+
443
+ modelFilters.innerHTML = "";
444
+ for (const model of allModels) {
445
+ const button = createButton(
446
+ manifest.model_labels[model] || titleCase(model),
447
+ state.selectedModels.has(model),
448
+ () => {
449
+ if (state.selectedModels.has(model)) state.selectedModels.delete(model);
450
+ else state.selectedModels.add(model);
451
+ if (state.selectedModels.size === 0) state.selectedModels.add(model);
452
+ renderFilterButtons();
453
+ renderSample();
454
+ },
455
+ "pill-btn"
456
+ );
457
+ modelFilters.appendChild(button);
458
+ }
459
+ }
460
+
461
+ function renderSampleOptions(filterText) {
462
+ const normalized = String(filterText || "").trim().toLowerCase();
463
+ sampleSelect.innerHTML = "";
464
+ let nextSelectedValue = null;
465
+
466
+ for (const sample of state.samples) {
467
+ const haystack = `${sample.id} ${sample.label} ${sample.difficulty} ${
468
+ sample.is_3d ? "3d" : "2d"
469
+ }`.toLowerCase();
470
+ if (normalized && !haystack.includes(normalized)) continue;
471
+
472
+ const option = document.createElement("option");
473
+ option.value = sample.id;
474
+ option.textContent = sample.label;
475
+ sampleSelect.appendChild(option);
476
+ if (sample.id === currentSample().id) nextSelectedValue = sample.id;
477
+ }
478
+
479
+ if (nextSelectedValue) {
480
+ sampleSelect.value = nextSelectedValue;
481
+ } else if (sampleSelect.options.length > 0) {
482
+ sampleSelect.selectedIndex = 0;
483
+ }
484
+ }
485
+
486
+ function setSampleByIndex(index) {
487
+ state.currentIndex = Math.max(0, Math.min(state.samples.length - 1, index));
488
+ sampleSelect.value = currentSample().id;
489
+ updateCounter();
490
+ renderSample();
491
+ }
492
+
493
+ function renderSampleMeta(sample) {
494
+ sampleMeta.innerHTML = "";
495
+
496
+ const chips = [
497
+ { label: sample.is_3d ? "3D" : "2D", tone: "dimension" },
498
+ { label: sample.difficulty || "unknown", tone: sample.difficulty || "default" },
499
+ { label: `${sample.available_result_count} results`, tone: "count" },
500
+ ];
501
+
502
+ for (const chip of chips) {
503
+ const span = document.createElement("span");
504
+ span.className = `meta-chip ${chip.tone}`;
505
+ span.textContent = chip.label;
506
+ sampleMeta.appendChild(span);
507
+ }
508
+
509
+ metaTask.textContent = sample.id;
510
+ metaSplit.textContent = sample.split || "sub";
511
+ metaResults.textContent = `${sample.available_result_count}`;
512
+ metaDetection.textContent = sample.detection_json_path ? "available" : "none";
513
+ }
514
+
515
+ function filteredResults(sample) {
516
+ return (sample.results || []).filter(
517
+ (result) =>
518
+ state.selectedEngines.has(result.engine) &&
519
+ state.selectedModels.has(result.model)
520
+ );
521
+ }
522
+
523
+ function renderSample() {
524
+ const sample = currentSample();
525
+ const visibleResults = filteredResults(sample);
526
+
527
+ sampleSelect.value = sample.id;
528
+ comparisonTitle.textContent = `${sample.id} · ${sample.is_3d ? "3D" : "2D"} · ${titleCase(sample.difficulty)}`;
529
+ gtTitle.textContent = `${sample.id} · Ground Truth`;
530
+ activeFilterSummary.textContent = describeFilters();
531
+ renderSampleMeta(sample);
532
+
533
+ setVideoSource(gtVideo, buildAssetUrl(state.sourceMode, manifest, "gt", sample.gt.path));
534
+
535
+ for (const card of Array.from(resultsGrid.querySelectorAll(".result-card"))) {
536
+ card.remove();
537
+ }
538
+
539
+ for (const result of visibleResults) {
540
+ const card = createResultCard(result);
541
+ setVideoSource(card.video, buildAssetUrl(state.sourceMode, manifest, "generated", result.path));
542
+ card.video.muted = state.muted;
543
+ card.video.playbackRate = state.playbackRate;
544
+ bindSync(card.video);
545
+ resultsGrid.appendChild(card.article);
546
+ }
547
+
548
+ if (gtCard && resultsGrid.firstElementChild !== gtCard) {
549
+ resultsGrid.insertBefore(gtCard, resultsGrid.firstElementChild);
550
+ }
551
+
552
+ resultsSummary.textContent = `${visibleResults.length} generated · ${sample.available_result_count} total`;
553
+ emptyState.hidden = visibleResults.length > 0;
554
+ applyPlaybackSettings();
555
+ updateUrlState();
556
+ }
557
+
558
+ bindSync(gtVideo);
559
+
560
+ for (const sample of state.samples) {
561
+ const option = document.createElement("option");
562
+ option.value = sample.id;
563
+ option.textContent = sample.label;
564
+ sampleSelect.appendChild(option);
565
+ }
566
+
567
+ const requestedSample = getQueryValue("sample");
568
+ if (requestedSample) {
569
+ const requestedIndex = state.samples.findIndex((sample) => sample.id === requestedSample);
570
+ if (requestedIndex >= 0) {
571
+ state.currentIndex = requestedIndex;
572
+ }
573
+ }
574
+
575
+ sampleSelect.addEventListener("change", () => {
576
+ const nextId = sampleSelect.value;
577
+ const nextIndex = state.samples.findIndex((sample) => sample.id === nextId);
578
+ if (nextIndex >= 0) setSampleByIndex(nextIndex);
579
+ });
580
+
581
+ sampleSearch.addEventListener("input", () => {
582
+ renderSampleOptions(sampleSearch.value);
583
+ });
584
+
585
+ sampleSearch.addEventListener("keydown", (event) => {
586
+ if (event.key !== "Enter") return;
587
+ const nextId = sampleSelect.value;
588
+ const nextIndex = state.samples.findIndex((sample) => sample.id === nextId);
589
+ if (nextIndex >= 0) setSampleByIndex(nextIndex);
590
+ });
591
+
592
+ el("prev-sample").addEventListener("click", () => setSampleByIndex(state.currentIndex - 1));
593
+ el("next-sample").addEventListener("click", () => setSampleByIndex(state.currentIndex + 1));
594
+
595
+ sourceLocal.addEventListener("click", () => {
596
+ if (!isLocalPreviewHost()) return;
597
+ state.sourceMode = "local";
598
+ updateSourceButtons();
599
+ renderSample();
600
+ });
601
+
602
+ sourceHub.addEventListener("click", () => {
603
+ state.sourceMode = "hub";
604
+ updateSourceButtons();
605
+ renderSample();
606
+ });
607
+
608
+ syncToggle.addEventListener("click", () => {
609
+ state.syncEnabled = !state.syncEnabled;
610
+ updateSyncButtons();
611
+ updateUrlState();
612
+ });
613
+
614
+ muteToggle.addEventListener("click", () => {
615
+ state.muted = !state.muted;
616
+ updateSyncButtons();
617
+ applyPlaybackSettings();
618
+ updateUrlState();
619
+ });
620
+
621
+ playbackRate.addEventListener("change", () => {
622
+ applyPlaybackSettings();
623
+ updateUrlState();
624
+ });
625
+
626
+ el("play-all").addEventListener("click", () => {
627
+ for (const video of allVisibleVideos()) {
628
+ video.play().catch(() => {
629
+ /* ignore autoplay restrictions */
630
+ });
631
+ }
632
+ });
633
+
634
+ el("pause-all").addEventListener("click", () => {
635
+ for (const video of allVisibleVideos()) {
636
+ video.pause();
637
+ }
638
+ });
639
+
640
+ el("restart-all").addEventListener("click", () => {
641
+ for (const video of allVisibleVideos()) {
642
+ video.currentTime = 0;
643
+ video.pause();
644
+ }
645
+ });
646
+
647
+ el("reset-filters").addEventListener("click", () => {
648
+ setFilterState(manifest.engine_order, allModels);
649
+ });
650
+
651
+ el("random-sample").addEventListener("click", () => {
652
+ const nextIndex = Math.floor(Math.random() * state.samples.length);
653
+ setSampleByIndex(nextIndex);
654
+ });
655
+
656
+ copyLinkButton.addEventListener("click", async () => {
657
+ const copied = await copyToClipboard(window.location.href).catch(() => false);
658
+ const original = "Copy link";
659
+ copyLinkButton.textContent = copied ? "Copied" : "Copy failed";
660
+ window.setTimeout(() => {
661
+ copyLinkButton.textContent = original;
662
+ }, 1200);
663
+ });
664
+
665
+ document.addEventListener("keydown", (event) => {
666
+ if (event.key === "ArrowLeft") setSampleByIndex(state.currentIndex - 1);
667
+ if (event.key === "ArrowRight") setSampleByIndex(state.currentIndex + 1);
668
+ });
669
+
670
+ renderSampleOptions("");
671
+ renderFilterButtons();
672
+ updateSourceButtons();
673
+ updateSyncButtons();
674
+ updateCounter();
675
+ renderSample();
676
+ }
677
+
678
+ window.addEventListener("DOMContentLoaded", () => {
679
+ main().catch((error) => {
680
+ console.error(error);
681
+ document.body.innerHTML = `<pre style="padding:24px;font:14px/1.5 monospace;">${String(
682
+ error && error.stack ? error.stack : error
683
+ )}</pre>`;
684
+ });
685
+ });
static/manifest.json ADDED
The diff for this file is too large to render. See raw diff
 
static/style.css ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f4efe6;
3
+ --bg-accent: radial-gradient(circle at top left, rgba(229, 122, 72, 0.24), transparent 34%),
4
+ radial-gradient(circle at top right, rgba(32, 86, 118, 0.18), transparent 30%),
5
+ linear-gradient(180deg, #f8f3eb 0%, #efe7da 100%);
6
+ --panel: rgba(255, 252, 246, 0.92);
7
+ --panel-strong: rgba(255, 251, 244, 0.98);
8
+ --border: rgba(88, 63, 43, 0.16);
9
+ --text: #24180f;
10
+ --muted: #65584f;
11
+ --accent: #c8572a;
12
+ --accent-deep: #8f3217;
13
+ --accent-cool: #225974;
14
+ --success: #285c44;
15
+ --shadow: 0 14px 40px rgba(65, 43, 25, 0.12);
16
+ --radius-xl: 24px;
17
+ --radius-lg: 18px;
18
+ --radius-md: 14px;
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ html {
26
+ background: #efe7da;
27
+ }
28
+
29
+ body {
30
+ margin: 0;
31
+ min-height: 100vh;
32
+ color: var(--text);
33
+ background: var(--bg-accent);
34
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
35
+ }
36
+
37
+ button,
38
+ select,
39
+ input,
40
+ video {
41
+ font: inherit;
42
+ }
43
+
44
+ .page-shell {
45
+ width: min(1480px, calc(100vw - 32px));
46
+ margin: 0 auto;
47
+ padding: 28px 0 36px;
48
+ }
49
+
50
+ .hero {
51
+ display: flex;
52
+ justify-content: space-between;
53
+ gap: 24px;
54
+ padding: 28px;
55
+ border: 1px solid var(--border);
56
+ border-radius: 28px;
57
+ background: linear-gradient(135deg, rgba(255, 246, 235, 0.96), rgba(245, 235, 221, 0.9));
58
+ box-shadow: var(--shadow);
59
+ margin-bottom: 18px;
60
+ }
61
+
62
+ .hero-copy {
63
+ max-width: 760px;
64
+ }
65
+
66
+ .eyebrow {
67
+ font-size: 12px;
68
+ letter-spacing: 0.18em;
69
+ text-transform: uppercase;
70
+ color: var(--accent-deep);
71
+ font-weight: 800;
72
+ }
73
+
74
+ .title {
75
+ margin: 10px 0 8px;
76
+ font-size: clamp(34px, 5vw, 54px);
77
+ line-height: 0.98;
78
+ font-weight: 900;
79
+ letter-spacing: -0.04em;
80
+ }
81
+
82
+ .subtitle {
83
+ margin: 0;
84
+ max-width: 56ch;
85
+ color: var(--muted);
86
+ font-size: 16px;
87
+ line-height: 1.5;
88
+ }
89
+
90
+ .hero-actions {
91
+ min-width: 320px;
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: 12px;
95
+ align-items: stretch;
96
+ }
97
+
98
+ .source-toggle {
99
+ display: inline-grid;
100
+ grid-template-columns: 1fr 1fr;
101
+ gap: 8px;
102
+ padding: 8px;
103
+ border-radius: 18px;
104
+ border: 1px solid var(--border);
105
+ background: rgba(255, 255, 255, 0.52);
106
+ }
107
+
108
+ .hero-tool-row {
109
+ display: grid;
110
+ grid-template-columns: 1fr 1fr;
111
+ gap: 8px;
112
+ }
113
+
114
+ .source-btn,
115
+ .nav-btn,
116
+ .action-btn,
117
+ .pill-btn,
118
+ .preset-btn {
119
+ border: 1px solid transparent;
120
+ background: rgba(255, 255, 255, 0.72);
121
+ color: var(--text);
122
+ border-radius: 14px;
123
+ cursor: pointer;
124
+ transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
125
+ }
126
+
127
+ .source-btn:hover,
128
+ .nav-btn:hover,
129
+ .action-btn:hover,
130
+ .pill-btn:hover,
131
+ .preset-btn:hover {
132
+ transform: translateY(-1px);
133
+ border-color: rgba(200, 87, 42, 0.34);
134
+ }
135
+
136
+ .source-btn:disabled {
137
+ opacity: 0.45;
138
+ cursor: not-allowed;
139
+ transform: none;
140
+ border-color: transparent;
141
+ }
142
+
143
+ .source-btn.active,
144
+ .action-btn.active,
145
+ .pill-btn.active,
146
+ .preset-btn.active {
147
+ background: var(--accent);
148
+ color: #fff9f1;
149
+ border-color: var(--accent);
150
+ }
151
+
152
+ .source-btn {
153
+ padding: 12px 14px;
154
+ font-weight: 700;
155
+ }
156
+
157
+ .action-btn {
158
+ padding: 12px 12px;
159
+ font-weight: 700;
160
+ }
161
+
162
+ .accent-outline {
163
+ background: rgba(255, 255, 255, 0.38);
164
+ border-color: rgba(200, 87, 42, 0.16);
165
+ }
166
+
167
+ .hero-note {
168
+ color: var(--muted);
169
+ font-size: 13px;
170
+ line-height: 1.45;
171
+ padding: 12px 14px;
172
+ border-radius: 16px;
173
+ background: rgba(255, 255, 255, 0.52);
174
+ border: 1px solid var(--border);
175
+ }
176
+
177
+ .layout {
178
+ display: grid;
179
+ grid-template-columns: 332px 1fr;
180
+ gap: 18px;
181
+ }
182
+
183
+ .control-panel,
184
+ .comparison-panel {
185
+ min-width: 0;
186
+ }
187
+
188
+ .control-panel {
189
+ display: flex;
190
+ flex-direction: column;
191
+ gap: 14px;
192
+ }
193
+
194
+ .comparison-panel {
195
+ display: flex;
196
+ flex-direction: column;
197
+ gap: 16px;
198
+ }
199
+
200
+ .panel,
201
+ .stage,
202
+ .footer-note {
203
+ border: 1px solid var(--border);
204
+ border-radius: var(--radius-xl);
205
+ background: var(--panel);
206
+ box-shadow: var(--shadow);
207
+ }
208
+
209
+ .panel {
210
+ padding: 18px;
211
+ }
212
+
213
+ .stage {
214
+ padding: 20px;
215
+ }
216
+
217
+ .panel-label {
218
+ margin-bottom: 12px;
219
+ font-size: 12px;
220
+ font-weight: 800;
221
+ letter-spacing: 0.16em;
222
+ text-transform: uppercase;
223
+ color: var(--accent-cool);
224
+ }
225
+
226
+ .nav-row {
227
+ display: grid;
228
+ grid-template-columns: 48px 48px 1fr;
229
+ gap: 8px;
230
+ margin-bottom: 12px;
231
+ }
232
+
233
+ .nav-btn {
234
+ width: 48px;
235
+ height: 48px;
236
+ font-size: 20px;
237
+ font-weight: 700;
238
+ }
239
+
240
+ .counter {
241
+ display: flex;
242
+ align-items: center;
243
+ justify-content: center;
244
+ border-radius: 14px;
245
+ background: rgba(34, 89, 116, 0.1);
246
+ color: var(--accent-cool);
247
+ font-weight: 800;
248
+ font-variant-numeric: tabular-nums;
249
+ }
250
+
251
+ .field-label {
252
+ display: block;
253
+ margin-bottom: 6px;
254
+ color: var(--muted);
255
+ font-size: 13px;
256
+ font-weight: 700;
257
+ }
258
+
259
+ .text-input,
260
+ .sample-select {
261
+ width: 100%;
262
+ min-width: 0;
263
+ padding: 12px 14px;
264
+ border: 1px solid rgba(88, 63, 43, 0.18);
265
+ border-radius: 14px;
266
+ background: rgba(255, 255, 255, 0.78);
267
+ color: var(--text);
268
+ outline: none;
269
+ }
270
+
271
+ .text-input:focus,
272
+ .sample-select:focus {
273
+ border-color: rgba(200, 87, 42, 0.48);
274
+ box-shadow: 0 0 0 4px rgba(200, 87, 42, 0.1);
275
+ }
276
+
277
+ .chip-row,
278
+ .pill-row,
279
+ .badge-stack {
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ gap: 8px;
283
+ }
284
+
285
+ .meta-chip,
286
+ .badge {
287
+ display: inline-flex;
288
+ align-items: center;
289
+ justify-content: center;
290
+ min-height: 28px;
291
+ padding: 0 10px;
292
+ border-radius: 999px;
293
+ font-size: 12px;
294
+ font-weight: 800;
295
+ letter-spacing: 0.04em;
296
+ text-transform: uppercase;
297
+ }
298
+
299
+ .meta-chip {
300
+ background: rgba(34, 89, 116, 0.09);
301
+ color: var(--accent-cool);
302
+ }
303
+
304
+ .meta-chip.easy {
305
+ background: rgba(40, 92, 68, 0.12);
306
+ color: var(--success);
307
+ }
308
+
309
+ .meta-chip.medium {
310
+ background: rgba(200, 126, 20, 0.12);
311
+ color: #9a5b11;
312
+ }
313
+
314
+ .meta-chip.hard {
315
+ background: rgba(164, 42, 42, 0.12);
316
+ color: #972d2d;
317
+ }
318
+
319
+ .meta-chip.count {
320
+ background: rgba(200, 87, 42, 0.12);
321
+ color: var(--accent-deep);
322
+ }
323
+
324
+ .meta-list {
325
+ margin-top: 12px;
326
+ display: grid;
327
+ gap: 8px;
328
+ }
329
+
330
+ .meta-list > div {
331
+ display: flex;
332
+ justify-content: space-between;
333
+ gap: 12px;
334
+ padding: 10px 12px;
335
+ border-radius: 14px;
336
+ background: rgba(255, 255, 255, 0.54);
337
+ }
338
+
339
+ .meta-key {
340
+ color: var(--muted);
341
+ }
342
+
343
+ .meta-value {
344
+ font-weight: 700;
345
+ text-align: right;
346
+ }
347
+
348
+ .button-grid {
349
+ display: grid;
350
+ grid-template-columns: 1fr 1fr;
351
+ gap: 8px;
352
+ margin-bottom: 12px;
353
+ }
354
+
355
+ .filter-group + .filter-group {
356
+ margin-top: 12px;
357
+ }
358
+
359
+ .preset-grid {
360
+ display: grid;
361
+ grid-template-columns: repeat(2, minmax(0, 1fr));
362
+ gap: 8px;
363
+ margin-bottom: 10px;
364
+ }
365
+
366
+ .preset-btn,
367
+ .pill-btn {
368
+ padding: 9px 12px;
369
+ font-size: 13px;
370
+ font-weight: 700;
371
+ }
372
+
373
+ .wide-btn {
374
+ width: 100%;
375
+ }
376
+
377
+ .section-header {
378
+ display: flex;
379
+ justify-content: space-between;
380
+ align-items: end;
381
+ gap: 16px;
382
+ margin-bottom: 16px;
383
+ }
384
+
385
+ .header-side {
386
+ display: flex;
387
+ flex-direction: column;
388
+ align-items: flex-end;
389
+ gap: 4px;
390
+ }
391
+
392
+ .section-title {
393
+ margin: 4px 0 0;
394
+ font-size: clamp(22px, 2vw, 30px);
395
+ line-height: 1.04;
396
+ letter-spacing: -0.04em;
397
+ }
398
+
399
+ .results-summary {
400
+ color: var(--text);
401
+ font-weight: 800;
402
+ }
403
+
404
+ .active-filter-summary {
405
+ color: var(--muted);
406
+ font-size: 13px;
407
+ }
408
+
409
+ .video-card {
410
+ padding: 14px;
411
+ border: 1px solid rgba(88, 63, 43, 0.18);
412
+ border-radius: var(--radius-lg);
413
+ background: var(--panel-strong);
414
+ }
415
+
416
+ .gt-card {
417
+ margin-bottom: 0;
418
+ }
419
+
420
+ .card-header {
421
+ display: flex;
422
+ flex-direction: column;
423
+ gap: 6px;
424
+ margin-bottom: 12px;
425
+ }
426
+
427
+ .card-title {
428
+ font-size: 15px;
429
+ font-weight: 800;
430
+ letter-spacing: -0.02em;
431
+ }
432
+
433
+ .card-subtitle {
434
+ color: var(--muted);
435
+ font-size: 13px;
436
+ }
437
+
438
+ .badge {
439
+ background: var(--accent-cool);
440
+ color: #f6fbff;
441
+ }
442
+
443
+ .badge-secondary {
444
+ background: rgba(200, 87, 42, 0.12);
445
+ color: var(--accent-deep);
446
+ }
447
+
448
+ .badge-gt {
449
+ background: var(--accent);
450
+ }
451
+
452
+ .video-card video {
453
+ display: block;
454
+ width: 100%;
455
+ aspect-ratio: 1 / 1;
456
+ background: #0e0e0e;
457
+ border-radius: 16px;
458
+ border: 1px solid rgba(33, 24, 17, 0.14);
459
+ }
460
+
461
+ .results-grid {
462
+ display: grid;
463
+ grid-template-columns: repeat(auto-fit, minmax(248px, 1fr));
464
+ gap: 14px;
465
+ }
466
+
467
+ .empty-state {
468
+ margin-top: 14px;
469
+ padding: 16px 18px;
470
+ border-radius: 16px;
471
+ border: 1px dashed rgba(88, 63, 43, 0.22);
472
+ background: rgba(255, 255, 255, 0.5);
473
+ color: var(--muted);
474
+ }
475
+
476
+ .footer-note {
477
+ margin-top: 18px;
478
+ padding: 18px 20px;
479
+ display: grid;
480
+ gap: 6px;
481
+ color: var(--muted);
482
+ font-size: 13px;
483
+ }
484
+
485
+ code {
486
+ padding: 2px 7px;
487
+ border-radius: 999px;
488
+ background: rgba(34, 89, 116, 0.08);
489
+ color: var(--accent-cool);
490
+ }
491
+
492
+ @media (max-width: 1120px) {
493
+ .layout {
494
+ grid-template-columns: 1fr;
495
+ }
496
+
497
+ .hero {
498
+ flex-direction: column;
499
+ }
500
+
501
+ .hero-actions {
502
+ min-width: 0;
503
+ }
504
+ }
505
+
506
+ @media (max-width: 720px) {
507
+ .page-shell {
508
+ width: min(100vw - 20px, 1480px);
509
+ padding-top: 16px;
510
+ }
511
+
512
+ .hero,
513
+ .stage,
514
+ .panel,
515
+ .footer-note {
516
+ border-radius: 22px;
517
+ }
518
+
519
+ .hero-tool-row,
520
+ .button-grid,
521
+ .preset-grid {
522
+ grid-template-columns: 1fr;
523
+ }
524
+
525
+ .section-header {
526
+ flex-direction: column;
527
+ align-items: stretch;
528
+ }
529
+
530
+ .header-side {
531
+ align-items: flex-start;
532
+ }
533
+
534
+ .results-grid {
535
+ grid-template-columns: 1fr;
536
+ }
537
+ }
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }