feat: Live2D outfit textures + expression system + canvas tweaks
- Generated 5 outfit texture variants via HSL recolor (saved skin tones) - Dynamic texture_02 swapping when outfit changes - Expression buttons (Normal, Smile, Sad, Angry, Surprised, Blushing) - Random idle expression changes every 8-15s - Responsive canvas sizing with devicePixelRatio support - Outfit generation script in scripts/gen_outfits.py - Smoother lip-sync with phase-based mouth animation
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 676 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 712 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 694 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 629 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 687 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import AnimatedAvatar from './AnimatedAvatar';
|
import AnimatedAvatar from './AnimatedAvatar';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,162 +9,193 @@ interface Props {
|
|||||||
onTalkToggle: () => void;
|
onTalkToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing';
|
||||||
|
|
||||||
|
const EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Sad', 'Angry', 'Surprised', 'Blushing'];
|
||||||
|
const IDLE_EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Blushing'];
|
||||||
|
|
||||||
|
const OUTFIT_TEXTURES: Record<string, string> = {
|
||||||
|
'cozy-hoodie': '/live2d/models/kira/outfits/cozy-hoodie.png',
|
||||||
|
'girly-dress': '/live2d/models/kira/outfits/girly-dress.png',
|
||||||
|
'pajama-set': '/live2d/models/kira/outfits/pajama-set.png',
|
||||||
|
'study-sweater': '/live2d/models/kira/outfits/study-sweater.png',
|
||||||
|
'going-out': '/live2d/models/kira/outfits/going-out.png',
|
||||||
|
};
|
||||||
|
|
||||||
export default function KiraAvatar(props: Props) {
|
export default function KiraAvatar(props: Props) {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const appRef = useRef<any>(null);
|
const appRef = useRef<any>(null);
|
||||||
const modelRef = useRef<any>(null);
|
const modelRef = useRef<any>(null);
|
||||||
|
const textureRef = useRef<any>(null);
|
||||||
|
const lipSyncRef = useRef<number>(0);
|
||||||
|
const idleExprRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
const [live2dReady, setLive2dReady] = useState(false);
|
const [live2dReady, setLive2dReady] = useState(false);
|
||||||
const [loadError, setLoadError] = useState(false);
|
const [loadError, setLoadError] = useState(false);
|
||||||
const animFrameRef = useRef<number>(0);
|
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
|
||||||
|
|
||||||
// Initialize Live2D
|
// ── Initialize Live2D ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
const canvasEl = canvasRef.current;
|
const container = canvasRef.current;
|
||||||
if (!canvasEl) return;
|
if (!container) return;
|
||||||
|
|
||||||
const init = async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// Check if model exists
|
|
||||||
const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' });
|
const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (mounted) setLoadError(true);
|
if (mounted) setLoadError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Cubism core script
|
|
||||||
await loadScript('/live2d/cubism/live2dcubismcore.min.js');
|
await loadScript('/live2d/cubism/live2dcubismcore.min.js');
|
||||||
|
const { Application, Texture } = await import('pixi.js');
|
||||||
// Dynamic imports
|
|
||||||
const { Application } = await import('pixi.js');
|
|
||||||
const { Live2DModel } = await import('pixi-live2d-display/cubism4');
|
const { Live2DModel } = await import('pixi-live2d-display/cubism4');
|
||||||
|
|
||||||
// Create Pixi app
|
// Responsive sizing
|
||||||
|
const size = Math.min(container.clientWidth || 260, 260);
|
||||||
const app = new Application({
|
const app = new Application({
|
||||||
width: 220,
|
width: size,
|
||||||
height: 280,
|
height: size * 1.25,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
antialias: true,
|
antialias: true,
|
||||||
resolution: 2,
|
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
||||||
backgroundAlpha: 0,
|
backgroundAlpha: 0,
|
||||||
});
|
});
|
||||||
appRef.current = app;
|
appRef.current = app;
|
||||||
|
if (!mounted) { app.destroy(true); return; }
|
||||||
|
|
||||||
if (!mounted) {
|
container.appendChild(app.view as HTMLCanvasElement);
|
||||||
app.destroy(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append canvas
|
// Load model with default textures
|
||||||
canvasEl.appendChild(app.view as HTMLCanvasElement);
|
|
||||||
|
|
||||||
// Load model
|
|
||||||
const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
|
const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
|
||||||
autoInteract: false,
|
autoInteract: false,
|
||||||
});
|
});
|
||||||
modelRef.current = model;
|
modelRef.current = model;
|
||||||
|
|
||||||
// Scale and center
|
// Fit model in canvas
|
||||||
const scale = Math.min(220 / model.width, 280 / model.height) * 0.85;
|
const maxW = size * 0.78;
|
||||||
|
const maxH = size * 1.1;
|
||||||
|
const scale = Math.min(maxW / model.width, maxH / model.height);
|
||||||
model.scale.set(scale);
|
model.scale.set(scale);
|
||||||
model.anchor.set(0.5, 0.5);
|
model.anchor.set(0.5, 0.5);
|
||||||
model.position.set(app.screen.width / 2, app.screen.height / 2 + 10);
|
model.position.set(app.screen.width / 2, app.screen.height / 2 + 6);
|
||||||
|
|
||||||
app.stage.addChild(model);
|
app.stage.addChild(model);
|
||||||
|
|
||||||
|
// Store reference to texture_02 for outfit swapping
|
||||||
|
try {
|
||||||
|
textureRef.current = {
|
||||||
|
index: 2,
|
||||||
|
original: model.internalModel.coreModel.getTexture(2),
|
||||||
|
};
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
if (mounted) setLive2dReady(true);
|
if (mounted) setLive2dReady(true);
|
||||||
|
|
||||||
// Start idle animation
|
// Start idle animation
|
||||||
try {
|
try { model.motion('Idle'); } catch { /* no idle */ }
|
||||||
model.motion('Idle');
|
try { model.expression('Normal'); } catch { /* no expressions */ }
|
||||||
} catch {
|
|
||||||
// Some models may not have Idle motion
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set default expression
|
// Random idle expression changes
|
||||||
try {
|
idleExprRef.current = setInterval(() => {
|
||||||
model.expression('Normal');
|
if (props.isSpeaking) return;
|
||||||
} catch {
|
const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)];
|
||||||
// Expressions are optional
|
try { model.expression(expr); } catch { /* */ }
|
||||||
}
|
setCurrentExpression(expr);
|
||||||
|
}, 8000 + Math.random() * 7000);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Live2D] Failed to load:', e);
|
console.warn('[Live2D]', e);
|
||||||
if (mounted) setLoadError(true);
|
if (mounted) setLoadError(true);
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
cancelAnimationFrame(animFrameRef.current);
|
cancelAnimationFrame(lipSyncRef.current);
|
||||||
|
clearInterval(idleExprRef.current);
|
||||||
if (appRef.current) {
|
if (appRef.current) {
|
||||||
appRef.current.destroy(true, { children: true });
|
appRef.current.destroy(true, { children: true });
|
||||||
appRef.current = null;
|
appRef.current = null;
|
||||||
}
|
}
|
||||||
modelRef.current = null;
|
modelRef.current = null;
|
||||||
|
textureRef.current = null;
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle speaking → lip sync
|
// ── Lip sync from TTS ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modelRef.current || !live2dReady) return;
|
|
||||||
|
|
||||||
const model = modelRef.current;
|
const model = modelRef.current;
|
||||||
const core = (window as any).Live2DCubismCore;
|
if (!model || !live2dReady) return;
|
||||||
|
|
||||||
|
cancelAnimationFrame(lipSyncRef.current);
|
||||||
|
|
||||||
if (props.isSpeaking) {
|
if (props.isSpeaking) {
|
||||||
// Animate mouth while speaking
|
let phase = 0;
|
||||||
let mouthPhase = 0;
|
const animate = () => {
|
||||||
const animateMouth = () => {
|
phase += 0.12;
|
||||||
mouthPhase += 0.15;
|
const openness = 0.25 + Math.sin(phase) * 0.35;
|
||||||
const openness = 0.3 + Math.sin(mouthPhase) * 0.35;
|
|
||||||
try {
|
try {
|
||||||
model.internalModel.coreModel.setParameterValueByIndex(
|
model.internalModel.coreModel.setParameterValueByIndex(
|
||||||
findParameterIndex(model, 'PARAM_MOUTH_OPEN_Y'),
|
findParam(model, 'PARAM_MOUTH_OPEN_Y'),
|
||||||
openness,
|
openness,
|
||||||
);
|
);
|
||||||
} catch { /* ignore */ }
|
} catch { /* */ }
|
||||||
animFrameRef.current = requestAnimationFrame(animateMouth);
|
lipSyncRef.current = requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
animateMouth();
|
animate();
|
||||||
} else {
|
} else {
|
||||||
// Close mouth when not speaking
|
|
||||||
cancelAnimationFrame(animFrameRef.current);
|
|
||||||
try {
|
try {
|
||||||
model.internalModel.coreModel.setParameterValueByIndex(
|
model.internalModel.coreModel.setParameterValueByIndex(
|
||||||
findParameterIndex(model, 'PARAM_MOUTH_OPEN_Y'),
|
findParam(model, 'PARAM_MOUTH_OPEN_Y'),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
} catch { /* ignore */ }
|
} catch { /* */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => cancelAnimationFrame(animFrameRef.current);
|
return () => cancelAnimationFrame(lipSyncRef.current);
|
||||||
}, [props.isSpeaking, live2dReady]);
|
}, [props.isSpeaking, live2dReady]);
|
||||||
|
|
||||||
// Handle outfit changes
|
// ── Outfit texture swapping ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modelRef.current || !live2dReady) return;
|
const model = modelRef.current;
|
||||||
// Texture swapping for outfits would go here
|
if (!model || !live2dReady) return;
|
||||||
// For now, the outfit system works on the fallback avatar
|
|
||||||
|
const outfitUrl = OUTFIT_TEXTURES[props.outfit];
|
||||||
|
if (!outfitUrl) return;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { Texture, BaseTexture } = await import('pixi.js');
|
||||||
|
const base = await BaseTexture.from(outfitUrl);
|
||||||
|
const tex = new Texture(base);
|
||||||
|
model.internalModel.coreModel.setTexture(2, base);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Outfit] texture swap failed:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, [props.outfit, live2dReady]);
|
}, [props.outfit, live2dReady]);
|
||||||
|
|
||||||
// Handle listening pulse effect
|
// ── Accessory toggle ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modelRef.current || !live2dReady) return;
|
const model = modelRef.current;
|
||||||
// Could add a glow/breathing effect when listening
|
if (!model || !live2dReady) return;
|
||||||
}, [props.isListening, live2dReady]);
|
|
||||||
|
// The model has hair clips on texture_01 that could be toggled
|
||||||
|
// For now, the accessory system works on the fallback avatar
|
||||||
|
}, [props.accessory, live2dReady]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card p-4 flex flex-col items-center" style={{ minHeight: 340 }}>
|
<div className="glass-card p-4 flex flex-col items-center" style={{ minHeight: 360 }}>
|
||||||
{/* Live2D canvas */}
|
{/* Live2D canvas */}
|
||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={`relative ${live2dReady ? 'block' : 'hidden'}`}
|
className={`relative w-full ${live2dReady ? 'block' : 'hidden'}`}
|
||||||
style={{ width: 220, height: 280 }}
|
style={{ maxWidth: 260, height: 325 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Animated SVG placeholder when Live2D isn't available */}
|
{/* SVG fallback */}
|
||||||
{(!live2dReady || loadError) && (
|
{(!live2dReady || loadError) && (
|
||||||
<div className={live2dReady ? 'hidden' : 'block'}>
|
<div className={live2dReady ? 'hidden' : 'block'}>
|
||||||
<AnimatedAvatar
|
<AnimatedAvatar
|
||||||
@@ -177,14 +208,35 @@ export default function KiraAvatar(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading spinner */}
|
||||||
{!live2dReady && !loadError && (
|
{!live2dReady && !loadError && (
|
||||||
<div className="flex flex-col items-center gap-2 py-8">
|
<div className="flex flex-col items-center gap-2 py-10">
|
||||||
<div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[11px] text-kira-plum/40">loading Kira...</p>
|
<p className="text-[11px] text-kira-plum/40">loading Kira...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Expression indicator */}
|
||||||
|
{live2dReady && (
|
||||||
|
<div className="mt-1 flex gap-1.5 flex-wrap justify-center">
|
||||||
|
{EXPRESSIONS.map((expr) => (
|
||||||
|
<button
|
||||||
|
key={expr}
|
||||||
|
onClick={() => {
|
||||||
|
try { modelRef.current?.expression(expr); setCurrentExpression(expr); } catch {}
|
||||||
|
}}
|
||||||
|
className={`text-[9px] px-2 py-0.5 rounded-full font-medium transition-all ${
|
||||||
|
currentExpression === expr
|
||||||
|
? 'bg-kira-pink text-white'
|
||||||
|
: 'text-kira-plum/30 hover:text-kira-plum/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{expr}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<div className="mt-2 flex items-center gap-3 text-xs text-kira-plum/40">
|
<div className="mt-2 flex items-center gap-3 text-xs text-kira-plum/40">
|
||||||
<span className={`w-2 h-2 rounded-full ${props.isSpeaking ? 'bg-kira-pink animate-pulse' : props.isListening ? 'bg-red-400 animate-pulse' : 'bg-kira-mint'}`} />
|
<span className={`w-2 h-2 rounded-full ${props.isSpeaking ? 'bg-kira-pink animate-pulse' : props.isListening ? 'bg-red-400 animate-pulse' : 'bg-kira-mint'}`} />
|
||||||
@@ -192,34 +244,25 @@ export default function KiraAvatar(props: Props) {
|
|||||||
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'}
|
{props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name + outfit indicator */}
|
|
||||||
<div className="flex gap-2 mt-0.5 text-[10px] text-kira-plum/30">
|
|
||||||
<span>Kira</span>
|
|
||||||
{live2dReady && <span>· Live2D</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load a script tag dynamically */
|
// ── Helpers ──
|
||||||
|
|
||||||
function loadScript(src: string): Promise<void> {
|
function loadScript(src: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Check if already loaded
|
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
|
||||||
if (document.querySelector(`script[src="${src}"]`)) {
|
const s = document.createElement('script');
|
||||||
resolve();
|
s.src = src;
|
||||||
return;
|
s.onload = () => resolve();
|
||||||
}
|
s.onerror = () => reject(new Error(`Failed ${src}`));
|
||||||
const script = document.createElement('script');
|
document.head.appendChild(s);
|
||||||
script.src = src;
|
|
||||||
script.onload = () => resolve();
|
|
||||||
script.onerror = () => reject(new Error(`Failed to load ${src}`));
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find a parameter index by name in the Live2D model */
|
function findParam(model: any, name: string): number {
|
||||||
function findParameterIndex(model: any, name: string): number {
|
try {
|
||||||
const parameters = model.internalModel.coreModel.getParameterIds();
|
return model.internalModel.coreModel.getParameterIds().indexOf(name);
|
||||||
return parameters.indexOf(name);
|
} catch { return 0; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate recolored outfit textures for Kira's Live2D model.
|
||||||
|
|
||||||
|
Takes the base texture_02.png (clothing layer) and creates
|
||||||
|
color variants for each outfit using HSL shifts on non-skin areas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
import colorsys
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = os.path.expanduser(
|
||||||
|
"~/Projects/ai-body-double/frontend/public/live2d/models/kira"
|
||||||
|
)
|
||||||
|
TEXTURE_DIR = os.path.join(BASE_DIR, "Epsilon.1024")
|
||||||
|
OUT_DIR = os.path.join(BASE_DIR, "outfits")
|
||||||
|
|
||||||
|
# Outfit → HSL shift mapping (hue_shift_deg, sat_mult, light_mult)
|
||||||
|
# Applied to non-white, non-skin pixels
|
||||||
|
OUTFITS = {
|
||||||
|
"cozy-hoodie": (10, 1.1, 1.0), # warm pink shift
|
||||||
|
"girly-dress": (240, 1.2, 0.95), # lavender/purple
|
||||||
|
"pajama-set": (140, 0.8, 1.1), # minty green
|
||||||
|
"study-sweater": (30, 1.1, 1.0), # warm orange/amber
|
||||||
|
"going-out": (320, 1.3, 1.05), # bright pink/magenta
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_skin(r, g, b):
|
||||||
|
"""Roughly detect skin tones (avoid recoloring skin)."""
|
||||||
|
return 180 < r < 250 and 120 < g < 200 and 80 < b < 170
|
||||||
|
|
||||||
|
def is_white_or_void(r, g, b, a):
|
||||||
|
"""Detect transparent or near-white pixels."""
|
||||||
|
if a < 10:
|
||||||
|
return True
|
||||||
|
return r > 240 and g > 240 and b > 240
|
||||||
|
|
||||||
|
def recolor_texture(src_path, dst_path, hue_shift, sat_mult, light_mult):
|
||||||
|
"""Recolor a texture by shifting HSL on non-skin, non-white pixels."""
|
||||||
|
img = Image.open(src_path).convert("RGBA")
|
||||||
|
pixels = img.load()
|
||||||
|
w, h = img.size
|
||||||
|
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
r, g, b, a = pixels[x, y]
|
||||||
|
if is_white_or_void(r, g, b, a) or is_skin(r, g, b):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert to HSL
|
||||||
|
h_val, l_val, s_val = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
|
||||||
|
|
||||||
|
# Apply shifts
|
||||||
|
h_val = (h_val + hue_shift / 360) % 1.0
|
||||||
|
s_val = min(1.0, s_val * sat_mult)
|
||||||
|
l_val = max(0, min(1.0, l_val * light_mult))
|
||||||
|
|
||||||
|
# Convert back to RGB
|
||||||
|
nr, ng, nb = colorsys.hls_to_rgb(h_val, l_val, s_val)
|
||||||
|
pixels[x, y] = (int(nr * 255), int(ng * 255), int(nb * 255), a)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||||
|
img.save(dst_path)
|
||||||
|
size = os.path.getsize(dst_path)
|
||||||
|
print(f" Created {os.path.basename(dst_path)} ({size//1024}KB)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
src = os.path.join(TEXTURE_DIR, "texture_02.png")
|
||||||
|
if not os.path.exists(src):
|
||||||
|
print(f"Source not found: {src}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Generating outfit textures from {src}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for outfit_name, (hue, sat, light) in OUTFITS.items():
|
||||||
|
dst = os.path.join(OUT_DIR, f"{outfit_name}.png")
|
||||||
|
print(f" {outfit_name}: hue={hue}° sat={sat:.1f}x light={light:.2f}x")
|
||||||
|
recolor_texture(src, dst, hue, sat, light)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Done! Texture variants ready.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
Reference in New Issue
Block a user