diff --git a/experiments/nopaint-ai-moves-2026-10-05/ac_account.mjs b/experiments/nopaint-ai-moves-2026-10-05/ac_account.mjs index a201dc766d..4544ec3fca 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/ac_account.mjs +++ b/experiments/nopaint-ai-moves-2026-10-05/ac_account.mjs @@ -87,7 +87,8 @@ export function createAccountBridge({session=new ACSession(), site=SITE, fetch=g if (input.account_id !== id) throw Error('Your AC account changed. Sign in again before generating.'); const image = (await readFile(input.before)).toString('base64'); return request('/api/nopaint-inference', token, {requestId:input.requestId, - image, seed:input.seed, strength:input.strength, quote:input.quote, model:input.model}); + image, seed:input.seed, strength:input.strength, quote:input.quote, model:input.model, + ...(input.hint ? {hint:input.hint} : {})}); } throw Error('Unknown AC account action'); }; diff --git a/experiments/nopaint-ai-moves-2026-10-05/ac_run.py b/experiments/nopaint-ai-moves-2026-10-05/ac_run.py index b026ef5fe6..9ec21a571a 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/ac_run.py +++ b/experiments/nopaint-ai-moves-2026-10-05/ac_run.py @@ -57,7 +57,7 @@ class Account: def move(pipe, embed, before, folder, step, strength, seed, cold=False, observe=None, - cancel=None, account=None, identity=None, engine="ac-klein"): + cancel=None, account=None, identity=None, engine="ac-klein", hint=None): identity = identity or account.snapshot() offer = next((item for item in identity.get("models", []) if item["id"] == engine), None) or (identity.get("remote") if engine == "ac-klein" else None) or {} if not identity.get("connected") or not offer.get("available"): @@ -71,12 +71,14 @@ def move(pipe, embed, before, folder, step, strength, seed, cold=False, observe= row = {"status": "running", "request_id": request_id, "handle": identity["handle"], "quoted_braincells": offer["braincells"], "identity": {"model": offer["model"], "input_sha256": sha(before), - "seed": seed, "strength": strength, "quote": offer["quote"], "size": [256, 256]}} + "seed": seed, "strength": strength, "quote": offer["quote"], "size": [256, 256], + **({"hint": hint} if hint else {})}} write(receipt, row) started = time.monotonic() request = {"action": "move", "before": str(before), "requestId": request_id, "seed": seed, "strength": strength, "quote": offer["quote"], - "account_id": identity["account_id"], "model": engine} + "account_id": identity["account_id"], "model": engine, + **({"hint": hint} if hint else {})} try: result = bridge(request, timeout=260, cancel=cancel) check_cancel(cancel) diff --git a/experiments/nopaint-ai-moves-2026-10-05/native/Sources/NoPaint/NoPaint.swift b/experiments/nopaint-ai-moves-2026-10-05/native/Sources/NoPaint/NoPaint.swift index 41832ecfd2..3557c52a92 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/native/Sources/NoPaint/NoPaint.swift +++ b/experiments/nopaint-ai-moves-2026-10-05/native/Sources/NoPaint/NoPaint.swift @@ -109,6 +109,7 @@ struct StatusIssue { @MainActor final class GameStore: ObservableObject { @Published var state: GameState? + @Published var hint = "" @Published var image: NSImage? @Published var imagePath = "" @Published var imageSequence = "" @@ -187,7 +188,7 @@ struct StatusIssue { guard pollTask == nil else { return } keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in guard let self, NSApp.keyWindow?.identifier?.rawValue == "NoPaintMain", - !self.showModels, !self.showHistory, + !self.showModels, !self.showHistory, !(NSApp.keyWindow?.firstResponder is NSTextView), event.modifierFlags.intersection([.command,.control,.option]).isEmpty else { return event } let key = event.charactersIgnoringModifiers?.lowercased() if key == " " { self.peek(event.type == .keyDown); return nil } @@ -259,10 +260,20 @@ struct StatusIssue { if state.engine.hasPrefix("ac-"), state.models.first(where: { $0.id == state.engine })?.braincells == nil { return ["Not enabled", "Choose a model"] } return ["Unavailable", "Choose a model below"] } - let time = generationTime(date) let amount = state?.quote?.braincells ?? state?.cost?.braincells - let cost = amount.map { "\($0.formatted()) braincells" } ?? "braincells pending" - return [time, cost] + return [generationTime(date), amount.map(Self.braincellCost) ?? "braincells pending"] + } + + // Only AC cloud models read the hint; local engines paint without words. + var hintable: Bool { state?.engine.hasPrefix("ac-") == true } + + // AC sells 1,000,000 braincells for $5, so the dollar figure is that pack's rate. + static let braincellsPerUSD = 200_000.0 + static func braincells(_ amount: Int) -> String { "🧠 " + amount.formatted(.number.notation(.compactName)) } + static func braincellCost(_ amount: Int) -> String { + guard amount > 0 else { return "free" } + let usd = Double(amount) / braincellsPerUSD + return braincells(amount) + " · " + String(format: usd < 0.01 ? "$%.4f" : "$%.2f", usd) } func generationTime(_ date: Date) -> String { @@ -346,6 +357,7 @@ struct StatusIssue { if let candidate = state.candidate { body["candidate"] = candidate.id } if let generation = state.generation { body["generation"] = generation } if action == "paint", let quote = state.quote { body["quote"] = quote.id } + if action == "paint", hintable, !hint.trimmingCharacters(in: .whitespaces).isEmpty { body["hint"] = hint } body.merge(extra) { _, new in new } Task { defer { sending = false } @@ -530,13 +542,17 @@ struct ContentView: View { let identity: [StatusBand.Field] if account?.connected == true { let balance = (account?.remaining ?? 0) + (account?.purchased ?? 0) - identity = [.init(text: account?.handle ?? "", weight: .medium), .init(text: "\(balance.formatted()) \(account?.stale == true ? "last known" : "left")", numeric: true)] + identity = [.init(text: account?.handle ?? "", weight: .medium), .init(text: (account?.stale == true ? "~" : "") + GameStore.braincells(balance), numeric: true)] } else { identity = [.init(text: account?.reconnecting == true ? "Reconnecting…" : account?.working == true ? "Signing in…" : "Sign in", weight: .medium)] } let serviceText = game.statusIssue?.title ?? (game.cloudUnavailable ? "AC cloud unavailable" : nil) let service: [StatusBand.Field] = serviceText.map { [.init(text: $0, size: 11, weight: .medium)] } ?? [] let price = game.state?.quote?.braincells ?? model?.paintPrice - let capacity: [StatusBand.Field] = price.map { [.init(text: "\(game.paintsLeft($0)) paints left", size: 11, numeric: true)] } ?? [] - return StatusBand.Content(groups: [modelFields, service, identity, capacity], context: game.upscaling ? "Local · 0 Braincells" : model?.location) + let capacity: [StatusBand.Field] = price.map { [.init(text: "×\(game.paintsLeft($0))", size: 11, numeric: true)] } ?? [] + let details = game.generationStatus(date) + // The bar speaks up only when Paint can't run or something else is working. + let move: [StatusBand.Field] = details.first == game.generationTime(date) ? [] + : [.init(text: details.joined(separator: " "), size: 11, weight: .medium)] + return StatusBand.Content(groups: [modelFields, move, service, identity, capacity], context: game.upscaling ? "Local · 0 Braincells" : model?.location) } func canvasSide(_ size: CGSize, status: StatusBand.Content, compact: Bool) -> CGFloat { let reserved: CGFloat = compact ? 52 : 76 @@ -565,16 +581,15 @@ struct ContentView: View { .onHover { game.hoverNo($0) } .help("Hover to preview going back. Click to go back one move or cancel an unfinished move. (N)") Button { game.act("paint") } label: { - VStack(spacing: max(3, min(8, buttonHeight / 16))) { - let details = game.generationStatus(time.date) - Text(details.first ?? "— s").font(.system(size: buttonFont, weight: .semibold).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.5) - if details.count > 1 { Text(details[1]).font(.system(size: max(10, min(15, side / 18))).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7) } - }.padding(.horizontal, 5).frame(width: side / 2, height: buttonHeight).contentShape(Rectangle()) + Text("Paint").frame(width: side / 2, height: buttonHeight).contentShape(Rectangle()) }.buttonStyle(PaintButton(dark: true, height: buttonHeight, fontSize: buttonFont)).disabled(!game.canPaint || game.pendingDone) .accessibilityLabel("Paint: confirm one generation").accessibilityValue(game.generationStatus(time.date).joined(separator: ", ")) .help(game.busy ? "Generating this confirmed move" : game.state?.quote == nil ? (game.state?.account.remote_status ?? "Choose an available model below.") : "Confirm this move and Braincell cost. ~ means estimated time; — means not measured yet. (P)") }.frame(width: side) - StatusBand(game: game, content: label).frame(width: geometry.size.width, height: bandHeight) + HStack(spacing: 0) { + HintField(game: game).frame(width: min(240, geometry.size.width * 0.36)) + StatusBand(game: game, content: label) + }.frame(width: geometry.size.width, height: bandHeight) .background(Color.primary.opacity(0.06)) .overlay(Rectangle().strokeBorder(Color.primary.opacity(0.35), lineWidth: 1).allowsHitTesting(false)) }.frame(width: geometry.size.width).frame(maxHeight: .infinity, alignment: .top) @@ -587,6 +602,31 @@ struct ContentView: View { } } +struct HintField: View { + @ObservedObject var game: GameStore + @FocusState private var focused: Bool + var body: some View { + HStack(spacing: 5) { + Image(systemName: "sparkle").font(.system(size: 10, weight: .semibold)) + .foregroundStyle(focused ? Color(red: 0.85, green: 0.24, blue: 0.44) : .secondary) + TextField("", text: $game.hint, prompt: Text(game.hintable ? "hint" : "hint · cloud only")) + .textFieldStyle(.plain).font(.system(size: 12, weight: .medium)).focused($focused) + .onSubmit { if game.canPaint && !game.pendingDone { game.act("paint") }; focused = false } + .onExitCommand { focused = false } + if !game.hint.isEmpty { + Button { game.hint = "" } label: { Image(systemName: "xmark.circle.fill").font(.system(size: 10)) } + .buttonStyle(.plain).foregroundStyle(.tertiary).help("Clear hint") + } + } + .padding(.horizontal, 9).frame(height: 22) + .background(Capsule().fill(Color.primary.opacity(focused ? 0.1 : 0.05))) + .overlay(Capsule().strokeBorder(Color.primary.opacity(focused ? 0.4 : 0.18), lineWidth: 1)) + .padding(.leading, 6).padding(.trailing, 2) + .disabled(!game.hintable) + .help("A word or phrase to steer the next cloud Paint along with your image. Return paints.") + } +} + struct ModelBrowserView: View { @ObservedObject var game: GameStore @State private var search = "" diff --git a/experiments/nopaint-ai-moves-2026-10-05/native/Tests/NoPaintTests/StatusTests.swift b/experiments/nopaint-ai-moves-2026-10-05/native/Tests/NoPaintTests/StatusTests.swift index d1e008609a..b36b19a177 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/native/Tests/NoPaintTests/StatusTests.swift +++ b/experiments/nopaint-ai-moves-2026-10-05/native/Tests/NoPaintTests/StatusTests.swift @@ -15,7 +15,7 @@ import SwiftUI func testConnectionDetailsStayOutOfPaintAndDoNotResizeTheCanvas() async throws { let game=GameStore();game.state=try state("Timed out") game.imagePath="/image/input.png";game.imageSettled=true - XCTAssertEqual(game.generationStatus(Date()),["~4.0s","0 braincells"]) + XCTAssertEqual(game.generationStatus(Date()),["~4.0s","free"]) XCTAssertTrue(game.canPaint) XCTAssertFalse(game.canDone) XCTAssertEqual(game.statusIssue?.title,"AC offline") @@ -23,10 +23,10 @@ import SwiftUI let before=view.canvasSide(size,status:view.status(Date()),compact:false) game.state=try state(String(repeating:"Long network error details. ",count:80)) XCTAssertEqual(view.canvasSide(size,status:view.status(Date()),compact:false),before) - XCTAssertEqual(game.generationStatus(Date()),["~4.0s","0 braincells"]) + XCTAssertEqual(game.generationStatus(Date()),["~4.0s","free"]) game.actionError="An action failed with a detailed explanation" XCTAssertEqual(game.statusIssue?.title,"Action failed") - XCTAssertEqual(game.generationStatus(Date()),["~4.0s","0 braincells"]) + XCTAssertEqual(game.generationStatus(Date()),["~4.0s","free"]) } func testSmallSlabTileKeepsOneStatusLineAndRoomForThePainting() async throws { diff --git a/experiments/nopaint-ai-moves-2026-10-05/play.py b/experiments/nopaint-ai-moves-2026-10-05/play.py index a7fd8bff06..c49bcacf4d 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/play.py +++ b/experiments/nopaint-ai-moves-2026-10-05/play.py @@ -336,7 +336,7 @@ class Game: amount = row.get("quoted_braincells", quoted) return {"braincells": amount, "status": "quoted" if amount is not None else "pending"} - def begin(self, quote_id): + def begin(self, quote_id, hint=None): """Only Paint can confirm the exact displayed offer and start work.""" if self.busy or not self.quote or quote_id != self.quote["id"]: raise Conflict("The move changed. Review the current cost before painting.") @@ -360,7 +360,7 @@ class Game: self.job = {"number": self.attempt, "engine": self.engine, "before": self.history[-1], "strength": quote["strength"], "mask": self.mask, "seed": quote["seed"], "cancel": threading.Event(), - "started": False, "cost": self.cost.copy()} + "started": False, "cost": self.cost.copy(), "hint": hint} if self.engine.startswith("ac-"): self.job["account"] = self.quote_identity self.quote = self.quote_identity = None @@ -399,7 +399,7 @@ class Game: output = self.move(self.pipe, self.embed, *args, cold=number == 1, observe=observe, cancel=job["cancel"], **region.kwargs, - **({"account": self.account, "identity": job["account"], "engine": job["engine"]} if job["engine"].startswith("ac-") else {})) + **({"account": self.account, "identity": job["account"], "engine": job["engine"], "hint": job["hint"]} if job["engine"].startswith("ac-") else {})) output = region.finish(output) with self.lock: check_cancel(job["cancel"]) @@ -549,7 +549,11 @@ class Game: self.mask = save_mask(data.get("mask"), self.folder / "masks") self.mask_bits = encode_mask(self.mask) if self.mask else None elif action == "paint": - self.begin(data.get("quote")) + # A hint steers cloud moves; local engines ignore it. + hint = data.get("hint") + if hint is not None and (not isinstance(hint, str) or len(hint) > 200): + raise ValueError("Keep the hint under 200 characters.") + self.begin(data.get("quote"), (hint or "").strip() or None) return self.state() elif action == "no": if self.busy: diff --git a/system/backend/nopaint-move-prompt.mjs b/system/backend/nopaint-move-prompt.mjs index 2a255272b4..75e9f03c6b 100644 --- a/system/backend/nopaint-move-prompt.mjs +++ b/system/backend/nopaint-move-prompt.mjs @@ -19,7 +19,7 @@ const operations = [ 'Turn the affected region into a dense stipple pattern of small dots, sampled from its original colors and brightness.', ]; -export function movePrompt({seed, strength, model}) { +export function movePrompt({seed, strength, model, hint}) { // Integer mixing spreads neighboring seeds across the operation palette. let value=(seed ^ 0x9e3779b9) >>> 0; value=Math.imul(value ^ (value >>> 16),0x21f0aaad) >>> 0; @@ -28,6 +28,8 @@ export function movePrompt({seed, strength, model}) { const region = strength===.75 ? 'the entire image' : strength===.5 ? ['the left half','the right half','the upper half','the lower half'][(value >>> 8)%4] + ' of the image' : ['upper-left','upper-right','lower-left','lower-right'][(value >>> 8)%4] + ' quarter of the image'; + // A painter's hint replaces the seeded operation but keeps the same frame. + if (hint) return `Transform this input bitmap by one move, guided by the painter's hint: "${hint}". The affected region is ${region}. Apply a clearly visible transformation, not a subtle touch-up. Keep pixels outside the affected region unchanged. Retain the existing rendering style and source material unless the hint asks otherwise. Do not add a border. Return one complete transformed image at the original composition and aspect ratio.`; let operation=value%operations.length; // These editors copied the input under exact strip/permutation instructions // in live checks. Give them visual edits rather than pixel arithmetic. diff --git a/system/backend/nopaint-provider.mjs b/system/backend/nopaint-provider.mjs index c28b2ef396..1a2cdc6e4a 100644 --- a/system/backend/nopaint-provider.mjs +++ b/system/backend/nopaint-provider.mjs @@ -18,12 +18,14 @@ export function moveInput(body) { if (!body || typeof body !== 'object' || Array.isArray(body)) throw fail(400, 'Expected a move'); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(body.requestId || '')) throw fail(400, 'Move ID must be a UUID'); if (![.25,.5,.75].includes(body.strength) || !Number.isInteger(body.seed) || body.seed < 0 || body.seed >= 2**31) throw fail(400, 'Invalid move settings'); + if (body.hint !== undefined && (typeof body.hint !== 'string' || body.hint.length > 200)) throw fail(400, 'Hint must be text under 200 characters'); + const hint = body.hint?.replace(/\s+/g,' ').trim() || undefined; try { imageInputBound({messages:[{type:'image',source:{type:'base64',media_type:'image/png',data:body.image}}]}); } catch { throw fail(400, 'Expected a complete PNG image'); } const png = Buffer.from(body.image,'base64'); if (png.readUInt32BE(16)!==256 || png.readUInt32BE(20)!==256 || png[24]!==8 || ![2,6].includes(png[25])) throw fail(400, 'Expected a 256×256 RGB PNG'); - const hash = createHash('sha256').update(png).update(JSON.stringify([body.seed,body.strength,body.quote])).digest('hex'); - return {...body, hash}; + const hash = createHash('sha256').update(png).update(JSON.stringify([body.seed,body.strength,body.quote,hint ?? null])).digest('hex'); + return {...body, hint, hash}; } export function createNoPaintProvider({key, fetch=globalThis.fetch, sleep=ms=>new Promise(r=>setTimeout(r,ms))}) { diff --git a/system/tests/nopaint-inference.test.mjs b/system/tests/nopaint-inference.test.mjs index 439037f38e..e147b8e06d 100644 --- a/system/tests/nopaint-inference.test.mjs +++ b/system/tests/nopaint-inference.test.mjs @@ -31,6 +31,14 @@ test('only bounded complete 256 RGB images and valid settings reach generation', const other=Buffer.from(image,'base64');other.writeUInt32BE(1024,16); assert.throws(()=>moveInput({...input,image:other.toString('base64')}),{status:400}); }); +test('a hint is bounded, changes the move identity, and steers the prompt',async()=>{ + const {movePrompt}=await import('../backend/nopaint-move-prompt.mjs'); + const input=JSON.parse(event().body), plain=moveInput(input), hinted=moveInput({...input,hint:' more moss '}); + assert.equal(hinted.hint,'more moss');assert.notEqual(hinted.hash,plain.hash); + for(const hint of [7,'x'.repeat(201)]) assert.throws(()=>moveInput({...input,hint}),{status:400}); + assert.match(movePrompt(hinted),/painter's hint: "more moss"/); + assert.doesNotMatch(movePrompt(plain),/hint/); +}); test('identity and price come from the server; replay never charges or generates twice',async()=>{ const {handler,calls}=fixture(); const input=event({handle:'@victim',user:'victim',braincells:1});