diff --git a/ventures/havre/tests/workflows.test.mjs b/ventures/havre/tests/workflows.test.mjs index b37e3bd..43c5e70 100644 --- a/ventures/havre/tests/workflows.test.mjs +++ b/ventures/havre/tests/workflows.test.mjs @@ -73,3 +73,29 @@ test("the idea schema is the writer's schema plus a look", () => { assert.match(m[1].content, /"name": "Lone"/); assert.doesNotMatch(W.ideaMessages("x")[1].content, /"name"/); }); + +test("/photoshoot takes a count first, then a brief; the count stays between 1 and 6", () => { + assert.deepEqual(W.parseShoot(""), { count: 3, brief: "" }); + assert.deepEqual(W.parseShoot("5 at the harbor at dusk"), { count: 5, brief: "at the harbor at dusk" }); + assert.deepEqual(W.parseShoot("at the harbor"), { count: 3, brief: "at the harbor" }); + assert.deepEqual(W.parseShoot("12"), { count: 6, brief: "" }); + assert.deepEqual(W.parseShoot("0 rain"), { count: 3, brief: "rain" }); + assert.deepEqual(W.parseShoot("2024 vibes"), { count: 3, brief: "2024 vibes" }); +}); + +test("a shoot is asked with the single photo's rules, for several shots as JSON", () => { + const sys = "You write the shot for a photo. Rules here. Reply with the shot only."; + const m = W.shootMessages({ name: "Ines", tagline: "ferry pilot", look: { description: "red braid" } }, sys, { count: 4, brief: "fog", lines: ["Them: hi", "Ines: hey"] }); + assert.match(m[0].content, /^You write the shot for a photo\. Rules here\. Write 4 different shots/); + assert.doesNotMatch(m[0].content, /Reply with the shot only/); + assert.match(m[1].content, /How they look: red braid/); + assert.match(m[1].content, /Ines: hey/); + assert.match(m[1].content, /The shoot: fog/); + assert.match(W.shootMessages({ name: "X" }, sys, { count: 99 })[0].content, /Write 6 different shots/); +}); + +test("shots come back cleaned and no more than asked", () => { + const s = W.normalizeShots({ shots: [{ title: " Dawn deck ", shot: " She leans\n on the rail. " }, { title: "", shot: "Rain." }, { title: "x", shot: "" }, { title: "y", shot: "z" }] }, 2); + assert.deepEqual(s, [{ title: "Dawn deck", shot: "She leans on the rail." }, { title: "shot 2", shot: "Rain." }]); + assert.deepEqual(W.normalizeShots(null), []); +}); diff --git a/ventures/havre/workflows.js b/ventures/havre/workflows.js index cde0ec8..4d50cc9 100644 --- a/ventures/havre/workflows.js +++ b/ventures/havre/workflows.js @@ -28,6 +28,11 @@ export const WORKFLOWS = [ about: "a new character from an idea in a line or two, written as a card of your own", usage: "/new ", }, + { + id: "photoshoot", command: "photoshoot", title: "a photoshoot", takes: "card pictures", gives: "photos", doors: ["chat"], + 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]", + }, ]; export const byId = (id) => WORKFLOWS.find((w) => w.id === id) || null; @@ -107,3 +112,47 @@ export function cardFromIdea(o, { model } = {}) { const look = str(o && o.look, 2000).replace(/\s+/g, " ").trim(); return normalizeCard({ ...base, look: { ...base.look, description: look } }); } + +// ---------- photoshoot: a few photos of them, from the pictures on their card ---------- + +export const SHOOT_MIN = 1, SHOOT_MAX = 6, SHOOT_DEFAULT = 3; + +/** the words after /photoshoot: a count first if there is one (1 to 6), the rest a brief for the shoot */ +export function parseShoot(arg = "") { + const m = /^\s*(\d{1,2})(?:\s+|$)([\s\S]*)$/.exec(str(arg, 2000)); + const n = m ? Number(m[1]) : SHOOT_DEFAULT; + return { count: Math.min(SHOOT_MAX, Math.max(SHOOT_MIN, n || SHOOT_DEFAULT)), brief: (m ? m[2] : str(arg, 2000)).replace(/\s+/g, " ").trim() }; +} + +export const SHOOT_SCHEMA = { + type: "object", additionalProperties: false, required: ["shots"], + properties: { shots: { type: "array", items: { type: "object", additionalProperties: false, required: ["title", "shot"], + properties: { title: { type: "string" }, shot: { type: "string" } } } } }, +}; + +/** + * The messages for a text model to write a shoot: the photo rules the single-photo ask uses (abilities.js, safe or + * adult), asked for several shots at once, as JSON. brief: what the shoot is about; nothing means a set that fits them. + */ +export function shootMessages(card, photoSystem, { count = SHOOT_DEFAULT, brief = "", lines = [] } = {}) { + const n = Math.min(SHOOT_MAX, Math.max(SHOOT_MIN, count)); + const system = str(photoSystem).replace(/\s*Reply with the shot only\.?\s*$/, "") + + ` Write ${n} different shots for one photoshoot: the same place or theme, a different moment, framing or mood in each. ` + + `Return one JSON object: shots, an array of ${n} objects with title (at most 4 words) and shot (the paragraph).`; + const look = card.look && card.look.description; + const user = [ + `The character: ${card.name}${card.tagline ? ". " + card.tagline : ""}`, + look ? `How they look: ${look}` : "", + lines.length ? "The conversation so far, latest last:\n" + lines.join("\n") : "The conversation hasn't started yet.", + "The shoot: " + (str(brief, 500).trim() || "nothing in particular. Pick a place and a mood that fit them."), + `Write the ${n} shots.`, + ].filter(Boolean).join("\n\n"); + return [{ role: "system", content: system }, { role: "user", content: user }]; +} + +/** the shots, cleaned: each a title and a shot; no more than n, none if the model sent none */ +export function normalizeShots(o, n = SHOOT_DEFAULT) { + const list = arr(o && o.shots).map((d) => ({ title: clip(str(d && d.title, 200).replace(/\s+/g, " ").trim(), 40), + shot: str(d && d.shot, 2000).replace(/\s+/g, " ").trim() })).filter((d) => d.shot); + return list.slice(0, n).map((d, i) => ({ ...d, title: d.title || `shot ${i + 1}` })); +}