/* Studio 76 — Studio Bench. Upload a jewellery photo → AI spec, technical blueprint,
front/side/top plate, and "see it worn" on a model (Instagram-ready).
Talks only to blueprint.php (keys server-side). CAD export = coming soon. */
const WEAR = [
{ k: 'face', label: 'Face' },
{ k: 'neck', label: 'Neckline' },
{ k: 'ear', label: 'Ear' },
{ k: 'finger', label: 'Hand' },
{ k: 'wrist', label: 'Wrist' },
{ k: 'ankle', label: 'Ankle' },
{ k: 'full', label: 'Full body' },
];
const MODELS = [
{ k: 'woman', label: 'On her' },
{ k: 'man', label: 'On him' },
];
/* aspect of a generated key, for the refine (perfect-it) pass */
function aspectFor(key) {
if (key === 'views') return '3:2';
if (key.slice(0, 4) === 'mag_') return '3:4';
if (key.slice(0, 2) === 'w_') return '4:5';
return '1:1';
}
const LOOKS = [
{ k: 'editorial', label: 'Editorial' },
{ k: 'indian', label: 'Indian' },
{ k: 'millennial', label: 'Millennial' },
{ k: 'glam', label: 'Glam' },
{ k: 'studio', label: 'Studio' },
{ k: 'goldenhour', label: 'Golden hour' },
];
const FORMS = [
{ k: 'ring', label: 'As a ring' },
{ k: 'earrings', label: 'As earrings' },
{ k: 'necklace', label: 'As a necklace' },
{ k: 'bracelet', label: 'As a bracelet' },
{ k: 'pendant', label: 'As a pendant' },
{ k: 'bangle', label: 'As a bangle' },
];
async function benchApi(action, payload) {
const r = await fetch('blueprint.php', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ action }, payload)) });
let j = {}; try { j = await r.json(); } catch (e) {}
if (!r.ok || j.error) throw new Error(j.error || ('Something went wrong (' + r.status + ')'));
return j;
}
function downscale(file, max) {
return new Promise((res, rej) => {
const fr = new FileReader();
fr.onerror = () => rej(new Error('read'));
fr.onload = () => {
const img = new Image();
img.onload = () => {
const s = Math.min(1, max / Math.max(img.width, img.height));
const w = Math.round(img.width * s), h = Math.round(img.height * s);
const c = document.createElement('canvas'); c.width = w; c.height = h;
c.getContext('2d').drawImage(img, 0, 0, w, h);
res(c.toDataURL('image/jpeg', 0.85));
};
img.onerror = () => rej(new Error('img'));
img.src = fr.result;
};
fr.readAsDataURL(file);
});
}
function isUrl(x) { return typeof x === 'string' && x.slice(0, 5) === 'data:'; }
function Bench() {
const [stage, setStage] = React.useState('idle'); // idle | analyzing | ready
const [src, setSrc] = React.useState(null);
const [id, setId] = React.useState(null);
const [spec, setSpec] = React.useState(null);
const [out, setOut] = React.useState({}); // {key: url|'loading'}
const [look, setLook] = React.useState('editorial');
const [model, setModel] = React.useState('woman');
const [shots, setShots] = React.useState([]); // [{key, placeLabel, lookLabel}]
const [morphs, setMorphs] = React.useState([]); // variations + forms [{key, label}]
const [fixes, setFixes] = React.useState([]); // perfected takes [{key, label}]
const [fixN, setFixN] = React.useState(0);
const [fixText, setFixText] = React.useState('');
const [varN, setVarN] = React.useState(0);
const [varText, setVarText] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [savedN, setSavedN] = React.useState('');
const [savedUrl, setSavedUrl] = React.useState('');
const [drag, setDrag] = React.useState(false);
const [err, setErr] = React.useState('');
const [urlText, setUrlText] = React.useState('');
const [modalSrc, setModalSrc] = React.useState('');
const [modalKey, setModalKey] = React.useState(''); // generated key behind the zoomed image ('' = not refinable)
const [cad, setCad] = React.useState(null); // {status, progress, glb, stl, obj}
const [cadTex, setCadTex] = React.useState(null); // {status, progress, glb}
const [cad3dView, setCad3dView] = React.useState('tex');
const fileRef = React.useRef(null);
const cadTaskRef = React.useRef(null);
const texTaskRef = React.useRef(null);
const savedSetRef = React.useRef(null); // set id from the first save — re-saving UPDATES it (no duplicates)
const savingRef = React.useRef(false); // synchronous double-click guard (state updates are async)
async function handleFile(file) {
if (!file || !/^image\//.test(file.type)) { setErr('Please choose an image file.'); return; }
setErr(''); setStage('analyzing'); setOut({}); setSpec(null); setId(null); setSavedUrl(''); savedSetRef.current = null;
try {
const dataUrl = await downscale(file, 1280);
setSrc(dataUrl);
const j = await benchApi('analyze', { image: dataUrl });
setId(j.id); setSpec(j.spec); setStage('ready');
} catch (e) { setErr(e.message || 'Could not read that image.'); setStage('idle'); }
}
async function handleUrl(url) {
url = (url || '').trim();
if (!/^https?:\/\//i.test(url)) { setErr('Paste a valid image URL.'); return; }
setErr(''); setStage('analyzing'); setOut({}); setSpec(null); setId(null); setShots([]); setMorphs([]); setVarN(0); setSavedUrl(''); savedSetRef.current = null;
setSrc(url);
try { const j = await benchApi('analyze', { imageUrl: url }); setId(j.id); setSpec(j.spec); setStage('ready'); }
catch (e) { setErr(e.message || 'Could not bring that image in.'); setStage('idle'); }
}
React.useEffect(() => {
const u = new URLSearchParams(window.location.search).get('img');
if (u) handleUrl(u);
}, []);
async function gen(key, action, payload) {
if (!id) return;
setOut(o => Object.assign({}, o, { [key]: 'loading' }));
try { const j = await benchApi(action, Object.assign({ id }, payload)); setOut(o => Object.assign({}, o, { [key]: j.dataUrl })); }
catch (e) { setOut(o => Object.assign({}, o, { [key]: null })); setErr(e.message || 'Generation failed.'); }
}
function genWear(place) {
const w = WEAR.find(x => x.k === place), l = LOOKS.find(x => x.k === look), m = MODELS.find(x => x.k === model);
const key = 'w_' + place + '_' + look + '_' + model;
const lookLabel = l.label + (model === 'man' ? ' · Him' : '');
if (!shots.find(s => s.key === key)) setShots(prev => [{ key, placeLabel: w.label, lookLabel }].concat(prev));
gen(key, 'wear', { place, look, model });
}
function genMagazine() {
const l = LOOKS.find(x => x.k === look);
const key = 'mag_' + look + '_' + model;
const lookLabel = l.label + (model === 'man' ? ' · Him' : '');
if (!shots.find(s => s.key === key)) setShots(prev => [{ key, placeLabel: 'Magazine cover', lookLabel }].concat(prev));
gen(key, 'magazine', { look, model });
}
const aspectsRef = React.useRef({}); // fix_N → aspect inherited from the render it perfected
function genFix() {
const srcKey = modalKey, base = out[srcKey];
const text = fixText.trim();
if (!srcKey || !isUrl(base) || !text) return;
const aspect = aspectsRef.current[srcKey] || aspectFor(srcKey);
const n = fixN + 1; setFixN(n);
const key = 'fix_' + n;
aspectsRef.current[key] = aspect;
const label = '“' + (text.length > 42 ? text.slice(0, 42) + '…' : text) + '”';
setFixes(prev => [{ key, label }].concat(prev));
setFixText(''); setModalSrc(''); setModalKey('');
setOut(o => Object.assign({}, o, { [key]: 'loading' }));
benchApi('refine', { image: base, instructions: text, aspect })
.then(j => setOut(o => Object.assign({}, o, { [key]: j.dataUrl })))
.catch(e => { setOut(o => Object.assign({}, o, { [key]: null })); setErr(e.message || 'Refine failed.'); });
}
function genVar() {
if (!id) return;
const n = varN + 1; setVarN(n);
const text = varText.trim();
const key = 'var_' + n;
const label = text ? ('“' + (text.length > 42 ? text.slice(0, 42) + '…' : text) + '”') : ('Variation ' + n);
setMorphs(prev => [{ key, label }].concat(prev));
gen(key, 'variation', { n, instructions: text });
setVarText('');
}
function genForm(form) {
const f = FORMS.find(x => x.k === form), key = 'form_' + form;
if (!morphs.find(m => m.key === key)) setMorphs(prev => [{ key, label: f.label }].concat(prev));
gen(key, 'reform', { form });
}
async function save() {
if (!id || saving || savingRef.current) return;
savingRef.current = true;
setSaving(true); setSavedUrl(''); setSavedN(''); setErr('');
try {
const start = await benchApi('save_start', { id, spec, original: isUrl(src) ? src : '', setid: savedSetRef.current || undefined });
savedSetRef.current = start.setid;
const items = [];
if (isUrl(out.blueprint)) items.push({ key: 'blueprint', label: 'Blueprint', url: out.blueprint });
if (isUrl(out.views)) items.push({ key: 'views', label: 'Front · Side · Top', url: out.views });
shots.forEach(s => { if (isUrl(out[s.key])) items.push({ key: s.key, label: s.placeLabel + ' · ' + s.lookLabel, url: out[s.key] }); });
morphs.forEach(m => { if (isUrl(out[m.key])) items.push({ key: m.key, label: m.label, url: out[m.key] }); });
fixes.forEach(f => { if (isUrl(out[f.key])) items.push({ key: f.key, label: 'Perfected · ' + f.label, url: out[f.key] }); });
let n = 0;
for (const it of items) { await benchApi('save_item', { setid: start.setid, key: it.key, label: it.label, dataUrl: it.url }); n++; setSavedN(n + '/' + items.length); }
try {
const k = 'foreve_sets', arr = JSON.parse(localStorage.getItem(k) || '[]').filter(s => s.id !== start.setid);
arr.unshift({ id: start.setid, url: start.url, title: (spec && spec.piece_type) || 'Saved set', n: items.length, created: Date.now() });
localStorage.setItem(k, JSON.stringify(arr.slice(0, 60)));
} catch (e) {}
const modelTask = texTaskRef.current || cadTaskRef.current;
if (modelTask && cad && cad.glb) { try { await benchApi('save_model', { setid: start.setid, taskId: modelTask }); } catch (e) {} }
setSavedUrl(start.url);
} catch (e) { setErr(e.message || 'Save failed.'); }
savingRef.current = false;
setSaving(false);
}
function cadStart() {
if (!id) return;
setCad({ status: 'starting', progress: 0 });
benchApi('cad_start', { id }).then(function (r) {
const tid = r.taskId;
cadTaskRef.current = tid;
const poll = function () {
benchApi('cad_status', { taskId: tid }).then(function (s) {
setCad(s);
if (s.status !== 'SUCCEEDED' && s.status !== 'FAILED') setTimeout(poll, 5000);
}).catch(function () { setTimeout(poll, 6000); });
};
setTimeout(poll, 4000);
}).catch(function (e) { setErr(e.message || '3D conversion failed.'); setCad(null); });
}
function texStart() {
if (!cadTaskRef.current) return;
setCadTex({ status: 'starting', progress: 0 });
benchApi('cad_texture', { taskId: cadTaskRef.current, spec }).then(function (r) {
const tt = r.texTaskId; texTaskRef.current = tt;
const poll = function () {
benchApi('cad_texture_status', { taskId: tt }).then(function (s) {
setCadTex(s);
if (s.glb) setCad3dView('tex');
if (s.status !== 'SUCCEEDED' && s.status !== 'FAILED') setTimeout(poll, 5000);
}).catch(function () { setTimeout(poll, 6000); });
};
setTimeout(poll, 4000);
}).catch(function (e) { setErr(e.message || 'Texture pass failed.'); setCadTex(null); });
}
function reset() { setStage('idle'); setSrc(null); setId(null); setSpec(null); setOut({}); setShots([]); setMorphs([]); setFixes([]); setFixN(0); setFixText(''); setModalSrc(''); setModalKey(''); aspectsRef.current = {}; setVarN(0); setSaving(false); setSavedN(''); setSavedUrl(''); setCad(null); setCadTex(null); setCad3dView('tex'); texTaskRef.current = null; savedSetRef.current = null; savingRef.current = false; setErr(''); }
const heroStone = spec && spec.hero_stone ? [spec.hero_stone.carat_estimate, spec.hero_stone.cut, spec.hero_stone.color, spec.hero_stone.type].filter(Boolean).join(' ') : '';
const dims = spec && spec.estimated_dimensions_mm ? [spec.estimated_dimensions_mm.height, spec.estimated_dimensions_mm.width, spec.estimated_dimensions_mm.depth].filter(Boolean).join(' × ') : '';
const accents = spec && spec.accent_stones && spec.accent_stones.length ? spec.accent_stones.map(a => [a.count, a.cut, a.type].filter(Boolean).join(' ')).filter(Boolean).join(', ') : '';
const Row = ({ l, v }) => v ?
{l} {v}
: null;
const Tile = ({ keyName, title, sub }) => {
const v = out[keyName];
return (
{v === 'loading'
?
{sub || 'Working…'}
: (typeof v === 'string')
?
{ setModalSrc(v); setModalKey(keyName); setFixText(''); }} />
:
{sub}
}
{title}
{(typeof v === 'string' && v !== 'loading') &&
Download }
);
};
return (
From a photo to a blueprint.
Upload any piece of jewellery — a photo, a screenshot, a memory you saved. Our bench reads it, drafts a
technical blueprint, breaks down the spec, and even shows it worn on a model , ready for the grid.
{stage === 'idle' && (
fileRef.current && fileRef.current.click()}
onDragOver={e => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={e => { e.preventDefault(); setDrag(false); handleFile(e.dataTransfer.files[0]); }}>
Drop a photo here, or browse
JPG / PNG · a clear, well-lit shot works best
handleFile(e.target.files[0])} />
)}
{stage === 'idle' && (
)}
{err && {err}
}
{stage !== 'idle' && (
{/* left: original + spec */}
{/* right: generators */}
{saving ? ('Saving… ' + savedN) : (savedUrl ? 'Update this set' : 'Save this set')}
{savedUrl
?
View / share your set ↗
:
Stores the spec, blueprint & every render at a shareable link. }
gen('blueprint', 'draw')}>Draft blueprint
gen('views', 'views')}>Front · Side · Top
✦ See it worn — Instagram-ready
Pick who wears it and a look, then tap a placement. Mix & match to build a shoot.
{MODELS.map(m => (
setModel(m.k)}>{m.label}
))}
{LOOKS.map(l => (
setLook(l.k)}>{l.label}
))}
{WEAR.map(w => {
const k = 'w_' + w.k + '_' + look + '_' + model;
return (
genWear(w.k)}>
{out[k] === 'loading' ? '…' : w.label}
);
})}
{(() => { const k = 'mag_' + look + '_' + model; return (
{out[k] === 'loading' ? '…' : '✦ Magazine cover'}
); })()}
{shots.length > 0 && (
{shots.map(s => )}
)}
✦ Make it a set — variations & forms
Tell us what to change, or just spin off a fresh take — then reimagine it as a matching ring, earrings, necklace…
setVarText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') genVar(); }} />
Create variation
{FORMS.map(f => {
const k = 'form_' + f.k;
return genForm(f.k)}>{out[k] === 'loading' ? '…' : f.label} ;
})}
{morphs.length > 0 && (
{morphs.map(m => )}
)}
{fixes.length > 0 && (
✦ Perfected takes
Corrected renders — zoom any image and tell the bench what to fix to make more.
{fixes.map(f => )}
)}
✦ Convert to 3D — spin it in real gold light
{!cad && (
Turn your piece into a rotatable 3D model — orbit it, zoom in, even view it in AR. A high-detail concept mesh from your blueprint; open-work & cut-outs are finalised on CAD. A head start for your jeweller, not yet castable.
✦ Convert to 3D
)}
{cad && cad.status !== 'SUCCEEDED' && cad.status !== 'FAILED' && (
Sculpting your 3D model… {cad.progress || 0}%
)}
{cad && cad.status === 'FAILED' &&
3D conversion didn’t complete — try again .
}
{cad && cad.glb && (
✦ 3D · drag to spin
{cadTex && cadTex.glb && (
setCad3dView('geo')}>Geometry
setCad3dView('tex')}>Textured
)}
Export:
.glb
{cad.stl &&
.stl }
{cad.obj &&
.obj }
{!cadTex &&
✦ Add gold & stones (+10 cr) }
{cadTex && cadTex.status !== 'SUCCEEDED' && cadTex.status !== 'FAILED' &&
Texturing… {cadTex.progress || 0}%}
{cadTex && cadTex.status === 'FAILED' &&
texture failed — retry }
)}
)}
{modalSrc && (
{ setModalSrc(''); setModalKey(''); }}>
×
e.stopPropagation()} />
{modalKey && isUrl(out[modalKey]) && (
e.stopPropagation()}>
setFixText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') genFix(); }} />
✦ Perfect it
)}
)}
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );