/* Studio 76 Atelier — Handbag Bench. Upload a handbag photo (or a sketch) → AI spec,
construction flat, front·side·interior plate, designer illustration, and "see it carried"
on a model, plus variations, reforms and guided edits.
Talks only to handbag.php (keys server-side). */
const WEAR = [
{ k: 'hand', label: 'In hand' },
{ k: 'shoulder', label: 'On shoulder' },
{ k: 'crossbody', label: 'Crossbody' },
{ k: 'elbow', label: 'Elbow / editorial' },
{ k: 'full', label: 'Full outfit' },
{ k: 'street', label: 'Street style' },
{ k: 'flatlay', label: 'Flat-lay' },
];
const MODELS = [
{ k: 'woman', label: 'On her' },
{ k: 'man', label: 'On him' },
];
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: 'tote', label: 'As a tote' },
{ k: 'clutch', label: 'As a clutch' },
{ k: 'crossbody', label: 'As a crossbody' },
{ k: 'shoulder', label: 'As a shoulder bag' },
{ k: 'backpack', label: 'As a backpack' },
{ k: 'mini', label: 'As a mini bag' },
];
/* Guided-edit vocab — discrete controls, each = one precise change on the zoomed render. */
const SHAPES = ['Tote', 'Clutch', 'Crossbody', 'Top-handle', 'Bucket', 'Hobo', 'Satchel', 'Baguette'];
const MATERIALS = ['Full-grain', 'Saffiano', 'Pebbled', 'Suede', 'Patent', 'Canvas', 'Woven', 'Croc-embossed'];
const HARDWARES = ['Gold', 'Silver', 'Gunmetal', 'Tonal'];
const STRAPS = ['Top handle', 'Chain shoulder strap', 'Wide shoulder strap', 'Crossbody strap', 'None'];
const COLORS = [
{ name: 'Black', hex: '#1B130D' }, { name: 'Tan', hex: '#B98A56' }, { name: 'Cognac', hex: '#9A5B2C' },
{ name: 'Cream', hex: '#EDE3CF' }, { name: 'Burgundy', hex: '#5E1F26' }, { name: 'Navy', hex: '#1C2B4A' },
{ name: 'Forest', hex: '#26432F' }, { name: 'Chocolate', hex: '#3E2A1E' }, { name: 'Blush', hex: '#E7C3C0' },
{ name: 'Red', hex: '#8E1B2E' },
];
const DETAILS = [
{ k: 'piping', op: 'add', params: { element: 'contrast piping along the panel seams' }, label: '+ Piping' },
{ k: 'plaque', op: 'add', params: { element: 'a subtle metal logo plaque on the front' }, label: '+ Logo plaque' },
{ k: 'feet', op: 'add', params: { element: 'protective metal feet on the base' }, label: '+ Base feet' },
{ k: 'nostrap', op: 'remove', params: { element: 'the shoulder strap' }, label: '– Strap' },
];
const SEL0 = { size: null, shape: null, material: null, color: null, hardware: null, strap: null, details: [], note: '' };
function cap1(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; }
/* 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';
}
async function benchApi(action, payload) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 95000);
let r;
try {
r = await fetch('handbag.php', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ action }, payload)), signal: ctrl.signal });
} catch (e) {
clearTimeout(timer);
throw new Error(e && e.name === 'AbortError' ? 'That took too long — the studio is busy. Please try again.' : 'Network hiccup — please try again.');
}
clearTimeout(timer);
let j = null; try { j = await r.json(); } catch (e) {}
if (!r.ok || !j || j.error) throw new Error((j && j.error) || ('The studio is busy right now (' + r.status + ') — please try again.'));
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');
const [entry, setEntry] = React.useState('piece');
const [src, setSrc] = React.useState(null);
const [id, setId] = React.useState(null);
const [spec, setSpec] = React.useState(null);
const [out, setOut] = React.useState({});
const [look, setLook] = React.useState('editorial');
const [model, setModel] = React.useState('woman');
const [shots, setShots] = React.useState([]);
const [morphs, setMorphs] = React.useState([]);
const [fixes, setFixes] = React.useState([]);
const [fixN, setFixN] = React.useState(0);
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('');
const [sel, setSel] = React.useState(SEL0);
const [cap, setCap] = React.useState(null);
const [copied, setCopied] = React.useState(false);
const fileRef = React.useRef(null);
const savedSetRef = React.useRef(null);
const savingRef = React.useRef(false);
const aspectsRef = React.useRef({});
async function ingestRealize(srcPayload) {
const a1 = await benchApi('analyze', srcPayload);
if (!a1.id) throw new Error('Could not read that sketch — please try again.');
const r = await benchApi('realize', { id: a1.id });
setSrc(r.dataUrl);
const a2 = await benchApi('analyze', { image: r.dataUrl });
if (!a2.id) throw new Error('Could not finish reading — please try again.');
setId(a2.id); setSpec(a2.spec);
}
function resetForIngest(sketch) {
setErr(''); setEntry(sketch ? 'sketch' : 'piece'); setStage('analyzing');
setOut({}); setSpec(null); setId(null); setShots([]); setMorphs([]); setFixes([]); setVarN(0); setSavedUrl(''); savedSetRef.current = null;
}
async function handleFile(file, sketch) {
if (!file || !/^image\//.test(file.type)) { setErr('Please choose an image file.'); return; }
resetForIngest(sketch);
try {
const dataUrl = await downscale(file, 1280);
setSrc(dataUrl);
if (sketch) { await ingestRealize({ image: dataUrl }); }
else { const j = await benchApi('analyze', { image: dataUrl }); if (!j.id) throw new Error('Could not read that — please try again.'); setId(j.id); setSpec(j.spec); }
setStage('ready');
} catch (e) { setErr(e.message || 'Could not read that image.'); setStage('idle'); }
}
async function handleUrl(url, sketch) {
url = (url || '').trim();
if (!/^https?:\/\//i.test(url)) { setErr('Paste a valid image URL.'); return; }
resetForIngest(sketch);
setSrc(url);
try {
if (sketch) { await ingestRealize({ imageUrl: url }); }
else { const j = await benchApi('analyze', { imageUrl: url }); if (!j.id) throw new Error('Could not read that — please try again.'); 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) { setUrlText(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);
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 });
}
function genEditSet(ops, label) {
const srcKey = modalKey, base = out[srcKey];
if (!id || !srcKey || !isUrl(base) || !ops.length) return;
const aspect = aspectsRef.current[srcKey] || aspectFor(srcKey);
const n = fixN + 1; setFixN(n);
const key = 'fix_' + n;
aspectsRef.current[key] = aspect;
setFixes(prev => [{ key, label }].concat(prev));
closeModal();
setOut(o => Object.assign({}, o, { [key]: 'loading' }));
benchApi('edit', { id, image: base, ops, aspect })
.then(j => setOut(o => Object.assign({}, o, { [key]: j.dataUrl })))
.catch(e => { setOut(o => Object.assign({}, o, { [key]: null })); setErr(e.message || 'Edit failed.'); });
}
function applyEdits() {
const ops = [], parts = [];
if (sel.size) { ops.push({ op: 'size', params: { dir: sel.size } }); parts.push(sel.size === 'up' ? 'Larger' : 'Smaller'); }
if (sel.shape) { ops.push({ op: 'shape', params: { to: sel.shape } }); parts.push(cap1(sel.shape)); }
if (sel.material) { ops.push({ op: 'material', params: { to: sel.material } }); parts.push(cap1(sel.material)); }
if (sel.color) { ops.push({ op: 'color', params: { to: sel.color } }); parts.push(cap1(sel.color)); }
if (sel.hardware) { ops.push({ op: 'hardware', params: { to: sel.hardware } }); parts.push(cap1(sel.hardware) + ' hw'); }
if (sel.strap) { ops.push({ op: 'strap', params: { to: sel.strap } }); parts.push(cap1(sel.strap)); }
sel.details.forEach(k => { const d = DETAILS.find(x => x.k === k); if (d) { ops.push({ op: d.op, params: d.params }); parts.push(d.label); } });
const note = sel.note.trim();
if (note) { ops.push({ op: 'note', params: { text: note } }); parts.push('“' + (note.length > 24 ? note.slice(0, 24) + '…' : note) + '”'); }
if (!ops.length) return;
let label = parts.slice(0, 3).join(' · ');
if (parts.length > 3) label += ' +' + (parts.length - 3);
genEditSet(ops, label);
}
function closeModal() { setModalSrc(''); setModalKey(''); setCap(null); setCopied(false); setSel(SEL0); }
function capFull(c) { return (c.caption || '') + '\n\n' + (c.hashtags || []).map(h => '#' + String(h).replace(/^#/, '').replace(/\s+/g, '')).join(' '); }
function loadCaption() {
if (!id) return;
setCap('loading'); setCopied(false);
benchApi('caption', { id }).then(j => setCap(j)).catch(e => { setCap(null); setErr(e.message || 'Could not write a caption.'); });
}
function copyCap(c) {
const t = capFull(c);
const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1800); };
try { navigator.clipboard.writeText(t).then(done, done); } catch (e) { done(); }
}
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: 'Construction flat', url: out.blueprint });
if (isUrl(out.views)) items.push({ key: 'views', label: 'Front · Side · Interior', url: out.views });
if (isUrl(out.realize)) items.push({ key: 'realize', label: 'Made real', url: out.realize });
if (isUrl(out.rendering)) items.push({ key: 'rendering', label: 'Designer illustration', url: out.rendering });
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 = 'studio76_bag_sets', arr = JSON.parse(localStorage.getItem(k) || '[]').filter(s => s.id !== start.setid);
arr.unshift({ id: start.setid, url: start.url, title: (spec && spec.bag_type) || 'Saved bag', n: items.length, created: Date.now() });
localStorage.setItem(k, JSON.stringify(arr.slice(0, 60)));
} catch (e) {}
setSavedUrl(start.url);
} catch (e) { setErr(e.message || 'Save failed.'); }
savingRef.current = false;
setSaving(false);
}
function reset() { setStage('idle'); setSrc(null); setId(null); setSpec(null); setOut({}); setShots([]); setMorphs([]); setFixes([]); setFixN(0); setModalSrc(''); setModalKey(''); setCap(null); setCopied(false); setSel(SEL0); aspectsRef.current = {}; setVarN(0); setSaving(false); setSavedN(''); setSavedUrl(''); savedSetRef.current = null; savingRef.current = false; setErr(''); }
const dims = spec && spec.estimated_dimensions_cm ? [spec.estimated_dimensions_cm.width, spec.estimated_dimensions_cm.height, spec.estimated_dimensions_cm.depth].filter(Boolean).join(' × ') : '';
const selCount = (sel.size ? 1 : 0) + (sel.shape ? 1 : 0) + (sel.material ? 1 : 0) + (sel.color ? 1 : 0) + (sel.hardware ? 1 : 0) + (sel.strap ? 1 : 0) + sel.details.length + (sel.note.trim() ? 1 : 0);
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); setCap(null); setCopied(false); setSel(SEL0); }} />
:
{sub}
}
{title}
{(typeof v === 'string' && v !== 'loading') &&
Download }
);
};
return (
From a photo to a handbag.
Upload any bag — a photo, a screenshot, the one you spotted and could never find again. Our atelier reads it,
drafts a construction flat, breaks down the spec, and shows it carried on a model , made by hand.
{stage === 'idle' && (
setEntry('piece')}>
A photo of a bag Remake something that already exists
setEntry('sketch')}>
A sketch or idea Bring a drawing to life in fine leather
)}
{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], entry === 'sketch'); }}>
{entry === 'sketch' ? 'Drop your sketch here, or ' : 'Drop a photo here, or '}browse
{entry === 'sketch' ? 'A drawing, doodle or concept — we’ll render it in fine leather.' : 'JPG / PNG · a clear, well-lit shot works best'}
handleFile(e.target.files[0], entry === 'sketch')} />
)}
{stage === 'idle' && (
setUrlText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleUrl(urlText, entry === 'sketch'); }} />
handleUrl(urlText, entry === 'sketch')}>Bring it in
)}
{stage === 'idle' && (
✦ Clip a bag from Pinterest in one tap ↗
)}
{err && {err}
}
{stage !== 'idle' && (
{saving ? ('Saving… ' + savedN) : (savedUrl ? 'Update this set' : 'Save this set')}
{savedUrl
?
View / share your set ↗
:
Stores the spec, flat & every render at a shareable link. }
gen('blueprint', 'draw')}>Draft construction flat
gen('views', 'views')}>Front · Side · Interior
gen('rendering', 'render_sketch')}>✦ Designer illustration
✦ See it carried — Instagram-ready
Pick who carries it and a look, then tap a shot. Mix & match to build a campaign.
{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 collection — variations & forms
Tell us what to change, or just spin off a fresh take — then reimagine it as a matching tote, clutch, crossbody…
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
Refined renders — zoom any image and tell the atelier what to change to make more.
{fixes.map(f => )}
)}
)}
{modalSrc && (
×
e.stopPropagation()}>
{modalKey && isUrl(out[modalKey]) && (
✦ Refine this design
Stack as many changes as you like, then Apply — they’re built in a single pass, the rest of the bag held steady. The new take lands in “Perfected takes”.
Size
setSel(s => ({ ...s, size: s.size === 'up' ? null : 'up' }))}>Larger ↑
setSel(s => ({ ...s, size: s.size === 'down' ? null : 'down' }))}>Smaller ↓
Shape
{SHAPES.map(c => { const v = c.toLowerCase(); return setSel(s => ({ ...s, shape: s.shape === v ? null : v }))}>{c} ; })}
Material
{MATERIALS.map(st => { const v = st.toLowerCase(); return setSel(s => ({ ...s, material: s.material === v ? null : v }))}>{st} ; })}
Colour
{COLORS.map(c => { const v = c.name.toLowerCase(); return setSel(s => ({ ...s, color: s.color === v ? null : v }))}> {c.name} ; })}
Hardware
{HARDWARES.map(m => { const v = m.toLowerCase(); return setSel(s => ({ ...s, hardware: s.hardware === v ? null : v }))}>{m} ; })}
Strap / handle
{STRAPS.map(m => { const v = m.toLowerCase(); return setSel(s => ({ ...s, strap: s.strap === v ? null : v }))}>{m} ; })}
Detailing
{DETAILS.map(d => = 0 ? ' on' : '')} onClick={() => setSel(s => ({ ...s, details: s.details.indexOf(d.k) >= 0 ? s.details.filter(x => x !== d.k) : s.details.concat(d.k) }))}>{d.label} )}
✦ Apply{selCount ? ' ' + selCount + ' change' + (selCount > 1 ? 's' : '') : ' changes'}
{selCount > 0 && setSel(SEL0)}>clear }
)}
{spec && (
✦ Post to Instagram
{!cap &&
We’ll write the caption & hashtags — download the image and post it in seconds.
}
{!cap &&
✦ Write a caption }
{cap === 'loading' &&
Writing a caption…
}
{cap && cap !== 'loading' && (
)}
)}
)}
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );