diff --git a/ventures/havre/abilities.js b/ventures/havre/abilities.js index 46c486b..b6affe7 100644 --- a/ventures/havre/abilities.js +++ b/ventures/havre/abilities.js @@ -151,36 +151,60 @@ export function cleanText(text, max = 900) { return clip(t, max); } -// the rules ride at the front of every picture and clip, where a long shot can't push them out +// the rules ride at the front of every picture and clip, where a long shot or a long look can't push them out const KEEP = "Safe for work: fully clothed, nothing sexual, no gore; everyone shown is an adult."; // an adult character's: the 18+ rule is the very first thing the model reads const KEEP_ADULT = ADULT_ONLY + " Never a real, identifiable person. No gore."; +/** how their card says they look, on one line, without a full stop at the end ("" when it doesn't say) */ +const lookWords = (card) => str(card && card.look && card.look.description, 4000).replace(/\s+/g, " ").trim().replace(/[.!?…]+$/, ""); +/** words that end a sentence: a full stop, unless they were cut short with … */ +const ended = (s) => (/[.!?…]$/.test(s) ? s : s + "."); +// how much of how they look rides in a photo or a clip: enough for a look, and never the room the shot needs +export const LOOK_ROOM = 600; + /** - * What the photo model is told: keep the character as the pictures show them, in the shot. adult: an adult - * character's photo (adultCheck), which keeps every rule but the one about work, and opens with the 18+ rule. + * A photo or a clip made from the character's pictures: the rules, then who they are, then what the camera sees (body). + * Their face comes from the pictures; the rest follows how their card says they look where the pictures differ, so a + * changed look changes the next one, and the shot where it says what they wear. Nothing else is pinned: an edit model + * keeps what it isn't told to change, and a shot that asks for other clothes gets them. The look takes the room the + * rest leaves, up to LOOK_ROOM; with hardly any, it's left out. + */ +function fromPictures(card, pictures, body, limit, adult, what) { + const them = pictures > 1 ? `The reference pictures show ${card.name}.` : `The reference picture shows ${card.name}.`; + const head = adult ? `${KEEP_ADULT} ${them}` : `${them} ${KEEP}`; + const withLook = (look) => `${head} Keep their face, so it's clearly them. How they look: ${look} Where the ` + + `${pictures > 1 ? "pictures differ" : "picture differs"} from these words, follow the words; where the ${what} says what ` + + `they wear, follow that. ${body}`; + const look = lookWords(card), room = Math.min(LOOK_ROOM, limit - withLook("").length - 1); + if (look && room >= 40) return clip(withLook(ended(clip(look, room))), limit); + return clip(`${head} Keep their face, hair and build, so it's clearly them; their clothes stay as they are unless the ` + + `${what} says what they wear. ${body}`, limit); +} +/** + * What the photo model is told: who the character is (fromPictures), in the shot. adult: an adult character's photo + * (adultCheck), which keeps every rule but the one about work, and opens with the 18+ rule. */ export function photoPrompt(card, shot, pictures = 1, limit = 1500, { adult = false } = {}) { - const lead = pictures > 1 - ? `The reference pictures show ${card.name}. Keep how they look exactly: face, hair, build, clothes and style.` - : `The reference picture shows ${card.name}. Keep how they look exactly: face, hair, build, clothes and style.`; - if (adult) return clip(`${KEEP_ADULT} ${lead} New photo: ${str(shot).trim()} A natural photo with no text in it.`, limit); - return clip(`${lead} ${KEEP} New photo: ${str(shot).trim()} A natural photo with no text in it.`, limit); + return fromPictures(card, pictures, `New photo: ${str(shot).trim()} A natural photo with no text in it.`, limit, adult, "new photo"); } export function clipPrompt(card, shot, limit = 2500, { adult = false } = {}) { - if (adult) return clip(`${KEEP_ADULT} The reference pictures show ${card.name}. Keep how they look exactly. ${str(shot).trim()}`, limit); - return clip(`The reference pictures show ${card.name}. Keep how they look exactly. ${KEEP} ${str(shot).trim()}`, limit); + return fromPictures(card, 2, `The clip: ${str(shot).trim()}`, limit, adult, "clip"); } /** - * A portrait drawn from how they look (a first one, or a new one for their card). adult: an adult character's - * (adultCheck, drawn), which keeps every rule but the one about work, and opens with the 18+ rule. + * A portrait drawn from how they look (a first one, or a new one for their card): the look takes the room the rules and + * the framing leave. adult: an adult character's (adultCheck, drawn), which keeps every rule but the one about work, + * and opens with the 18+ rule. */ export function portraitPrompt(card, limit = 1500, { adult = false } = {}) { - const look = ((card.look && card.look.description) || card.tagline || "an original character").trim().replace(/[.!?…]+$/, ""); - if (adult) return clip(`${KEEP_ADULT} Portrait photo of ${card.name}, an original adult character: ${look}. Head and shoulders, ` + - "soft light, plain background, no text.", limit); - return clip(`Portrait photo of ${card.name}, an original adult character: ${look}. ${KEEP} Head and shoulders, soft light, ` + - "plain background, no text.", limit); + const look = lookWords(card) || str(card.tagline).trim().replace(/[.!?…]+$/, "") || "an original character"; + const make = (w) => (adult + ? `${KEEP_ADULT} Portrait photo of ${card.name}, an original adult character: ${w} Head and shoulders, soft light, plain ` + + "background, no text." + : `Portrait photo of ${card.name}, an original adult character: ${w} ${KEEP} Head and shoulders, soft light, plain ` + + "background, no text."); + const room = limit - make("").length - 1; + return clip(make(ended(room >= 40 ? clip(look, room) : look)), limit); } /** diff --git a/ventures/havre/app.js b/ventures/havre/app.js index f063dec..30c61e2 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -2379,7 +2379,7 @@ function renderRefs(c) { paintFace(f, src); return h("figure", { class: "ref" }, f, h("figcaption", {}, label, remove ? [" ", linkish("remove", remove, { "aria-label": `Remove ${label}` })] : [])); }; - box.append(frame(portraitSrc(c), "portrait")); + box.append(frame(portraitSrc(c), "portrait", c.portrait ? removePortrait : null)); c.look.references.forEach((r, i) => box.append(frame(pictureSrc(r), `picture ${i + 2}`, () => { const now = card(); replaceCard({ ...now, look: { ...now.look, references: now.look.references.filter((_, j) => j !== i) } }); @@ -2390,6 +2390,16 @@ function renderRefs(c) { renderMakers(); renderAdultNote(c); } +/** + * Their portrait off their card (from the top of the sheet, or under look): photos come from the pictures left, and with + * none left, the next picture made with AI is drawn from how they look alone, as their new portrait. + */ +function removePortrait() { + const c = card(); + if (!c || c.plain) return; + replaceCard({ ...c, portrait: "" }); + renderSheet(); renderThreads(); picturesChanged(); +} async function addReference(file) { const c = card(); if (!c || c.plain) return; @@ -3075,12 +3085,7 @@ function bind() { try { const c = card(); replaceCard({ ...c, portrait: await shrinkImage(pf) }); renderSheet(); renderThreads(); picturesChanged(); } catch { toast("That image didn't load."); } }); - $("#portrait-clear").addEventListener("click", () => { - const c = card(); - if (!c) return; - replaceCard({ ...c, portrait: "" }); - renderSheet(); renderThreads(); picturesChanged(); - }); + $("#portrait-clear").addEventListener("click", removePortrait); $("#ref-add").addEventListener("click", () => { if (!local.get(K.refOk, false)) { if (!confirm("Photos and clips of a character are made from their pictures. Add only pictures of the character: art, or pictures you made. Never a photo of a real person.\n\nAdd a picture of the character?")) return; diff --git a/ventures/havre/index.html b/ventures/havre/index.html index e45f7ac..af143a6 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -157,11 +157,12 @@
- LOOK their photos and clips are made from these pictures + LOOK their photos and clips are made from these pictures and words
-

/ up to three besides the portrait. ai makes the next one from the pictures they have, so it's still them, or from how they look if they have none. pictures of the character only, never a photo of a real person.

+

/ up to three besides the portrait. ai makes the next one from the pictures they have, so it's still them, changed to match how they look below; or from how they look alone, if they have none: to start over with someone new, remove every picture here first. pictures of the character only, never a photo of a real person.

+

new photos, clips and pictures keep their face from the pictures above and follow these words for everything else, so changing them changes the next ones. a new portrait is drawn from these words alone.

VOICE NOTES how they sound when they send one diff --git a/ventures/havre/skill.md b/ventures/havre/skill.md index 27e3248..b2d3418 100644 --- a/ventures/havre/skill.md +++ b/ventures/havre/skill.md @@ -71,8 +71,10 @@ Save it as `.havre.json`. Only `havre` (always 1), `name` and `persona` are - **look** (optional): how they look. `description` is a sentence or two (an adult; face, hair, build, clothes). `references` holds up to three more pictures besides the `portrait`, best as data: URIs (PNG, JPEG, WebP or GIF) so they travel with the card. Havre makes photos and clips of the character from the portrait and these, when - they're kept in the card (or the character's own photo on Venice). Pictures of the character only: art, or - pictures the person made. Never a photo of a real person. + they're kept in the card (or the character's own photo on Venice): their face comes from the pictures, and + everything else from `description`, so a new description shows in the next photo. A new portrait is drawn from + `description` alone. Pictures of the character only: art, or pictures the person made. Never a photo of a real + person. - **speech** (optional): how they sound in a voice note. `model` is a Venice voice model (`tts-kokoro` if empty), `voice` one of its voices, `mood` a short delivery note ("warm, teasing") that Qwen 3 voices follow, and `speed` 0.25 to 4. @@ -133,8 +135,8 @@ In Havre a character can send more than texts. `abilities.js` has the prompts an - **A photo**: a text model writes the shot from the conversation (`A.shotMessages(card, A.apiHistory(history), ask)`), then `venice.editImage({ model, prompt: A.photoPrompt(card, shot, n), images })` edits the character's own - pictures (`/image/edit`, or `/image/multi-edit` for several). Safe mode is always on. A character with no picture - yet gets a first portrait from `A.portraitPrompt(card)` through `/image/generate`. + pictures (`/image/edit`, or `/image/multi-edit` for several). Safe mode is on, except for adult characters (below). + A character with no picture yet gets a first portrait from `A.portraitPrompt(card)` through `/image/generate`. - **A clip**: the same kind of shot (`kind: "clip"`), priced first with `venice.quoteVideo()`, then `venice.queueVideo()` with a reference-to-video model and the character's pictures, polled with `venice.retrieveVideo()`. Always show the price before queueing. diff --git a/ventures/havre/tests/abilities.test.mjs b/ventures/havre/tests/abilities.test.mjs index 78a1c52..1aadc71 100644 --- a/ventures/havre/tests/abilities.test.mjs +++ b/ventures/havre/tests/abilities.test.mjs @@ -77,12 +77,48 @@ test("the shot is written from the conversation, with the rules, and the photo k assert.ok(p.startsWith("The reference pictures show Ines Corbel.")); assert.ok(p.length <= 120); assert.match(A.portraitPrompt(ines), /^Portrait photo of Ines Corbel, an original adult character: forties.*Safe for work: fully clothed, nothing sexual, no gore; everyone shown is an adult\./); - assert.match(A.photoPrompt(ines, "On deck."), /Keep how they look exactly.*Safe for work.*everyone shown is an adult\. New photo: On deck\./); - assert.match(A.clipPrompt(ines, "She waves."), /everyone shown is an adult\. She waves\.$/); + assert.match(A.photoPrompt(ines, "On deck."), /^The reference picture shows Ines Corbel\. Safe for work.*everyone shown is an adult\. Keep their face.* New photo: On deck\. A natural photo with no text in it\.$/); + assert.match(A.clipPrompt(ines, "She waves."), /^The reference pictures show Ines Corbel\. Safe for work.*How they look: forties.* The clip: She waves\.$/); assert.match(A.lookMessages(ines)[1].content, /Ferry pilot/); // a picture for their card, made from the ones they have: the next view of a character sheet assert.deepEqual([0, 1, 2, 3, -1, NaN].map(A.sheetView), [A.SHEET_VIEWS[0], A.SHEET_VIEWS[1], A.SHEET_VIEWS[2], A.SHEET_VIEWS[0], A.SHEET_VIEWS[2], A.SHEET_VIEWS[0]]); - assert.match(A.photoPrompt(ines, A.sheetView(0)), /^The reference picture shows Ines Corbel\. Keep how they look exactly.*Safe for work.*New photo: Full length, standing/); + assert.match(A.photoPrompt(ines, A.sheetView(0)), /^The reference picture shows Ines Corbel\. Safe for work.*How they look: forties.*New photo: Full length, standing/); +}); + +test("pictures made from their pictures keep their face and follow how their card says they look, so a new look shows", () => { + const p = A.photoPrompt(ines, "On deck, in a red dress."); + assert.match(p, /Keep their face, so it's clearly them\. How they look: forties, short grey hair, yellow oilskin\. Where the picture differs from these words, follow the words; where the new photo says what they wear, follow that\. New photo: On deck, in a red dress\./); + assert.ok(!/exactly|clothes and style/.test(p), "nothing else is pinned as it was: " + p); + assert.match(A.photoPrompt(ines, "On deck.", 2), /Where the pictures differ from these words/); + // a changed look is in the next picture, the old one isn't + const red = { ...ines, look: { ...ines.look, description: "Thirties, red hair in a braid,\n a green raincoat." } }; + for (const q of [A.photoPrompt(red, "On deck."), A.photoPrompt(red, A.sheetView(1)), A.clipPrompt(red, "She waves."), A.portraitPrompt(red)]) { + assert.match(q, /Thirties, red hair in a braid, a green raincoat\. /, q); + assert.ok(!q.includes("grey hair"), q); + } + // no words about how they look: their face, hair and build stay, and their clothes unless the shot says otherwise + const bare = { ...ines, look: { ...ines.look, description: "" } }; + assert.match(A.photoPrompt(bare, "On deck."), /Safe for work.*Keep their face, hair and build, so it's clearly them; their clothes stay as they are unless the new photo says what they wear\. New photo: On deck\./); + assert.match(A.clipPrompt(bare, "She waves."), /unless the clip says what they wear\. The clip: She waves\.$/); + // a long look takes only the room the shot leaves (and at most LOOK_ROOM): the rules, the shot and its ending all stay + const long = { ...ines, look: { ...ines.look, description: "salt in the stubble, ".repeat(200) } }; + const shot = "On deck at dusk, the gulls wheeling. ".repeat(10).trim(); + for (const [q, limit] of [[A.photoPrompt(long, shot), 1500], [A.photoPrompt(long, shot, 1, 1500, { adult: true }), 1500], [A.clipPrompt(long, shot, 1000), 1000]]) { + assert.ok(q.length <= limit, q.length); + assert.ok(q.endsWith(shot + (q.includes("New photo:") ? " A natural photo with no text in it." : "")), q); + const words = q.match(/How they look: (.*?) Where the/)[1]; + assert.ok(words.endsWith("…") && words.length <= A.LOOK_ROOM, words); + } + assert.ok(A.photoPrompt(long, shot, 1, 1500, { adult: true }).startsWith(A.ADULT_ONLY)); + // with hardly any room, the look goes before the shot does + assert.match(A.photoPrompt(long, "On deck.", 1, 380), /Keep their face, hair and build/); + // a portrait: the look takes the room the rules and the framing leave + const drawn = A.portraitPrompt(long); + assert.ok(drawn.length <= 1500); + assert.match(drawn, /… Safe for work: fully clothed, nothing sexual, no gore; everyone shown is an adult\. Head and shoulders, soft light, plain background, no text\.$/); + assert.match(A.portraitPrompt(long, 1500, { adult: true }), /^Hard rule.*Portrait photo of Ines Corbel.*… Head and shoulders, soft light, plain background, no text\.$/); + assert.match(A.portraitPrompt({ ...bare, tagline: "Ferry pilot." }), /an original adult character: Ferry pilot\. Safe for work/); + assert.match(A.portraitPrompt({ ...bare, tagline: "" }), /an original adult character: an original character\. Safe for work/); }); // ---------- adult characters ---------- diff --git a/ventures/havre/tests/smoke.mjs b/ventures/havre/tests/smoke.mjs index 6b227fb..da3be6d 100644 --- a/ventures/havre/tests/smoke.mjs +++ b/ventures/havre/tests/smoke.mjs @@ -734,13 +734,17 @@ async function main() { assert.match((await inesNow()).look.description, /^Thirties, wiry/, "how she looks, written on her card for next time"); await until(page, () => { const i = document.querySelector("#sheet-face img"); return !!i && i.src.startsWith("data:image/webp"); }, null, "her portrait on the card"); await page.evaluate(() => { document.querySelector("#look-group").open = true; }); + // how she looks, changed on her card: the next picture keeps her face, and follows the new words for the rest + await page.fill('#sheet [name="look_description"]', "Thirties, red hair in a braid, a green raincoat."); + await until(page, () => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.id === "ines-corbel").look.description === "Thirties, red hair in a braid, a green raincoat.", + null, "her new look saved"); const edited = seen.edits.length; await has(page, "#ref-make", /^make one with ai · \$0\.03$/); await page.click("#ref-make"); await count(page, "#refs .ref", 2); assert.equal(seen.edits.length, edited + 1, "made from the pictures she has"); assert.equal(seen.edits.at(-1).body.image, (await inesNow()).portrait.split(",")[1], "her portrait, so it's still her"); - assert.match(seen.edits.at(-1).body.prompt, /^The reference picture shows Ines Corbel\..*New photo: Full length, standing/); + assert.match(seen.edits.at(-1).body.prompt, /^The reference picture shows Ines Corbel\..*Keep their face, so it's clearly them\. How they look: Thirties, red hair in a braid, a green raincoat\. Where the picture differs from these words, follow the words.*New photo: Full length, standing/); assert.equal(seen.edits.at(-1).body.safe_mode, true); await until(page, () => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Ines Corbel").look.references.length === 1, null, "the picture kept on her card"); await shot(page, "desktop-card-pictures.png"); @@ -926,6 +930,27 @@ async function main() { assert.match(safeEdit.prompt, /Safe for work: fully clothed, nothing sexual/); await count(page, "#ask", 1); await until(page, () => document.querySelector("#ask").hidden, null, "the panel closed"); + // starting her look over, from under look: every picture off her card, the portrait too. The next one is drawn from + // how she looks alone (hers on an uncensored model, as an adult's), and it's her new portrait + await page.evaluate(() => { document.querySelector("#look-group").open = true; }); + await count(page, "#refs .ref", 3); + for (const name of ["Remove picture 3", "Remove picture 2", "Remove portrait"]) await page.getByRole("button", { name, exact: true }).click(); + await count(page, "#refs .ref", 1); + assert.equal(await page.locator("#refs .ref button").count(), 0, "nothing left to remove"); + await until(page, () => { const c = JSON.parse(localStorage.getItem("havre.cards.v1")).find((x) => x.id === "ines-corbel"); return !c.portrait && !c.look.references.length; }, + null, "every picture gone"); + await has(page, "#adult-note", /has no picture made here yet/); + await has(page, "#ref-make", /^make one with ai · \$0\.04$/); + const generatedBefore = seen.generated.length, editsBefore2 = seen.edits.length; + await page.click("#ref-make"); + await until(page, () => /^data:image\/webp/.test(JSON.parse(localStorage.getItem("havre.cards.v1")).find((x) => x.id === "ines-corbel").portrait), null, + "her new portrait, drawn from words"); + assert.equal(seen.generated.length, generatedBefore + 1); + assert.equal(seen.edits.length, editsBefore2, "made from no picture of before"); + assert.equal(seen.generated.at(-1).model, "krea-2-turbo"); + assert.match(seen.generated.at(-1).prompt, /Portrait photo of Ines Corbel, an original adult character: Thirties, red hair in a braid, a green raincoat\./); + await count(page, "#refs .ref", 1); + await has(page, "#adult-note", /^their photos and clips can be adult/); // exports, made in the browser; the PNG comes back in whole await page.getByRole("button", { name: "Export Ines Corbel" }).click();