mesmertech commited on
Commit
2760563
·
verified ·
1 Parent(s): c438971

Deploy static space build

Browse files
Files changed (7) hide show
  1. README.md +56 -5
  2. app.js +259 -0
  3. index.html +244 -17
  4. shared/api-client.js +114 -0
  5. shared/config.js +84 -0
  6. shared/ui.css +250 -0
  7. shared/ui.js +166 -0
README.md CHANGED
@@ -1,10 +1,61 @@
1
  ---
2
- title: Mesmer Video Benchmark
3
- emoji: 🏢
4
- colorFrom: indigo
5
- colorTo: purple
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: AI Video Generation Benchmark — Which LLM Codes the Best Video?
3
+ emoji: 🏁
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: static
7
  pinned: false
8
+ short_description: 12 AI models, same briefs, rendered to video. See which LLM
9
  ---
10
 
11
+ # AI Video Generation Benchmark — Which LLM Codes the Best Video?
12
+
13
+ An **AI video generation benchmark** that pits 12 LLMs against the same creative
14
+ briefs. Each model is asked to generate motion-graphics animation code, which is
15
+ then compiled and rendered to a real video — same pipeline for every model, no
16
+ hand-fixing in between. The result is a side-by-side **leaderboard** plus a
17
+ gallery of every rendered clip, so you can judge the **best LLM for video** with
18
+ your own eyes.
19
+
20
+ This is a free, no-signup mirror of the full
21
+ **[AI Video Generation Benchmark on MesmerTools](https://mesmer.tools/benchmarks/ai-video-generation)**.
22
+
23
+ ## What's inside
24
+
25
+ - **The leaderboard** — every model ranked by output quality, with videos
26
+ rendered, average generation time, average code size, total cost, and retry
27
+ count. A clean read on which model is the **best LLM for motion graphics**.
28
+ - **Every result, brief by brief** — seven prompts (YouTube intro, SaaS teaser,
29
+ Instagram story, app walkthrough, smartwatch reveal, music "wrapped" recap,
30
+ sneaker drop) with each model's rendered video, plus the failures shown
31
+ honestly.
32
+ - **Claude vs Gemini vs GPT video**, head to head — Claude Fable 5, Claude Opus
33
+ 4.8, Claude Sonnet 4.6, GPT-5.5, Gemini 3.1 Pro, Gemini 3.5 Flash, DeepSeek V4,
34
+ Kimi K2.6, GLM 5.1 / 5.2, and MiMo V2.5 Pro.
35
+
36
+ ## How it works
37
+
38
+ The page fetches a single generated JSON
39
+ (`https://mesmer.tools/benchmarks/ai-video-generation.json`) directly in your
40
+ browser and renders the leaderboard and video gallery. The data is regenerated
41
+ whenever MesmerTools redeploys, so the benchmark stays current with no backend.
42
+
43
+ Want the lightbox, **side-by-side compare**, and the raw generated code for each
44
+ model? Open the full interactive version:
45
+ **[mesmer.tools/benchmarks/ai-video-generation](https://mesmer.tools/benchmarks/ai-video-generation)**.
46
+
47
+ ## More free tools on MesmerTools
48
+
49
+ - [AI Logo Maker](https://mesmer.tools/free-tools/ai-logo-maker) — generate a brand logo from a prompt.
50
+ - [Text to Speech](https://mesmer.tools/api-tools/tts) — natural AI voices, free.
51
+ - [Website Screenshot API](https://mesmer.tools/api-tools/screenshot) — capture any URL as an image.
52
+ - [Site Logo / Favicon](https://mesmer.tools/api-tools/site-logo) — fetch any website's logo.
53
+ - [Token Price Calculator](https://mesmer.tools/free-tools/token-price-calculator) — estimate LLM API costs.
54
+
55
+ Browse everything at **[mesmer.tools](https://mesmer.tools)** — free developer & AI tools.
56
+
57
+ ---
58
+
59
+ **Keywords:** ai video generation benchmark, best llm for video, llm motion
60
+ graphics benchmark, claude vs gemini vs gpt video, ai video model comparison,
61
+ best ai model for motion design.
app.js ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AI Video Generation Benchmark — a static "display" space.
3
+ *
4
+ * Fetches the canonical benchmark JSON from mesmer.tools once at load and renders
5
+ * a leaderboard + per-prompt video gallery. No input form, no per-visitor API
6
+ * calls — the data is regenerated whenever the main site redeploys, so this page
7
+ * stays current on its own.
8
+ */
9
+
10
+ import { mountChrome, el } from "./shared/ui.js";
11
+ import { fetchData } from "./shared/api-client.js";
12
+ import { DATA, ENDPOINTS, siteUrl } from "./shared/config.js";
13
+
14
+ const FULL_BENCHMARK_URL = siteUrl(ENDPOINTS.benchmark.fullToolPath); // mesmer.tools/benchmarks/ai-video-generation
15
+
16
+ /* --- Formatting helpers --------------------------------------------------- */
17
+
18
+ function fmtSeconds(ms) {
19
+ return ms == null ? "—" : `${(ms / 1000).toFixed(1)}s`;
20
+ }
21
+
22
+ function fmtCodeKB(chars) {
23
+ return chars == null ? "—" : `${(chars / 1000).toFixed(1)} KB`;
24
+ }
25
+
26
+ function fmtCost(usd, estimate) {
27
+ if (usd == null) return "—";
28
+ return `${estimate ? "~$" : "$"}${usd.toFixed(2)}`;
29
+ }
30
+
31
+ function fmtDate(iso) {
32
+ const d = new Date(iso);
33
+ if (Number.isNaN(d.getTime())) return iso || "—";
34
+ return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
35
+ }
36
+
37
+ function runnerLabel(runner) {
38
+ return runner === "claude-code-subagent" ? "Claude Code" : "OpenRouter";
39
+ }
40
+
41
+ function truncate(text, max = 90) {
42
+ if (!text) return "";
43
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
44
+ }
45
+
46
+ /* --- Page shell ----------------------------------------------------------- */
47
+
48
+ const main = el("main", { className: "bm-main" });
49
+ main.appendChild(el("div", { className: "bm-state" },
50
+ el("span", { className: "ms-spinner", "aria-hidden": "true" }),
51
+ el("span", { text: "Loading benchmark…" }),
52
+ ));
53
+
54
+ // Insert main first, then mount the shared chrome so ordering is header → main → footer.
55
+ const root = document.getElementById("app") || document.body;
56
+ root.appendChild(main);
57
+ mountChrome("benchmark");
58
+
59
+ /* --- Fetch + render ------------------------------------------------------- */
60
+
61
+ fetchData(DATA.videoBenchmark)
62
+ .then((data) => {
63
+ if (!data || !Array.isArray(data.summaries) || !Array.isArray(data.prompts)) {
64
+ throw new Error("The benchmark data was empty or in an unexpected shape.");
65
+ }
66
+ renderBenchmark(data);
67
+ })
68
+ .catch((err) => {
69
+ main.replaceChildren(
70
+ el("div", { className: "bm-state is-error" },
71
+ el("strong", { text: "Couldn't load the benchmark." }),
72
+ el("span", { text: (err && err.message) || "Please try again in a moment." }),
73
+ el("a", { className: "bm-retry", href: FULL_BENCHMARK_URL, target: "_blank", rel: "noopener",
74
+ text: "See it on mesmer.tools →" }),
75
+ ),
76
+ );
77
+ });
78
+
79
+ function renderBenchmark(data) {
80
+ const { generatedAt, models = [], prompts = [], summaries = [], sections = [], totals = {} } = data;
81
+ const ranking = Array.isArray(data.ranking) ? data.ranking : [];
82
+
83
+ const modelBySlug = new Map(models.map((m) => [m.slug, m]));
84
+ const rankOf = (slug) => {
85
+ const i = ranking.indexOf(slug);
86
+ return i >= 0 ? i + 1 : null;
87
+ };
88
+
89
+ const frag = document.createDocumentFragment();
90
+ frag.appendChild(hero(generatedAt, models, prompts, totals));
91
+ frag.appendChild(leaderboard(summaries));
92
+ frag.appendChild(promptGallery(prompts, sections, modelBySlug, rankOf));
93
+ frag.appendChild(ctaCard());
94
+
95
+ main.replaceChildren(frag);
96
+ }
97
+
98
+ /* --- Hero ----------------------------------------------------------------- */
99
+
100
+ function hero(generatedAt, models, prompts, totals) {
101
+ return el("section", { className: "bm-hero" },
102
+ el("div", { className: "bm-hero-emoji", text: "🏁" }),
103
+ el("h1", { className: "bm-title", text: "AI Video Generation Benchmark" }),
104
+ el("p", { className: "bm-subtitle",
105
+ text: `Which LLM writes the best motion-graphics code? ${models.length} models, ${prompts.length} briefs, rendered to video.` }),
106
+ el("div", { className: "bm-method" },
107
+ el("span", { className: "bm-method-icon", text: "🧪" }),
108
+ el("p", { className: "bm-method-body" },
109
+ el("strong", { text: "How it works. " }),
110
+ "Every model got the same creative briefs and was asked to generate animation code, which was then compiled and rendered to video — the same pipeline for all, with no hand-fixing in between. Failed cards are the model's own: the code either didn't generate or crashed while rendering. The ranking below is editorial, judged by hand on output quality.",
111
+ ),
112
+ ),
113
+ el("p", { className: "bm-updated",
114
+ text: generatedAt
115
+ ? `Updated ${fmtDate(generatedAt)} · ${totals.rendered ?? 0}/${totals.total ?? 0} videos rendered`
116
+ : `${totals.rendered ?? 0}/${totals.total ?? 0} videos rendered · live from mesmer.tools` }),
117
+ );
118
+ }
119
+
120
+ /* --- Leaderboard ---------------------------------------------------------- */
121
+
122
+ function leaderboard(summaries) {
123
+ const head = el("tr", {},
124
+ el("th", { text: "Rank" }),
125
+ el("th", { text: "Model" }),
126
+ el("th", { text: "Runner" }),
127
+ el("th", { text: "Rendered" }),
128
+ el("th", { text: "Avg time" }),
129
+ el("th", { text: "Avg code" }),
130
+ el("th", { text: "Cost" }),
131
+ el("th", { text: "Tries" }),
132
+ );
133
+
134
+ const rows = summaries.map((s, i) => {
135
+ const m = s.model || {};
136
+ const isClaude = m.runner === "claude-code-subagent";
137
+ const renderedCls = s.rendered === s.total ? "is-full" : s.rendered === 0 ? "is-zero" : "is-partial";
138
+ return el("tr", {},
139
+ el("td", { className: "bm-rank", text: String(i + 1) }),
140
+ el("td", {},
141
+ el("div", { className: "bm-model-name", text: m.displayName || m.slug || "—" }),
142
+ el("div", { className: "bm-model-prov", text: m.provider || "" }),
143
+ ),
144
+ el("td", {},
145
+ el("span", { className: `bm-badge ${isClaude ? "is-cc" : "is-or"}`, text: runnerLabel(m.runner) }),
146
+ ),
147
+ el("td", {},
148
+ el("span", { className: `bm-rendered ${renderedCls}`, text: `${s.rendered}/${s.total}` }),
149
+ ),
150
+ el("td", { className: "bm-num", text: fmtSeconds(s.avgGenerationMs) }),
151
+ el("td", { className: "bm-num", text: fmtCodeKB(s.avgCodeChars) }),
152
+ el("td", { className: "bm-num", text: fmtCost(s.totalCostUsd, isClaude) }),
153
+ el("td", { className: "bm-num", text: s.totalAttempts != null ? String(s.totalAttempts) : "—" }),
154
+ );
155
+ });
156
+
157
+ return el("section", { className: "bm-section" },
158
+ el("h2", { className: "bm-h2", text: "Leaderboard" }),
159
+ el("p", { className: "bm-h2-note",
160
+ text: "Ranked by hand after watching every model's videos. Claude models ran through Claude Code, so their gen times are omitted and cost is an estimate (~)." }),
161
+ el("p", { className: "bm-scroll-hint", text: "← swipe the table for all columns →" }),
162
+ el("div", { className: "bm-table-wrap" },
163
+ el("table", { className: "bm-table" },
164
+ el("thead", {}, head),
165
+ el("tbody", {}, ...rows),
166
+ ),
167
+ ),
168
+ );
169
+ }
170
+
171
+ /* --- Per-prompt video gallery -------------------------------------------- */
172
+
173
+ function promptGallery(prompts, sections, modelBySlug, rankOf) {
174
+ const sectionByPrompt = new Map(sections.map((s) => [s.promptId, s]));
175
+
176
+ const wrap = el("section", { className: "bm-section" },
177
+ el("h2", { className: "bm-h2", text: "Every result, brief by brief" }),
178
+ el("p", { className: "bm-h2-note", text: "Same prompt, every model. Best-ranked first; failures last." }),
179
+ );
180
+
181
+ for (const prompt of prompts) {
182
+ const section = sectionByPrompt.get(prompt.id);
183
+ if (!section || !Array.isArray(section.runs)) continue;
184
+
185
+ const aspect = prompt.aspectRatio || "16:9";
186
+ const grid = el("div", { className: "bm-grid", dataset: { aspect } });
187
+ for (const run of section.runs) {
188
+ grid.appendChild(videoCard(run, prompt, modelBySlug, rankOf));
189
+ }
190
+
191
+ wrap.appendChild(
192
+ el("div", { className: "bm-prompt" },
193
+ el("div", { className: "bm-prompt-head" },
194
+ el("h3", { className: "bm-prompt-title", text: prompt.title || prompt.id }),
195
+ el("span", { className: "bm-prompt-spec", text: `${prompt.aspectRatio} · ${prompt.fps}fps` }),
196
+ ),
197
+ el("details", { className: "bm-prompt-details" },
198
+ el("summary", { text: "Show the full brief" }),
199
+ el("div", { className: "bm-prompt-text", text: prompt.prompt || "" }),
200
+ ),
201
+ grid,
202
+ ),
203
+ );
204
+ }
205
+
206
+ return wrap;
207
+ }
208
+
209
+ function videoCard(run, prompt, modelBySlug, rankOf) {
210
+ const m = modelBySlug.get(run.modelSlug) || {};
211
+ const name = m.displayName || run.modelSlug;
212
+ const rank = rankOf(run.modelSlug);
213
+ const sub = [rank ? `#${rank}` : null, m.provider].filter(Boolean).join(" · ");
214
+ const aspect = (prompt.aspectRatio || "16:9").replace(":", " / ");
215
+
216
+ if (run.rendered && run.videoUrl) {
217
+ const metaParts = [];
218
+ if (run.generationMs != null) metaParts.push(`${(run.generationMs / 1000).toFixed(1)}s gen`);
219
+ if (run.codeChars != null) metaParts.push(`${(run.codeChars / 1000).toFixed(1)} KB code`);
220
+
221
+ return el("div", { className: "bm-card" },
222
+ el("video", {
223
+ src: run.videoUrl,
224
+ controls: "",
225
+ muted: "",
226
+ loop: "",
227
+ playsinline: "",
228
+ preload: "metadata",
229
+ width: String(prompt.width || ""),
230
+ height: String(prompt.height || ""),
231
+ "aria-label": `${name} — ${prompt.title}`,
232
+ }),
233
+ el("div", { className: "bm-card-name", text: name }),
234
+ el("div", { className: "bm-card-sub", text: sub }),
235
+ metaParts.length ? el("div", { className: "bm-card-meta", text: metaParts.join(" · ") }) : null,
236
+ );
237
+ }
238
+
239
+ // Failed run — dashed placeholder sized to the brief's aspect ratio.
240
+ const reason = run.error ? truncate(run.error) : "Code didn't compile";
241
+ return el("div", { className: "bm-card is-fail" },
242
+ el("div", { className: "bm-fail", style: { aspectRatio: aspect },
243
+ text: run.codeUrl ? "Code didn't render" : "No usable code produced" }),
244
+ el("div", { className: "bm-card-name", text: name }),
245
+ el("div", { className: "bm-card-sub", text: sub }),
246
+ el("div", { className: "bm-card-meta", text: reason }),
247
+ );
248
+ }
249
+
250
+ /* --- CTA ------------------------------------------------------------------ */
251
+
252
+ function ctaCard() {
253
+ return el("section", { className: "bm-cta" },
254
+ el("h3", { text: "Want to compare them side by side?" }),
255
+ el("p", { text: "The full interactive benchmark adds a lightbox, side-by-side compare, and the raw generated code for every model." }),
256
+ el("a", { className: "bm-cta-btn", href: FULL_BENCHMARK_URL, target: "_blank", rel: "noopener",
257
+ text: "See the full interactive benchmark with side-by-side compare →" }),
258
+ );
259
+ }
index.html CHANGED
@@ -1,19 +1,246 @@
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>AI Video Generation Benchmark — Which LLM Codes the Best Video?</title>
7
+ <meta name="description" content="AI video generation benchmark: 12 AI models get the same creative briefs and generate motion-graphics code that's rendered to video. See the best LLM for video — Claude vs Gemini vs GPT — in a side-by-side leaderboard and gallery." />
8
+ <meta name="keywords" content="ai video generation benchmark, best llm for video, llm motion graphics benchmark, claude vs gemini vs gpt video" />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
11
+ <link rel="stylesheet" href="./shared/ui.css" />
12
+ <style>
13
+ /* ----- Benchmark-specific layout, layered on the shared dark theme ----- */
14
+ .bm-main {
15
+ max-width: 1100px;
16
+ margin: 0 auto;
17
+ padding: 40px 20px 8px;
18
+ }
19
+
20
+ /* Hero */
21
+ .bm-hero { margin-bottom: 28px; }
22
+ .bm-hero-emoji { font-size: 38px; line-height: 1; margin-bottom: 12px; }
23
+ .bm-title {
24
+ margin: 0 0 12px;
25
+ font-size: clamp(28px, 5vw, 42px);
26
+ font-weight: 800;
27
+ letter-spacing: -0.9px;
28
+ background: var(--grad);
29
+ -webkit-background-clip: text;
30
+ background-clip: text;
31
+ color: transparent;
32
+ }
33
+ .bm-subtitle { margin: 0; max-width: 680px; color: var(--text-secondary); font-size: 16px; }
34
+ .bm-method {
35
+ display: flex;
36
+ gap: 12px;
37
+ align-items: flex-start;
38
+ margin: 20px 0 14px;
39
+ max-width: 820px;
40
+ padding: 14px 16px;
41
+ border: 1px solid var(--amber-glow);
42
+ border-radius: 12px;
43
+ background: var(--amber-dim);
44
+ }
45
+ .bm-method-icon { font-size: 18px; line-height: 1.4; }
46
+ .bm-method-body { font-size: 13px; color: var(--text-secondary); line-height: 1.55; }
47
+ .bm-method-body strong { color: var(--text-primary); }
48
+ .bm-updated {
49
+ display: inline-block;
50
+ margin-top: 4px;
51
+ padding: 5px 12px;
52
+ border: 1px solid var(--border);
53
+ border-radius: 20px;
54
+ background: var(--bg-elevated);
55
+ color: var(--text-muted);
56
+ font-size: 12px;
57
+ }
58
+
59
+ /* Section heading */
60
+ .bm-section { margin-top: 44px; }
61
+ .bm-h2 {
62
+ margin: 0 0 4px;
63
+ font-size: 20px;
64
+ font-weight: 800;
65
+ letter-spacing: -0.4px;
66
+ color: var(--text-primary);
67
+ }
68
+ .bm-h2-note { margin: 0 0 16px; font-size: 12.5px; color: var(--text-muted); }
69
+
70
+ /* Leaderboard table */
71
+ .bm-table-wrap {
72
+ border: 1px solid var(--border);
73
+ border-radius: 12px;
74
+ overflow-x: auto;
75
+ -webkit-overflow-scrolling: touch;
76
+ }
77
+ .bm-scroll-hint { display: none; margin: 0 0 8px; font-size: 11px; color: var(--text-muted); }
78
+ .bm-table { width: 100%; min-width: 680px; border-collapse: collapse; font-size: 13.5px; }
79
+ .bm-table th {
80
+ padding: 11px 14px;
81
+ text-align: left;
82
+ white-space: nowrap;
83
+ font-size: 11px;
84
+ font-weight: 600;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.5px;
87
+ color: var(--text-muted);
88
+ background: var(--bg-elevated);
89
+ border-bottom: 1px solid var(--border);
90
+ }
91
+ .bm-table td {
92
+ padding: 11px 14px;
93
+ border-bottom: 1px solid var(--border);
94
+ vertical-align: middle;
95
+ }
96
+ .bm-table tbody tr:last-child td { border-bottom: none; }
97
+ .bm-table tbody tr:hover { background: var(--bg-card); }
98
+ .bm-rank { color: var(--text-muted); font-variant-numeric: tabular-nums; }
99
+ .bm-model-name { font-weight: 600; color: var(--text-primary); }
100
+ .bm-model-prov { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
101
+ .bm-num { color: var(--text-secondary); white-space: nowrap; font-variant-numeric: tabular-nums; }
102
+ .bm-rendered { font-weight: 700; color: var(--text-primary); }
103
+ .bm-rendered.is-full { color: #4ade80; }
104
+ .bm-rendered.is-partial { color: #fcd34d; }
105
+ .bm-badge {
106
+ display: inline-block;
107
+ padding: 2px 8px;
108
+ border-radius: 6px;
109
+ font-size: 11px;
110
+ font-weight: 600;
111
+ white-space: nowrap;
112
+ background: var(--amber-dim);
113
+ border: 1px solid var(--amber-glow);
114
+ color: #fcd9a0;
115
+ }
116
+ .bm-badge.is-or { background: var(--bg-elevated); border-color: var(--border); color: var(--text-secondary); }
117
+
118
+ /* Per-prompt sections */
119
+ .bm-prompt { margin-top: 40px; }
120
+ .bm-prompt-head {
121
+ display: flex;
122
+ align-items: baseline;
123
+ flex-wrap: wrap;
124
+ gap: 10px;
125
+ margin-bottom: 10px;
126
+ }
127
+ .bm-prompt-title { margin: 0; font-size: 18px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.3px; }
128
+ .bm-prompt-spec {
129
+ display: inline-block;
130
+ padding: 2px 9px;
131
+ border-radius: 20px;
132
+ font-size: 11px;
133
+ font-weight: 600;
134
+ background: var(--bg-elevated);
135
+ border: 1px solid var(--border);
136
+ color: var(--text-secondary);
137
+ }
138
+ .bm-prompt-details {
139
+ margin-bottom: 16px;
140
+ border: 1px solid var(--border);
141
+ border-radius: 10px;
142
+ background: var(--bg-card);
143
+ }
144
+ .bm-prompt-details summary {
145
+ cursor: pointer;
146
+ padding: 10px 14px;
147
+ font-size: 12.5px;
148
+ font-weight: 600;
149
+ color: var(--text-secondary);
150
+ list-style: none;
151
+ }
152
+ .bm-prompt-details summary::-webkit-details-marker { display: none; }
153
+ .bm-prompt-details summary::before { content: "▸ "; color: var(--amber); }
154
+ .bm-prompt-details[open] summary::before { content: "▾ "; }
155
+ .bm-prompt-text {
156
+ padding: 0 14px 14px;
157
+ font-size: 13px;
158
+ line-height: 1.6;
159
+ color: var(--text-secondary);
160
+ white-space: pre-wrap;
161
+ }
162
+
163
+ /* Video grid — column width tuned to the brief's aspect ratio */
164
+ .bm-grid { display: grid; gap: 16px; }
165
+ .bm-grid[data-aspect="16:9"] { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
166
+ .bm-grid[data-aspect="1:1"] { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
167
+ .bm-grid[data-aspect="9:16"] { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
168
+
169
+ .bm-card { display: flex; flex-direction: column; gap: 7px; }
170
+ .bm-card video {
171
+ width: 100%;
172
+ height: auto;
173
+ display: block;
174
+ border-radius: 10px;
175
+ border: 1px solid var(--border);
176
+ background: #000;
177
+ }
178
+ .bm-card-name { font-size: 13.5px; font-weight: 600; color: var(--text-primary); }
179
+ .bm-card-sub { font-size: 11.5px; color: var(--text-muted); }
180
+ .bm-card-meta { font-size: 11px; color: var(--text-muted); }
181
+
182
+ .bm-card.is-fail .bm-fail {
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ text-align: center;
187
+ padding: 12px;
188
+ border: 1px dashed var(--border-hover);
189
+ border-radius: 10px;
190
+ background: var(--bg-elevated);
191
+ color: var(--text-muted);
192
+ font-size: 12px;
193
+ font-weight: 600;
194
+ }
195
+ .bm-card.is-fail .bm-card-name,
196
+ .bm-card.is-fail .bm-card-sub { opacity: 0.7; }
197
+
198
+ /* CTA */
199
+ .bm-cta {
200
+ margin: 48px 0 8px;
201
+ padding: 26px 24px;
202
+ border: 1px solid var(--amber-glow);
203
+ border-radius: 14px;
204
+ background: var(--bg-card);
205
+ text-align: center;
206
+ }
207
+ .bm-cta h3 { margin: 0 0 6px; font-size: 19px; font-weight: 800; color: var(--text-primary); letter-spacing: -0.3px; }
208
+ .bm-cta p { margin: 0 0 16px; font-size: 14px; color: var(--text-secondary); }
209
+ .bm-cta-btn {
210
+ display: inline-flex;
211
+ align-items: center;
212
+ gap: 8px;
213
+ padding: 12px 22px;
214
+ border-radius: 8px;
215
+ background: var(--grad);
216
+ color: #fff !important;
217
+ font-size: 14px;
218
+ font-weight: 700;
219
+ }
220
+ .bm-cta-btn:hover { filter: brightness(1.08); text-decoration: none; }
221
+
222
+ /* Loading / error */
223
+ .bm-state {
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: center;
227
+ gap: 12px;
228
+ min-height: 200px;
229
+ color: var(--text-secondary);
230
+ font-size: 15px;
231
+ }
232
+ .bm-state.is-error { color: #fca5a5; flex-direction: column; }
233
+ .bm-state .bm-retry { color: var(--amber); font-weight: 600; }
234
+
235
+ @media (max-width: 560px) {
236
+ .bm-main { padding-top: 28px; }
237
+ .bm-scroll-hint { display: block; }
238
+ .bm-grid[data-aspect="9:16"] { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
239
+ }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <div id="app"></div>
244
+ <script type="module" src="./app.js"></script>
245
+ </body>
246
  </html>
shared/api-client.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared API client for all MesmerTools HuggingFace Spaces.
3
+ *
4
+ * Spaces are static pages that call the mesmer.tools API directly from the
5
+ * visitor's browser. That is deliberate: per-IP rate limits then apply per
6
+ * visitor instead of being shared through one Space backend. CORS on
7
+ * /api/v1/* is already `*`, so cross-origin fetches work with no proxy.
8
+ *
9
+ * Every helper throws `RateLimitError` on a 429 so the UI can surface the
10
+ * "use the full tool for higher limits" cross-sell, and `ApiError` otherwise.
11
+ */
12
+
13
+ import { SITE } from "./config.js";
14
+
15
+ export class RateLimitError extends Error {
16
+ constructor(message) {
17
+ super(message || "You've hit the free hourly limit.");
18
+ this.name = "RateLimitError";
19
+ this.isRateLimit = true;
20
+ }
21
+ }
22
+
23
+ export class ApiError extends Error {
24
+ constructor(message, status = 0) {
25
+ super(message || "Something went wrong.");
26
+ this.name = "ApiError";
27
+ this.status = status;
28
+ }
29
+ }
30
+
31
+ const DEFAULT_TIMEOUT = 60_000;
32
+
33
+ async function doFetch(url, init, timeout) {
34
+ const ctrl = new AbortController();
35
+ const timer = setTimeout(() => ctrl.abort(), timeout ?? DEFAULT_TIMEOUT);
36
+ try {
37
+ return await fetch(url, { ...init, signal: ctrl.signal });
38
+ } catch (err) {
39
+ if (err && err.name === "AbortError") {
40
+ throw new ApiError(
41
+ "The request timed out. The free demo can be slow under load — try the full tool on mesmer.tools.",
42
+ 408,
43
+ );
44
+ }
45
+ throw new ApiError("Network error. Check your connection and try again.", 0);
46
+ } finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+
51
+ async function readJson(res) {
52
+ try {
53
+ return await res.json();
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * REST call. GET with `params`, or POST with a JSON `body`.
61
+ * @returns parsed JSON response
62
+ */
63
+ export async function callRest(apiPath, { method = "GET", params, body, timeout } = {}) {
64
+ let url = SITE.origin + apiPath;
65
+ const init = { method, headers: {} };
66
+ if (params) {
67
+ const qs = new URLSearchParams(params).toString();
68
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
69
+ }
70
+ if (body !== undefined) {
71
+ init.headers["Content-Type"] = "application/json";
72
+ init.body = JSON.stringify(body);
73
+ }
74
+ const res = await doFetch(url, init, timeout);
75
+ const data = await readJson(res);
76
+ if (res.status === 429) throw new RateLimitError(data && data.error);
77
+ if (!res.ok) throw new ApiError((data && data.error) || `Request failed (${res.status}).`, res.status);
78
+ return data;
79
+ }
80
+
81
+ /**
82
+ * tRPC v11 mutation (superjson transformer). Used by the logo space.
83
+ * Input is wrapped as `{ json: input }`; the unwrapped value is returned.
84
+ */
85
+ export async function callTrpcMutation(trpcPath, input, { timeout } = {}) {
86
+ const res = await doFetch(
87
+ SITE.origin + trpcPath,
88
+ {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({ json: input }),
92
+ },
93
+ timeout,
94
+ );
95
+ const data = await readJson(res);
96
+ if (res.status === 429) throw new RateLimitError(extractTrpcError(data));
97
+ if (!res.ok) throw new ApiError(extractTrpcError(data) || `Request failed (${res.status}).`, res.status);
98
+ // superjson success envelope: { result: { data: { json: <value> } } }
99
+ const out = data && data.result && data.result.data;
100
+ return out && typeof out === "object" && "json" in out ? out.json : out;
101
+ }
102
+
103
+ function extractTrpcError(data) {
104
+ if (!data || !data.error) return null;
105
+ // superjson error envelope: { error: { json: { message, data: {...} } } }
106
+ return (data.error.json && data.error.json.message) || data.error.message || null;
107
+ }
108
+
109
+ /** Fetch a generated JSON artifact (benchmark, voices) from mesmer.tools. */
110
+ export async function fetchData(url, { timeout } = {}) {
111
+ const res = await doFetch(url, { method: "GET" }, timeout ?? 20_000);
112
+ if (!res.ok) throw new ApiError(`Could not load data (${res.status}).`, res.status);
113
+ return readJson(res);
114
+ }
shared/config.js ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Single source of shared config for every MesmerTools HuggingFace Space.
3
+ * Authored once here and imported by all spaces so nothing is duplicated.
4
+ *
5
+ * Data that changes often (TTS voices, benchmark runs) is NOT hardcoded — it is
6
+ * fetched at runtime from generated JSON on mesmer.tools (see DATA) so the spaces
7
+ * always reflect the canonical constants in the main repo.
8
+ */
9
+
10
+ export const SITE = {
11
+ name: "MesmerTools",
12
+ origin: "https://mesmer.tools",
13
+ cdn: "https://cdn.mesmer.tools",
14
+ tagline: "Free developer & AI tools.",
15
+ };
16
+
17
+ /**
18
+ * Per-endpoint contract.
19
+ * `freeLimitPerHour` is the per-IP free cap the API enforces (null = no hard
20
+ * hourly cap). Because each space calls the API directly from the visitor's
21
+ * browser, this limit is per-user — every HuggingFace visitor gets their own
22
+ * bucket. On a 429 the space points users at `fullToolPath` for higher limits.
23
+ */
24
+ export const ENDPOINTS = {
25
+ screenshot: {
26
+ apiPath: "/api/v1/screenshot",
27
+ method: "GET",
28
+ freeLimitPerHour: 10,
29
+ fullToolPath: "/api-tools/screenshot",
30
+ },
31
+ siteLogo: {
32
+ apiPath: "/api/v1/site-logo",
33
+ method: "GET",
34
+ freeLimitPerHour: null, // cached server-side; no hard hourly cap
35
+ fullToolPath: "/api-tools/site-logo",
36
+ },
37
+ tts: {
38
+ apiPath: "/api/v1/tts",
39
+ method: "POST",
40
+ freeLimitPerHour: 20,
41
+ fullToolPath: "/api-tools/tts",
42
+ },
43
+ logo: {
44
+ trpcPath: "/api/trpc/logo.generate",
45
+ method: "POST",
46
+ freeLimitPerHour: 10,
47
+ fullToolPath: "/free-tools/ai-logo-maker",
48
+ },
49
+ benchmark: {
50
+ fullToolPath: "/benchmarks/ai-video-generation",
51
+ },
52
+ };
53
+
54
+ /**
55
+ * Live data endpoints. These serve JSON straight from the canonical constants
56
+ * in the main repo (no generated snapshot, no build step) so the spaces always
57
+ * reflect the current voices / benchmark dict. Edge-cached for an hour.
58
+ */
59
+ export const DATA = {
60
+ ttsVoices: `${SITE.origin}/api/v1/tts/voices`,
61
+ videoBenchmark: `${SITE.origin}/api/v1/benchmarks/ai-video-generation`,
62
+ };
63
+
64
+ /**
65
+ * Cross-sell catalog shown in the footer strip of every space. These are the
66
+ * SEO backlinks to mesmer.tools. `key` matches ENDPOINTS keys so a space can
67
+ * exclude itself from its own strip.
68
+ */
69
+ export const TOOL_CATALOG = [
70
+ { key: "logo", emoji: "🎨", name: "AI Logo Maker", desc: "Generate a brand logo from a prompt.", path: "/free-tools/ai-logo-maker" },
71
+ { key: "tts", emoji: "🔊", name: "Text to Speech", desc: "Natural AI voices, free.", path: "/api-tools/tts" },
72
+ { key: "screenshot", emoji: "📸", name: "Screenshot API", desc: "Capture any URL as an image.", path: "/api-tools/screenshot" },
73
+ { key: "siteLogo", emoji: "🏷️", name: "Site Logo / Favicon", desc: "Fetch any website's logo.", path: "/api-tools/site-logo" },
74
+ { key: "music", emoji: "🎵", name: "AI Music Maker", desc: "Generate short music clips.", path: "/free-tools/ai-music-maker" },
75
+ { key: "tokens", emoji: "🧮", name: "Token Price Calculator", desc: "Estimate LLM API costs.", path: "/free-tools/token-price-calculator" },
76
+ { key: "json", emoji: "{ }", name: "JSON Formatter", desc: "Format & validate JSON.", path: "/free-tools/json-formatter" },
77
+ { key: "synthid", emoji: "🔍", name: "Detect SynthID", desc: "Check images for AI watermarks.", path: "/free-tools/detect-synthid" },
78
+ { key: "benchmark", emoji: "🏁", name: "AI Video Benchmark", desc: "Which LLM codes the best video?", path: "/benchmarks/ai-video-generation" },
79
+ ];
80
+
81
+ /** Absolute mesmer.tools URL for a path. */
82
+ export function siteUrl(path) {
83
+ return `${SITE.origin}${path}`;
84
+ }
shared/ui.css ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* MesmerTools HuggingFace Spaces — shared dark theme.
2
+ Tokens mirror DESIGN.md so the spaces feel like the main site. */
3
+
4
+ :root {
5
+ --bg: #0d0d0d;
6
+ --bg-elevated: #161616;
7
+ --bg-card: #1a1a1a;
8
+ --bg-card-hover: #222222;
9
+ --border: #2a2a2a;
10
+ --border-hover: #3a3a3a;
11
+ --amber: #f59e0b;
12
+ --amber-dim: rgba(245, 158, 11, 0.15);
13
+ --amber-glow: rgba(245, 158, 11, 0.35);
14
+ --sienna: #c2410c;
15
+ --text-primary: #eeeeee;
16
+ --text-secondary: #999999;
17
+ --text-muted: #666666;
18
+ --grad: linear-gradient(135deg, var(--amber), var(--sienna));
19
+ --radius: 10px;
20
+ --maxw: 880px;
21
+ }
22
+
23
+ * { box-sizing: border-box; }
24
+
25
+ html { -webkit-text-size-adjust: 100%; }
26
+
27
+ body {
28
+ margin: 0;
29
+ background: var(--bg);
30
+ color: var(--text-primary);
31
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
32
+ -webkit-font-smoothing: antialiased;
33
+ line-height: 1.5;
34
+ }
35
+
36
+ a { color: var(--amber); text-decoration: none; }
37
+ a:hover { text-decoration: underline; }
38
+
39
+ /* --- Header --------------------------------------------------------- */
40
+ .ms-header {
41
+ position: sticky;
42
+ top: 0;
43
+ z-index: 10;
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: space-between;
47
+ gap: 16px;
48
+ height: 56px;
49
+ padding: 0 20px;
50
+ background: rgba(13, 13, 13, 0.85);
51
+ backdrop-filter: blur(10px);
52
+ border-bottom: 1px solid var(--border);
53
+ }
54
+ .ms-brand { display: flex; align-items: center; gap: 9px; color: var(--text-primary); }
55
+ .ms-brand:hover { text-decoration: none; }
56
+ .ms-brand-mark {
57
+ display: grid;
58
+ place-items: center;
59
+ width: 26px; height: 26px;
60
+ border-radius: 7px;
61
+ background: var(--grad);
62
+ color: var(--bg);
63
+ font-weight: 800;
64
+ font-size: 15px;
65
+ }
66
+ .ms-brand-name { font-weight: 700; letter-spacing: -0.3px; }
67
+ .ms-header-link { color: var(--text-secondary); font-size: 13px; font-weight: 500; }
68
+ .ms-header-link:hover { color: var(--amber); text-decoration: none; }
69
+
70
+ /* --- Layout --------------------------------------------------------- */
71
+ .ms-main {
72
+ max-width: var(--maxw);
73
+ margin: 0 auto;
74
+ padding: 40px 20px 8px;
75
+ }
76
+
77
+ .ms-hero { text-align: center; margin-bottom: 28px; }
78
+ .ms-hero-emoji { font-size: 40px; line-height: 1; margin-bottom: 12px; }
79
+ .ms-title {
80
+ margin: 0 0 10px;
81
+ font-size: clamp(26px, 5vw, 38px);
82
+ font-weight: 800;
83
+ letter-spacing: -0.8px;
84
+ background: var(--grad);
85
+ -webkit-background-clip: text;
86
+ background-clip: text;
87
+ color: transparent;
88
+ }
89
+ .ms-subtitle { margin: 0 auto; max-width: 620px; color: var(--text-secondary); font-size: 16px; }
90
+ .ms-intro { margin: 12px auto 0; max-width: 620px; color: var(--text-muted); font-size: 14px; }
91
+ .ms-limit-note {
92
+ display: inline-block;
93
+ margin-top: 16px;
94
+ padding: 5px 12px;
95
+ border: 1px solid var(--border);
96
+ border-radius: 20px;
97
+ background: var(--bg-elevated);
98
+ color: var(--text-muted);
99
+ font-size: 12px;
100
+ }
101
+
102
+ /* --- Card ----------------------------------------------------------- */
103
+ .ms-card {
104
+ background: var(--bg-card);
105
+ border: 1px solid var(--border);
106
+ border-radius: 14px;
107
+ padding: 24px;
108
+ }
109
+
110
+ /* --- Forms (generic, reusable by every space) ----------------------- */
111
+ .ms-form { display: flex; flex-direction: column; gap: 16px; }
112
+ .ms-field { display: flex; flex-direction: column; gap: 6px; }
113
+ .ms-field label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
114
+ .ms-field .ms-hint { font-size: 12px; color: var(--text-muted); }
115
+ .ms-row { display: flex; gap: 12px; flex-wrap: wrap; }
116
+ .ms-row > .ms-field { flex: 1 1 160px; }
117
+
118
+ .ms-input, .ms-textarea, .ms-select {
119
+ width: 100%;
120
+ padding: 11px 13px;
121
+ background: var(--bg-elevated);
122
+ border: 1px solid var(--border);
123
+ border-radius: 8px;
124
+ color: var(--text-primary);
125
+ font-family: inherit;
126
+ font-size: 14px;
127
+ transition: border-color 0.15s, box-shadow 0.15s;
128
+ }
129
+ .ms-textarea { resize: vertical; min-height: 110px; }
130
+ .ms-input:focus, .ms-textarea:focus, .ms-select:focus {
131
+ outline: none;
132
+ border-color: var(--amber);
133
+ box-shadow: 0 0 0 3px var(--amber-dim);
134
+ }
135
+ .ms-input::placeholder, .ms-textarea::placeholder { color: var(--text-muted); }
136
+ .ms-select { appearance: none; cursor: pointer; }
137
+
138
+ /* --- Buttons -------------------------------------------------------- */
139
+ .ms-btn {
140
+ display: inline-flex;
141
+ align-items: center;
142
+ justify-content: center;
143
+ gap: 8px;
144
+ padding: 12px 20px;
145
+ border: none;
146
+ border-radius: 8px;
147
+ background: var(--grad);
148
+ color: #fff;
149
+ font-family: inherit;
150
+ font-size: 14px;
151
+ font-weight: 700;
152
+ cursor: pointer;
153
+ transition: transform 0.15s, opacity 0.15s, filter 0.15s;
154
+ }
155
+ .ms-btn:hover { filter: brightness(1.08); }
156
+ .ms-btn:active { transform: translateY(1px); }
157
+ .ms-btn:disabled { opacity: 0.55; cursor: not-allowed; filter: none; }
158
+ .ms-btn-ghost {
159
+ background: var(--bg-elevated);
160
+ color: var(--text-primary);
161
+ border: 1px solid var(--border);
162
+ }
163
+ .ms-btn-ghost:hover { border-color: var(--border-hover); filter: none; }
164
+
165
+ .ms-char-count { font-size: 12px; color: var(--text-muted); text-align: right; }
166
+
167
+ /* --- Status banners ------------------------------------------------- */
168
+ .ms-status:empty { display: none; }
169
+ .ms-status { margin-top: 16px; }
170
+ .ms-banner {
171
+ padding: 12px 14px;
172
+ border-radius: 8px;
173
+ font-size: 14px;
174
+ border: 1px solid var(--border);
175
+ }
176
+ .ms-banner-error { background: rgba(220, 38, 38, 0.1); border-color: rgba(220, 38, 38, 0.4); color: #fca5a5; }
177
+ .ms-banner-limit { background: var(--amber-dim); border-color: var(--amber-glow); color: #fcd9a0; }
178
+ .ms-banner-limit .ms-banner-cta { display: inline-block; margin-top: 4px; font-weight: 700; }
179
+ .ms-banner-loading { display: flex; align-items: center; gap: 10px; background: var(--bg-elevated); color: var(--text-secondary); }
180
+ .ms-spinner {
181
+ width: 16px; height: 16px;
182
+ border: 2px solid var(--border-hover);
183
+ border-top-color: var(--amber);
184
+ border-radius: 50%;
185
+ animation: ms-spin 0.7s linear infinite;
186
+ }
187
+ @keyframes ms-spin { to { transform: rotate(360deg); } }
188
+
189
+ /* --- Output --------------------------------------------------------- */
190
+ .ms-output:empty { display: none; }
191
+ .ms-output { margin-top: 18px; }
192
+ .ms-output img, .ms-output video { max-width: 100%; border-radius: 10px; display: block; }
193
+ .ms-result-frame {
194
+ border: 1px solid var(--border);
195
+ border-radius: 10px;
196
+ overflow: hidden;
197
+ background: var(--bg-elevated);
198
+ }
199
+ .ms-result-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
200
+ .ms-meta { margin-top: 10px; font-size: 12px; color: var(--text-muted); }
201
+ .ms-audio { width: 100%; margin-top: 6px; }
202
+
203
+ /* --- Footer cross-sell ---------------------------------------------- */
204
+ .ms-footer {
205
+ max-width: var(--maxw);
206
+ margin: 48px auto 0;
207
+ padding: 28px 20px 56px;
208
+ border-top: 1px solid var(--border);
209
+ }
210
+ .ms-footer-title {
211
+ margin: 0 0 16px;
212
+ font-size: 13px;
213
+ font-weight: 600;
214
+ text-transform: uppercase;
215
+ letter-spacing: 0.6px;
216
+ color: var(--text-muted);
217
+ }
218
+ .ms-grid {
219
+ display: grid;
220
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
221
+ gap: 12px;
222
+ }
223
+ .ms-tool-card {
224
+ display: flex;
225
+ align-items: flex-start;
226
+ gap: 11px;
227
+ padding: 14px;
228
+ border: 1px solid var(--border);
229
+ border-radius: var(--radius);
230
+ background: var(--bg-card);
231
+ transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.2s, background 0.2s;
232
+ }
233
+ .ms-tool-card:hover {
234
+ transform: translateY(-3px);
235
+ background: var(--bg-card-hover);
236
+ border-color: var(--amber-glow);
237
+ text-decoration: none;
238
+ }
239
+ .ms-tool-emoji { font-size: 20px; line-height: 1.3; }
240
+ .ms-tool-text { display: flex; flex-direction: column; gap: 2px; }
241
+ .ms-tool-name { color: var(--text-primary); font-weight: 600; font-size: 14px; }
242
+ .ms-tool-desc { color: var(--text-secondary); font-size: 12.5px; }
243
+ .ms-powered { margin: 22px 0 0; color: var(--text-muted); font-size: 12.5px; }
244
+
245
+ /* --- Responsive ----------------------------------------------------- */
246
+ @media (max-width: 560px) {
247
+ .ms-main { padding-top: 28px; }
248
+ .ms-card { padding: 18px; }
249
+ .ms-grid { grid-template-columns: 1fr; }
250
+ }
shared/ui.js ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared page chrome for all MesmerTools HuggingFace Spaces.
3
+ *
4
+ * `createSpace()` builds the header, hero, a card with a <form> + output slot,
5
+ * a status area (errors + the rate-limit cross-sell banner), and the footer
6
+ * "more free tools" strip (the SEO backlinks). A tool space only has to fill
7
+ * the returned `form` and `output` elements and wire its submit handler.
8
+ */
9
+
10
+ import { SITE, ENDPOINTS, TOOL_CATALOG, siteUrl } from "./config.js";
11
+ import { RateLimitError } from "./api-client.js";
12
+
13
+ /** Tiny hyperscript helper. `props` supports className, text, html, on*, attrs, dataset, style. */
14
+ export function el(tag, props = {}, ...children) {
15
+ const node = document.createElement(tag);
16
+ for (const [key, value] of Object.entries(props || {})) {
17
+ if (value == null) continue;
18
+ if (key === "className") node.className = value;
19
+ else if (key === "text") node.textContent = value;
20
+ else if (key === "html") node.innerHTML = value;
21
+ else if (key === "dataset") Object.assign(node.dataset, value);
22
+ else if (key === "style" && typeof value === "object") Object.assign(node.style, value);
23
+ else if (key.startsWith("on") && typeof value === "function") {
24
+ node.addEventListener(key.slice(2).toLowerCase(), value);
25
+ } else node.setAttribute(key, value);
26
+ }
27
+ for (const child of children.flat()) {
28
+ if (child == null || child === false) continue;
29
+ node.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
30
+ }
31
+ return node;
32
+ }
33
+
34
+ function header() {
35
+ return el("header", { className: "ms-header" },
36
+ el("a", { className: "ms-brand", href: SITE.origin, target: "_blank", rel: "noopener" },
37
+ el("span", { className: "ms-brand-mark", text: "M" }),
38
+ el("span", { className: "ms-brand-name", text: SITE.name }),
39
+ ),
40
+ el("a", { className: "ms-header-link", href: SITE.origin, target: "_blank", rel: "noopener",
41
+ html: "All tools <span aria-hidden=\"true\">↗</span>" }),
42
+ );
43
+ }
44
+
45
+ function crossSell(toolKey) {
46
+ const items = TOOL_CATALOG.filter((t) => t.key !== toolKey);
47
+ return el("footer", { className: "ms-footer" },
48
+ el("p", { className: "ms-footer-title", text: "More free tools on MesmerTools" }),
49
+ el("div", { className: "ms-grid" },
50
+ ...items.map((t) =>
51
+ el("a", { className: "ms-tool-card", href: siteUrl(t.path), target: "_blank", rel: "noopener" },
52
+ el("span", { className: "ms-tool-emoji", text: t.emoji }),
53
+ el("span", { className: "ms-tool-text" },
54
+ el("span", { className: "ms-tool-name", text: t.name }),
55
+ el("span", { className: "ms-tool-desc", text: t.desc }),
56
+ ),
57
+ ),
58
+ ),
59
+ ),
60
+ el("p", { className: "ms-powered" },
61
+ "Powered by ",
62
+ el("a", { href: SITE.origin, target: "_blank", rel: "noopener", text: "mesmer.tools" }),
63
+ " — runs on the public MesmerTools API.",
64
+ ),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * @param {object} opts
70
+ * @param {string} opts.toolKey key in ENDPOINTS / TOOL_CATALOG (also excluded from the strip)
71
+ * @param {string} opts.emoji
72
+ * @param {string} opts.title
73
+ * @param {string} opts.subtitle
74
+ * @param {string} [opts.intro] optional longer paragraph under the subtitle
75
+ * @returns {{form: HTMLFormElement, output: HTMLElement, setLoading: Function,
76
+ * showError: Function, showRateLimit: Function, clearStatus: Function,
77
+ * clearOutput: Function, limitNote: string}}
78
+ */
79
+ export function createSpace(opts) {
80
+ const { toolKey, emoji, title, subtitle, intro } = opts;
81
+ const endpoint = ENDPOINTS[toolKey] || {};
82
+ const limit = endpoint.freeLimitPerHour;
83
+ const limitNote = limit
84
+ ? `${limit} free requests/hour per user · higher limits on mesmer.tools`
85
+ : `Free · backed by mesmer.tools`;
86
+
87
+ const root = document.getElementById("app") || document.body;
88
+
89
+ const status = el("div", { className: "ms-status", role: "status", "aria-live": "polite" });
90
+ const form = el("form", { className: "ms-form" });
91
+ const output = el("div", { className: "ms-output" });
92
+
93
+ const hero = el("section", { className: "ms-hero" },
94
+ el("div", { className: "ms-hero-emoji", text: emoji }),
95
+ el("h1", { className: "ms-title", text: title }),
96
+ el("p", { className: "ms-subtitle", text: subtitle }),
97
+ intro ? el("p", { className: "ms-intro", text: intro }) : null,
98
+ el("p", { className: "ms-limit-note", text: limitNote }),
99
+ );
100
+
101
+ const card = el("section", { className: "ms-card" }, form, status, output);
102
+ const main = el("main", { className: "ms-main" }, hero, card);
103
+
104
+ root.appendChild(header());
105
+ root.appendChild(main);
106
+ root.appendChild(crossSell(toolKey));
107
+
108
+ function clearStatus() {
109
+ status.replaceChildren();
110
+ }
111
+
112
+ function showRateLimit() {
113
+ const full = siteUrl(endpoint.fullToolPath || "/");
114
+ const msg = limit
115
+ ? `You've used your ${limit} free requests this hour. Each visitor gets their own quota — `
116
+ : `You've hit a temporary limit — `;
117
+ clearStatus();
118
+ status.appendChild(
119
+ el("div", { className: "ms-banner ms-banner-limit" },
120
+ el("strong", { text: "Free limit reached. " }),
121
+ msg,
122
+ el("a", { className: "ms-banner-cta", href: full, target: "_blank", rel: "noopener",
123
+ text: "use the full tool on mesmer.tools →" }),
124
+ ),
125
+ );
126
+ }
127
+
128
+ /** Renders a RateLimitError as the cross-sell banner; anything else as an error. */
129
+ function showError(err) {
130
+ if (err instanceof RateLimitError || (err && err.isRateLimit)) {
131
+ showRateLimit();
132
+ return;
133
+ }
134
+ const text = typeof err === "string" ? err : (err && err.message) || "Something went wrong.";
135
+ clearStatus();
136
+ status.appendChild(el("div", { className: "ms-banner ms-banner-error", text }));
137
+ }
138
+
139
+ function setLoading(isLoading, label) {
140
+ card.classList.toggle("is-loading", !!isLoading);
141
+ if (isLoading) {
142
+ clearStatus();
143
+ status.appendChild(
144
+ el("div", { className: "ms-banner ms-banner-loading" },
145
+ el("span", { className: "ms-spinner", "aria-hidden": "true" }),
146
+ el("span", { text: label || "Working…" }),
147
+ ),
148
+ );
149
+ } else {
150
+ clearStatus();
151
+ }
152
+ }
153
+
154
+ function clearOutput() {
155
+ output.replaceChildren();
156
+ }
157
+
158
+ return { form, output, status, setLoading, showError, showRateLimit, clearStatus, clearOutput, limitNote };
159
+ }
160
+
161
+ /** Standalone footer for non-tool spaces (e.g. the benchmark) that build their own body. */
162
+ export function mountChrome(toolKey) {
163
+ const root = document.getElementById("app") || document.body;
164
+ root.prepend(header());
165
+ root.appendChild(crossSell(toolKey));
166
+ }