Havre: check uploaded art for adult characters; send a photo → upload or make one; /snap #219

Open
teahubxyz wants to merge 6 commits from havre-art-and-snap into gh-pages
pull from: havre-art-and-snap
5 changed files with 119 additions and 9 deletions

No files matched your search

+17 -1
View File
@@ -460,7 +460,7 @@ export function adultCheck(card, { of18 = false, on = true, made = () => false,
if (!of18) return { ok: false, why: "adult photos and clips wait until you say you're 18 or older, under abilities." }; if (!of18) return { ok: false, why: "adult photos and clips wait until you say you're 18 or older, under abilities." };
const pics = drawn ? [] : [card.portrait, ...((card.look && card.look.references) || [])].filter(Boolean); const pics = drawn ? [] : [card.portrait, ...((card.look && card.look.references) || [])].filter(Boolean);
if (!drawn && !pics.length) return { ok: false, why: `${card.name} has no picture made here yet (card → look: make one with ai), ${sfw}` }; if (!drawn && !pics.length) return { ok: false, why: `${card.name} has no picture made here yet (card → look: make one with ai), ${sfw}` };
if (!pics.every((p) => made(p))) return { ok: false, why: `a picture on their card wasn't made here with ai (it was uploaded, or came from venice), ${sfw}` }; if (!pics.every((p) => made(p))) return { ok: false, uploaded: true, why: `a picture on their card was uploaded and hasn't been checked yet, ${sfw} If it's art you have the rights to, check it below.` };
const onCard = underage(cardWords(card), UNDERAGE_CARD); const onCard = underage(cardWords(card), UNDERAGE_CARD);
if (onCard) return { ok: false, why: `their card says “${onCard}”, which sounds like someone under 18, ${sfw}` }; if (onCard) return { ok: false, why: `their card says “${onCard}”, which sounds like someone under 18, ${sfw}` };
const now = underage([shot, said]); const now = underage([shot, said]);
@@ -468,6 +468,22 @@ export function adultCheck(card, { of18 = false, on = true, made = () => false,
return { ok: true, why: "" }; return { ok: true, why: "" };
} }
/**
* Checking an uploaded picture for an adult character: is it art (drawn, painted, rendered, AI-made) or a photograph
* of a real person, and does everyone in it look grown up? A real person's photo never becomes the reference for an
* adult picture: that's how a deepfake of someone who never agreed is made. Art you have the rights to can.
*/
export const ART_PROMPT = "Look at this picture and answer with one JSON object, nothing else: " +
"{\"kind\": \"art\" or \"photo\", \"adult\": \"yes\", \"no\" or \"unsure\"}. kind is \"art\" for a drawing, painting, " +
"illustration, anime, 3D render, or an obviously AI-generated or stylised image, and \"photo\" for what looks like a real " +
"photograph of a real person. adult is \"yes\" only if everyone shown clearly looks 18 or older.";
/** the checker's answer: { art, adult }, false for anything it didn't clearly say */
export function parseArt(text) {
const t = str(text, 2000).toLowerCase();
const kind = /"kind"\s*:\s*"(art|photo)"/.exec(t), adult = /"adult"\s*:\s*"(yes|no|unsure)"/.exec(t);
return { art: !!kind && kind[1] === "art", adult: !!adult && adult[1] === "yes", answered: !!kind && !!adult };
}
/** /**
* A short fingerprint of a picture (the data: URI a card keeps it as), for the list of pictures this browser made with * A short fingerprint of a picture (the data: URI a card keeps it as), for the list of pictures this browser made with
* AI. It only has to tell this browser's pictures apart; it guards nothing secret. * AI. It only has to tell this browser's pictures apart; it guards nothing secret.
+89 -5
View File
@@ -54,7 +54,7 @@ const K = { cards: "havre.cards.v1", sel: "havre.selected", key: "havre.key", mo
memory: "havre.memory.v1", memory: "havre.memory.v1",
// the pictures this browser made with AI (their fingerprints): an adult character's photos and clips can be adult // the pictures this browser made with AI (their fingerprints): an adult character's photos and clips can be adult
// only when every picture on their card is one of these // only when every picture on their card is one of these
made: "havre.pictures.made.v1", made: "havre.pictures.made.v1", art: "havre.pictures.art.v1", artOk: "havre.pictures.art-ok",
// the pages of Venice's characters this tab saw lately (in sessionStorage, so they last as long as the tab) // the pages of Venice's characters this tab saw lately (in sessionStorage, so they last as long as the tab)
pages: "havre.venice.pages", pages: "havre.venice.pages",
// your chats: the list of threads, each thread's messages under its own key, the threads deleted (so another tab // your chats: the list of threads, each thread's messages under its own key, the threads deleted (so another tab
@@ -1423,6 +1423,7 @@ function mediaLine(c, m) {
disabled: md.kept === "ref" || full || null, title: "one of the pictures their photos and clips are made from", onclick: () => keepFromChat(c, m, "") })); disabled: md.kept === "ref" || full || null, title: "one of the pictures their photos and clips are made from", onclick: () => keepFromChat(c, m, "") }));
said.append(row); said.append(row);
} }
else if (md.saw && md.made) said.append(h("span", { class: "cap", text: `made with ai${md.model ? " (" + md.model + (md.price != null ? ", " + A.usd(md.price) : "") + ")" : ""}: ${md.saw}` }));
else if (md.saw) said.append(h("span", { class: "cap", text: `${md.seer || "a model"} told them: ${md.saw}` })); else if (md.saw) said.append(h("span", { class: "cap", text: `${md.seer || "a model"} told them: ${md.saw}` }));
} else if (md.kind === "clip" && !md.id) { } else if (md.kind === "clip" && !md.id) {
// a clip this browser couldn't download: it plays from Venice's link until the link lapses // a clip this browser couldn't download: it plays from Venice's link until the link lapses
@@ -1816,7 +1817,11 @@ function renderTools() {
} }
} }
if (S.ab.hear) items.push(linkish("record a voice note", startRecording)); if (S.ab.hear) items.push(linkish("record a voice note", startRecording));
if (S.ab.see) items.push(linkish("send a photo", () => $("#photo-in").click())); if (S.photoPick) { // send a photo: one of yours, or one made with ai
items.push(linkish("upload a photo", () => { S.photoPick = false; renderTools(); $("#photo-in").click(); }),
linkish("make one with ai", () => { S.photoPick = false; renderTools(); snap($("#say").value.trim()); }),
linkish("cancel", () => { S.photoPick = false; renderTools(); }));
} else if (S.ab.see) items.push(linkish("send a photo", () => { S.photoPick = true; renderTools(); }));
items.push(linkish("abilities", openAbilities)); items.push(linkish("abilities", openAbilities));
p.append(...slashed(items)); p.append(...slashed(items));
} }
@@ -2222,15 +2227,60 @@ function noteMade(pic, from = []) {
function adultFor(c, t = null, shot = "", { drawn = false } = {}) { function adultFor(c, t = null, shot = "", { drawn = false } = {}) {
const made = madeKeys(); const made = madeKeys();
const said = t ? t.msgs.filter((m) => m && !m.demo).slice(-12).flatMap((m) => [typeof m.content === "string" ? m.content : "", m.media && m.media.shot]) : []; const said = t ? t.msgs.filter((m) => m && !m.demo).slice(-12).flatMap((m) => [typeof m.content === "string" ? m.content : "", m.media && m.media.shot]) : [];
return A.adultCheck(c, { of18: of18(), on: S.ab.adult !== false, made: (p) => made.has(keyOf(p)) || OUTERFACE.test(p || ""), shot, said, drawn }); const art = artKeys();
return A.adultCheck(c, { of18: of18(), on: S.ab.adult !== false, made: (p) => made.has(keyOf(p)) || art.has(keyOf(p)) || OUTERFACE.test(p || ""), shot, said, drawn });
}
/** uploaded pictures that passed the check: art you have the rights to, everyone in it grown up (clearArt) */
const artKeys = () => new Set(local.get(K.art, []) || []);
const artFlags = new Map(); // this session's "why not" per picture key
/**
* Uploaded pictures on an adult character's card, checked: you confirm once that they're art you have the rights to
* and that everyone in them is an adult; then a private model that can see looks at each one (A.ART_PROMPT). Art that
* looks grown up joins the cleared list (and counts as made here); a photo of a real person never does: it stays
* fine for safe-for-work pictures, and "make one with ai" redraws the character from words if it was flagged wrongly.
*/
async function clearArt(c) {
if (!c || c.plain || !S.key) return;
const made = madeKeys(), art = artKeys();
const pics = [c.portrait, ...((c.look && c.look.references) || [])].filter(Boolean)
.filter((p) => !made.has(keyOf(p)) && !art.has(keyOf(p)) && !OUTERFACE.test(p));
if (!pics.length) { adultChanged(); return; }
if (!local.get(K.artOk, false)) {
if (!confirm("Check these pictures for adult photos and clips?\n\nBy going on you confirm that each one is art of a fictional character (drawn, painted, rendered or made with AI), that you own it or have permission to use it, and that everyone in it is an adult, 18 or older.\n\nA private Venice model looks at each one. A photograph of a real person is never used for adult pictures.")) return;
local.set(K.artOk, true);
}
await loadModels();
const sm = seeModel();
if (!sm) { toast("No Venice model can see pictures right now."); return; }
toast(`${sm.name} is checking ${pics.length === 1 ? "the picture" : pics.length + " pictures"}…`);
let ok = 0, photo = 0, young = 0, unsure = 0;
for (const p of pics) {
try {
const url = p.startsWith("data:") ? await shrinkImage(dataBlob(p), 768, "image/jpeg", { svg: false }) : p;
const r = await new Venice(S.key).chat({ model: sm.id, maxTokens: 60, temperature: 0,
messages: [{ role: "user", content: [{ type: "text", text: A.ART_PROMPT }, { type: "image_url", image_url: { url } }] }] });
const a = A.parseArt(r.text), key = keyOf(p);
if (!a.answered) { unsure++; continue; }
if (!a.art) { photo++; artFlags.set(key, "photo"); continue; }
if (!a.adult) { young++; artFlags.set(key, "young"); continue; }
local.set(K.art, [...(local.get(K.art, []) || []).filter((k) => k !== key), key].slice(-400));
artFlags.delete(key);
ok++;
} catch { unsure++; }
}
const bits = [ok && `${ok} cleared`, photo && `${photo} looked like a photo of a real person (if it's AI art, card → look: make one with ai redraws them from words)`,
young && `${young} didn't clearly show an adult`, unsure && `${unsure} couldn't be checked; try again`].filter(Boolean);
toast(bits.join("; ") + ".");
adultChanged();
} }
/** under the adult box on their card: whether their photos and clips can be adult, and if not, why */ /** under the adult box on their card: whether their photos and clips can be adult, and if not, why */
function renderAdultNote(c) { function renderAdultNote(c) {
const p = $("#adult-note"); const p = $("#adult-note");
const ad = c && !c.plain && c.adult ? adultFor(c) : null; const ad = c && !c.plain && c.adult ? adultFor(c) : null;
p.hidden = !ad; p.hidden = !ad;
if (ad) p.textContent = ad.ok ? "their photos and clips can be adult: every picture on their card was made here with ai. never anyone under 18, never a real person." if (ad) p.textContent = ad.ok ? "their photos and clips can be adult: every picture on their card was made here with ai or checked as art. never anyone under 18, never a real person."
: ad.why; : ad.why;
if (ad && !ad.ok && ad.uploaded && S.key) p.append(" ", linkish("check them", () => clearArt(card())));
} }
/** the adult switch, or what you've said about your age, changed: what shows it catches up */ /** the adult switch, or what you've said about your age, changed: what shows it catches up */
function adultChanged() { function adultChanged() {
@@ -2345,6 +2395,7 @@ function runWorkflow(id, { door = "chat", idea = "", name = "" } = {}) {
if (id === "photoshoot") return photoshoot(idea, { door }); if (id === "photoshoot") return photoshoot(idea, { door });
if (id === "recall") return recall(idea); if (id === "recall") return recall(idea);
if (id === "whisper") return whisper(idea); if (id === "whisper") return whisper(idea);
if (id === "snap") return snap(idea);
} }
/** the chat a workflow's result lands in: the one of theirs on screen, else their latest, else a new one */ /** the chat a workflow's result lands in: the one of theirs on screen, else their latest, else a new one */
function threadFor(c) { function threadFor(c) {
@@ -3002,6 +3053,39 @@ async function hearVoice(blob, to) {
} }
// ---------- abilities: your photos ---------- // ---------- abilities: your photos ----------
/**
* /snap (or send a photo → make one with ai): a picture you describe, made with the portrait model (safe mode on),
* sent as yours. They're told what's in it in words, and it's marked as made with ai.
*/
let snapping = false;
async function snap(words = "") {
const c = card();
if (!c) return;
if (!S.key) return needKey("pictures need your venice key.");
if (snapping) { toast("A picture is already being made."); return; }
let what = String(words || "").trim();
if (!what) what = (prompt("What's in the picture? (the pizza you made, the view from your window…)") || "").trim();
if (!what) return;
await loadList("image");
const pm = portraitModel();
if (!pm) { err("No Venice image model is available right now."); return; }
if (!confirm(`Make a picture of “${what.slice(0, 120)}” with ${pm.name} and send it as yours? About ${pm.price != null ? A.usd(pm.price) : "a few cents"} on your Venice key.`)) return;
const to = { card: S.sel, thread: S.open, at: Date.now() };
snapping = true;
S.inflight++;
const note = put(line(`making your picture with ${pm.name}…`, "havre", "typing"));
scrollLog(true);
try {
const { blob, blurred } = await new Venice(S.key).generateImage({ model: pm.id, prompt: what.slice(0, pm.promptLimit || 1500), safe: true });
note.remove();
if (blurred) { err("Venice's safe mode blurred that picture, so it wasn't sent. Try describing it differently."); return; }
const id = await M.put(blob);
if ($("#say").value.trim() === what) { $("#say").value = ""; growSay(); }
await idle();
if (stillWith(to, "photo")) await send("", { media: { kind: "photo-in", id, saw: what, seer: "made with ai", made: true, model: pm.name, price: pm.price } });
balanceSoon();
} catch (e) { note.remove(); err(e.message || String(e)); } finally { snapping = false; S.inflight--; }
}
async function sendPhoto(file) { async function sendPhoto(file) {
S.inflight++; S.inflight++;
try { await showPhoto(file); } finally { S.inflight--; } try { await showPhoto(file); } finally { S.inflight--; }
@@ -4147,7 +4231,7 @@ function bind() {
else if (e.key === K.jobs || e.key === K.jobsDone) syncJobs(); else if (e.key === K.jobs || e.key === K.jobsDone) syncJobs();
else if (e.key === K.spent) { renderTools(); if ($("#abilitydlg").open) renderAbilities(); } else if (e.key === K.spent) { renderTools(); if ($("#abilitydlg").open) renderAbilities(); }
else if (e.key === K.memory) { const c = card(); if (c && !c.plain) renderMemory(c); } else if (e.key === K.memory) { const c = card(); if (c && !c.plain) renderMemory(c); }
else if (e.key === K.made || e.key === K.adult18) adultChanged(); else if (e.key === K.made || e.key === K.art || e.key === K.adult18) adultChanged();
else if (e.key === K.ab) { else if (e.key === K.ab) {
S.ab = { ...S.ab, ...(local.get(K.ab, {}) || {}) }; S.ab = { ...S.ab, ...(local.get(K.ab, {}) || {}) };
renderTools(); renderTools();
+6 -1
View File
@@ -245,7 +245,12 @@ test("adult characters: adult pictures only when marked adult, 18+ said, every p
assert.match(A.adultCheck(mara, { ...ok, of18: false }).why, /until you say you're 18 or older/); assert.match(A.adultCheck(mara, { ...ok, of18: false }).why, /until you say you're 18 or older/);
assert.match(A.adultCheck(mara, { ...ok, on: false }).why, /switched off under abilities/); assert.match(A.adultCheck(mara, { ...ok, on: false }).why, /switched off under abilities/);
const uploaded = { ...mara, look: { ...mara.look, references: [...mara.look.references, "data:image/png;base64,UPLD"] } }; const uploaded = { ...mara, look: { ...mara.look, references: [...mara.look.references, "data:image/png;base64,UPLD"] } };
assert.match(A.adultCheck(uploaded, ok).why, /wasn't made here with ai/, "one uploaded picture keeps it safe for work"); assert.match(A.adultCheck(uploaded, ok).why, /was uploaded and hasn't been checked yet/, "one uploaded picture keeps it safe for work");
assert.equal(A.adultCheck(uploaded, ok).uploaded, true, "and says it can be checked");
assert.deepEqual(A.parseArt('{"kind": "art", "adult": "yes"}'), { art: true, adult: true, answered: true }, "art, grown up: cleared");
assert.equal(A.parseArt('{"kind": "photo", "adult": "yes"}').art, false, "a photo of a real person never is");
assert.equal(A.parseArt('{"kind": "art", "adult": "unsure"}').adult, false, "nor art that doesn't clearly show an adult");
assert.equal(A.parseArt("I can't tell").answered, false, "no answer, no verdict");
assert.equal(A.adultCheck({ ...mara, portrait: "https://outerface.venice.ai/x.png" }, ok).ok, false, "nor is a Venice photo made here"); assert.equal(A.adultCheck({ ...mara, portrait: "https://outerface.venice.ai/x.png" }, ok).ok, false, "nor is a Venice photo made here");
assert.match(A.adultCheck({ ...mara, portrait: "", look: { references: [] } }, ok).why, /no picture made here yet/); assert.match(A.adultCheck({ ...mara, portrait: "", look: { references: [] } }, ok).why, /no picture made here yet/);
assert.match(A.adultCheck({ ...mara, persona: "You are Mara, 16 years old." }, ok).why, /their card says “16 years old”/); assert.match(A.adultCheck({ ...mara, persona: "You are Mara, 16 years old." }, ok).why, /their card says “16 years old”/);
+2 -2
View File
@@ -946,14 +946,14 @@ async function main() {
await page.click("#ref-add"); await page.click("#ref-add");
await page.setInputFiles("#ref-file", { name: "upload.png", mimeType: "image/png", buffer: picture(200) }); await page.setInputFiles("#ref-file", { name: "upload.png", mimeType: "image/png", buffer: picture(200) });
await count(page, "#refs .ref", 3); await count(page, "#refs .ref", 3);
await has(page, "#adult-note", /^a picture on their card wasn't made here with ai \(it was uploaded, or came from venice\)/); await has(page, "#adult-note", /^a picture on their card was uploaded and hasn't been checked yet/);
await page.click("#ref-make"); await page.click("#ref-make");
await count(page, "#refs .ref", 4); await count(page, "#refs .ref", 4);
assert.equal(seen.edits.at(-1).body.safe_mode, true, "a picture made from an uploaded one keeps safe mode on"); assert.equal(seen.edits.at(-1).body.safe_mode, true, "a picture made from an uploaded one keeps safe mode on");
assert.equal(seen.edits.at(-1).body.modelId || seen.edits.at(-1).body.model, "grok-imagine-edit"); assert.equal(seen.edits.at(-1).body.modelId || seen.edits.at(-1).body.model, "grok-imagine-edit");
await page.getByRole("button", { name: "Remove picture 4" }).click(); await page.getByRole("button", { name: "Remove picture 4" }).click();
await count(page, "#refs .ref", 3); await count(page, "#refs .ref", 3);
await has(page, "#adult-note", /wasn't made here with ai/); await has(page, "#adult-note", /was uploaded and hasn't been checked yet/);
await page.getByRole("button", { name: "Remove picture 2" }).click(); await page.getByRole("button", { name: "Remove picture 2" }).click();
await count(page, "#refs .ref", 2); await count(page, "#refs .ref", 2);
await has(page, "#adult-note", /^their photos and clips can be adult/); await has(page, "#adult-note", /^their photos and clips can be adult/);
+5
View File
@@ -33,6 +33,11 @@ export const WORKFLOWS = [
about: "a few photos of them from the pictures on their card, one shoot, each a different moment (3 unless you say)", about: "a few photos of them from the pictures on their card, one shoot, each a different moment (3 unless you say)",
usage: "/photoshoot [how many] [what the shoot is about]", usage: "/photoshoot [how many] [what the shoot is about]",
}, },
{
id: "snap", command: "snap", title: "a picture to send", takes: "words", gives: "photo", doors: ["chat"],
about: "make a picture with ai and send it as yours (say what's in it: the pizza you made)",
usage: "/snap [what's in the picture]",
},
{ {
id: "whisper", command: "whisper", title: "a whisper", takes: "card", gives: "voice note", doors: ["chat"], id: "whisper", command: "whisper", title: "a whisper", takes: "card", gives: "voice note", doors: ["chat"],
about: "a short ASMR voice note from them, whispered over soft rain (luna or eve; add what it's about)", about: "a short ASMR voice note from them, whispered over soft rain (luna or eve; add what it's about)",