aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/companion
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/companion')
-rw-r--r--desktop/companion/frames.txt68
-rwxr-xr-xdesktop/companion/gen-frames142
-rwxr-xr-xdesktop/companion/prep-frames45
3 files changed, 255 insertions, 0 deletions
diff --git a/desktop/companion/frames.txt b/desktop/companion/frames.txt
new file mode 100644
index 0000000..e97391c
--- /dev/null
+++ b/desktop/companion/frames.txt
@@ -0,0 +1,68 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The companion's frames, for gen-frames. One frame per line:
+#
+# <output> <- <reference> : <prompt>
+#
+# Both are paths in the art directory (~/.local/share/keystone/companion/2b)
+# without .png. The reference is the image FLUX.2 klein edits; the output is
+# written over. Lines run top to bottom, so a frame made from another comes
+# after it. ${NAME} expands a NAME = ... line defined above it. Blank lines
+# and lines starting with # are skipped.
+#
+# dress/base is the one frame not made here: a klein edit of base-sitting.png,
+# recorded in recipe.txt. Every other dress frame is made from it, and every
+# leotard frame from its own dress frame, so the pose carries over.
+
+KEEP = She keeps her black gloves on both hands, her puffy black juliet sleeves, her black dress with its skirt, her black thighhigh boots, her black blindfold and hairband, her lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
+POSE = keep her pose exactly, standing, arms relaxed at her sides.
+LEOTARD = Her skirt is gone, revealing a plain white leotard underneath; everything else stays exactly the same: pose, expression, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, blindfold, hairband, lips, the small mole under her mouth, chibi proportions, art style and white background.
+
+# --- expressions
+dress/smile <- dress/base : Change only her expression to a small warm closed-mouth smile; ${POSE} ${KEEP}
+dress/mad <- dress/base : Change only her expression to annoyed and angry, a frown and a puffed cheek; ${POSE} ${KEEP}
+dress/smirk <- dress/base : Change only her expression to a teasing one-sided smirk; ${POSE} ${KEEP}
+dress/blush1 <- dress/base : Change only her expression: a light pink blush on her cheeks and a shy small smile; ${POSE} ${KEEP}
+dress/blush2 <- dress/base : Change only her expression: a strong red blush across her cheeks and a flustered wavy mouth; ${POSE} ${KEEP}
+dress/blush3 <- dress/base : Change only her expression: her whole face bright red, small steam puffs above her head, very flustered; ${POSE} ${KEEP}
+dress/sleepy <- dress/base : Change only her expression to sleepy and drowsy, head tilted slightly, mouth relaxed; ${POSE} ${KEEP}
+
+# --- poses
+dress/wave <- dress/base : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other arm stays at her side. ${KEEP}
+dress/watch <- dress/base : Turn her head and upper body toward the left side of the image, as if watching a screen beside her. ${KEEP}
+dress/point <- dress/base : She points with the hand on the left side of the image toward the viewer, with a teasing smirk; her other hand rests on her hip. ${KEEP}
+dress/yawn <- dress/base : A big yawn, mouth wide open, one gloved hand covering her mouth. ${KEEP}
+dress/lying <- dress/base : Make her lie on her side on the ground, asleep, her head resting on her folded arms. ${KEEP}
+dress/listen <- dress/base : She cups the hand on the left side of the image behind her ear, listening attentively. ${KEEP}
+dress/think <- dress/base : One gloved hand at her chin, thinking, head tilted slightly. ${KEEP}
+dress/muse <- dress/base : One hand on her chest, head turned slightly away and down, contemplative and solemn. ${KEEP}
+dress/stretch <- dress/base : She stretches both arms up above her head, waking up. ${KEEP}
+
+# --- the leotard set: the same frames, after the self-destruct
+leotard/base <- dress/base : ${LEOTARD}
+leotard/smile <- dress/smile : ${LEOTARD}
+leotard/mad <- dress/mad : ${LEOTARD}
+leotard/smirk <- dress/smirk : ${LEOTARD}
+leotard/blush1 <- dress/blush1 : ${LEOTARD}
+leotard/blush2 <- dress/blush2 : ${LEOTARD}
+leotard/blush3 <- dress/blush3 : ${LEOTARD}
+leotard/sleepy <- dress/sleepy : ${LEOTARD}
+leotard/wave <- dress/wave : ${LEOTARD}
+leotard/watch <- dress/watch : ${LEOTARD}
+leotard/point <- dress/point : ${LEOTARD}
+leotard/yawn <- dress/yawn : ${LEOTARD}
+leotard/lying <- dress/lying : ${LEOTARD}
+leotard/listen <- dress/listen : ${LEOTARD}
+leotard/think <- dress/think : ${LEOTARD}
+leotard/muse <- dress/muse : ${LEOTARD}
+leotard/stretch <- dress/stretch : ${LEOTARD}
+leotard/scorched <- leotard/base : Right after an explosion: her clothes lightly scorched and smudged with soot, a few wisps of smoke rising from her, hair slightly messy, a dazed flat expression; standing, arms at her sides. She keeps her plain white leotard, black gloves, puffy black juliet sleeves, black thighhigh boots, blindfold, hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background.
diff --git a/desktop/companion/gen-frames b/desktop/companion/gen-frames
new file mode 100755
index 0000000..75e6655
--- /dev/null
+++ b/desktop/companion/gen-frames
@@ -0,0 +1,142 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# gen-frames: make the companion's frames listed in frames.txt.
+#
+# gen-frames [--seed N] [--list] [OUTPUT...]
+#
+# With no OUTPUT, makes every frame whose image does not exist yet. Naming
+# outputs (dress/mad, leotard/mad) remakes those, existing or not; a dress
+# frame remade leaves its leotard frame stale, so name both. --seed changes
+# the seed (default 42) to reroll a result; --list prints what would be made.
+# Then runs prep-frames.
+#
+# It drives a running sd-server rather than sd-cli, which would reload the
+# model for every frame: start sd-webui and pick FLUX.2 klein first. sd-webui
+# also unloads llama-server's model, which otherwise leaves too little VRAM.
+# The model is checked from sd-server's command line, since its API does not
+# name it.
+
+set -euo pipefail
+
+here=$(cd "$(dirname "$0")" && pwd)
+art=${ART_DIR:-$HOME/.local/share/keystone/companion/2b}
+url=${SD_URL:-http://127.0.0.1:7860}
+seed=42
+list=0
+want=()
+while (($#)); do
+ case $1 in
+ --seed) seed=$2; shift 2 ;;
+ --list) list=1; shift ;;
+ -h|--help) sed -n '14,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) want+=("$1"); shift ;;
+ esac
+done
+
+# frames.txt to "output<TAB>reference<TAB>prompt" lines, variables expanded.
+parse() {
+ local line name out ref prompt
+ local frame='^[[:space:]]*([a-z0-9/_-]+)[[:space:]]*<-[[:space:]]*([a-z0-9/_-]+)[[:space:]]*:[[:space:]]*(.*)$'
+ declare -A vars=()
+ while IFS= read -r line || [[ -n $line ]]; do
+ [[ $line =~ ^[[:space:]]*(#|$) ]] && continue
+ if [[ $line =~ ^([A-Z_]+)[[:space:]]*=[[:space:]]*(.*)$ ]]; then
+ vars[${BASH_REMATCH[1]}]=${BASH_REMATCH[2]}
+ continue
+ fi
+ if [[ ! $line =~ $frame ]]; then
+ echo "gen-frames: frames.txt: cannot read: $line" >&2
+ exit 1
+ fi
+ out=${BASH_REMATCH[1]} ref=${BASH_REMATCH[2]} prompt=${BASH_REMATCH[3]}
+ for name in "${!vars[@]}"; do prompt=${prompt//\$\{$name\}/${vars[$name]}}; done
+ if [[ $prompt == *'${'* ]]; then
+ echo "gen-frames: frames.txt: $out: undefined variable in: $prompt" >&2
+ exit 1
+ fi
+ printf '%s\t%s\t%s\n' "$out" "$ref" "$prompt"
+ done <"$here/frames.txt"
+}
+
+# What to make: the named outputs, else every missing one, in file order.
+# (No grep -q in a pipe: under pipefail its early exit fails the printf.)
+todo=()
+rows=$(parse) || exit 1
+mapfile -t rows <<<"$rows"
+declare -A known=() wanted=()
+for row in "${rows[@]}"; do known[${row%%$'\t'*}]=1; done
+for name in "${want[@]}"; do
+ [[ -n ${known[$name]:-} ]] || { echo "gen-frames: $name is not in frames.txt" >&2; exit 1; }
+ wanted[$name]=1
+done
+for row in "${rows[@]}"; do
+ out=${row%%$'\t'*}
+ if ((${#want[@]})); then
+ [[ -n ${wanted[$out]:-} ]] && todo+=("$row")
+ elif [[ ! -e $art/$out.png ]]; then
+ todo+=("$row")
+ fi
+done
+if ((list)); then
+ for row in "${todo[@]}"; do printf '%s <- %s\n' "${row%%$'\t'*}" "$(cut -f2 <<<"$row")"; done
+ exit 0
+fi
+if ((${#todo[@]} == 0)); then
+ echo "gen-frames: nothing to make"
+ exec "$here/prep-frames" "$art"
+fi
+
+pgrep -ax sd-server | grep -q klein ||
+ { echo "gen-frames: sd-server is not running FLUX.2 klein; start sd-webui and pick it" >&2; exit 1; }
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+made=0
+for row in "${todo[@]}"; do
+ IFS=$'\t' read -r out ref prompt <<<"$row"
+ if [[ ! -e $art/$ref.png ]]; then
+ echo "gen-frames: $out: its reference $ref.png does not exist yet, skipped" >&2
+ continue
+ fi
+ echo "gen-frames: $out <- $ref"
+ { printf 'data:image/png;base64,'; base64 -w0 "$art/$ref.png"; } >"$tmp/ref"
+ # The settings the frames were tuned with in sd-webui: recipe.txt.
+ jq -n --arg prompt "$prompt" --rawfile ref "$tmp/ref" --argjson seed "$seed" '{
+ prompt: $prompt, negative_prompt: "", width: 1024, height: 1024, seed: $seed,
+ batch_count: 1, ref_images: [$ref], auto_resize_ref_image: true,
+ increase_ref_index: false, lora: [], output_format: "png", output_compression: 100,
+ sample_params: { sample_steps: 4, sample_method: "euler", scheduler: "default",
+ guidance: { txt_cfg: 1.0 } }
+ }' >"$tmp/body"
+ id=$(curl -sf -m 30 -X POST "$url/sdcpp/v1/img_gen" -H 'Content-Type: application/json' \
+ --data-binary @"$tmp/body" | jq -r '.id // empty') || id=""
+ [[ -n $id ]] || { echo "gen-frames: $out: sd-server refused the job" >&2; exit 1; }
+ while :; do
+ curl -sf -m 30 "$url/sdcpp/v1/jobs/$id" >"$tmp/job" || { echo "gen-frames: $out: lost the job" >&2; exit 1; }
+ status=$(jq -r '.status' "$tmp/job")
+ case $status in
+ queued|generating) sleep 2 ;;
+ completed) break ;;
+ *) echo "gen-frames: $out: $status: $(jq -r '.error.message // "no message"' "$tmp/job")" >&2; exit 1 ;;
+ esac
+ done
+ mkdir -p "$art/$(dirname "$out")"
+ jq -r '.result.images[0].b64_json' "$tmp/job" | base64 -d >"$tmp/out.png"
+ mv "$tmp/out.png" "$art/$out.png"
+ printf '%s seed %s %s <- %s\n' "$(date +%F)" "$seed" "$out" "$ref" >>"$art/generated.log"
+ made=$((made + 1))
+done
+echo "gen-frames: made $made"
+exec "$here/prep-frames" "$art"
diff --git a/desktop/companion/prep-frames b/desktop/companion/prep-frames
new file mode 100755
index 0000000..462657e
--- /dev/null
+++ b/desktop/companion/prep-frames
@@ -0,0 +1,45 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# prep-frames: the companion's generated images to the frames she shows.
+#
+# prep-frames [ART_DIR] default: ~/.local/share/keystone/companion/2b
+#
+# Reads ART_DIR/dress/*.png and ART_DIR/leotard/*.png, 1024x1024 on white,
+# and writes ART_DIR/frames/<outfit>/<name>.png: background removed, trimmed
+# to her, scaled by a quarter. One scale for every frame keeps her the same
+# size whatever the pose, which scaling each to a fixed height would not:
+# lying down would come out huge. Sprite.qml draws them at half size and
+# stands each on its bottom edge.
+#
+# The background goes by flood fill from the four corners: the dark line art
+# stops the fill, so the white hair and the white leotard inside it stay.
+
+set -euo pipefail
+
+art=${1:-$HOME/.local/share/keystone/companion/2b}
+n=0
+for outfit in dress leotard; do
+ [[ -d $art/$outfit ]] || continue
+ mkdir -p "$art/frames/$outfit"
+ for src in "$art/$outfit"/*.png; do
+ [[ -e $src ]] || continue
+ read -r w h < <(magick identify -format '%w %h\n' "$src")
+ magick "$src" -alpha set -fuzz 6% -fill none \
+ -draw "color 0,0 floodfill" -draw "color $((w - 1)),0 floodfill" \
+ -draw "color 0,$((h - 1)) floodfill" -draw "color $((w - 1)),$((h - 1)) floodfill" \
+ -trim +repage -resize 25% "$art/frames/$outfit/$(basename "$src")"
+ n=$((n + 1))
+ done
+done
+echo "prep-frames: $n frames in $art/frames"