Deploy static space build
Browse files- README.md +56 -5
- app.js +259 -0
- index.html +244 -17
- shared/api-client.js +114 -0
- shared/config.js +84 -0
- shared/ui.css +250 -0
- shared/ui.js +166 -0
README.md
CHANGED
|
@@ -1,10 +1,61 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|