242 lines
7.6 KiB
TypeScript
242 lines
7.6 KiB
TypeScript
import * as fs from "node:fs";
|
|||
|
|
import * as path from "node:path";
|
||
|
|
import { generateImage } from "../src/api/gemini";
|
||
|
|
import { loadConfigFromObject } from "../src/api/config";
|
||
|
|
import type { GeminiConfig } from "../src/api/config";
|
||
|
|
|
||
|
|
// Visual descriptions per companion (from SPEC.md section 9.2)
|
||
|
|
const companionDefs = [
|
||
|
|
{
|
||
|
|
type: "fee-lila",
|
||
|
|
name: "Fee Lila",
|
||
|
|
visualDescription:
|
||
|
|
"a small fairy with lavender-purple wings, a flowing lilac dress, big warm brown eyes, a tiny flower crown made of forget-me-nots, light skin, and short wavy auburn hair",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: "einhorn-stella",
|
||
|
|
name: "Einhorn Stella",
|
||
|
|
visualDescription:
|
||
|
|
"a small unicorn with a white coat, a shimmering golden horn, a flowing mane in soft rainbow pastels (pink, lavender, light blue), big dark eyes with long lashes, and small silver hooves",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: "fuchs-finn",
|
||
|
|
name: "Fuchs Finn",
|
||
|
|
visualDescription:
|
||
|
|
"a young red fox with warm orange-brown fur, a white chest and belly, a bushy tail with a white tip, bright curious amber eyes, and slightly oversized pointed ears",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
type: "eule-elsa",
|
||
|
|
name: "Eule Elsa",
|
||
|
|
visualDescription:
|
||
|
|
"a small round owl with soft brown-and-cream feathers, a heart-shaped face, large round golden eyes, tiny ear tufts, and small talons perched on a mossy branch",
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
function sleep(ms: number): Promise<void> {
|
||
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveBlobToFile(blob: Blob, filePath: string): Promise<void> {
|
||
|
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
||
|
|
const dir = path.dirname(filePath);
|
||
|
|
if (!fs.existsSync(dir)) {
|
||
|
|
fs.mkdirSync(dir, { recursive: true });
|
||
|
|
}
|
||
|
|
fs.writeFileSync(filePath, buffer);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main(): Promise<void> {
|
||
|
|
// Load config from public/config.json
|
||
|
|
const configPath = path.resolve("public/config.json");
|
||
|
|
if (!fs.existsSync(configPath)) {
|
||
|
|
console.error("ERROR: public/config.json not found");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const configData = JSON.parse(
|
||
|
|
fs.readFileSync(configPath, "utf-8"),
|
||
|
|
) as GeminiConfig;
|
||
|
|
|
||
|
|
if (!configData.geminiApiKey || !configData.imageModel) {
|
||
|
|
console.error("ERROR: config.json missing geminiApiKey or imageModel");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
loadConfigFromObject(configData);
|
||
|
|
|
||
|
|
let failures = 0;
|
||
|
|
|
||
|
|
// Generate character sheets and avatars for each companion
|
||
|
|
for (const companion of companionDefs) {
|
||
|
|
const charSheetPath = path.resolve(
|
||
|
|
`src/assets/companions/${companion.type}/character-sheet.png`,
|
||
|
|
);
|
||
|
|
const avatarPath = path.resolve(
|
||
|
|
`src/assets/companions/${companion.type}/avatar.png`,
|
||
|
|
);
|
||
|
|
|
||
|
|
// Skip if both files already exist and are non-empty
|
||
|
|
if (
|
||
|
|
fs.existsSync(charSheetPath) &&
|
||
|
|
fs.statSync(charSheetPath).size > 1024 &&
|
||
|
|
fs.existsSync(avatarPath) &&
|
||
|
|
fs.statSync(avatarPath).size > 1024
|
||
|
|
) {
|
||
|
|
console.log(
|
||
|
|
`Skipping ${companion.name} — character-sheet.png and avatar.png already exist`,
|
||
|
|
);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate character sheet
|
||
|
|
console.log(`Generating character sheet for ${companion.name}...`);
|
||
|
|
const charSheetPrompt = `Character sheet for a children's book. The character is ${companion.name}, ${companion.visualDescription}.
|
||
|
|
Show the character in 3 views: front view, side view, and a close-up of the face.
|
||
|
|
All views must show the EXACT SAME character with identical colors, proportions, and features.
|
||
|
|
Art style: Watercolor children's book illustration, warm pastel colors, soft edges, magical forest theme. White background.
|
||
|
|
Label each view clearly: "FRONT" "SIDE" "FACE"
|
||
|
|
The character must be: cute, friendly, approachable, with large expressive eyes. Suitable for a 7-year-old audience.`;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const charSheetBlob = await generateImage(
|
||
|
|
charSheetPrompt,
|
||
|
|
[],
|
||
|
|
configData,
|
||
|
|
);
|
||
|
|
if (charSheetBlob) {
|
||
|
|
await saveBlobToFile(charSheetBlob, charSheetPath);
|
||
|
|
console.log(
|
||
|
|
` Saved character sheet: ${charSheetPath} (${fs.statSync(charSheetPath).size} bytes)`,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
console.error(
|
||
|
|
` ERROR: Failed to generate character sheet for ${companion.name}`,
|
||
|
|
);
|
||
|
|
failures++;
|
||
|
|
continue; // Skip avatar if character sheet failed (needed as reference)
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error(
|
||
|
|
` ERROR generating character sheet for ${companion.name}:`,
|
||
|
|
err,
|
||
|
|
);
|
||
|
|
failures++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
await sleep(2000); // Rate limit delay
|
||
|
|
|
||
|
|
// Generate avatar using character sheet as reference
|
||
|
|
console.log(`Generating avatar for ${companion.name}...`);
|
||
|
|
const avatarPrompt = `Create a single portrait of this exact character from the reference sheet.
|
||
|
|
Show only the face and upper body, looking directly at the viewer with a warm, friendly expression.
|
||
|
|
Same art style as the reference. Square format. No text. No background (or simple soft gradient).`;
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Read the character sheet back as a reference image
|
||
|
|
const charSheetBuffer = fs.readFileSync(charSheetPath);
|
||
|
|
const charSheetRef = new Blob([charSheetBuffer], {
|
||
|
|
type: "image/png",
|
||
|
|
});
|
||
|
|
|
||
|
|
const avatarBlob = await generateImage(
|
||
|
|
avatarPrompt,
|
||
|
|
[charSheetRef],
|
||
|
|
configData,
|
||
|
|
);
|
||
|
|
if (avatarBlob) {
|
||
|
|
await saveBlobToFile(avatarBlob, avatarPath);
|
||
|
|
console.log(
|
||
|
|
` Saved avatar: ${avatarPath} (${fs.statSync(avatarPath).size} bytes)`,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
console.error(
|
||
|
|
` ERROR: Failed to generate avatar for ${companion.name}`,
|
||
|
|
);
|
||
|
|
failures++;
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error(
|
||
|
|
` ERROR generating avatar for ${companion.name}:`,
|
||
|
|
err,
|
||
|
|
);
|
||
|
|
failures++;
|
||
|
|
}
|
||
|
|
|
||
|
|
await sleep(2000); // Rate limit delay
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate style reference (initial forest scene)
|
||
|
|
const styleRefPath = path.resolve(
|
||
|
|
"src/assets/style-reference/initial-forest-scene.png",
|
||
|
|
);
|
||
|
|
|
||
|
|
if (
|
||
|
|
fs.existsSync(styleRefPath) &&
|
||
|
|
fs.statSync(styleRefPath).size > 1024
|
||
|
|
) {
|
||
|
|
console.log(
|
||
|
|
"Skipping style reference — initial-forest-scene.png already exists",
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
console.log("Generating style reference (initial forest scene)...");
|
||
|
|
const styleRefPrompt = `Watercolor children's book illustration of a magical forest clearing.
|
||
|
|
Warm pastel colors, soft edges, dreamy atmosphere. A gentle meadow surrounded
|
||
|
|
by friendly trees, dappled sunlight, small flowers, and a hint of magic sparkles.
|
||
|
|
No text, no letters, no characters. This sets the visual style for all future forest images.`;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const styleRefBlob = await generateImage(
|
||
|
|
styleRefPrompt,
|
||
|
|
[],
|
||
|
|
configData,
|
||
|
|
);
|
||
|
|
if (styleRefBlob) {
|
||
|
|
await saveBlobToFile(styleRefBlob, styleRefPath);
|
||
|
|
console.log(
|
||
|
|
` Saved style reference: ${styleRefPath} (${fs.statSync(styleRefPath).size} bytes)`,
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
console.error(" ERROR: Failed to generate style reference");
|
||
|
|
failures++;
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error(" ERROR generating style reference:", err);
|
||
|
|
failures++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Summary
|
||
|
|
console.log("\n--- Generation Summary ---");
|
||
|
|
const pngFiles = [
|
||
|
|
"src/assets/companions/fee-lila/character-sheet.png",
|
||
|
|
"src/assets/companions/fee-lila/avatar.png",
|
||
|
|
"src/assets/companions/einhorn-stella/character-sheet.png",
|
||
|
|
"src/assets/companions/einhorn-stella/avatar.png",
|
||
|
|
"src/assets/companions/fuchs-finn/character-sheet.png",
|
||
|
|
"src/assets/companions/fuchs-finn/avatar.png",
|
||
|
|
"src/assets/companions/eule-elsa/character-sheet.png",
|
||
|
|
"src/assets/companions/eule-elsa/avatar.png",
|
||
|
|
"src/assets/style-reference/initial-forest-scene.png",
|
||
|
|
];
|
||
|
|
|
||
|
|
let existing = 0;
|
||
|
|
for (const f of pngFiles) {
|
||
|
|
const resolved = path.resolve(f);
|
||
|
|
if (fs.existsSync(resolved) && fs.statSync(resolved).size > 1024) {
|
||
|
|
existing++;
|
||
|
|
console.log(` OK: ${f}`);
|
||
|
|
} else {
|
||
|
|
console.log(` MISSING: ${f}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`\n${existing}/9 assets generated successfully.`);
|
||
|
|
if (failures > 0) {
|
||
|
|
console.log(`${failures} generation(s) failed.`);
|
||
|
|
}
|
||
|
|
process.exit(failures > 0 ? 1 : 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
main();
|