[object Object]

← 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

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 →