ventures: refresh havre from havre-next
This commit is contained in:
1 parent
ee7e68dcba
commit
8e7bacfdd6
8 files changed
+188
-42
No files matched your search
@@ -148,6 +148,17 @@ export function portraitPrompt(card, limit = 1500) {
|
||||
"plain background, no text.", limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The views of a character sheet: a picture made for a character's card, from the pictures they have, shows them
|
||||
* the next way along, so their photos and clips have them from more than one side.
|
||||
*/
|
||||
export const SHEET_VIEWS = [
|
||||
"Full length, standing and facing the camera, against a plain light background, in even light.",
|
||||
"Head and shoulders in a three-quarter view, against a plain background, in soft daylight.",
|
||||
"Full length, seen from the side in mid-step, against a plain background, in even light.",
|
||||
];
|
||||
export const sheetView = (n) => SHEET_VIEWS[((Math.trunc(n) || 0) % SHEET_VIEWS.length + SHEET_VIEWS.length) % SHEET_VIEWS.length];
|
||||
|
||||
/** the words of a reply, ready to be read out: no thinking, no code, no stage directions */
|
||||
export function speakable(text, max = 4000) {
|
||||
const t = str(text, 100000).replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/```[\s\S]*?(```|$)/g, " ")
|
||||
|
||||
+103
-18
@@ -1190,6 +1190,7 @@ function listsChanged() {
|
||||
if ($("#modeldlg").open) renderModels();
|
||||
if ($("#abilitydlg").open) renderAbilities();
|
||||
renderSpeech();
|
||||
renderMakers();
|
||||
const a = S.ask;
|
||||
if (a && !a.busy) {
|
||||
renderAsk();
|
||||
@@ -1594,27 +1595,15 @@ async function firstPortrait() {
|
||||
if (!c) return;
|
||||
if (refsOf(c).length) { renderAsk(); writeShot(); return; } // they've had a picture since: no portrait to pay for
|
||||
a.busy = true;
|
||||
Object.assign(a, { status: "imagining how they look…", bad: false });
|
||||
Object.assign(a, { status: c.look.description ? `drawing ${c.name} with ${pm.name}…` : "imagining how they look…", bad: false });
|
||||
renderAsk();
|
||||
try {
|
||||
const v = new Venice(S.key);
|
||||
let look = c.look.description;
|
||||
if (!look) look = A.cleanText((await v.chat({ model: c.model || S.model, messages: A.lookMessages(c), maxTokens: 200, temperature: 0.9 })).text, 1500);
|
||||
const v = new Venice(S.key), look = await lookOf(c, v);
|
||||
a.status = `drawing ${c.name} with ${pm.name}…`;
|
||||
renderAsk();
|
||||
const { blob } = await v.generateImage({ model: pm.id, prompt: A.portraitPrompt({ ...c, look: { ...c.look, description: look } }, pm.promptLimit),
|
||||
aspect: pm.aspects.includes("1:1") ? "1:1" : "" });
|
||||
const portrait = await shrinkImage(blob, 512, "image/webp", { svg: false });
|
||||
const portrait = await drawPicture(c, look, pm, v);
|
||||
const now = S.cards.find((x) => x.id === c.id);
|
||||
if (now && !refsOf(now).length) {
|
||||
// their portrait if they had none, else the first of their pictures (a drawing kept as an SVG portrait stays)
|
||||
const looks = { ...now.look, description: now.look.description || look };
|
||||
replaceCard(now.portrait ? { ...now, look: { ...looks, references: [portrait, ...now.look.references].slice(0, C.MAX_REFERENCES) } }
|
||||
: { ...now, portrait, look: looks });
|
||||
renderRoster();
|
||||
renderThreads();
|
||||
if ((card() || {}).id === c.id) renderSheet();
|
||||
}
|
||||
if (now && !refsOf(now).length) keepPicture(now, portrait, look);
|
||||
a.busy = false;
|
||||
Object.assign(a, { status: "", bad: false });
|
||||
toast(`${c.name} has a picture now, on their card.`);
|
||||
@@ -1628,6 +1617,96 @@ async function firstPortrait() {
|
||||
balanceSoon();
|
||||
}
|
||||
|
||||
// ---------- pictures for a character's card, made with AI ----------
|
||||
const BLURRED = "Venice's safe mode blurred that picture, so it wasn't kept. Change how they look on their card (look), then try again.";
|
||||
/** how they look, in words: what their card says, else a description their model writes now */
|
||||
async function lookOf(c, v) {
|
||||
if (c.look.description) return c.look.description;
|
||||
return A.cleanText((await v.chat({ model: c.model || S.model, messages: A.lookMessages(c), maxTokens: 200, temperature: 0.9 })).text, 1500);
|
||||
}
|
||||
/** a picture of them drawn from how they look, by an image model, as a data: URI (one safe mode blurred isn't kept) */
|
||||
async function drawPicture(c, look, pm, v) {
|
||||
const { blob, blurred } = await v.generateImage({ model: pm.id, prompt: A.portraitPrompt({ ...c, look: { ...c.look, description: look } }, pm.promptLimit),
|
||||
aspect: pm.aspects.includes("1:1") ? "1:1" : "" });
|
||||
if (blurred) throw new Error(BLURRED);
|
||||
return shrinkImage(blob, 512, "image/webp", { svg: false });
|
||||
}
|
||||
/**
|
||||
* A picture made for a character, kept on their card: as their portrait (asked for, or when they have none), else as
|
||||
* the first of their other pictures. How they look goes on their card too, if it was written just now.
|
||||
*/
|
||||
function keepPicture(c, pic, look, as = "") {
|
||||
const looks = { ...c.look, description: c.look.description || look || "" };
|
||||
replaceCard(as === "portrait" || !c.portrait ? { ...c, portrait: pic, look: looks }
|
||||
: { ...c, look: { ...looks, references: [pic, ...c.look.references].slice(0, C.MAX_REFERENCES) } });
|
||||
renderRoster();
|
||||
renderThreads();
|
||||
if ((card() || {}).id === c.id) renderSheet();
|
||||
picturesChanged();
|
||||
}
|
||||
let drawing = false;
|
||||
function lookStatus(sel, text, bad = false) { const s = $(sel); s.textContent = text; s.classList.toggle("bad", bad); }
|
||||
/**
|
||||
* What a picture made for their card would be: a portrait is drawn from how they look; another picture is made from
|
||||
* the pictures they have (so it's still them), or drawn too when they have none. { model, pictures, price, list }.
|
||||
*/
|
||||
function makerPlan(c, as) {
|
||||
const refs = as === "portrait" ? [] : refsOf(c);
|
||||
if (!refs.length) { const m = portraitModel(); return { model: m, pictures: [], price: m ? m.price : null, list: "image" }; }
|
||||
const m = photoModel(), pictures = refs.slice(0, Math.max(1, m ? m.maxIn : 1));
|
||||
return { model: m, pictures, price: m ? A.photoPrice(m, pictures.length) : null, list: "inpaint" };
|
||||
}
|
||||
/** the two links that make a picture for their card, with what the next one costs */
|
||||
function renderMakers() {
|
||||
const c = card();
|
||||
if (!c || c.plain) return;
|
||||
for (const [sel, as] of [["#portrait-make", "portrait"], ["#ref-make", ""]]) {
|
||||
const { price } = makerPlan(c, as), b = $(sel);
|
||||
b.textContent = "make one with ai" + (price != null ? " · " + A.usd(price) : "");
|
||||
b.disabled = drawing || (as !== "portrait" && !!c.portrait && c.look.references.length >= C.MAX_REFERENCES);
|
||||
}
|
||||
}
|
||||
/** a picture for their card, made with AI on your key: a new portrait ("portrait"), or one more picture of them */
|
||||
async function makePicture(as = "") {
|
||||
const c = card(), st = as === "portrait" ? "#portrait-status" : "#look-status";
|
||||
if (!c || c.plain || drawing) return;
|
||||
if (!S.key) return needKey("pictures need your venice key.");
|
||||
if (as !== "portrait" && c.portrait && c.look.references.length >= C.MAX_REFERENCES) return;
|
||||
await loadList(makerPlan(c, as).list);
|
||||
const { model: pm, pictures, price, list } = makerPlan(c, as);
|
||||
if (!pm || price == null) { lookStatus(st, S.listErr[list] || "Venice has no model for that right now.", true); return; }
|
||||
if (as === "portrait" && c.portrait && !confirm(`Replace ${c.name}'s portrait with a new one, drawn from how they look? It costs ${A.usd(price)} on your Venice key.`)) return;
|
||||
drawing = true;
|
||||
S.inflight++;
|
||||
renderMakers();
|
||||
try {
|
||||
const v = new Venice(S.key);
|
||||
let look = "", pic;
|
||||
if (pictures.length) {
|
||||
lookStatus(st, `making a picture of ${c.name} with ${pm.name}…`);
|
||||
const { blob, blurred } = await v.editImage({ model: pm.id, prompt: A.photoPrompt(c, A.sheetView(c.look.references.length), pictures.length, pm.promptLimit), images: pictures });
|
||||
if (blurred) throw new Error(BLURRED);
|
||||
pic = await shrinkImage(blob, 768, "image/webp", { svg: false });
|
||||
} else {
|
||||
if (!c.look.description) lookStatus(st, "imagining how they look…");
|
||||
look = await lookOf(c, v);
|
||||
lookStatus(st, `drawing ${c.name} with ${pm.name}…`);
|
||||
pic = await drawPicture(c, look, pm, v);
|
||||
}
|
||||
const now = S.cards.find((x) => x.id === c.id);
|
||||
if (now) keepPicture(now, pic, look, as);
|
||||
lookStatus(st, "");
|
||||
toast(as === "portrait" || !c.portrait ? `${c.name} has a new portrait.` : `A new picture of ${c.name} is on their card.`);
|
||||
} catch (e) {
|
||||
lookStatus(st, e.message, true);
|
||||
} finally {
|
||||
drawing = false;
|
||||
S.inflight--;
|
||||
renderMakers();
|
||||
balanceSoon();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- abilities: clips on their way ----------
|
||||
/** the clips on their way, as this tab and the others know them: a union, less the ones that are home */
|
||||
function saveJobs() {
|
||||
@@ -1994,6 +2073,9 @@ function renderSheet() {
|
||||
if (![...sp.options].some((o) => Number(o.value) === c.speech.speed)) sp.append(h("option", { value: String(c.speech.speed), text: `${c.speech.speed}×` }));
|
||||
sp.value = [...sp.options].find((o) => Number(o.value) === c.speech.speed).value;
|
||||
$("#speech-status").textContent = "";
|
||||
if (!drawing) for (const s of ["#portrait-status", "#look-status"]) lookStatus(s, "");
|
||||
loadList("image");
|
||||
if (refsOf(c).length) loadList("inpaint");
|
||||
renderRefs(c);
|
||||
renderSpeech();
|
||||
renderFlow(c);
|
||||
@@ -2018,6 +2100,7 @@ function renderRefs(c) {
|
||||
picturesChanged();
|
||||
})));
|
||||
$("#ref-add").disabled = c.look.references.length >= C.MAX_REFERENCES;
|
||||
renderMakers();
|
||||
}
|
||||
async function addReference(file) {
|
||||
const c = card();
|
||||
@@ -2181,8 +2264,8 @@ function setTab(name) {
|
||||
// ---------- the key ----------
|
||||
function updateKeyUI() {
|
||||
const on = !!S.key;
|
||||
$("#keyin").placeholder = !on ? "paste it here"
|
||||
: S.keyRemembered ? "saved on this device. paste a new one to replace it" : "saved for this tab. paste a new one to replace it";
|
||||
$("#keyin").placeholder = !on ? "paste it here" : S.keyRemembered ? "saved on this device" : "saved for this tab";
|
||||
$("#keyin").title = on ? "Paste a new key here to replace this one" : "";
|
||||
$("#keyforget").hidden = !on;
|
||||
$("#keyremember").checked = on ? S.keyRemembered : $("#keyremember").checked;
|
||||
}
|
||||
@@ -2676,6 +2759,8 @@ function bind() {
|
||||
$("#ref-file").click();
|
||||
});
|
||||
$("#ref-file").addEventListener("change", () => { const rf = $("#ref-file").files[0]; $("#ref-file").value = ""; if (rf) addReference(rf); });
|
||||
$("#portrait-make").addEventListener("click", () => makePicture("portrait"));
|
||||
$("#ref-make").addEventListener("click", () => makePicture());
|
||||
$("#speech-sample").addEventListener("click", hearSample);
|
||||
$("#speech-group").addEventListener("toggle", () => { if ($("#speech-group").open) loadList("tts"); });
|
||||
$("#speech-on").addEventListener("change", (e) => { e.stopPropagation(); const c = card(); if (c && !c.plain) setVoiceNotes(c, $("#speech-on").checked); });
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<header class="mast" id="top">
|
||||
<h1>HAVRE</h1>
|
||||
<p class="motto">a harbor for characters.<br>talk to venice characters on your own key.<br>no account. no server. nothing kept but what you keep.</p>
|
||||
<p class="motto">a harbor for characters. <br>talk to venice characters on your own key. <br>no account. no server. nothing kept but what you keep.</p>
|
||||
<nav class="nav" aria-label="On this page"><a href="#how">how it works</a> / <a href="#agents">for agents</a> / <a href="#costs">costs</a> / <a href="#plans">early access</a></nav>
|
||||
</header>
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
<div class="col">
|
||||
<div class="portrait">
|
||||
<span class="face big" id="sheet-face" aria-hidden="true"></span>
|
||||
<p class="mono"><button type="button" class="linkish" id="portrait-pick">change portrait</button> / <button type="button" class="linkish" id="portrait-clear">remove</button></p>
|
||||
<p class="mono"><button type="button" class="linkish" id="portrait-pick">change portrait</button> / <button type="button" class="linkish" id="portrait-make">make one with ai</button> / <button type="button" class="linkish" id="portrait-clear">remove</button> <span id="portrait-status" class="status" role="status"></span></p>
|
||||
<input type="file" id="portrait-file" accept="image/png,image/jpeg,image/webp,image/svg+xml" hidden>
|
||||
</div>
|
||||
<label>NAME <input name="name" maxlength="120" required></label>
|
||||
@@ -155,7 +155,7 @@
|
||||
<details class="group" id="look-group">
|
||||
<summary>LOOK <span class="hint">their photos and clips are made from these pictures</span></summary>
|
||||
<div class="refs" id="refs"></div>
|
||||
<p class="mono"><button type="button" class="linkish" id="ref-add">add a picture</button> <span class="hint">up to three besides the portrait. pictures of the character only, never a photo of a real person.</span></p>
|
||||
<p class="mono"><button type="button" class="linkish" id="ref-add">add a picture</button> / <button type="button" class="linkish" id="ref-make">make one with ai</button> <span class="hint">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.</span> <span id="look-status" class="status" role="status"></span></p>
|
||||
<input type="file" id="ref-file" accept="image/png,image/jpeg,image/webp" hidden>
|
||||
<label>HOW THEY LOOK <textarea name="look_description" rows="3" maxlength="1500" placeholder="sixties, tall, grey braid, oilskin coat, reading glasses on a cord"></textarea></label>
|
||||
</details>
|
||||
@@ -254,6 +254,7 @@
|
||||
<tr><th scope="row">a chat turn</th><td>$0.0007 to $0.005, by model (3,000 tokens in, 150 out)</td></tr>
|
||||
<tr><th scope="row">a hello from a venice character</th><td>one chat turn</td></tr>
|
||||
<tr><th scope="row">a photo</th><td>$0.02 to $0.34, by model ($0.03 on grok imagine), plus a short call to write the shot</td></tr>
|
||||
<tr><th scope="row">a picture for their card</th><td>the same as a photo: $0.03 on grok imagine, plus a short call to describe them if their card doesn't say how they look</td></tr>
|
||||
<tr><th scope="row">a clip</th><td>venice prices it before it's made: about $0.50 for five seconds</td></tr>
|
||||
<tr><th scope="row">a voice note</th><td>about $0.0007 with kokoro, up to about $0.04 with the priciest voices</td></tr>
|
||||
<tr><th scope="row">hearing your voice note</th><td>$0.006 a minute</td></tr>
|
||||
|
||||
@@ -56,7 +56,7 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
||||
.btn.solid:disabled { background: #767676; color: var(--paper); }
|
||||
.linkish {
|
||||
padding: 0; border: 0; background: none; color: var(--link); text-decoration: underline;
|
||||
font: inherit; cursor: pointer;
|
||||
font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.linkish:hover, .linkish:focus-visible { color: var(--ink); background: var(--hi); }
|
||||
.linkish:disabled { color: #767676; text-decoration: none; cursor: default; background: none; }
|
||||
@@ -322,6 +322,8 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
|
||||
#speech-group .check { margin-top: 10px; }
|
||||
#speech-group p.mono { margin-top: 8px; }
|
||||
#speech-status { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
#portrait-status, #look-status { display: block; margin-top: 6px; }
|
||||
#portrait-status:empty, #look-status:empty { display: none; }
|
||||
.lineage h3 { font: 700 13px var(--mono); margin-bottom: 4px; }
|
||||
.lineage-list { margin: 0 0 4px; padding-left: 20px; font-size: 16px; }
|
||||
.lineage-list:empty::before { content: "nothing yet: written here."; font: 13px var(--mono); margin-left: -20px; }
|
||||
@@ -451,6 +453,16 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
|
||||
.table td:nth-of-type(1), .table td:nth-of-type(2) { font: 13px var(--mono); }
|
||||
.table td.files { padding-top: 6px; white-space: normal; }
|
||||
}
|
||||
/* phones and other touch screens: a field under 16px makes iOS Safari zoom in when it's tapped, and the page stays
|
||||
zoomed in, cut off at the sides */
|
||||
@media (max-width: 640px), (pointer: coarse) {
|
||||
.keybar input:not([type=checkbox]), .filters input[type=search], .pick select, .ask .opts select, .abilities select,
|
||||
.chip.add input { font-size: 16px; }
|
||||
}
|
||||
/* a small phone (an iPhone SE is 375px wide): the left column's tabs a little smaller, so a count of what's new fits */
|
||||
@media (max-width: 400px) {
|
||||
.lefthead [role=tab], .lefthead .sep { font-size: 24px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { scroll-behavior: auto !important; }
|
||||
}
|
||||
@@ -80,6 +80,9 @@ test("the shot is written from the conversation, with the rules, and the photo k
|
||||
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.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/);
|
||||
});
|
||||
|
||||
test("voice notes read the words, not the code or the stage directions", () => {
|
||||
|
||||
@@ -291,15 +291,15 @@ test("venice: a page of characters asks for safe-for-work ones, sorted, and says
|
||||
assert.ok(!tries[1].url.includes("sortBy") && !tries[1].url.includes("isAdult"));
|
||||
});
|
||||
|
||||
test("venice: adult characters only when asked for, and tags narrow the list", async () => {
|
||||
test("venice: adult characters only when asked for, and then only them; tags narrow the list", async () => {
|
||||
const urls = [];
|
||||
const data = [{ slug: "a", name: "A", adult: false, tags: ["sea"] }, { slug: "b", name: "B", adult: true, tags: [] }];
|
||||
const v = new Venice("sk", { fetch: fakeFetch(() => ({ status: 200, body: { data } }), urls) });
|
||||
const all = await v.characterPage({ adult: true, tags: "sea" });
|
||||
const adult = await v.characterPage({ adult: true, tags: "sea" });
|
||||
const q = new URL(urls[0].url).searchParams;
|
||||
assert.equal(q.get("isAdult"), null, "no filter when adult characters are wanted");
|
||||
assert.equal(q.get("isAdult"), "true", "asked for as such: left out, Venice mixes them in with the rest");
|
||||
assert.equal(q.get("tags"), "sea");
|
||||
assert.deepEqual(all.list.map((c) => [c.slug, c.adult]), [["a", false], ["b", true]]);
|
||||
assert.deepEqual(adult.list.map((c) => [c.slug, c.adult]), [["b", true]], "only the adult ones, whatever comes back");
|
||||
assert.deepEqual((await v.characters()).map((c) => c.slug), ["a"], "and none when they aren't");
|
||||
});
|
||||
|
||||
|
||||
@@ -111,8 +111,8 @@ async function fakeVenice(route) {
|
||||
}
|
||||
if (url.includes("/characters")) {
|
||||
seen.characters.push(url);
|
||||
const q = new URL(url).searchParams, tag = q.get("tags");
|
||||
return ok({ object: "list", data: CHARACTERS.filter((c) => (q.get("isAdult") !== "false" || !c.adult) && (!tag || c.tags.includes(tag))) });
|
||||
const q = new URL(url).searchParams, tag = q.get("tags"), adult = q.get("isAdult");
|
||||
return ok({ object: "list", data: CHARACTERS.filter((c) => (adult === null || String(c.adult) === adult) && (!tag || c.tags.includes(tag))) });
|
||||
}
|
||||
if (url.includes("/chat/completions")) {
|
||||
const b = JSON.parse(body);
|
||||
@@ -317,17 +317,20 @@ async function main() {
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
assert.equal(seen.characters.length, asked, "a page seen a moment ago isn't asked for again");
|
||||
assert.equal(await page.locator('#vgrid img[data-kept="yes"]').count(), 1, "nor is her photo drawn again");
|
||||
// adult characters, once someone says they're 18 or older
|
||||
// adult characters, once someone says they're 18 or older: then those, and their tags
|
||||
page.once("dialog", (d) => d.accept());
|
||||
await page.check("#vadult");
|
||||
await count(page, "#vgrid li:not(.ghost)", 3);
|
||||
assert.ok(!seen.characters.at(-1).includes("isAdult"), "every character, adult ones too");
|
||||
await count(page, "#vgrid li:not(.ghost)", 1);
|
||||
assert.ok(seen.characters.at(-1).includes("isAdult=true"), "asked for as such: left out, Venice mixes them in with the rest");
|
||||
await has(page, "#vgrid .nm", /^After Dark$/);
|
||||
await has(page, "#vgrid .x18", /18\+/);
|
||||
assert.ok(await page.locator("#vtags").isHidden(), "their tags (they have none)");
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("havre.adult.18")), "true");
|
||||
asked = seen.characters.length;
|
||||
await page.uncheck("#vadult");
|
||||
await count(page, "#vgrid li:not(.ghost)", 2);
|
||||
assert.equal(seen.characters.length, asked, "the page without them comes back without asking either");
|
||||
await has(page, "#vtags", /#neighbor/);
|
||||
assert.equal(seen.characters.length, asked, "the page without them comes back, tags and all, without asking either");
|
||||
// the same search again asks Venice afresh
|
||||
await page.click("#vgo");
|
||||
await poll(() => seen.characters.length > asked, "the same search, asked again");
|
||||
@@ -657,6 +660,29 @@ async function main() {
|
||||
assert.equal(kept.lineage[0].name, "Captain Ro");
|
||||
assert.match(kept.lineage[0].ref, /^sha256:/);
|
||||
|
||||
// pictures for her card, made with AI: a portrait drawn from how she looks, then one more picture made from it
|
||||
const inesNow = () => page.evaluate(() => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Ines Corbel"));
|
||||
await has(page, "#portrait-make", /^make one with ai · \$0\.03$/);
|
||||
const drawn = seen.generated.length;
|
||||
await page.click("#portrait-make");
|
||||
await until(page, () => /^data:image\/webp/.test((JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Ines Corbel") || {}).portrait || ""), null, "her portrait");
|
||||
assert.equal(seen.generated.length, drawn + 1, "one picture, drawn");
|
||||
assert.match(seen.generated.at(-1).prompt, /^Portrait photo of Ines Corbel, an original adult character: Thirties, wiry.*Safe for work/);
|
||||
assert.equal(seen.generated.at(-1).safe_mode, true);
|
||||
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; });
|
||||
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.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");
|
||||
|
||||
// exports, made in the browser; the PNG comes back in whole
|
||||
await page.getByRole("button", { name: "Export Ines Corbel" }).click();
|
||||
let png = "";
|
||||
@@ -758,15 +784,21 @@ async function main() {
|
||||
await count(p2, "#threads > li", 1);
|
||||
await old.close();
|
||||
|
||||
// ---------- phone ----------
|
||||
const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
|
||||
// ---------- phone: an iPhone SE's screen, 375 by 667 ----------
|
||||
const W = 375;
|
||||
const phone = await browser.newContext({ viewport: { width: W, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
|
||||
const p3 = await phone.newPage();
|
||||
watch(p3, "phone: ");
|
||||
await venice(p3);
|
||||
await p3.goto(base);
|
||||
await count(p3, "#vgrid li.ghost", 8);
|
||||
const wide = () => p3.evaluate(() => document.documentElement.scrollWidth);
|
||||
assert.ok(await wide() <= 390, `no sideways scroll on a phone (scrollWidth ${await wide()})`);
|
||||
assert.ok(await wide() <= W, `no sideways scroll on a phone (scrollWidth ${await wide()})`);
|
||||
// a field under 16px makes iOS Safari zoom in when it's tapped, and the page stays cut off
|
||||
const small = () => p3.evaluate(() => [...document.querySelectorAll("input:not([type=checkbox]):not([type=file]), select, textarea")]
|
||||
.filter((el) => el.offsetParent).map((el) => [el.id || el.name || el.className, parseFloat(getComputedStyle(el).fontSize)]).filter(([, s]) => s < 16));
|
||||
assert.deepEqual(await small(), [], "no field small enough for the phone to zoom in on");
|
||||
assert.match(await p3.locator(".motto").evaluate((el) => el.innerText), /characters\. talk to venice characters on your own key\. no account/, "the motto keeps its spaces on one line");
|
||||
await shot(p3, "phone.png");
|
||||
await p3.fill("#keyin", "sk-good");
|
||||
await p3.click("#keysave");
|
||||
@@ -774,7 +806,8 @@ async function main() {
|
||||
await p3.locator("#vgrid .tile", { hasText: "Wren" }).click();
|
||||
await has(p3, "#log .line.them .said", /Careful, the boxes bite\./);
|
||||
assert.ok(await p3.locator("#say").isVisible(), "the chat is on screen");
|
||||
assert.ok(await wide() <= 390, `still no sideways scroll (scrollWidth ${await wide()})`);
|
||||
assert.ok(await wide() <= W, `still no sideways scroll (scrollWidth ${await wide()})`);
|
||||
assert.deepEqual(await small(), [], "nor here");
|
||||
await p3.waitForTimeout(500);
|
||||
await shot(p3, "phone-chat.png");
|
||||
await shot(p3, "phone-full.png", { fullPage: true });
|
||||
|
||||
@@ -224,13 +224,14 @@ export class Venice {
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of public Venice characters: { list, more }. Adult characters only with adult: true. sort: featured,
|
||||
* imports, highestRating, mostRecent (or any other sortBy Venice takes); search matches names, descriptions and
|
||||
* #tags; tags narrows to a tag.
|
||||
* One page of public Venice characters: { list, more }. adult: true lists the adult ones, and only them (left
|
||||
* unasked, Venice mixes them into the rest, where the first pages barely change); otherwise there are none. sort:
|
||||
* featured, imports, highestRating, mostRecent (or any other sortBy Venice takes); search matches names,
|
||||
* descriptions and #tags; tags narrows to a tag.
|
||||
*/
|
||||
async characterPage({ search = "", limit = 24, offset = 0, sort = "", tags = "", adult = false } = {}) {
|
||||
const q = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
if (!adult) q.set("isAdult", "false");
|
||||
q.set("isAdult", adult ? "true" : "false");
|
||||
if (search) q.set("search", search);
|
||||
if (sort) q.set("sortBy", sort);
|
||||
if (tags) q.set("tags", tags);
|
||||
@@ -243,10 +244,10 @@ export class Venice {
|
||||
d = await this.call("/characters?" + q);
|
||||
}
|
||||
const raw = d && Array.isArray(d.data) ? d.data : [];
|
||||
return { list: raw.filter((c) => c && (adult || !c.adult)).map(characterInfo), more: raw.length >= limit };
|
||||
return { list: raw.filter((c) => c && !!c.adult === !!adult).map(characterInfo), more: raw.length >= limit };
|
||||
}
|
||||
|
||||
/** public Venice characters (adult ones only with adult: true) */
|
||||
/** public Venice characters (the adult ones with adult: true, else the rest) */
|
||||
async characters(opts = {}) {
|
||||
return (await this.characterPage(opts)).list;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user