feat(02-03): add asset generation build script and generate all character assets
- Create scripts/generate-assets.ts: reads config.json, calls Gemini API for each companion - Generate 4 character sheets (3-view: front, side, face) for Fee Lila, Einhorn Stella, Fuchs Finn, Eule Elsa - Generate 4 avatars using character sheets as reference images for consistency - Generate 1 style reference (initial forest scene) for art direction - Add tsx devDependency and npm run generate-assets script - Fix imageModel name: gemini-3.1-flash-preview-image -> gemini-3.1-flash-image-preview - Script handles errors gracefully, skips already-generated assets, logs progress Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user