← back to Model Arena
photoshop design tool: Adobe Firefly Services — IMS auth, Firefly text-to-image (jpeg-compressed assets), Photoshop cutout via unguessable auth-exempt asset GET + single-use PUT-back routes, {{PS_ASSET:id}} placeholders inlined as data URIs at artifact save; tool self-registers only when ADOBE_CLIENT_ID/SECRET are set
7bd9ccce460b88de592ff06dc1944b4957285d45 · 2026-07-23 08:49:39 -0700 · Steve Abrams
Files touched
M design-tools.jsM server.js
Diff
commit 7bd9ccce460b88de592ff06dc1944b4957285d45
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 23 08:49:39 2026 -0700
photoshop design tool: Adobe Firefly Services — IMS auth, Firefly text-to-image (jpeg-compressed assets), Photoshop cutout via unguessable auth-exempt asset GET + single-use PUT-back routes, {{PS_ASSET:id}} placeholders inlined as data URIs at artifact save; tool self-registers only when ADOBE_CLIENT_ID/SECRET are set
---
design-tools.js | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
server.js | 27 ++++++++++-
2 files changed, 168 insertions(+), 3 deletions(-)
diff --git a/design-tools.js b/design-tools.js
index bf75060..0e23128 100644
--- a/design-tools.js
+++ b/design-tools.js
@@ -4,7 +4,13 @@
// injected as a compact "design pack" for models without tool support.
// Every tool returns INLINE-ABLE material only (hex values, CSS, SVG paths,
// vanilla JS) because artifacts run under CSP default-src 'none' — no CDNs.
+// The photoshop tool is the exception: it produces real images server-side
+// (Adobe Firefly Services), saved under data/ps-assets/ and inlined into the
+// final artifact as data URIs via {{PS_ASSET:id}} placeholders.
'use strict';
+const fs = require('fs'), path = require('path'), os = require('os');
+const crypto = require('crypto');
+const { execFile } = require('child_process');
// ---------- opendesign: curated open design-system data ----------
@@ -154,6 +160,125 @@ async function figmaTokens(fileKey) {
return { file: r.json.name, colors, styles, note: 'colors are the most-used solid fills in the file — treat as the brand palette.' };
}
+// ---------- photoshop: Adobe Firefly Services (Firefly image gen + Photoshop cutout) ----------
+
+const PS_DIR = path.join(__dirname, 'data', 'ps-assets');
+const PS_PUBLIC = process.env.ARENA_PUBLIC_URL || 'https://modelarena.agentabrams.com';
+const PS_PUT_TOKENS = new Map(); // put-token -> target asset filename (single-use, for Adobe's output PUT-back)
+const psAvailable = () => !!(process.env.ADOBE_CLIENT_ID && process.env.ADOBE_CLIENT_SECRET);
+
+let ims = null; // cached IMS token
+async function adobeToken() {
+ if (!psAvailable()) return null;
+ if (ims && ims.exp > Date.now() + 60000) return ims.token;
+ const r = await fetch('https://ims-na1.adobelogin.com/ims/token/v3', {
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ client_id: process.env.ADOBE_CLIENT_ID, client_secret: process.env.ADOBE_CLIENT_SECRET,
+ grant_type: 'client_credentials', scope: 'openid,AdobeID,firefly_api,ff_apis',
+ }),
+ });
+ const j = await r.json();
+ if (!j.access_token) throw new Error('adobe IMS auth failed: ' + JSON.stringify(j).slice(0, 150));
+ ims = { token: j.access_token, exp: Date.now() + (j.expires_in || 3600) * 1000 };
+ return ims.token;
+}
+
+function psLogSpend(op, cost) {
+ const entry = { ts: new Date().toISOString(), provider: 'adobe', model: op, task: 'model-arena-tools', cost_usd: cost };
+ for (const f of [path.join(__dirname, 'data', 'costlog.jsonl'), path.join(os.homedir(), '.claude', 'cost-ledger.jsonl')])
+ try { fs.appendFileSync(f, JSON.stringify(entry) + '\n'); } catch {}
+}
+
+// save an image buffer as an asset; convert PNG→JPEG via sips (macOS) to keep data URIs small
+function saveAsset(buf, keepPng) {
+ fs.mkdirSync(PS_DIR, { recursive: true });
+ const id = 'ps_' + crypto.randomBytes(5).toString('hex');
+ const png = path.join(PS_DIR, id + '.png');
+ fs.writeFileSync(png, buf);
+ if (keepPng) return Promise.resolve(id); // cutouts need alpha — stay PNG
+ return new Promise(resolve => {
+ const jpg = path.join(PS_DIR, id + '.jpg');
+ execFile('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '82', png, '--out', jpg], (e) => {
+ if (!e && fs.existsSync(jpg)) { try { fs.unlinkSync(png); } catch {} }
+ resolve(id);
+ });
+ });
+}
+
+function findAsset(id) {
+ for (const ext of ['.jpg', '.png']) { const f = path.join(PS_DIR, String(id).replace(/[^\w-]/g, '') + ext); if (fs.existsSync(f)) return f; }
+ return null;
+}
+
+const psUsage = (id) => ({
+ asset: id,
+ use: `<img src="{{PS_ASSET:${id}}}" alt="describe it">`,
+ note: 'the placeholder is swapped for the real image (data URI) when your HTML is saved — treat it as a normal <img>, style/crop with CSS (object-fit:cover etc). Do NOT invent asset ids.',
+});
+
+async function fireflyGenerate(a) {
+ const token = await adobeToken();
+ if (!token) return { unavailable: true, note: 'Adobe credentials not configured on the arena server — design with CSS gradients/SVG instead.' };
+ const sizes = { square: { width: 1024, height: 1024 }, wide: { width: 1344, height: 768 }, tall: { width: 768, height: 1344 } };
+ const r = await fetch('https://firefly-api.adobe.io/v3/images/generate', {
+ method: 'POST',
+ headers: { Authorization: 'Bearer ' + token, 'x-api-key': process.env.ADOBE_CLIENT_ID, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prompt: String(a.prompt || '').slice(0, 1000), numVariations: 1, size: sizes[a.size] || sizes.square }),
+ });
+ const j = await r.json();
+ const url = j.outputs && j.outputs[0] && j.outputs[0].image && j.outputs[0].image.url;
+ if (!url) throw new Error('firefly: ' + JSON.stringify(j.error_code || j.message || j).slice(0, 180));
+ const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
+ const id = await saveAsset(buf, false);
+ psLogSpend('firefly-generate', 0.04);
+ return psUsage(id);
+}
+
+async function psRemoveBackground(a) {
+ const token = await adobeToken();
+ if (!token) return { unavailable: true, note: 'Adobe credentials not configured on the arena server.' };
+ const src = findAsset(a.asset);
+ if (!src) return { error: 'unknown asset id — generate_image first, then pass its asset id here.' };
+ const outId = 'ps_' + crypto.randomBytes(5).toString('hex');
+ const putTok = crypto.randomBytes(12).toString('hex');
+ PS_PUT_TOKENS.set(putTok, outId + '.png');
+ const headers = { Authorization: 'Bearer ' + token, 'x-api-key': process.env.ADOBE_CLIENT_ID, 'Content-Type': 'application/json' };
+ const r = await fetch('https://image.adobe.io/sensei/cutout', {
+ method: 'POST', headers,
+ body: JSON.stringify({
+ input: { href: PS_PUBLIC + '/ps-asset/' + path.basename(src), storage: 'external' },
+ output: { href: PS_PUBLIC + '/ps-asset-put/' + putTok, storage: 'external', mask: { format: 'soft' } },
+ }),
+ });
+ const j = await r.json();
+ const poll = j._links && j._links.self && j._links.self.href;
+ if (!poll) throw new Error('photoshop cutout: ' + JSON.stringify(j).slice(0, 180));
+ for (let i = 0; i < 30; i++) {
+ await new Promise(s => setTimeout(s, 2000));
+ const st = await (await fetch(poll, { headers })).json();
+ const status = st.status || (st.output && st.output.status);
+ if (status === 'succeeded') {
+ for (let w = 0; w < 10 && !findAsset(outId); w++) await new Promise(s => setTimeout(s, 500));
+ if (!findAsset(outId)) throw new Error('cutout succeeded but result never arrived at the PUT-back URL');
+ psLogSpend('photoshop-cutout', 0.03);
+ return psUsage(outId);
+ }
+ if (status === 'failed') throw new Error('cutout failed: ' + JSON.stringify(st.errors || st).slice(0, 150));
+ }
+ throw new Error('cutout timed out');
+}
+
+// swap {{PS_ASSET:id}} placeholders in a finished artifact for real data URIs
+function inlineAssets(html) {
+ return String(html).replace(/\{\{PS_ASSET:([\w-]+)\}\}/g, (m, id) => {
+ const f = findAsset(id);
+ if (!f) return m;
+ const mime = f.endsWith('.png') ? 'image/png' : 'image/jpeg';
+ try { return 'data:' + mime + ';base64,' + fs.readFileSync(f).toString('base64'); } catch { return m; }
+ });
+}
+
// ---------- the tool belt ----------
const clamp = (s, n) => { s = JSON.stringify(s, null, 1); return s.length > n ? s.slice(0, n) + '…' : s; };
@@ -217,6 +342,23 @@ const TOOLS = [
},
];
+// photoshop joins the belt only when Adobe creds are configured — models never see a dead tool
+if (psAvailable()) TOOLS.push({
+ name: 'photoshop',
+ description: 'Adobe Photoshop/Firefly image services: generate a real photographic or artistic image from a text prompt (generate_image), or strip the background from a generated asset (remove_background). Returns an asset id + placeholder <img> you inline; the server swaps it for the actual image when your HTML is saved. Use for hero imagery a CSS gradient cannot fake. Max 2 images per artifact.',
+ parameters: {
+ type: 'object',
+ properties: {
+ action: { type: 'string', enum: ['generate_image', 'remove_background'], description: 'which operation' },
+ prompt: { type: 'string', description: 'for generate_image: the image description (subject, style, lighting, mood)' },
+ size: { type: 'string', enum: ['square', 'wide', 'tall'], description: 'for generate_image: aspect (default square)' },
+ asset: { type: 'string', description: 'for remove_background: the asset id returned by a previous generate_image call' },
+ },
+ required: ['action'],
+ },
+ run: async (a) => clamp(a.action === 'remove_background' ? await psRemoveBackground(a) : await fireflyGenerate(a), 1200),
+});
+
// compact design pack for models that can't call tools — the essentials, pre-injected
function designPack() {
const p = PALETTES.slice(0, 4).map(x => `${x.name} (${x.mood}): bg ${x.bg} surface ${x.surface} text ${x.text} muted ${x.muted} accent ${x.accent}/${x.accent2}`).join('\n');
@@ -229,4 +371,4 @@ Shadows: card ${SHADOWS.elevation[2]}; hover ${SHADOWS.elevation[3]}; glow = 0 0
Motion: entrances via @keyframes fadeUp{from{opacity:0;transform:translateY(28px)}to{opacity:1;transform:none}} .7s cubic-bezier(.22,1,.36,1) both, stagger siblings 90ms; ambient bg = slow 18s gradient drift; micro-interactions 150-250ms; everything settles and STOPS.`;
}
-module.exports = { TOOLS, designPack };
+module.exports = { TOOLS, designPack, inlineAssets, PS_DIR, PS_PUT_TOKENS, psAvailable };
diff --git a/server.js b/server.js
index 9291dae..3c42913 100644
--- a/server.js
+++ b/server.js
@@ -12,7 +12,7 @@ process.on('uncaughtException', (e) => console.error('[uncaughtException]', (e &
const http = require('http'), fs = require('fs'), path = require('path'), os = require('os');
const crypto = require('crypto');
const { execFile } = require('child_process');
-const { TOOLS: DESIGN_TOOLS, designPack } = require('./design-tools');
+const { TOOLS: DESIGN_TOOLS, designPack, inlineAssets, PS_DIR, PS_PUT_TOKENS } = require('./design-tools');
// headless-Chrome path (macOS default; override with CHROME_BIN)
const CHROME_BIN = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
@@ -457,8 +457,9 @@ function runModel(challenge, modelId) {
: m.kind === 'cli' ? await generateCli(m, prompt)
: (dt && m.tools ? await generateMeteredTools(m, prompt) : await generateMetered(m, prompt));
if (out.toolCalls) run.toolCalls = out.toolCalls;
- const html = extractHtml(out.text);
+ let html = extractHtml(out.text);
if (!html) throw new Error('no HTML document in model output (' + String(out.text || '').length + ' chars)');
+ html = inlineAssets(html); // swap {{PS_ASSET:id}} placeholders for real data-URI images
const dir = path.join(ART, challenge.id);
fs.mkdirSync(dir, { recursive: true });
const htmlPath = path.join(dir, modelId + '.html');
@@ -555,6 +556,28 @@ const server = http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://x');
const p = u.pathname;
if (p === '/api/healthz') return send(res, 200, { ok: true, challenges: challenges.length });
+
+ // photoshop-tool I/O — auth-exempt (Adobe's servers fetch inputs and PUT results
+ // back here); safe because filenames/tokens are unguessable and single-purpose
+ let psm;
+ if ((psm = p.match(/^\/ps-asset\/(ps_[a-f0-9]+\.(?:png|jpg))$/)) && req.method === 'GET') {
+ const f = path.join(PS_DIR, psm[1]);
+ if (!fs.existsSync(f)) { res.writeHead(404); return res.end('not found'); }
+ res.writeHead(200, { 'Content-Type': psm[1].endsWith('.png') ? 'image/png' : 'image/jpeg' });
+ return res.end(fs.readFileSync(f));
+ }
+ if ((psm = p.match(/^\/ps-asset-put\/([a-f0-9]{24})$/)) && req.method === 'PUT') {
+ const target = PS_PUT_TOKENS.get(psm[1]);
+ if (!target) { res.writeHead(404); return res.end('unknown token'); }
+ const chunks = []; let size = 0;
+ req.on('data', d => { size += d.length; if (size > 20 * 1024 * 1024) req.destroy(); else chunks.push(d); });
+ req.on('end', () => {
+ try { fs.mkdirSync(PS_DIR, { recursive: true }); fs.writeFileSync(path.join(PS_DIR, target), Buffer.concat(chunks)); PS_PUT_TOKENS.delete(psm[1]); res.writeHead(200); res.end('ok'); }
+ catch (e) { res.writeHead(500); res.end('write failed'); }
+ });
+ return;
+ }
+
if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="model-arena"' }); return res.end('auth required'); }
if (p === '/api/models') {
← 8a62aa0 qwen3: disable thinking (think:false) and raise num_predict
·
back to Model Arena
·
README: photoshop tool docs 7c56033 →