// Studio Uariní — Generator app
// Wires together: sidebar (template picker), form (per-template fields),
// preview (live render at scale), download (html-to-image), and AI captions (window.claude).
const { UARINI, SpiralPatch, Wordmark, LogoLockup } = window;
const TEMPLATES = window.UARINI_TEMPLATES;
// ─── Brand mark for sidebar ─────────────────────────────────────────────────
function BrandHeader() {
return (
Studio Uariní
Gerador · v1
);
}
// ─── Sidebar ────────────────────────────────────────────────────────────────
function Sidebar({ activeId, onPick }) {
const groups = {};
TEMPLATES.forEach((t, i) => {
(groups[t.group] = groups[t.group] || []).push({ ...t, idx: i });
});
return (
{Object.entries(groups).map(([g, items]) => (
{g}
{items.map((t) => (
onPick(t.id)}
>
{String(t.idx + 1).padStart(2, '0')}
{t.label}
))}
))}
Escolha um template, edite os campos e baixe a arte em PNG. As legendas são geradas pela IA.
);
}
// ─── Field renderers ────────────────────────────────────────────────────────
function Field({ field, value, onChange }) {
const { kind, key, label, hint, options } = field;
if (kind === 'text') {
return (
{label}
onChange(key, e.target.value)}/>
{hint &&
{hint}
}
);
}
if (kind === 'textarea') {
return (
);
}
if (kind === 'select') {
return (
{label}
onChange(key, e.target.value)}>
{options.map((o) => (
{o.label || o.name || o.value}
))}
);
}
if (kind === 'swatch') {
return (
{label}
{options.map((o) => (
onChange(key, o.value)}
/>
))}
);
}
if (kind === 'color') {
return (
);
}
if (kind === 'heading') {
return (
{label}
);
}
if (kind === 'slider') {
const v = value == null ? field.def : value;
return (
{label} {Number(v).toFixed(field.step < 1 ? 2 : 0)}{field.unit || ''}
onChange(key, parseFloat(e.target.value))}
style={{ width: '100%', accentColor: 'var(--green)' }}
/>
);
}
if (kind === 'toggle') {
const on = value == null ? field.def : value;
return (
{label}
onChange(key, true)}>{field.onLabel || 'Sim'}
onChange(key, false)}>{field.offLabel || 'Não'}
);
}
if (kind === 'media') {
const onPick = (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
onChange(key, { url, type: file.type, name: file.name });
};
const onDrop = (e) => {
e.preventDefault();
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
onChange(key, { url, type: file.type, name: file.name });
};
const has = value && value.url;
const isVideo = has && value.type && value.type.startsWith('video');
return (
{label}
e.preventDefault()}
onDrop={onDrop}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: 10, border: '1.5px dashed var(--border)', borderRadius: 10,
background: '#fafbf6', cursor: 'pointer', minHeight: 64,
}}
>
{has ? (
{isVideo ? (
) : (
)}
) : (
↑
)}
{has ? (value.name || 'mídia carregada') : 'Clique ou arraste foto/vídeo'}
{has ? (isVideo ? 'vídeo · auto-loop' : 'imagem · será cortada para encaixar') : 'JPG, PNG ou MP4'}
{has && (
{ e.preventDefault(); onChange(key, null); }}
style={{ width: 28, height: 28, borderRadius: 999, border: '1px solid var(--border)', background: '#fff', cursor: 'pointer', color: 'var(--gray)', flex: 'none' }}
title="Remover"
>×
)}
{has && (
Ajustar imagem
{[
['scale', 'Zoom', 1, 3, 0.05, 1],
['posX', 'Horizontal', 0, 100, 1, 50],
['posY', 'Vertical', 0, 100, 1, 50],
].map(([mk, mlabel, mmin, mmax, mstep, mdef]) => {
const cur = value[mk] == null ? mdef : value[mk];
return (
);
})}
Encaixe
onChange(key, { ...value, fit: 'cover' })}>Preencher
onChange(key, { ...value, fit: 'contain' })}>Conter
onChange(key, { url: value.url, type: value.type, name: value.name })}
style={{ marginTop: 12, width: '100%', padding: '7px', border: '1px solid var(--border)', background: '#fff', borderRadius: 8, fontSize: 11, color: 'var(--gray)', cursor: 'pointer', fontFamily: 'Poppins', letterSpacing: '0.1em' }}
>restaurar ajuste
)}
);
}
if (kind === 'rows') {
const cols = field.columns || [];
const rows = Array.isArray(value) ? value : [];
const setCell = (r, c, v) => {
const next = rows.map((row) => [...row]);
next[r][c] = v;
onChange(key, next);
};
const addRow = () => onChange(key, [...rows, cols.map(() => '')]);
const removeRow = (r) => onChange(key, rows.filter((_, i) => i !== r));
return (
{label}
{rows.map((row, r) => (
{row.map((cell, c) => (
setCell(r, c, e.target.value)}
style={{ flex: c === 0 ? 2 : 1, minWidth: 0 }}
/>
))}
removeRow(r)}
style={{ width: 28, height: 28, border: '1px solid var(--border)', background: '#fafbf6', borderRadius: 6, cursor: 'pointer', color: '#888', flex: 'none' }}
title="Remover"
>×
))}
+ adicionar linha
);
}
return null;
}
// ─── Live preview ───────────────────────────────────────────────────────────
// Global text scaler — multiplies every text element's font-size by `scale`.
// Runs after each render so it composes with React re-renders cleanly.
function TextScaler({ scale, children }) {
const ref = React.useRef(null);
React.useLayoutEffect(() => {
const root = ref.current;
if (!root) return;
const s = scale == null ? 1 : scale;
root.querySelectorAll('*').forEach((el) => {
const hasText = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent.trim());
if (!hasText) return;
const base = parseFloat(window.getComputedStyle(el).fontSize);
if (!base) return;
el.style.fontSize = (base * s) + 'px';
});
});
return {children}
;
}
function Preview({ template, data, captureRef }) {
const { w, h } = template.size;
// fit preview into ~720 wide
const maxW = 720;
const maxH = 760;
const scale = Math.min(maxW / w, maxH / h);
const dispW = w * scale, dispH = h * scale;
return (
{/* Hidden full-size capture target */}
{template.render(data)}
);
}
// ─── AI Caption box ─────────────────────────────────────────────────────────
function AIBox({ template, data }) {
const [seed, setSeed] = React.useState('');
const [caption, setCaption] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState('');
// Reset caption when template changes
React.useEffect(() => { setCaption(''); setErr(''); }, [template.id]);
const generate = async () => {
setLoading(true);
setErr('');
try {
const ctx = JSON.stringify(data, null, 2);
const prompt =
`Você é o copywriter do @studiouarini, uma barbearia + terapia capilar com identidade ancestral indígena (símbolo da espiral, raiz, ritual). Tom: caloroso, contemplativo, lowercase, frases curtas, pouco emoji (no máximo um ✦).
Template do post: ${template.label}
Dados visíveis na arte:
${ctx}
${seed ? `Direção extra do usuário: ${seed}\n\n` : ''}Escreva uma legenda para Instagram em português brasileiro, com no máximo 5 linhas, terminando com 3 a 5 hashtags relevantes. Não comece com "Legenda:" — entregue só o texto pronto pra colar.`;
const text = await window.claude.complete(prompt);
setCaption(text.trim());
} catch (e) {
setErr('Falha ao gerar. Tenta de novo.');
} finally {
setLoading(false);
}
};
const fallback = template.caption ? template.caption(data) : '';
return (
Legenda · com IA
setSeed(e.target.value)}
/>
{loading ? '...' : 'Gerar'}
A IA usa os dados que você preencheu ao lado para escrever a legenda. Pode pedir um tom diferente no campo acima.
{err && {err} }
{(caption || fallback) && (
{caption ? 'gerado pela IA' : 'sugestão base'}
{caption || fallback}
)}
);
}
// ─── Main App ───────────────────────────────────────────────────────────────
function App() {
const [activeId, setActiveId] = React.useState(TEMPLATES[0].id);
const template = TEMPLATES.find((t) => t.id === activeId);
const [allData, setAllData] = React.useState(() => {
const o = {};
TEMPLATES.forEach((t) => { o[t.id] = { ...t.defaults }; });
return o;
});
const data = allData[activeId];
const setField = (key, value) =>
setAllData((prev) => ({ ...prev, [activeId]: { ...prev[activeId], [key]: value } }));
const reset = () =>
setAllData((prev) => ({ ...prev, [activeId]: { ...template.defaults } }));
const captureRef = React.useRef(null);
const showToast = (msg = 'imagem baixada ✦') => {
const el = document.getElementById('toast');
if (!el) return;
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 1600);
};
const download = async () => {
const node = captureRef.current;
if (!node) return;
// Temporarily un-scale for clean export
const prev = node.style.transform;
node.style.transform = 'none';
try {
const dataUrl = await window.htmlToImage.toPng(node, {
width: template.size.w,
height: template.size.h,
pixelRatio: 2,
cacheBust: true,
backgroundColor: '#000',
});
const a = document.createElement('a');
a.href = dataUrl;
a.download = `studiouarini-${template.id}.png`;
a.click();
showToast('imagem baixada ✦');
} catch (e) {
console.error(e);
showToast('erro ao exportar');
} finally {
node.style.transform = prev;
}
};
const copyCaption = async () => {
const seed = template.caption ? template.caption(data) : '';
try {
await navigator.clipboard.writeText(seed);
showToast('legenda copiada ✦');
} catch {
showToast('não consegui copiar');
}
};
// The form column content
const formNode = (
{template.group}
{template.label}
Edite os campos abaixo. A arte se atualiza em tempo real.
{template.fields.map((f) => (
))}
baixar PNG
copiar legenda base
restaurar padrão
);
return { template, data, formNode, captureRef };
}
// ─── Mount three roots ──────────────────────────────────────────────────────
function Root() {
const [activeId, setActiveId] = React.useState(TEMPLATES[0].id);
const template = TEMPLATES.find((t) => t.id === activeId);
const [allData, setAllData] = React.useState(() => {
const o = {};
TEMPLATES.forEach((t) => { o[t.id] = { ...t.defaults }; });
return o;
});
const data = allData[activeId];
const setField = (key, value) =>
setAllData((prev) => ({ ...prev, [activeId]: { ...prev[activeId], [key]: value } }));
const reset = () =>
setAllData((prev) => ({ ...prev, [activeId]: { ...template.defaults } }));
const captureRef = React.useRef(null);
const showToast = (msg = 'imagem baixada ✦') => {
const el = document.getElementById('toast');
if (!el) return;
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 1600);
};
// Detect if any media in current data is a video
const hasVideo = () => {
for (const k in data) {
const v = data[k];
if (v && typeof v === 'object' && v.type && v.type.startsWith('video')) return true;
}
return false;
};
const recordVideo = async (durationMs = 5000) => {
const node = captureRef.current;
if (!node) throw new Error('no node');
// Find video elements inside the captured node
const videos = Array.from(node.querySelectorAll('video'));
if (videos.length === 0) throw new Error('no video');
// Wait for videos to be ready
await Promise.all(videos.map((v) => new Promise((res) => {
if (v.readyState >= 2) return res();
v.addEventListener('loadeddata', res, { once: true });
})));
const W = template.size.w, H = template.size.h;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
// Use html-to-image once per frame is slow but works. We'll use a simpler
// approach: rasterize the static layer once via html-to-image, then redraw
// video frames on top each tick.
// Simpler: render full DOM to image every frame? Too slow.
// Best: snapshot the DOM (without videos) as bg image, then draw videos on top each frame.
// Approach: snapshot the whole node (incl. video poster frame) once as backdrop,
// then per-frame, redraw that snapshot + video frames on top at their positions.
// This keeps text/spiral/grain animated-static and the video moving.
// 1) Compute video positions relative to node
const nodeRect = node.getBoundingClientRect();
const videoRects = videos.map((v) => {
const r = v.getBoundingClientRect();
return {
v,
x: (r.left - nodeRect.left) * (W / nodeRect.width),
y: (r.top - nodeRect.top) * (H / nodeRect.height),
w: r.width * (W / nodeRect.width),
h: r.height * (H / nodeRect.height),
};
});
// 2) Snapshot the static layer: hide videos with visibility:hidden then render
const prevVis = videos.map((v) => v.style.visibility);
videos.forEach((v) => { v.style.visibility = 'hidden'; });
const bgUrl = await window.htmlToImage.toPng(node, {
width: W, height: H, pixelRatio: 1, cacheBust: true, backgroundColor: '#0E0D0D',
});
videos.forEach((v, i) => { v.style.visibility = prevVis[i] || ''; });
const bg = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = rej;
im.src = bgUrl;
});
// 3) Restart videos
videos.forEach((v) => { try { v.currentTime = 0; v.play(); } catch {} });
// 4) Record canvas
const stream = canvas.captureStream(30);
const recorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9' });
const chunks = [];
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
const done = new Promise((res) => { recorder.onstop = () => res(new Blob(chunks, { type: 'video/webm' })); });
recorder.start();
const start = performance.now();
let raf;
const tick = () => {
ctx.drawImage(bg, 0, 0, W, H);
// draw videos with object-fit: cover behavior
for (const { v, x, y, w, h } of videoRects) {
const vw = v.videoWidth, vh = v.videoHeight;
if (!vw || !vh) continue;
const scale = Math.max(w / vw, h / vh);
const dw = vw * scale, dh = vh * scale;
const dx = x + (w - dw) / 2;
const dy = y + (h - dh) / 2;
ctx.save();
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.clip();
ctx.drawImage(v, dx, dy, dw, dh);
ctx.restore();
}
if (performance.now() - start < durationMs) {
raf = requestAnimationFrame(tick);
} else {
cancelAnimationFrame(raf);
recorder.stop();
}
};
raf = requestAnimationFrame(tick);
return await done;
};
const download = async () => {
const node = captureRef.current;
if (!node) return;
const prev = node.style.transform;
node.style.transform = 'none';
try {
if (hasVideo()) {
showToast('gravando vídeo · 5s ⏺');
const blob = await recordVideo(5000);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `studiouarini-${template.id}.webm`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000);
showToast('vídeo baixado ✦');
} else {
const dataUrl = await window.htmlToImage.toPng(node, {
width: template.size.w,
height: template.size.h,
pixelRatio: 2,
cacheBust: true,
backgroundColor: '#0E0D0D',
});
const a = document.createElement('a');
a.href = dataUrl;
a.download = `studiouarini-${template.id}.png`;
a.click();
showToast('imagem baixada ✦');
}
} catch (e) {
console.error(e);
showToast('erro ao exportar');
} finally {
node.style.transform = prev;
}
};
const copyCaption = async () => {
const seed = template.caption ? template.caption(data) : '';
try {
await navigator.clipboard.writeText(seed);
showToast('legenda copiada ✦');
} catch {
showToast('não consegui copiar');
}
};
const isVideo = hasVideo();
return (
<>
{/* Sidebar */}
{ReactDOM.createPortal(
,
document.getElementById('nav-mount')
)}
{/* Form */}
{ReactDOM.createPortal(
{template.group}
{template.label}
Edite os campos abaixo. A arte se atualiza em tempo real.
{template.fields.map((f) => (
))}
{isVideo ? 'baixar vídeo (WebM · 5s)' : 'baixar PNG'}
copiar legenda base
restaurar padrão
,
document.getElementById('form-mount')
)}
{/* Preview */}
{ReactDOM.createPortal(
<>
{template.id}
{template.size.w} × {template.size.h} px
>,
document.getElementById('preview-mount')
)}
>
);
}
// We need a single render root that owns shared state but paints into 3 DOM mounts.
// Mount onto a dummy host so portals can stamp into the real columns.
const _host = document.createElement('div');
_host.style.display = 'none';
document.body.appendChild(_host);
ReactDOM.createRoot(_host).render( );