v4.1.9: 스튜디오 이미지 편집·저장 플로우 + 음성엔진 정리 + GPU 게이지 분리
- 스튜디오 앱: 사진 편집(스타일 변환/이모티콘 세트) + 결과 뷰어 내 크롭/회전/필터/보정/텍스트 오버레이 편집 툴바 + 저장 플로우(중간 편집은 갤러리에 안 남기고 최종본만 저장+업로드 목록에 재사용 등록) - image_style_transform: SDXL img2img + IP-Adapter-FaceID로 cartoon 스타일 추가 (watercolor/sketch는 identity drift로 재검토 후 제외) - 음성엔진 provider 값 xtts_gpu → omnivoice_gpu로 리네이밍 (실제 로딩 모델과 이름 일치, XTTS는 이미 OmniVoice로 교체된 지 오래) - 메인창 사이드바 GPU 게이지를 GPU0/GPU1로 분리 표시 - 단종된 gemini-3-flash-preview를 기본 모델값에서 전부 제거, kimi-k2.6:cloud로 교체 - vault 백업을 cron→systemd timer(Persistent=true)로 전환, /DATA 전체 동기화 스크립트 추가 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -208,7 +208,9 @@
|
||||
"Bash(~/.local/bin/arduino-cli core *)",
|
||||
"Bash(nvidia-smi --query-gpu=memory.used,memory.total,memory.free --format=csv)",
|
||||
"Bash(/home/kim/homeclaw/.smallclaw/voice-venv/bin/pip list *)",
|
||||
"Bash(apt-cache policy *)"
|
||||
"Bash(apt-cache policy *)",
|
||||
"Bash(find / -maxdepth 4 -iname \"config.json\" -path \"*smallclaw*\")",
|
||||
"Read(//root/**)"
|
||||
]
|
||||
},
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"endpoint": "http://localhost:11434",
|
||||
"model": "gemini-3-flash-preview:cloud"
|
||||
"model": "kimi-k2.6:cloud"
|
||||
},
|
||||
"lm_studio": {
|
||||
"endpoint": "http://host.docker.internal:1234",
|
||||
@@ -45,11 +45,11 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"primary": "gemini-3-flash-preview:cloud",
|
||||
"primary": "kimi-k2.6:cloud",
|
||||
"roles": {
|
||||
"manager": "gemini-3-flash-preview:cloud",
|
||||
"executor": "gemini-3-flash-preview:cloud",
|
||||
"verifier": "gemini-3-flash-preview:cloud",
|
||||
"manager": "kimi-k2.6:cloud",
|
||||
"executor": "kimi-k2.6:cloud",
|
||||
"verifier": "kimi-k2.6:cloud",
|
||||
"background_task": ""
|
||||
}
|
||||
},
|
||||
@@ -239,7 +239,7 @@
|
||||
"language": "ko"
|
||||
},
|
||||
"tts": {
|
||||
"provider": "xtts_gpu",
|
||||
"provider": "omnivoice_gpu",
|
||||
"model": "ko_KR-kss-medium",
|
||||
"voice": "ko-KR-SunHiNeural"
|
||||
},
|
||||
@@ -266,7 +266,8 @@
|
||||
"google_cx": "",
|
||||
"ollama_model": "",
|
||||
"searxng_url": "http://localhost:8888",
|
||||
"ollama_api_key": "vault:search.ollama_api_key"
|
||||
"ollama_api_key": "vault:search.ollama_api_key",
|
||||
"tavily_api_key": "vault:search.tavily_api_key"
|
||||
},
|
||||
"agent_policy": {
|
||||
"force_web_for_fresh": true,
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"known": [
|
||||
"Salom",
|
||||
"Xayr",
|
||||
"Ha",
|
||||
"Yoʻq",
|
||||
"Iltimos"
|
||||
"Xayr"
|
||||
],
|
||||
"lessonsDone": {
|
||||
"1": true,
|
||||
"2": true,
|
||||
"3": true
|
||||
}
|
||||
"lessonsDone": {}
|
||||
}
|
||||
@@ -22,4 +22,18 @@
|
||||
- Local LLM Server name: 지서버 (Z-Server). [2026-07-03] [2026-07-03]
|
||||
- Web server name: 클로서버 (Claw-Server). [2026-07-03] [2026-07-03]
|
||||
- Web server (클로서버) 사양: HP ProDesk 600 G1 DM, 16GB RAM. [2026-07-06] [2026-07-06]
|
||||
- Web server (클로서버) 사양 업그레이드: Dell T5810, Xeon E5-2683 v4, 40GB RAM, RTX 3060 12GB. [2026-07-09] [2026-07-09]
|
||||
- Web server (클로서버) 사양 업그레이드: Dell T5810, Xeon E5-2683 v4, 40GB RAM, RTX 3060 12GB. [2026-07-09] [2026-07-09]
|
||||
- 클로서버(Claw-Server) AI 미디어 파이프라인 구성:
|
||||
|
||||
[음성 STT/TTS]
|
||||
- STT: faster-whisper medium, GPU 상주(voice_engine.py)
|
||||
- TTS 메인: OmniVoice(k2-fsa/OmniVoice), GPU 상주, 음성 클로닝 지원. 설정값 voice.tts.provider === 'xtts_gpu'일 때 이 경로 탐(설정명은 xtts로 남아있으나 실제 모델은 OmniVoice)
|
||||
- TTS 폴백: Piper(로컬 wav→ogg, ffmpeg), provider가 xtts_gpu가 아닐 때 구형 경로
|
||||
- 음성통화는 별도 실시간 경로(GPU 엔진 직결), 텍스트 버튼 TTS와 파이프라인 상이
|
||||
|
||||
[이미지/비디오 생성·편집]
|
||||
- image_generate: SDXL(stabilityai/stable-diffusion-xl-base-1.0, 기본/fast) + FLUX.1-schnell(4-bit 양자화, quality="high" 옵션)
|
||||
- video_generate: LTX-Video(Lightricks/LTX-Video) 텍스트→영상
|
||||
- image_style_transform: SDXL img2img + IP-Adapter-FaceID, style="cartoon"만 지원(watercolor·sketch는 identity drift로 되돌림)
|
||||
- image_edit: crop/resize/rotate/flip/filter/vignette/watermark/speech_bubble/stylize/remove_bg 등. stylize=AnimeGANv2(torch.hub), remove_bg=rembg birefnet-portrait
|
||||
- 모든 로컬 diffusion/LTX 작업은 전용 venv(imagegen-venv)에서 GPU1 고정 실행 [2026-07-15]
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smallclaw",
|
||||
"version": "4.1.8",
|
||||
"version": "4.1.9",
|
||||
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# /srv/homeclaw 전체를 /DATA(sda1, 별도 물리 디스크)로 동기화하는 백업 스크립트.
|
||||
# cron: 0 4 * * * bash /srv/homeclaw/scripts/sync-data-backup.sh >> /home/kim/.smallclaw-backup/sync.log 2>&1
|
||||
set -e
|
||||
|
||||
SRC="/srv/homeclaw/"
|
||||
DST="/DATA/"
|
||||
LOG_DIR="$HOME/.smallclaw-backup"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync start"
|
||||
rsync -a --delete --exclude=lost+found --exclude=.Trash-1000 "$SRC" "$DST"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync done ($(du -sh "$DST" | cut -f1))"
|
||||
@@ -116,11 +116,11 @@ export const DEFAULT_CONFIG: SmallClawConfig = {
|
||||
},
|
||||
} as any,
|
||||
models: {
|
||||
primary: 'gemini-3-flash-preview:cloud',
|
||||
primary: 'kimi-k2.6:cloud',
|
||||
roles: {
|
||||
manager: 'gemini-3-flash-preview:cloud',
|
||||
executor: 'gemini-3-flash-preview:cloud',
|
||||
verifier: 'gemini-3-flash-preview:cloud',
|
||||
manager: 'kimi-k2.6:cloud',
|
||||
executor: 'kimi-k2.6:cloud',
|
||||
verifier: 'kimi-k2.6:cloud',
|
||||
background_task: ''
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ app.post('/api/voice/tts', async (req: express.Request, res: express.Response) =
|
||||
try {
|
||||
const os = await import('os');
|
||||
const { spawn: spawnProc } = await import('child_process');
|
||||
if (provider === 'xtts_gpu') {
|
||||
if (provider === 'omnivoice_gpu') {
|
||||
const { synthesizeFullGPU } = await import('../tools/voice-engine-client.js');
|
||||
const { pcm, sampleRate } = await synthesizeFullGPU(text);
|
||||
const pcmPath = path.join(os.tmpdir(), `tts_gpu_${Date.now()}.pcm`);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Express, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { imageGenerateTool, videoGenerateTool } from '../../tools/imagegen.js';
|
||||
import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '../../tools/imagegen.js';
|
||||
import { imageEditTool } from '../../tools/image.js';
|
||||
|
||||
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const VIDEO_EXTS = new Set(['.mp4']);
|
||||
@@ -10,6 +11,52 @@ function galleryDir(workspace: string): string {
|
||||
return path.join(workspace, 'generated-media');
|
||||
}
|
||||
|
||||
// Photo-edit style presets exposed in the studio UI, mapped onto image_edit's
|
||||
// existing stylize/filter operations (AnimeGANv2 for neural styles — identity-
|
||||
// preserving, already used by the chat tool — no separate img2img pipeline needed).
|
||||
const EDIT_STYLE_MAP: Record<string, { operation: string; style?: string; preset?: string }> = {
|
||||
anime: { operation: 'stylize', style: 'painting' }, // 개인 인물 일러스트에 최적화된 프리셋
|
||||
anime_group: { operation: 'stylize', style: 'anime' }, // 단체/풍경용
|
||||
anime_celeba: { operation: 'stylize', style: 'celeba' }, // 만화체/웹툰 느낌 프리셋
|
||||
cartoon: { operation: 'stylize', style: 'cartoon' }, // image_edit의 카툰 필터 (SDXL_STYLES에서 오버라이드됨)
|
||||
sketch: { operation: 'stylize', style: 'sketch' },
|
||||
sketch_color: { operation: 'stylize', style: 'sketch_color' },
|
||||
watercolor: { operation: 'stylize', style: 'watercolor' },
|
||||
bw: { operation: 'filter', preset: 'bw_film' },
|
||||
};
|
||||
|
||||
// cartoon supports multiple engines (engine param currently unused by the UI — it
|
||||
// always sends 'sdxl' implicitly by omitting the field — but still callable via
|
||||
// the chat tool or API directly):
|
||||
// fast — image_edit's classic OpenCV filter (safe, mild, ~2-3s)
|
||||
// sdxl — SDXL img2img + face-ID lock (default, ~15-40s)
|
||||
// sdxl_no_facelock — SDXL img2img without face-ID lock (looser, more "painted")
|
||||
// Other styles only have one engine and ignore this param — watercolor and sketch
|
||||
// were both tried on SDXL and dropped (see imagegen.ts): the style jump from a
|
||||
// color photo needed enough strength that, with no seed pinned, results sometimes
|
||||
// drifted the face into a different-looking person.
|
||||
const SDXL_STYLES = new Set(['cartoon']);
|
||||
type Engine = 'fast' | 'sdxl' | 'sdxl_no_facelock';
|
||||
const ENGINES = new Set(['fast', 'sdxl', 'sdxl_no_facelock']);
|
||||
|
||||
async function runStylize(style: string, srcPath: string, workspace: string, outputRelPath: string, engine: Engine = 'sdxl', strength?: number) {
|
||||
if (SDXL_STYLES.has(style) && engine !== 'fast') {
|
||||
return imageStyleTransformTool.execute({
|
||||
image: srcPath, style,
|
||||
face_lock: engine !== 'sdxl_no_facelock',
|
||||
strength,
|
||||
output: outputRelPath,
|
||||
_workspacePath: workspace,
|
||||
});
|
||||
}
|
||||
const cfg = EDIT_STYLE_MAP[style];
|
||||
return imageEditTool.execute({
|
||||
path: srcPath, operation: cfg.operation, style: cfg.style, preset: cfg.preset,
|
||||
output: outputRelPath,
|
||||
_workspacePath: workspace,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerImagegenRoutes(app: Express): void {
|
||||
app.post('/api/imagegen/generate-image', async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
@@ -79,6 +126,228 @@ export function registerImagegenRoutes(app: Express): void {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/imagegen/edit-image', async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const srcPath = String(req.body?.path || '').trim();
|
||||
const style = String(req.body?.style || '').trim();
|
||||
const engine = ENGINES.has(req.body?.engine) ? (req.body.engine as Engine) : 'sdxl';
|
||||
const strength = typeof req.body?.strength === 'number' ? req.body.strength : undefined;
|
||||
const cfg = EDIT_STYLE_MAP[style];
|
||||
if (!srcPath) return res.status(400).json({ error: 'path is required' });
|
||||
if (!cfg) return res.status(400).json({ error: `Unknown style: ${style}` });
|
||||
|
||||
console.log(`[imagegen] edit-image start user=${user.username} style=${style} engine=${engine} strength=${strength} path=${srcPath}`);
|
||||
try {
|
||||
const outDir = galleryDir(user.workspace);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const filename = `edit_${Date.now()}.png`;
|
||||
|
||||
const result = await runStylize(style, srcPath, user.workspace, path.join('generated-media', filename), engine, strength);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[imagegen] edit-image failed for user=${user.username}: ${result.error}`);
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
url: `/api/files/${result.data.rel_path}`,
|
||||
width: result.data.width,
|
||||
height: result.data.height,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(`[imagegen] edit-image threw for user=${user.username}:`, err);
|
||||
res.status(500).json({ error: err?.message || 'image_edit failed unexpectedly' });
|
||||
}
|
||||
});
|
||||
|
||||
// General-purpose editor for the result-area toolbar (crop/rotate/flip/adjust/
|
||||
// filter/vignette/auto_enhance/watermark/speech_bubble/remove_bg) — a thin pass-
|
||||
// through to image_edit rather than a route per operation, since image_edit
|
||||
// already dispatches on `operation` internally.
|
||||
//
|
||||
// When the client sends a `session` id (studio UI does, for every edit after the
|
||||
// first in a chain), output goes to a dot-prefixed subfolder under generated-media/
|
||||
// instead of the top level. fs.readdirSync in the gallery route is non-recursive
|
||||
// and only matches known image/video extensions, so these intermediate steps never
|
||||
// show up as gallery entries — same trick the sticker-set pipeline already uses for
|
||||
// its own intermediate files. Nothing is "real" until /api/imagegen/save-result
|
||||
// copies it out (see below), so idle tweaking doesn't clutter the gallery.
|
||||
const QUICK_EDIT_PARAMS = ['x', 'y', 'width', 'height', 'keep_aspect', 'degrees', 'direction', 'value', 'radius', 'preset', 'strength', 'text', 'position', 'opacity', 'bg_color', 'text_color', 'border_color', 'font_size', 'quality'];
|
||||
app.post('/api/imagegen/quick-edit', async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const srcPath = String(req.body?.path || '').trim();
|
||||
const operation = String(req.body?.operation || '').trim();
|
||||
const session = String(req.body?.session || '').trim().replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
if (!srcPath) return res.status(400).json({ error: 'path is required' });
|
||||
if (!operation) return res.status(400).json({ error: 'operation is required' });
|
||||
|
||||
console.log(`[imagegen] quick-edit start user=${user.username} operation=${operation} session=${session||'-'} path=${srcPath}`);
|
||||
try {
|
||||
const outRelDir = session ? path.join('generated-media', `.edit-session-${session}`) : 'generated-media';
|
||||
fs.mkdirSync(path.join(user.workspace, outRelDir), { recursive: true });
|
||||
const filename = `edit_${Date.now()}.png`;
|
||||
|
||||
const params: Record<string, any> = {};
|
||||
for (const k of QUICK_EDIT_PARAMS) if (req.body[k] !== undefined) params[k] = req.body[k];
|
||||
|
||||
const result = await imageEditTool.execute({
|
||||
path: srcPath, operation, ...params,
|
||||
output: path.join(outRelDir, filename),
|
||||
_workspacePath: user.workspace,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[imagegen] quick-edit failed for user=${user.username}: ${result.error}`);
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
url: `/api/files/${result.data.rel_path}`,
|
||||
path: result.data.rel_path,
|
||||
width: result.data.width,
|
||||
height: result.data.height,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(`[imagegen] quick-edit threw for user=${user.username}:`, err);
|
||||
res.status(500).json({ error: err?.message || 'image_edit failed unexpectedly' });
|
||||
}
|
||||
});
|
||||
|
||||
// Commits the current result to the gallery (copying it out of a hidden
|
||||
// .edit-session-*/ subfolder if it's in one) and optionally also to uploads/ so
|
||||
// it becomes pickable as a source photo for future style transforms/stickers.
|
||||
// No-op copy for the gallery half if the file is already directly under
|
||||
// generated-media/ (a fresh generation/style-transform, never edited) — avoids
|
||||
// creating a redundant duplicate entry.
|
||||
app.post('/api/imagegen/save-result', async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const srcRel = String(req.body?.path || '').trim().replace(/\\/g, '/');
|
||||
const alsoUploads = !!req.body?.save_to_uploads;
|
||||
if (!srcRel) return res.status(400).json({ error: 'path is required' });
|
||||
|
||||
const srcFull = path.join(user.workspace, srcRel);
|
||||
if (!fs.existsSync(srcFull)) return res.status(404).json({ error: 'Source file not found' });
|
||||
|
||||
console.log(`[imagegen] save-result start user=${user.username} path=${srcRel} alsoUploads=${alsoUploads}`);
|
||||
try {
|
||||
const ext = path.extname(srcRel) || '.png';
|
||||
const ts = Date.now();
|
||||
const isHidden = /^generated-media\/\.[^/]+\//.test(srcRel);
|
||||
const isTopLevelGallery = srcRel.startsWith('generated-media/') && !isHidden;
|
||||
|
||||
let galleryUrl: string;
|
||||
if (isTopLevelGallery) {
|
||||
galleryUrl = `/api/files/${srcRel}`;
|
||||
} else {
|
||||
const name = `saved_${ts}${ext}`;
|
||||
const dest = path.join(galleryDir(user.workspace), name);
|
||||
fs.copyFileSync(srcFull, dest);
|
||||
galleryUrl = `/api/files/generated-media/${name}`;
|
||||
}
|
||||
|
||||
const out: Record<string, any> = { success: true, gallery_url: galleryUrl };
|
||||
|
||||
if (alsoUploads) {
|
||||
const name = `saved_${ts}${ext}`;
|
||||
const uploadsDir = path.join(user.workspace, 'uploads');
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
const dest = path.join(uploadsDir, name);
|
||||
fs.copyFileSync(srcFull, dest);
|
||||
out.uploads_url = `/api/files/uploads/${name}`;
|
||||
out.uploads_path = `uploads/${name}`;
|
||||
}
|
||||
|
||||
// Clean up the temp session dir now that its result has been committed.
|
||||
if (isHidden) {
|
||||
const sessionDir = path.dirname(srcFull);
|
||||
fs.rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
res.json(out);
|
||||
} catch (err: any) {
|
||||
console.error(`[imagegen] save-result threw for user=${user.username}:`, err);
|
||||
res.status(500).json({ error: err?.message || 'save failed unexpectedly' });
|
||||
}
|
||||
});
|
||||
|
||||
// Sticker set: stylize -> remove background -> resize -> stamp a caption per phrase.
|
||||
// Each step reuses image_edit's existing operations; kept as a small chain of
|
||||
// single-purpose calls rather than a new bespoke script.
|
||||
app.post('/api/imagegen/make-sticker-set', async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const srcPath = String(req.body?.path || '').trim();
|
||||
const style = String(req.body?.style || '').trim();
|
||||
const engine = ENGINES.has(req.body?.engine) ? (req.body.engine as Engine) : 'sdxl';
|
||||
const strength = typeof req.body?.strength === 'number' ? req.body.strength : undefined;
|
||||
const cfg = EDIT_STYLE_MAP[style];
|
||||
const phrases = (Array.isArray(req.body?.phrases) ? req.body.phrases : [])
|
||||
.map((p: any) => String(p).trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12);
|
||||
if (!srcPath) return res.status(400).json({ error: 'path is required' });
|
||||
if (!cfg) return res.status(400).json({ error: `Unknown style: ${style}` });
|
||||
if (!phrases.length) return res.status(400).json({ error: 'At least one phrase is required' });
|
||||
|
||||
console.log(`[imagegen] sticker-set start user=${user.username} style=${style} engine=${engine} strength=${strength} phrases=${phrases.length}`);
|
||||
try {
|
||||
const setId = `stickers_${Date.now()}`;
|
||||
const setDir = path.join('generated-media', setId);
|
||||
fs.mkdirSync(path.join(user.workspace, setDir), { recursive: true });
|
||||
|
||||
const styled = await runStylize(style, srcPath, user.workspace, path.join(setDir, 'styled.png'), engine, strength);
|
||||
if (!styled.success) {
|
||||
console.error(`[imagegen] sticker-set stylize failed for user=${user.username}: ${styled.error}`);
|
||||
return res.status(500).json({ error: styled.error || 'Stylize step failed' });
|
||||
}
|
||||
|
||||
const cutout = await imageEditTool.execute({
|
||||
path: styled.data.rel_path, operation: 'remove_bg',
|
||||
output: path.join(setDir, 'cutout.png'),
|
||||
_workspacePath: user.workspace,
|
||||
});
|
||||
if (!cutout.success) {
|
||||
console.error(`[imagegen] sticker-set remove_bg failed for user=${user.username}: ${cutout.error}`);
|
||||
return res.status(500).json({ error: cutout.error || 'Background removal step failed' });
|
||||
}
|
||||
|
||||
const base = await imageEditTool.execute({
|
||||
path: cutout.data.rel_path, operation: 'thumbnail', width: 480, height: 480,
|
||||
output: path.join(setDir, 'base.png'),
|
||||
_workspacePath: user.workspace,
|
||||
});
|
||||
if (!base.success) {
|
||||
console.error(`[imagegen] sticker-set resize failed for user=${user.username}: ${base.error}`);
|
||||
return res.status(500).json({ error: base.error || 'Resize step failed' });
|
||||
}
|
||||
|
||||
const stickers: { url: string; phrase: string }[] = [];
|
||||
for (let i = 0; i < phrases.length; i++) {
|
||||
const r = await imageEditTool.execute({
|
||||
path: base.data.rel_path, operation: 'watermark',
|
||||
text: phrases[i], position: 'bottom-right', opacity: 0.9,
|
||||
output: path.join(setDir, `sticker_${i + 1}.png`),
|
||||
_workspacePath: user.workspace,
|
||||
});
|
||||
if (r.success) stickers.push({ url: `/api/files/${r.data.rel_path}`, phrase: phrases[i] });
|
||||
else console.error(`[imagegen] sticker-set caption "${phrases[i]}" failed for user=${user.username}: ${r.error}`);
|
||||
}
|
||||
if (!stickers.length) return res.status(500).json({ error: 'No stickers were generated' });
|
||||
|
||||
res.json({ success: true, stickers });
|
||||
} catch (err: any) {
|
||||
console.error(`[imagegen] sticker-set threw for user=${user.username}:`, err);
|
||||
res.status(500).json({ error: err?.message || 'sticker set generation failed unexpectedly' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/imagegen/gallery', (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
@@ -116,4 +385,45 @@ export function registerImagegenRoutes(app: Express): void {
|
||||
fs.unlinkSync(target);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Uploaded source photos (shared uploads/ dir — also used by other apps like
|
||||
// pptx/dental — filtered to image files so the studio picker only shows photos).
|
||||
app.get('/api/imagegen/uploads', (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const dir = path.join(user.workspace, 'uploads');
|
||||
if (!fs.existsSync(dir)) return res.json({ items: [] });
|
||||
|
||||
const items = fs.readdirSync(dir)
|
||||
.map((name) => {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (!IMAGE_EXTS.has(ext)) return null;
|
||||
const full = path.join(dir, name);
|
||||
const stat = fs.statSync(full);
|
||||
if (!stat.isFile()) return null;
|
||||
return {
|
||||
name,
|
||||
path: `uploads/${name}`,
|
||||
url: `/api/files/uploads/${encodeURIComponent(name)}`,
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
res.json({ items });
|
||||
});
|
||||
|
||||
app.delete('/api/imagegen/uploads/:name', (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const name = path.basename(String(req.params.name || ''));
|
||||
const target = path.join(user.workspace, 'uploads', name);
|
||||
if (!fs.existsSync(target)) return res.status(404).json({ error: 'Not found' });
|
||||
fs.unlinkSync(target);
|
||||
res.json({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
+27
-10
@@ -1388,10 +1388,12 @@ stylize: style 파라미터로 스타일 지정.
|
||||
- 신경망(고품질): "anime"(기본, 애니/그림체), "painting"(유화), "celeba"(만화체), "anime_v1"
|
||||
- 전통필터: "sketch"(연필스케치), "sketch_color"(컬러스케치), "cartoon"(만화), "watercolor"(수채화)
|
||||
사용 예: image_edit({path:"uploads/photo.jpg", operation:"stylize", style:"anime"})
|
||||
remove_bg: AI 배경 제거, PNG 출력
|
||||
remove_bg: AI 배경 제거(birefnet-portrait, 머리카락 디테일까지 정교), PNG 출력, CPU라 20~30초 정도 걸림 — 오래 걸린다고 미리 안내할 것
|
||||
speech_bubble: text, position("top-left"|"top-right"|"bottom-left"|"bottom-right"), bg_color, text_color, font_size(자동)
|
||||
워크플로우: (1) [IMAGE PATH for image_edit/python_eval — use EXACTLY this string: ...] 힌트에서 입력 경로 읽기 → (2) image_edit 호출 → (3) 결과 자동 표시 → 재편집 가능.
|
||||
출력: uploads/ 폴더에 자동 저장.`,
|
||||
출력: uploads/ 폴더에 자동 저장.
|
||||
|
||||
IMAGE STYLE TRANSFORM TOOL (SDXL+IP-Adapter-FaceID, 고품질 화풍): image_style_transform(image, style) → style="cartoon"(캐리커쳐/만화 일러스트)만 지원. image_edit의 stylize cartoon은 기계적 필터라 은은한 보정에 가깝고, 이 툴은 SDXL img2img로 실제 화풍을 새로 그려서 훨씬 더 그림다운 결과를 냄. 얼굴이 감지되면 IP-Adapter-FaceID로 identity를 고정해서 원본과 다른 사람이 되는 문제를 막음. 대신 15~40초로 느림. 사용자가 "캐리커쳐로", "화질 좋게 카툰으로" 등 고품질 화풍 변환을 명시적으로 요청할 때만 사용 — 기본 카툰 요청에는 image_edit(stylize)를 먼저 쓰고, 결과가 밋밋하다고 하면 이 툴을 제안. watercolor와 sketch는 SDXL로 시도했다가 모두 되돌렸음(시드 고정 없이 강한 strength가 필요해서 원본과 다른 사람 얼굴로 드리프트하는 경우가 있었음) — 항상 image_edit 사용. anime/흑백 요청에도 이 툴 대신 image_edit 사용(더 빠름).`,
|
||||
|
||||
weather: `WEATHER TOOLS (8개):
|
||||
① weather_search(location, type?, units?) → OpenWeather 현재날씨/5일예보. type:"current"|"forecast".
|
||||
@@ -12895,7 +12897,7 @@ app.get('/api/system-stats', async (req, res) => {
|
||||
// GPU stats — use the cached detector (probed once at startup, never calls
|
||||
// nvidia-smi again). On non-NVIDIA systems this is instant and silent.
|
||||
const gpuInfo = detectGpu();
|
||||
let gpuStats = { available: false, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: '' };
|
||||
let gpuStats: any = { available: false, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: '', gpus: [] as any[] };
|
||||
if (gpuInfo.nvidiaAvailable) {
|
||||
// Re-query utilization metrics only when NVIDIA is confirmed present.
|
||||
// This is the *only* place nvidia-smi runs at runtime; startup detection
|
||||
@@ -12911,6 +12913,8 @@ app.get('/api/system-stats', async (req, res) => {
|
||||
// (e.g. memory.total gets "12288\nNVIDIA GeForce RTX 3060" glued on, NaN's
|
||||
// the VRAM percent). Aggregate across all detected GPUs: sum VRAM (so the
|
||||
// meter reflects total usage), max utilization (so activity on any card shows).
|
||||
// Also keep the per-GPU breakdown (gpus[]) so the UI can show each card
|
||||
// separately instead of only a blended combined figure.
|
||||
const gpuLines = smiOut.trim().split('\n')
|
||||
.map((line: string) => line.split(',').map((s: string) => s.trim()))
|
||||
.filter((parts: string[]) => parts.length >= 4);
|
||||
@@ -12919,12 +12923,24 @@ app.get('/api/system-stats', async (req, res) => {
|
||||
let vramTotalMbSum = 0;
|
||||
let utilMax = 0;
|
||||
const names: string[] = [];
|
||||
for (const parts of gpuLines) {
|
||||
vramUsedMbSum += Number(parts[2]) || 0;
|
||||
vramTotalMbSum += Number(parts[3]) || 0;
|
||||
utilMax = Math.max(utilMax, Number(parts[1]) || 0);
|
||||
const perGpu: any[] = [];
|
||||
gpuLines.forEach((parts: string[], idx: number) => {
|
||||
const vramUsedMb = Number(parts[2]) || 0;
|
||||
const vramTotalMb = Number(parts[3]) || 0;
|
||||
const util = Number(parts[1]) || 0;
|
||||
vramUsedMbSum += vramUsedMb;
|
||||
vramTotalMbSum += vramTotalMb;
|
||||
utilMax = Math.max(utilMax, util);
|
||||
names.push(parts[0]);
|
||||
}
|
||||
perGpu.push({
|
||||
index: idx,
|
||||
name: parts[0],
|
||||
gpu_util_percent: util,
|
||||
vram_used_percent: vramTotalMb > 0 ? (vramUsedMb / vramTotalMb) * 100 : 0,
|
||||
vram_used_gb: vramUsedMb / 1024,
|
||||
vram_total_gb: vramTotalMb / 1024,
|
||||
});
|
||||
});
|
||||
gpuStats = {
|
||||
available: true,
|
||||
name: gpuLines.length > 1 ? `${names[0]} ×${gpuLines.length}` : names[0],
|
||||
@@ -12932,6 +12948,7 @@ app.get('/api/system-stats', async (req, res) => {
|
||||
vram_used_percent: vramTotalMbSum > 0 ? (vramUsedMbSum / vramTotalMbSum) * 100 : 0,
|
||||
vram_used_gb: vramUsedMbSum / 1024,
|
||||
vram_total_gb: vramTotalMbSum / 1024,
|
||||
gpus: perGpu,
|
||||
};
|
||||
}
|
||||
} catch { /* nvidia-smi already confirmed working at startup; ignore transient errors */ }
|
||||
@@ -14429,10 +14446,10 @@ server.listen(PORT, HOST, async () => {
|
||||
// Auto-connect enabled MCP servers
|
||||
getMCPManager().startEnabledServers().catch(err => console.warn('[MCP] Startup error:', err?.message));
|
||||
|
||||
// GPU voice engine (faster-whisper + XTTS) — only if configured as the active provider,
|
||||
// GPU voice engine (faster-whisper + OmniVoice) — only if configured as the active provider,
|
||||
// starts lazily in the background so a slow/failed GPU load never blocks gateway boot.
|
||||
const voiceCfg = (liveConfig as any).voice;
|
||||
if (voiceCfg?.enabled && (voiceCfg?.stt?.provider === 'whisper_gpu' || voiceCfg?.tts?.provider === 'xtts_gpu')) {
|
||||
if (voiceCfg?.enabled && (voiceCfg?.stt?.provider === 'whisper_gpu' || voiceCfg?.tts?.provider === 'omnivoice_gpu')) {
|
||||
import('../tools/voice-engine-client.js')
|
||||
.then((m) => m.ensureEngineRunning())
|
||||
.then(() => console.log('[VoiceEngine] GPU voice engine ready.'))
|
||||
|
||||
+8
-3
@@ -490,11 +490,15 @@ try:
|
||||
|
||||
elif op == "remove_bg":
|
||||
try:
|
||||
from rembg import remove as _rembg_remove
|
||||
from rembg import remove as _rembg_remove, new_session as _rembg_new_session
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "rembg not installed. Run: pip install rembg[cpu]"})); sys.exit(0)
|
||||
_inp = img.convert("RGBA")
|
||||
img = _rembg_remove(_inp)
|
||||
# birefnet-portrait: much finer hair-strand matting than the old u2net default,
|
||||
# at the cost of speed (~4s -> ~25s on CPU, no GPU onnxruntime provider installed).
|
||||
# Worth it for the sticker-set pipeline where cutout edge quality matters.
|
||||
_session = _rembg_new_session("birefnet-portrait")
|
||||
img = _rembg_remove(_inp, session=_session)
|
||||
# force PNG output (preserves transparency)
|
||||
if not dst.lower().endswith(".png"):
|
||||
dst = os.path.splitext(dst)[0] + ".png"
|
||||
@@ -624,7 +628,7 @@ export const imageEditTool = {
|
||||
' watermark — overlay text (text, position, opacity)',
|
||||
' speech_bubble — add a speech bubble with tail (text, position, bg_color, text_color, border_color, font_size)',
|
||||
' stylize — convert to artistic style. Neural: anime(default)|painting|celeba|anime_v1 (AnimeGANv2). Classic: sketch|sketch_color|cartoon|watercolor',
|
||||
' remove_bg — remove background using AI (rembg); output is PNG with transparency',
|
||||
' remove_bg — remove background using AI (rembg, birefnet-portrait model — fine hair-strand matting); output is PNG with transparency. Slower than most operations (~20-30s on CPU).',
|
||||
' convert — change file format (output path determines format)',
|
||||
'Returns the output file path and new dimensions.',
|
||||
].join('\n'),
|
||||
@@ -726,6 +730,7 @@ export const imageEditTool = {
|
||||
opacity: args?.opacity ?? 0.5,
|
||||
quality: args?.quality ?? 92,
|
||||
style: args?.style ?? 'painting',
|
||||
preset: args?.preset ?? 'warm',
|
||||
};
|
||||
|
||||
// Neural stylize (anime/painting/celeba) downloads PyTorch models on first run (~5 min).
|
||||
|
||||
@@ -184,6 +184,197 @@ except Exception as e:
|
||||
print("###RESULT###" + json.dumps({"error": str(e), "trace": traceback.format_exc()[-800:]}))
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// image_style_transform — local SDXL img2img (cartoon/watercolor only)
|
||||
// ---------------------------------------------------------------------------
|
||||
// image_edit's stylize op (AnimeGANv2 + OpenCV) already covers anime/sketch/bw
|
||||
// well — those are identity-preserving and near-instant. But its "cartoon" and
|
||||
// "watercolor" presets are mechanical edge/color filters that read as a mild
|
||||
// photo filter rather than real artistic reinterpretation. SDXL img2img trades
|
||||
// speed and some facial-identity fidelity for a genuinely more painterly result
|
||||
// on those two styles specifically.
|
||||
const IMG2IMG_SCRIPT = (p: Record<string, any>) => `
|
||||
import os, json, sys
|
||||
try:
|
||||
import torch
|
||||
from diffusers import StableDiffusionXLImg2ImgPipeline
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
pipe = StableDiffusionXLImg2ImgPipeline.from_pretrained(
|
||||
"stabilityai/stable-diffusion-xl-base-1.0",
|
||||
torch_dtype=torch.float16, variant="fp16", use_safetensors=True,
|
||||
)
|
||||
pipe = pipe.to("cuda")
|
||||
pipe.enable_vae_slicing()
|
||||
|
||||
init_image = ImageOps.exif_transpose(Image.open(${JSON.stringify(p.src)})).convert("RGB")
|
||||
max_side = int(${p.max_side})
|
||||
w, h = init_image.size
|
||||
scale = min(1.0, max_side / max(w, h))
|
||||
w, h = max(8, int(w * scale) // 8 * 8), max(8, int(h * scale) // 8 * 8)
|
||||
init_image = init_image.resize((w, h))
|
||||
|
||||
# Face-ID lock (IP-Adapter-FaceID/SDXL): without this, img2img alone lets the
|
||||
# style prompt pull the face toward SDXL's own learned priors — this is what
|
||||
# caused the watercolor identity-drift problem. Extracting a face embedding and
|
||||
# conditioning generation on it keeps the result recognizably the same person.
|
||||
# Falls back to plain img2img if no face is detected (e.g. non-portrait photo).
|
||||
face_embeds = None
|
||||
if ${p.face_lock ? 'True' : 'False'}:
|
||||
try:
|
||||
import cv2
|
||||
from insightface.app import FaceAnalysis
|
||||
_face_app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"])
|
||||
_face_app.prepare(ctx_id=0, det_size=(640, 640))
|
||||
_cv_img = cv2.imread(${JSON.stringify(p.src)})
|
||||
_faces = _face_app.get(_cv_img)
|
||||
if _faces:
|
||||
_emb = torch.from_numpy(_faces[0].normed_embedding).unsqueeze(0).unsqueeze(0)
|
||||
face_embeds = torch.cat([torch.zeros_like(_emb), _emb], dim=0).to(dtype=torch.float16, device="cuda")
|
||||
except Exception:
|
||||
face_embeds = None
|
||||
|
||||
kwargs = dict(
|
||||
prompt=${JSON.stringify(p.prompt)},
|
||||
negative_prompt=${JSON.stringify(p.negative_prompt)},
|
||||
image=init_image,
|
||||
strength=float(${p.strength}),
|
||||
num_inference_steps=int(${p.steps}),
|
||||
guidance_scale=float(${p.guidance_scale}),
|
||||
)
|
||||
${p.seed != null ? `kwargs["generator"] = torch.Generator("cuda").manual_seed(int(${p.seed}))` : ''}
|
||||
|
||||
if face_embeds is not None:
|
||||
pipe.load_ip_adapter("h94/IP-Adapter-FaceID", subfolder=None, weight_name="ip-adapter-faceid_sdxl.bin", image_encoder_folder=None)
|
||||
pipe.set_ip_adapter_scale(0.8)
|
||||
kwargs["ip_adapter_image_embeds"] = [face_embeds]
|
||||
|
||||
image = pipe(**kwargs).images[0]
|
||||
dst = ${JSON.stringify(p.dst)}
|
||||
os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
|
||||
image.save(dst)
|
||||
print("###RESULT###" + json.dumps({
|
||||
"output": dst, "width": image.width, "height": image.height,
|
||||
"face_locked": face_embeds is not None,
|
||||
"vram_peak_mb": torch.cuda.max_memory_allocated() / 1024**2,
|
||||
}))
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print("###RESULT###" + json.dumps({"error": str(e), "trace": traceback.format_exc()[-800:]}))
|
||||
`;
|
||||
|
||||
const IMG2IMG_STYLE_PRESETS: Record<string, { prompt: string; negative: string; strength: number; guidance_scale: number }> = {
|
||||
cartoon: {
|
||||
// strength was 0.6 — looked like a genuine caricature but the pose/expression
|
||||
// ("인상") drifted too far from the source photo even with face-ID identity
|
||||
// locked, since face-ID only conditions identity, not expression/composition.
|
||||
// 0.45 keeps the photo's actual expression and framing intact while still
|
||||
// applying visible cel-shaded/cartoon coloring and clean outlines.
|
||||
prompt: 'caricature portrait illustration, exaggerated facial features, bold clean outlines, vibrant flat colors, humorous comic art style, digital illustration',
|
||||
negative: 'photorealistic, blurry, deformed hands, extra limbs, low quality, watermark, text',
|
||||
strength: 0.45,
|
||||
guidance_scale: 7.0,
|
||||
},
|
||||
// "watercolor" was tried here too but dropped: the "watercolor painting portrait"
|
||||
// prompt pulled SDXL toward its own learned face priors much harder than cartoon
|
||||
// does — even down to strength 0.25-0.3 it reliably drifted the subject toward a
|
||||
// different-looking (often different-gender-presenting) face, with the watercolor
|
||||
// effect barely visible at the strengths low enough to keep identity intact.
|
||||
// image_edit's classic OpenCV watercolor filter (mild but identity-safe) is used
|
||||
// instead — see EDIT_STYLE_MAP in gateway/routes/imagegen.ts.
|
||||
//
|
||||
// "sketch" was tried too and also dropped: going from a color photo to monochrome
|
||||
// graphite linework needed strength/guidance high enough (0.8/9.0) that, with no
|
||||
// seed pinned, run-to-run variance sometimes drifted the face into a different-
|
||||
// looking person (same failure shape as watercolor). Backing off to 0.6/8.0 for
|
||||
// more consistent identity made the result look worse than image_edit's classic
|
||||
// sketch filter — flat/muddy rather than either a clean sketch or a good likeness.
|
||||
// Reverted to image_edit's OpenCV sketch filter (see EDIT_STYLE_MAP).
|
||||
};
|
||||
|
||||
export const imageStyleTransformTool = {
|
||||
name: 'image_style_transform',
|
||||
description: [
|
||||
'Transform an existing photo (e.g. a portrait) into a new artistic style using local SDXL img2img with IP-Adapter-FaceID identity locking (runs on-machine GPU, no external API).',
|
||||
'Supports style="cartoon" (caricature/comic illustration) — more artistically interpretive than image_edit\'s classic OpenCV cartoon filter, at the cost of speed (~15-40s vs ~2-3s). When a face is detected in the source photo, a face embedding (insightface/buffalo_l) conditions the generation via IP-Adapter-FaceID so the result stays recognizably the same person even under a strong style prompt — falls back to plain img2img if no face is found.',
|
||||
'For anime/sketch/watercolor/black-and-white, use image_edit\'s stylize/filter operations instead — those are faster (both "watercolor" and "sketch" SDXL presets were tried and dropped: the style jump needed enough strength that, with no seed pinned, results sometimes drifted the face into a different-looking person).',
|
||||
'Returns the transformed image inline in the chat.',
|
||||
].join('\n'),
|
||||
schema: {
|
||||
image: 'Path to the source image to transform (required)',
|
||||
style: '"cartoon" (caricature/comic illustration) — currently the only supported style',
|
||||
face_lock: 'Whether to lock facial identity via IP-Adapter-FaceID when a face is detected (optional, default true). Turn off to compare plain img2img — face-ID conditioning sometimes reads as slightly uncanny/over-smoothed; plain img2img gives looser but more natural-looking results.',
|
||||
strength: 'How strongly to restyle, 0.05-1.0 (optional; default 0.45). Lower preserves the original photo\'s pose/expression more, higher restyles more aggressively but can drift away from them.',
|
||||
steps: 'Denoising steps (default 40, range 15-50)',
|
||||
guidance_scale: 'How closely to follow the style prompt (default 7.0)',
|
||||
seed: 'Random seed for reproducibility (optional)',
|
||||
output: 'Output file path (optional; defaults to a timestamped file in the workspace)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
image: { type: 'string' },
|
||||
style: { type: 'string', enum: ['cartoon'] },
|
||||
face_lock: { type: 'boolean' },
|
||||
strength: { type: 'number' },
|
||||
steps: { type: 'number' },
|
||||
guidance_scale: { type: 'number' },
|
||||
seed: { type: 'number' },
|
||||
output: { type: 'string' },
|
||||
},
|
||||
required: ['image', 'style'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const imageArg = String(args?.image || '').trim();
|
||||
if (!imageArg) return { success: false, error: 'image is required' };
|
||||
const style = String(args?.style || '').trim();
|
||||
const preset = IMG2IMG_STYLE_PRESETS[style];
|
||||
if (!preset) return { success: false, error: `Unknown style: ${style} (expected "cartoon")` };
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const srcPath = path.isAbsolute(imageArg) ? imageArg : path.resolve(workspacePath, imageArg);
|
||||
if (!fs.existsSync(srcPath)) return { success: false, error: `Source image not found: ${imageArg}` };
|
||||
|
||||
let outPath = String(args?.output || '').trim();
|
||||
if (!outPath) {
|
||||
outPath = path.join(workspacePath, `${style}_${Date.now()}.png`);
|
||||
} else if (!path.isAbsolute(outPath)) {
|
||||
outPath = path.resolve(workspacePath, outPath);
|
||||
}
|
||||
|
||||
const params = {
|
||||
src: srcPath,
|
||||
prompt: preset.prompt,
|
||||
negative_prompt: preset.negative,
|
||||
strength: Math.min(1, Math.max(0.05, args?.strength ?? preset.strength)),
|
||||
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
|
||||
guidance_scale: args?.guidance_scale ?? preset.guidance_scale,
|
||||
seed: args?.seed,
|
||||
face_lock: args?.face_lock ?? true,
|
||||
max_side: 1024,
|
||||
dst: outPath,
|
||||
};
|
||||
// 600s (not 180s like the other scripts): the face-ID adapter weights (~1.7GB)
|
||||
// download from HF Hub on first run and can take several minutes.
|
||||
const result = await runVenvPython(IMG2IMG_SCRIPT(params), 600_000);
|
||||
if (result.error || !result.output) {
|
||||
return { success: false, error: result.error || 'Generator returned no output file', stderr: result.trace || result.raw };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: [
|
||||
`Style: ${style}`,
|
||||
`Transformed: ${result.width} × ${result.height} px`,
|
||||
'',
|
||||
buildImageMarkdown(result.output, workspacePath),
|
||||
].join('\n'),
|
||||
data: { ...result, rel_path: path.relative(workspacePath, result.output).replace(/\\/g, '/') },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const imageGenerateTool = {
|
||||
name: 'image_generate',
|
||||
description: [
|
||||
|
||||
@@ -17,7 +17,7 @@ import { openalexSearchTool, semanticSearchTool } from './scholar.js';
|
||||
import { pdfReadTool } from './pdf.js';
|
||||
import { pdfExtractImagesTool, pdfExtractTablesTool } from './pdf-extract.js';
|
||||
import { imageReadTool, imagePreviewTool, imageInfoTool, imageEditTool } from './image.js';
|
||||
import { imageGenerateTool, videoGenerateTool } from './imagegen.js';
|
||||
import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from './imagegen.js';
|
||||
import { audioTranscribeTool } from './audio-transcribe.js';
|
||||
import { pythonEvalTool } from './python.js';
|
||||
import { sqliteTool } from './sqlite.js';
|
||||
@@ -206,6 +206,7 @@ class ToolRegistry {
|
||||
this.registerSafe(imageInfoTool);
|
||||
this.registerSafe(imageEditTool);
|
||||
this.registerSafe(imageGenerateTool);
|
||||
this.registerSafe(imageStyleTransformTool);
|
||||
this.registerSafe(videoGenerateTool);
|
||||
this.registerSafe(audioTranscribeTool);
|
||||
this.registerSafe(pythonEvalTool);
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export async function synthesizeSpeech(text: string): Promise<Buffer> {
|
||||
const provider = cfg?.voice?.tts?.provider;
|
||||
const truncatedText = text.slice(0, 4000);
|
||||
|
||||
if (provider === 'xtts_gpu') {
|
||||
if (provider === 'omnivoice_gpu') {
|
||||
const tempDir = cfg?.voice?.tempDir || os.tmpdir();
|
||||
const ffmpegPath = cfg?.voice?.ffmpegPath || 'ffmpeg';
|
||||
const { synthesizeFullGPU } = await import('./voice-engine-client.js');
|
||||
|
||||
+1
-1
@@ -364,7 +364,7 @@ export interface SmallClawConfig {
|
||||
language?: string;
|
||||
};
|
||||
tts: {
|
||||
provider: 'piper' | 'edge_tts' | 'xtts_gpu';
|
||||
provider: 'piper' | 'edge_tts' | 'omnivoice_gpu';
|
||||
model: string;
|
||||
piperPath?: string;
|
||||
modelPath?: string;
|
||||
|
||||
+17
-4
@@ -4736,10 +4736,21 @@ async function loadSystemStats() {
|
||||
// 사이드바 리소스 바
|
||||
setText('sb-sys-cpu', fmtPercent(cpu));
|
||||
setText('sb-sys-ram', fmtPercent(ram));
|
||||
setText('sb-sys-vram', gpuAvailable ? fmtPercent(vram) : 'N/A');
|
||||
setMeter('sb-sys-cpu-meter', cpu);
|
||||
setMeter('sb-sys-ram-meter', ram);
|
||||
setMeter('sb-sys-vram-meter', gpuAvailable ? vram : 0);
|
||||
|
||||
// GPU별 VRAM — 카드가 2개 이상이면 GPU0/GPU1 분리 표시, 1개면 GPU1 행 숨김
|
||||
const gpuList = Array.isArray(stats?.gpu?.gpus) ? stats.gpu.gpus : [];
|
||||
const gpu0 = gpuList[0];
|
||||
const gpu1 = gpuList[1];
|
||||
setText('sb-sys-vram0', gpu0 ? fmtPercent(gpu0.vram_used_percent) : (gpuAvailable ? fmtPercent(vram) : 'N/A'));
|
||||
setMeter('sb-sys-vram0-meter', gpu0 ? gpu0.vram_used_percent : (gpuAvailable ? vram : 0));
|
||||
const gpu1Row = document.getElementById('sb-gpu1-row');
|
||||
if (gpu1Row) gpu1Row.style.display = gpu1 ? 'flex' : 'none';
|
||||
if (gpu1) {
|
||||
setText('sb-sys-vram1', fmtPercent(gpu1.vram_used_percent));
|
||||
setMeter('sb-sys-vram1-meter', gpu1.vram_used_percent);
|
||||
}
|
||||
const rxBps = stats?.network?.rx_bps || 0;
|
||||
const txBps = stats?.network?.tx_bps || 0;
|
||||
setText('sb-sys-rx', fmtNetSpeed(rxBps));
|
||||
@@ -4800,10 +4811,12 @@ async function loadSystemStats() {
|
||||
setMeter('sys-vram-meter', 0);
|
||||
setText('sb-sys-cpu', '--%');
|
||||
setText('sb-sys-ram', '--%');
|
||||
setText('sb-sys-vram', 'N/A');
|
||||
setText('sb-sys-vram0', 'N/A');
|
||||
setText('sb-sys-vram1', 'N/A');
|
||||
setMeter('sb-sys-cpu-meter', 0);
|
||||
setMeter('sb-sys-ram-meter', 0);
|
||||
setMeter('sb-sys-vram-meter', 0);
|
||||
setMeter('sb-sys-vram0-meter', 0);
|
||||
setMeter('sb-sys-vram1-meter', 0);
|
||||
setText('sb-sys-rx', '0 B/s');
|
||||
setText('sb-sys-tx', '0 B/s');
|
||||
setMeter('sb-sys-rx-meter', 0);
|
||||
|
||||
+10
-5
@@ -153,10 +153,15 @@
|
||||
<div class="sys-meter-track"><i id="sb-sys-ram-meter"></i></div>
|
||||
<span id="sb-sys-ram" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">GPU</span>
|
||||
<div class="sys-meter-track"><i id="sb-sys-vram-meter"></i></div>
|
||||
<span id="sb-sys-vram" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
|
||||
<div id="sb-gpu0-row" style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">GPU0</span>
|
||||
<div class="sys-meter-track"><i id="sb-sys-vram0-meter"></i></div>
|
||||
<span id="sb-sys-vram0" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
|
||||
</div>
|
||||
<div id="sb-gpu1-row" style="display:none;align-items:center;gap:8px">
|
||||
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">GPU1</span>
|
||||
<div class="sys-meter-track"><i id="sb-sys-vram1-meter"></i></div>
|
||||
<span id="sb-sys-vram1" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -829,7 +834,7 @@
|
||||
</select>
|
||||
<div id="search-ollama-model-row" style="display:none;margin-top:8px">
|
||||
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">Ollama 검색 모델</label>
|
||||
<input id="settings-search-ollama-model" type="text" placeholder="예: gemini-3-flash-preview:cloud" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12px;box-sizing:border-box">
|
||||
<input id="settings-search-ollama-model" type="text" placeholder="예: kimi-k2.6:cloud" style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12px;box-sizing:border-box">
|
||||
</div>
|
||||
<div id="search-searxng-url-row" style="display:none;margin-top:8px">
|
||||
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">SearXNG 인스턴스 URL</label>
|
||||
|
||||
+746
-42
@@ -27,6 +27,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
|
||||
.st-field{display:flex;flex-direction:column;gap:4px;}
|
||||
.st-field label{font-size:11px;font-weight:700;color:var(--muted);}
|
||||
.st-field textarea,.st-field input,.st-field select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:7px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;resize:vertical;}
|
||||
.st-field input[type=range]{padding:0;background:none;border:none;accent-color:var(--brand);cursor:pointer;}
|
||||
.st-field textarea:focus,.st-field input:focus,.st-field select:focus{border-color:var(--brand);}
|
||||
.st-row{display:flex;gap:8px;}
|
||||
.st-row .st-field{flex:1;}
|
||||
@@ -36,8 +37,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
|
||||
.st-hint{font-size:10px;color:var(--muted);line-height:1.4;}
|
||||
|
||||
.st-main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden;}
|
||||
.st-result{flex:1;display:flex;align-items:center;justify-content:center;padding:20px;overflow:auto;min-height:0;}
|
||||
.st-result img,.st-result video{max-width:100%;max-height:100%;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.25);}
|
||||
.st-result{flex:1;display:flex;align-items:center;justify-content:center;padding:20px;overflow:auto;min-height:0;position:relative;}
|
||||
.st-result img,.st-result video,.st-result-wrap img,.st-result-wrap video{max-width:100%;max-height:100%;object-fit:contain;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,.25);transition:transform .15s;}
|
||||
.st-placeholder{color:var(--muted);font-size:13px;text-align:center;}
|
||||
.st-spinner{width:32px;height:32px;border:3px solid var(--line);border-top-color:var(--brand);border-radius:50%;animation:st-spin 0.8s linear infinite;margin:0 auto 10px;}
|
||||
@keyframes st-spin{to{transform:rotate(360deg);}}
|
||||
@@ -45,13 +46,85 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
|
||||
.st-error{color:#ef4444;font-size:12px;}
|
||||
|
||||
.st-gallery{flex-shrink:0;border-top:1px solid var(--line);background:var(--panel);padding:8px 12px;max-height:130px;overflow-y:auto;}
|
||||
.st-gallery-hdr{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;}
|
||||
.st-gallery-hdr{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;display:flex;align-items:center;gap:8px;}
|
||||
.st-gallery-grid{display:flex;gap:8px;flex-wrap:wrap;}
|
||||
.st-gitem{position:relative;width:76px;height:76px;border-radius:6px;overflow:hidden;border:1px solid var(--line);cursor:pointer;flex-shrink:0;background:var(--panel-2);}
|
||||
.st-gitem img,.st-gitem video{width:100%;height:100%;object-fit:cover;}
|
||||
.st-gitem .st-gtype{position:absolute;top:2px;left:2px;font-size:9px;background:rgba(0,0,0,.6);color:#fff;padding:1px 4px;border-radius:3px;}
|
||||
.st-gitem .st-gdel{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:3px;font-size:10px;width:16px;height:16px;line-height:1;cursor:pointer;display:none;}
|
||||
.st-gitem:hover .st-gdel{display:block;}
|
||||
.st-gitem .st-gtype{position:absolute;bottom:2px;left:2px;font-size:9px;background:rgba(0,0,0,.6);color:#fff;padding:1px 4px;border-radius:3px;}
|
||||
.st-gitem .st-gchk{position:absolute;top:3px;left:3px;width:15px;height:15px;cursor:pointer;z-index:2;accent-color:var(--brand);}
|
||||
.st-gitem .st-gdel{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:3px;font-size:10px;width:16px;height:16px;line-height:1;cursor:pointer;}
|
||||
.st-gitem .st-gdl{position:absolute;bottom:2px;right:2px;background:rgba(0,0,0,.6);color:#fff;border-radius:3px;font-size:10px;width:16px;height:16px;line-height:16px;text-align:center;text-decoration:none;}
|
||||
|
||||
.st-toolbar{display:flex;align-items:center;gap:6px;text-transform:none;letter-spacing:normal;font-weight:400;}
|
||||
.st-toolbar label{display:flex;align-items:center;gap:3px;cursor:pointer;}
|
||||
.st-toolbar input[type=checkbox]{accent-color:var(--brand);cursor:pointer;}
|
||||
.st-toolbar-sp{flex:1;}
|
||||
.st-toolbar button{background:none;border:1px solid var(--line);border-radius:5px;padding:2px 7px;font-size:10px;color:var(--muted);cursor:pointer;font-family:inherit;}
|
||||
.st-toolbar button:hover:not(:disabled){border-color:#ef4444;color:#ef4444;}
|
||||
.st-toolbar button:disabled{opacity:.4;cursor:not-allowed;}
|
||||
.st-sel-count{font-size:10px;color:var(--muted);}
|
||||
|
||||
.st-dropzone{border:1.5px dashed var(--line);border-radius:8px;padding:14px;text-align:center;font-size:11px;color:var(--muted);cursor:pointer;transition:.15s;}
|
||||
.st-dropzone:hover{border-color:var(--brand);color:var(--brand);}
|
||||
.st-dropzone img{max-width:100%;max-height:140px;border-radius:6px;display:block;margin:0 auto;}
|
||||
|
||||
.st-result img,.st-result video,.st-result-wrap img,.st-result-wrap video{cursor:zoom-in;}
|
||||
.st-zoom-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:1000;align-items:center;justify-content:center;cursor:zoom-out;}
|
||||
.st-zoom-modal.show{display:flex;}
|
||||
.st-zoom-modal img,.st-zoom-modal video{max-width:96vw;max-height:96vh;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,.5);}
|
||||
|
||||
.st-sticker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:14px;width:100%;padding:4px;}
|
||||
.st-sticker-tile{position:relative;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:
|
||||
linear-gradient(45deg,#8886 25%,transparent 25%) 0 0/16px 16px,
|
||||
linear-gradient(-45deg,#8886 25%,transparent 25%) 0 0/16px 16px,
|
||||
linear-gradient(45deg,transparent 75%,#8886 75%) 0 0/16px 16px,
|
||||
linear-gradient(-45deg,transparent 75%,#8886 75%) 0 0/16px 16px,
|
||||
var(--panel-2);}
|
||||
.st-sticker-tile img{width:100%;aspect-ratio:1/1;object-fit:contain;display:block;cursor:zoom-in;}
|
||||
.st-sticker-cap{font-size:11px;text-align:center;padding:5px 4px;color:var(--muted);border-top:1px solid var(--line);background:var(--panel);}
|
||||
.st-sticker-dl{position:absolute;top:6px;right:6px;background:rgba(0,0,0,.6);color:#fff;border-radius:5px;width:22px;height:22px;line-height:22px;text-align:center;text-decoration:none;font-size:12px;}
|
||||
|
||||
.st-result-wrap{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;}
|
||||
.st-result-dl{position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,.65);color:#fff;padding:6px 12px;border-radius:20px;font-size:12px;text-decoration:none;backdrop-filter:blur(4px);}
|
||||
.st-result-dl:hover{background:rgba(0,0,0,.85);}
|
||||
|
||||
.st-zoom-ctl{position:absolute;top:10px;right:10px;display:flex;align-items:center;gap:2px;background:rgba(0,0,0,.6);border-radius:20px;padding:3px;backdrop-filter:blur(4px);z-index:5;}
|
||||
.st-zoom-ctl button{background:none;border:none;color:#fff;font-size:14px;font-family:inherit;cursor:pointer;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;}
|
||||
.st-zoom-ctl button:hover{background:rgba(255,255,255,.18);}
|
||||
.st-zoom-ctl span{color:#fff;font-size:10px;min-width:34px;text-align:center;user-select:none;}
|
||||
|
||||
.st-edit-ctl{position:absolute;top:10px;left:10px;display:flex;flex-wrap:wrap;align-items:center;gap:2px;background:rgba(0,0,0,.6);border-radius:16px;padding:3px;backdrop-filter:blur(4px);max-width:calc(100% - 20px);z-index:5;}
|
||||
.st-edit-ctl button{background:none;border:none;color:#fff;font-size:15px;font-family:inherit;cursor:pointer;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;}
|
||||
.st-edit-ctl button:hover{background:rgba(255,255,255,.18);}
|
||||
.st-edit-ctl button.active{background:var(--brand);color:#1e1b2e;}
|
||||
.st-edit-ctl button:disabled{opacity:.35;cursor:wait;}
|
||||
|
||||
.st-edit-panel{position:absolute;top:44px;left:10px;background:rgba(20,20,24,.92);border-radius:10px;padding:10px;backdrop-filter:blur(4px);display:none;flex-direction:column;gap:8px;width:220px;z-index:6;color:#fff;font-size:11px;}
|
||||
.st-edit-panel.show{display:flex;}
|
||||
.st-edit-panel-row{display:flex;align-items:center;gap:6px;}
|
||||
.st-edit-panel-row label{flex-shrink:0;width:44px;color:#ccc;}
|
||||
.st-edit-panel-row input[type=range]{flex:1;accent-color:var(--brand);}
|
||||
.st-edit-panel-row span{width:28px;text-align:right;color:#ccc;font-size:10px;}
|
||||
.st-edit-panel .st-filter-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;}
|
||||
.st-edit-panel .st-filter-grid button{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:6px;color:#fff;font-size:10px;padding:6px 2px;cursor:pointer;font-family:inherit;width:auto;height:auto;}
|
||||
.st-edit-panel .st-filter-grid button:hover{background:rgba(255,255,255,.2);}
|
||||
.st-edit-panel input[type=text]{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);border-radius:6px;padding:5px 7px;color:#fff;font-size:11px;font-family:inherit;outline:none;}
|
||||
.st-edit-panel select{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);border-radius:6px;padding:4px 6px;color:#fff;font-size:11px;font-family:inherit;}
|
||||
.st-edit-panel .st-apply-btn{background:var(--brand);border:none;border-radius:6px;color:#1e1b2e;font-size:11px;font-weight:700;padding:6px;cursor:pointer;font-family:inherit;width:auto;height:auto;}
|
||||
.st-edit-panel .st-text-mode{display:flex;gap:4px;}
|
||||
.st-edit-panel .st-text-mode button{flex:1;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:6px;color:#fff;font-size:10px;padding:5px;cursor:pointer;font-family:inherit;width:auto;height:auto;}
|
||||
.st-edit-panel .st-text-mode button.active{background:var(--brand);color:#1e1b2e;}
|
||||
|
||||
.st-crop-box{position:absolute;border:2px dashed #fff;background:rgba(255,255,255,.1);box-shadow:0 0 0 2000px rgba(0,0,0,.45);cursor:move;touch-action:none;}
|
||||
.st-crop-hint{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.7);color:#fff;font-size:11px;padding:6px 12px;border-radius:16px;white-space:nowrap;z-index:6;}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media(max-width:1024px){
|
||||
.st-layout{flex-direction:column;}
|
||||
.st-form{width:100%;max-height:40vh;border-right:none;border-bottom:1px solid var(--line);}
|
||||
.st-main{width:100%;min-height:0;}
|
||||
textarea,input,select{font-size:16px !important;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -66,36 +139,40 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
|
||||
<div class="st-tabs">
|
||||
<button class="st-tab active" id="tab-image" onclick="switchTab('image')">🖼️ 이미지</button>
|
||||
<button class="st-tab" id="tab-video" onclick="switchTab('video')">🎬 동영상</button>
|
||||
<button class="st-tab" id="tab-edit" onclick="switchTab('edit')">📷 사진 편집</button>
|
||||
<button class="st-tab" id="tab-sticker" onclick="switchTab('sticker')">😊 이모티콘 세트</button>
|
||||
</div>
|
||||
|
||||
<div class="st-layout">
|
||||
<div class="st-form">
|
||||
<div class="st-field">
|
||||
<label>프롬프트</label>
|
||||
<textarea id="f-prompt" rows="4" placeholder="예: a red panda reading a book, cozy lighting"></textarea>
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<label>네거티브 프롬프트 (선택)</label>
|
||||
<textarea id="f-neg" rows="2" placeholder="blurry, low quality, deformed"></textarea>
|
||||
</div>
|
||||
<div class="st-row">
|
||||
<div id="common-fields" style="display:contents">
|
||||
<div class="st-field">
|
||||
<label>가로</label>
|
||||
<input type="number" id="f-width" value="1024" step="8">
|
||||
<label>프롬프트</label>
|
||||
<textarea id="f-prompt" rows="4" placeholder="예: a red panda reading a book, cozy lighting"></textarea>
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<label>세로</label>
|
||||
<input type="number" id="f-height" value="1024" step="8">
|
||||
<label>네거티브 프롬프트 (선택)</label>
|
||||
<textarea id="f-neg" rows="2" placeholder="blurry, low quality, deformed"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-row">
|
||||
<div class="st-field">
|
||||
<label>스텝수 (품질 ↔ 속도)</label>
|
||||
<input type="number" id="f-steps" value="30" min="10" max="50">
|
||||
<div class="st-row">
|
||||
<div class="st-field">
|
||||
<label>가로</label>
|
||||
<input type="number" id="f-width" value="1024" step="8">
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<label>세로</label>
|
||||
<input type="number" id="f-height" value="768" step="8">
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<label>가이던스 스케일</label>
|
||||
<input type="number" id="f-guidance" value="7.0" step="0.5">
|
||||
<div class="st-row">
|
||||
<div class="st-field">
|
||||
<label>스텝수 (품질 ↔ 속도)</label>
|
||||
<input type="number" id="f-steps" value="30" min="10" max="50">
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<label>가이던스 스케일</label>
|
||||
<input type="number" id="f-guidance" value="7.0" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-row" id="row-image-only">
|
||||
@@ -121,7 +198,88 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
|
||||
<input type="number" id="f-fps" value="24">
|
||||
</div>
|
||||
</div>
|
||||
<button class="st-gen-btn" id="gen-btn" onclick="generate()">생성</button>
|
||||
<div id="row-edit-only" style="display:none;flex-direction:column;gap:10px;">
|
||||
<div class="st-field">
|
||||
<label>사진 업로드</label>
|
||||
<input type="file" id="f-photo" accept="image/*" style="display:none" onchange="onPhotoSelected(this.files[0])">
|
||||
<div class="st-dropzone" id="photo-dropzone" onclick="document.getElementById('f-photo').click()">사진을 선택하려면 탭하세요</div>
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<div class="st-toolbar">
|
||||
<span style="flex-shrink:0">저장된 사진</span>
|
||||
<div class="st-toolbar-sp"></div>
|
||||
<label><input type="checkbox" id="up1-selall" onchange="toggleSelectAll('uploads-grid','up1-selall',uploadsSelected1,'up1')"> 전체선택</label>
|
||||
<span class="st-sel-count" id="up1-count"></span>
|
||||
<button type="button" id="up1-delbtn" disabled onclick="deleteSelected(uploadsSelected1,n=>'/api/imagegen/uploads/'+encodeURIComponent(n),()=>loadUploadsGrid('1'),'up1-selall')">삭제</button>
|
||||
</div>
|
||||
<div class="st-gallery-grid" id="uploads-grid"></div>
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<label>스타일</label>
|
||||
<select id="f-style" onchange="toggleStrengthField('f-style','strength-field')">
|
||||
<option value="anime">🎨 애니메이션 (인물)</option>
|
||||
<option value="anime_group">🎨 애니메이션 (단체/풍경)</option>
|
||||
<option value="anime_celeba">🎨 애니메이션 (만화체)</option>
|
||||
<option value="cartoon">😄 카툰 / 캐리커쳐</option>
|
||||
<option value="sketch">✏️ 스케치</option>
|
||||
<option value="sketch_color">🖍️ 컬러 스케치</option>
|
||||
<option value="watercolor">🖌️ 수채화</option>
|
||||
<option value="bw">⚫ 흑백 사진</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="st-field" id="strength-field" style="display:none">
|
||||
<label>카툰 강도: <span id="strength-val">0.45</span></label>
|
||||
<input type="range" id="f-strength" min="0.2" max="0.8" step="0.05" value="0.45" oninput="document.getElementById('strength-val').textContent=this.value">
|
||||
<div class="st-hint" style="margin:0">낮음 = 원본 표정·자세에 가까움 · 높음 = 더 과장된 캐리커쳐</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="row-sticker-only" style="display:none;flex-direction:column;gap:10px;">
|
||||
<div class="st-field">
|
||||
<label>사진 업로드</label>
|
||||
<input type="file" id="f-photo2" accept="image/*" style="display:none" onchange="onStickerPhotoSelected(this.files[0])">
|
||||
<div class="st-dropzone" id="sticker-dropzone" onclick="document.getElementById('f-photo2').click()">사진을 선택하려면 탭하세요</div>
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<div class="st-toolbar">
|
||||
<span style="flex-shrink:0">저장된 사진</span>
|
||||
<div class="st-toolbar-sp"></div>
|
||||
<label><input type="checkbox" id="up2-selall" onchange="toggleSelectAll('uploads-grid2','up2-selall',uploadsSelected2,'up2')"> 전체선택</label>
|
||||
<span class="st-sel-count" id="up2-count"></span>
|
||||
<button type="button" id="up2-delbtn" disabled onclick="deleteSelected(uploadsSelected2,n=>'/api/imagegen/uploads/'+encodeURIComponent(n),()=>loadUploadsGrid('2'),'up2-selall')">삭제</button>
|
||||
</div>
|
||||
<div class="st-gallery-grid" id="uploads-grid2"></div>
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<label>스타일</label>
|
||||
<select id="f-style2" onchange="toggleStrengthField('f-style2','strength-field2')">
|
||||
<option value="anime">🎨 애니메이션 (인물)</option>
|
||||
<option value="anime_group">🎨 애니메이션 (단체/풍경)</option>
|
||||
<option value="anime_celeba">🎨 애니메이션 (만화체)</option>
|
||||
<option value="cartoon">😄 카툰 / 캐리커쳐</option>
|
||||
<option value="sketch">✏️ 스케치</option>
|
||||
<option value="sketch_color">🖍️ 컬러 스케치</option>
|
||||
<option value="watercolor">🖌️ 수채화</option>
|
||||
<option value="bw">⚫ 흑백 사진</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="st-field" id="strength-field2" style="display:none">
|
||||
<label>카툰 강도: <span id="strength-val2">0.45</span></label>
|
||||
<input type="range" id="f-strength2" min="0.2" max="0.8" step="0.05" value="0.45" oninput="document.getElementById('strength-val2').textContent=this.value">
|
||||
<div class="st-hint" style="margin:0">낮음 = 원본 표정·자세에 가까움 · 높음 = 더 과장된 캐리커쳐</div>
|
||||
</div>
|
||||
<div class="st-field">
|
||||
<label>문구 (한 줄에 하나씩, 최대 12개)</label>
|
||||
<textarea id="f-phrases" rows="8">안녕!
|
||||
ㅋㅋㅋ
|
||||
오키오키
|
||||
굿모닝
|
||||
고마워!
|
||||
화이팅
|
||||
미안ㅠㅠ
|
||||
사랑해</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button class="st-gen-btn" id="gen-btn" onclick="runGenerate()">생성</button>
|
||||
<div class="st-hint" id="hint-text">SDXL 로컬 생성 · 보통 10~20초</div>
|
||||
</div>
|
||||
|
||||
@@ -130,12 +288,23 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
|
||||
<div class="st-placeholder">프롬프트를 입력하고 생성 버튼을 눌러주세요</div>
|
||||
</div>
|
||||
<div class="st-gallery">
|
||||
<div class="st-gallery-hdr">최근 생성 이력</div>
|
||||
<div class="st-gallery-hdr st-toolbar">
|
||||
<span style="flex-shrink:0">최근 생성 이력</span>
|
||||
<div class="st-toolbar-sp"></div>
|
||||
<label><input type="checkbox" id="gal-selall" onchange="toggleSelectAll('gallery-grid','gal-selall',gallerySelected,'gal')"> 전체선택</label>
|
||||
<span class="st-sel-count" id="gal-count"></span>
|
||||
<button type="button" id="gal-delbtn" disabled onclick="deleteSelected(gallerySelected,n=>'/api/imagegen/gallery/'+encodeURIComponent(n),loadGallery,'gal-selall')">삭제</button>
|
||||
</div>
|
||||
<div class="st-gallery-grid" id="gallery-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="st-zoom-modal" id="zoom-modal" onclick="closeZoom()">
|
||||
<img id="zoom-img" style="display:none">
|
||||
<video id="zoom-video" style="display:none" controls></video>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TOKEN_KEY='smallclaw_token';
|
||||
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
||||
@@ -147,21 +316,357 @@ async function checkAuth(){
|
||||
}
|
||||
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_theme',n);}catch{}}
|
||||
|
||||
// ── Selection / bulk-delete helpers (shared by the gallery grid and both saved-photo pickers) ──
|
||||
let uploadsSelected1=new Set(), uploadsSelected2=new Set(), gallerySelected=new Set();
|
||||
|
||||
function updateGridToolbar(prefix, selectedSet){
|
||||
const btn=document.getElementById(prefix+'-delbtn');
|
||||
const cnt=document.getElementById(prefix+'-count');
|
||||
if(btn) btn.disabled = selectedSet.size===0;
|
||||
if(cnt) cnt.textContent = selectedSet.size ? selectedSet.size+'개 선택됨' : '';
|
||||
}
|
||||
|
||||
function wireGridItem(el, name, selectedSet, prefix){
|
||||
el.dataset.name = name;
|
||||
const chk = el.querySelector('.st-gchk');
|
||||
chk.addEventListener('click', ev=>{
|
||||
ev.stopPropagation();
|
||||
if(chk.checked) selectedSet.add(name); else selectedSet.delete(name);
|
||||
updateGridToolbar(prefix, selectedSet);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll(gridId, selectAllId, selectedSet, prefix){
|
||||
const checkAll = document.getElementById(selectAllId).checked;
|
||||
selectedSet.clear();
|
||||
document.getElementById(gridId).querySelectorAll('.st-gitem').forEach(el=>{
|
||||
const chk = el.querySelector('.st-gchk');
|
||||
chk.checked = checkAll;
|
||||
if(checkAll) selectedSet.add(el.dataset.name);
|
||||
});
|
||||
updateGridToolbar(prefix, selectedSet);
|
||||
}
|
||||
|
||||
async function deleteSelected(selectedSet, deleteUrlFn, reloadFn, selectAllId){
|
||||
if(!selectedSet.size) return;
|
||||
if(!confirm(selectedSet.size+'개 삭제할까요?')) return;
|
||||
await Promise.all([...selectedSet].map(name => fetch(deleteUrlFn(name), {method:'DELETE', headers:authH()})));
|
||||
selectedSet.clear();
|
||||
const sa=document.getElementById(selectAllId); if(sa) sa.checked=false;
|
||||
reloadFn();
|
||||
}
|
||||
|
||||
function resultMediaHtml(url, type, filename, isChainedEdit){
|
||||
resultZoom=1;
|
||||
cancelCropUI();
|
||||
currentResultPath = type==='video' ? null : url.replace(/^\/api\/files\//, '');
|
||||
if(!isChainedEdit) currentEditSession=null;
|
||||
const media = type==='video'
|
||||
? '<video src="'+url+'" controls autoplay loop></video>'
|
||||
: '<img src="'+url+'" alt="result">';
|
||||
const editToolbar = type==='video' ? '' :
|
||||
'<div class="st-edit-ctl" onclick="event.stopPropagation()">'
|
||||
+'<button type="button" id="edit-crop-btn" onclick="toggleCropMode()" title="자르기">✂️</button>'
|
||||
+'<button type="button" onclick="quickRotate()" title="회전">🔄</button>'
|
||||
+'<button type="button" onclick="quickFlip()" title="좌우반전">↔️</button>'
|
||||
+'<button type="button" onclick="quickAutoEnhance()" title="자동보정">✨</button>'
|
||||
+'<button type="button" onclick="quickRemoveBg()" title="배경투명화">🪄</button>'
|
||||
+'<button type="button" onclick="togglePanel(\'filter-panel\')" title="필터">🎨</button>'
|
||||
+'<button type="button" onclick="togglePanel(\'adjust-panel\')" title="보정">🎚️</button>'
|
||||
+'<button type="button" onclick="togglePanel(\'text-panel\')" title="텍스트">📝</button>'
|
||||
+'<button type="button" id="save-result-btn" onclick="saveResult()" title="저장">💾</button>'
|
||||
+'</div>'
|
||||
+'<div class="st-edit-panel" id="filter-panel" onclick="event.stopPropagation()">'
|
||||
+'<div class="st-filter-grid">'
|
||||
+'<button onclick="applyFilter(\'warm\')">따뜻하게</button>'
|
||||
+'<button onclick="applyFilter(\'cool\')">차갑게</button>'
|
||||
+'<button onclick="applyFilter(\'vintage\')">빈티지</button>'
|
||||
+'<button onclick="applyFilter(\'sepia\')">세피아</button>'
|
||||
+'<button onclick="applyFilter(\'fade\')">페이드</button>'
|
||||
+'<button onclick="applyFilter(\'dramatic\')">드라마틱</button>'
|
||||
+'</div>'
|
||||
+'<div class="st-edit-panel-row">'
|
||||
+'<label>비네트</label>'
|
||||
+'<input type="range" id="vignette-strength" min="0.3" max="0.8" step="0.05" value="0.5">'
|
||||
+'<button class="st-apply-btn" onclick="applyVignette()">적용</button>'
|
||||
+'</div>'
|
||||
+'</div>'
|
||||
+'<div class="st-edit-panel" id="adjust-panel" onclick="event.stopPropagation()">'
|
||||
+'<div class="st-edit-panel-row"><label>밝기</label><input type="range" id="adj-brightness" min="0.5" max="2" step="0.05" value="1" oninput="document.getElementById(\'adj-brightness-val\').textContent=parseFloat(this.value).toFixed(2)"><span id="adj-brightness-val">1.00</span></div>'
|
||||
+'<div class="st-edit-panel-row"><label>대비</label><input type="range" id="adj-contrast" min="0.5" max="2" step="0.05" value="1" oninput="document.getElementById(\'adj-contrast-val\').textContent=parseFloat(this.value).toFixed(2)"><span id="adj-contrast-val">1.00</span></div>'
|
||||
+'<div class="st-edit-panel-row"><label>채도</label><input type="range" id="adj-saturation" min="0" max="2" step="0.05" value="1" oninput="document.getElementById(\'adj-saturation-val\').textContent=parseFloat(this.value).toFixed(2)"><span id="adj-saturation-val">1.00</span></div>'
|
||||
+'<div class="st-edit-panel-row"><label>선명도</label><input type="range" id="adj-sharpen" min="1" max="3" step="0.1" value="1" oninput="document.getElementById(\'adj-sharpen-val\').textContent=parseFloat(this.value).toFixed(2)"><span id="adj-sharpen-val">1.00</span></div>'
|
||||
+'<button class="st-apply-btn" onclick="applyAdjust()">적용</button>'
|
||||
+'</div>'
|
||||
+'<div class="st-edit-panel" id="text-panel" onclick="event.stopPropagation()">'
|
||||
+'<div class="st-text-mode">'
|
||||
+'<button type="button" class="active" id="text-mode-watermark" onclick="setTextMode(\'watermark\')">워터마크</button>'
|
||||
+'<button type="button" id="text-mode-bubble" onclick="setTextMode(\'speech_bubble\')">말풍선</button>'
|
||||
+'</div>'
|
||||
+'<input type="text" id="text-content" placeholder="텍스트 입력">'
|
||||
+'<select id="text-position">'
|
||||
+'<option value="bottom-right">우하단</option>'
|
||||
+'<option value="bottom-left">좌하단</option>'
|
||||
+'<option value="top-right">우상단</option>'
|
||||
+'<option value="top-left">좌상단</option>'
|
||||
+'<option value="center">중앙(워터마크 전용)</option>'
|
||||
+'</select>'
|
||||
+'<button class="st-apply-btn" onclick="applyText()">적용</button>'
|
||||
+'</div>';
|
||||
return '<div class="st-result-wrap">'+media
|
||||
+'<div class="st-zoom-ctl" onclick="event.stopPropagation()">'
|
||||
+'<button type="button" onclick="adjustResultZoom(-0.25)" title="축소">−</button>'
|
||||
+'<span id="zoom-pct">100%</span>'
|
||||
+'<button type="button" onclick="adjustResultZoom(0.25)" title="확대">+</button>'
|
||||
+'<button type="button" onclick="resetResultZoom()" title="원래크기">⟲</button>'
|
||||
+'</div>'
|
||||
+editToolbar
|
||||
+'<a class="st-result-dl" href="'+url+'" download="'+(filename||'')+'" title="다운로드" onclick="event.stopPropagation()">⬇ 다운로드</a></div>';
|
||||
}
|
||||
|
||||
let resultZoom=1;
|
||||
function applyResultZoom(){
|
||||
const el=document.querySelector('.st-result-wrap img, .st-result-wrap video');
|
||||
if(el) el.style.transform='scale('+resultZoom+')';
|
||||
const pct=document.getElementById('zoom-pct');
|
||||
if(pct) pct.textContent=Math.round(resultZoom*100)+'%';
|
||||
}
|
||||
function adjustResultZoom(delta){
|
||||
resultZoom=Math.max(0.25, Math.min(4, +(resultZoom+delta).toFixed(2)));
|
||||
applyResultZoom();
|
||||
}
|
||||
function resetResultZoom(){
|
||||
resultZoom=1;
|
||||
applyResultZoom();
|
||||
}
|
||||
|
||||
// ── Quick photo editor (result-area toolbar: crop/rotate/flip/adjust/filter/text/remove-bg) ──
|
||||
let currentResultPath=null;
|
||||
let currentEditSession=null;
|
||||
let editBusy=false;
|
||||
|
||||
async function quickEdit(operation, extraParams, statusMsg){
|
||||
if(editBusy) return;
|
||||
if(!currentResultPath){alert('편집할 이미지가 없습니다');return;}
|
||||
if(!currentEditSession) currentEditSession=Date.now().toString(36)+Math.random().toString(36).slice(2,8);
|
||||
const wrap=document.querySelector('.st-result-wrap');
|
||||
let ov=null;
|
||||
if(wrap){
|
||||
ov=document.createElement('div');
|
||||
ov.style.cssText='position:absolute;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:10;border-radius:10px;';
|
||||
ov.innerHTML='<div style="text-align:center;color:#fff"><div class="st-spinner"></div><div style="font-size:11px;margin-top:4px">'+(statusMsg||'적용 중...')+'</div></div>';
|
||||
wrap.appendChild(ov);
|
||||
}
|
||||
editBusy=true;
|
||||
try{
|
||||
const body=Object.assign({path:currentResultPath, operation, session:currentEditSession}, extraParams||{});
|
||||
const r=await fetch('/api/imagegen/quick-edit',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
|
||||
const d=await r.json();
|
||||
if(!r.ok || !d.success){
|
||||
alert('편집 실패: '+(d.error||'알 수 없는 오류'));
|
||||
return;
|
||||
}
|
||||
document.getElementById('result-area').innerHTML=resultMediaHtml(d.url, 'image', 'edited_'+Date.now()+'.png', true);
|
||||
}catch(e){
|
||||
alert('요청 실패: '+e.message);
|
||||
}finally{
|
||||
editBusy=false;
|
||||
ov?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function quickRotate(){ quickEdit('rotate', {degrees:90}, '회전 중...'); }
|
||||
function quickFlip(){ quickEdit('flip', {direction:'horizontal'}, '반전 중...'); }
|
||||
function quickAutoEnhance(){ quickEdit('auto_enhance', {}, '자동보정 중...'); }
|
||||
function quickRemoveBg(){ quickEdit('remove_bg', {}, '배경 제거 중... (20~30초 걸려요)'); }
|
||||
|
||||
async function saveResult(){
|
||||
if(!currentResultPath){alert('저장할 이미지가 없습니다');return;}
|
||||
const btn=document.getElementById('save-result-btn');
|
||||
if(btn){btn.disabled=true; btn.textContent='⏳';}
|
||||
try{
|
||||
const r=await fetch('/api/imagegen/save-result',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({path:currentResultPath, save_to_uploads:true})});
|
||||
const d=await r.json();
|
||||
if(!r.ok || !d.success){
|
||||
alert('저장 실패: '+(d.error||'알 수 없는 오류'));
|
||||
return;
|
||||
}
|
||||
// The session dir (if any) was just deleted server-side, so currentResultPath
|
||||
// would now point at a gone file — repoint the view at the saved copy instead
|
||||
// of just leaving stale state for the next edit to trip over.
|
||||
document.getElementById('result-area').innerHTML=resultMediaHtml(d.gallery_url, 'image', 'saved_'+Date.now()+'.png');
|
||||
loadGallery();
|
||||
loadUploadsGrid('1');
|
||||
loadUploadsGrid('2');
|
||||
const btn2=document.getElementById('save-result-btn');
|
||||
if(btn2){
|
||||
btn2.textContent='✅';
|
||||
setTimeout(()=>{ if(btn2){btn2.textContent='💾'; btn2.disabled=false;} }, 1200);
|
||||
}
|
||||
}catch(e){
|
||||
alert('요청 실패: '+e.message);
|
||||
if(btn){btn.textContent='💾'; btn.disabled=false;}
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel(id){
|
||||
const panel=document.getElementById(id);
|
||||
const wasShown=panel && panel.classList.contains('show');
|
||||
closeAllPanels();
|
||||
if(panel && !wasShown) panel.classList.add('show');
|
||||
}
|
||||
function closeAllPanels(){
|
||||
document.querySelectorAll('.st-edit-panel').forEach(p=>p.classList.remove('show'));
|
||||
}
|
||||
|
||||
function applyFilter(preset){
|
||||
closeAllPanels();
|
||||
quickEdit('filter', {preset}, '필터 적용 중...');
|
||||
}
|
||||
function applyVignette(){
|
||||
const v=parseFloat(document.getElementById('vignette-strength').value);
|
||||
closeAllPanels();
|
||||
quickEdit('vignette', {strength:v}, '비네트 적용 중...');
|
||||
}
|
||||
|
||||
async function applyAdjust(){
|
||||
const ops=[
|
||||
['brightness', parseFloat(document.getElementById('adj-brightness').value)],
|
||||
['contrast', parseFloat(document.getElementById('adj-contrast').value)],
|
||||
['saturation', parseFloat(document.getElementById('adj-saturation').value)],
|
||||
['sharpen', parseFloat(document.getElementById('adj-sharpen').value)],
|
||||
].filter(([op,val])=>Math.abs(val-1)>0.001);
|
||||
if(!ops.length){alert('변경된 값이 없습니다');return;}
|
||||
closeAllPanels();
|
||||
for(const [op,val] of ops){
|
||||
await quickEdit(op, {value:val}, op+' 적용 중...');
|
||||
}
|
||||
}
|
||||
|
||||
let textMode='watermark';
|
||||
function setTextMode(mode){
|
||||
textMode=mode;
|
||||
document.getElementById('text-mode-watermark').classList.toggle('active', mode==='watermark');
|
||||
document.getElementById('text-mode-bubble').classList.toggle('active', mode==='speech_bubble');
|
||||
}
|
||||
function applyText(){
|
||||
const text=document.getElementById('text-content').value.trim();
|
||||
if(!text){alert('텍스트를 입력해주세요');return;}
|
||||
const position=document.getElementById('text-position').value;
|
||||
closeAllPanels();
|
||||
quickEdit(textMode, {text, position}, '텍스트 적용 중...');
|
||||
}
|
||||
|
||||
let cropState=null;
|
||||
function toggleCropMode(){
|
||||
if(cropState){ cancelCropUI(); return; }
|
||||
closeAllPanels();
|
||||
const wrap=document.querySelector('.st-result-wrap');
|
||||
const img=wrap?.querySelector('img');
|
||||
if(!img){alert('자를 이미지가 없습니다');return;}
|
||||
const hint=document.createElement('div');
|
||||
hint.className='st-crop-hint';
|
||||
hint.id='crop-hint';
|
||||
hint.textContent='드래그해서 자를 영역을 선택하세요';
|
||||
wrap.appendChild(hint);
|
||||
document.getElementById('edit-crop-btn')?.classList.add('active');
|
||||
cropState={img};
|
||||
img.addEventListener('pointerdown', onCropStart);
|
||||
}
|
||||
function onCropStart(ev){
|
||||
ev.preventDefault();
|
||||
const wrap=document.querySelector('.st-result-wrap');
|
||||
const img=ev.currentTarget;
|
||||
const rect=img.getBoundingClientRect();
|
||||
const startX=Math.min(Math.max(ev.clientX-rect.left,0),rect.width);
|
||||
const startY=Math.min(Math.max(ev.clientY-rect.top,0),rect.height);
|
||||
document.getElementById('crop-box')?.remove();
|
||||
const box=document.createElement('div');
|
||||
box.className='st-crop-box';
|
||||
box.id='crop-box';
|
||||
wrap.appendChild(box);
|
||||
cropState={img, rect, startX, startY};
|
||||
updateCropBox(startX, startY);
|
||||
const onMove=(mv)=>{
|
||||
const x=Math.min(Math.max(mv.clientX-rect.left,0),rect.width);
|
||||
const y=Math.min(Math.max(mv.clientY-rect.top,0),rect.height);
|
||||
updateCropBox(x,y);
|
||||
};
|
||||
const onUp=()=>{
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
finishCropSelection();
|
||||
};
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
}
|
||||
function updateCropBox(curX, curY){
|
||||
const box=document.getElementById('crop-box');
|
||||
if(!box || !cropState) return;
|
||||
const x=Math.min(cropState.startX, curX), y=Math.min(cropState.startY, curY);
|
||||
const w=Math.abs(curX-cropState.startX), h=Math.abs(curY-cropState.startY);
|
||||
box.style.left=x+'px'; box.style.top=y+'px'; box.style.width=w+'px'; box.style.height=h+'px';
|
||||
cropState.sel={x,y,w,h};
|
||||
}
|
||||
function finishCropSelection(){
|
||||
if(!cropState?.sel || cropState.sel.w<10 || cropState.sel.h<10){
|
||||
document.getElementById('crop-box')?.remove();
|
||||
return;
|
||||
}
|
||||
const hint=document.getElementById('crop-hint');
|
||||
if(hint) hint.innerHTML='영역을 확인하고 적용하세요 '
|
||||
+'<button onclick="confirmCrop()" style="margin-left:6px;background:var(--brand);color:#1e1b2e;border:none;border-radius:10px;padding:3px 10px;cursor:pointer;font-family:inherit;font-size:11px;">적용</button> '
|
||||
+'<button onclick="cancelCropUI()" style="margin-left:4px;background:rgba(255,255,255,.2);color:#fff;border:none;border-radius:10px;padding:3px 10px;cursor:pointer;font-family:inherit;font-size:11px;">취소</button>';
|
||||
}
|
||||
function confirmCrop(){
|
||||
const img=cropState?.img, sel=cropState?.sel, rect=cropState?.rect;
|
||||
if(!img || !sel || !rect) return;
|
||||
const fitScale=Math.min(rect.width/img.naturalWidth, rect.height/img.naturalHeight);
|
||||
const renderedW=img.naturalWidth*fitScale, renderedH=img.naturalHeight*fitScale;
|
||||
const offsetX=(rect.width-renderedW)/2, offsetY=(rect.height-renderedH)/2;
|
||||
const x=Math.max(0, Math.round((sel.x-offsetX)/fitScale));
|
||||
const y=Math.max(0, Math.round((sel.y-offsetY)/fitScale));
|
||||
const w=Math.round(sel.w/fitScale);
|
||||
const h=Math.round(sel.h/fitScale);
|
||||
cancelCropUI();
|
||||
quickEdit('crop', {x, y, width:w, height:h}, '자르는 중...');
|
||||
}
|
||||
function cancelCropUI(){
|
||||
document.getElementById('crop-box')?.remove();
|
||||
document.getElementById('crop-hint')?.remove();
|
||||
document.getElementById('edit-crop-btn')?.classList.remove('active');
|
||||
if(cropState?.img) cropState.img.removeEventListener('pointerdown', onCropStart);
|
||||
cropState=null;
|
||||
}
|
||||
|
||||
let currentTab='image';
|
||||
function genVerb(){
|
||||
return currentTab==='edit' ? '변환' : currentTab==='sticker' ? '세트 만들기' : '생성';
|
||||
}
|
||||
function switchTab(tab){
|
||||
currentTab=tab;
|
||||
document.getElementById('tab-image').classList.toggle('active', tab==='image');
|
||||
document.getElementById('tab-video').classList.toggle('active', tab==='video');
|
||||
document.getElementById('tab-edit').classList.toggle('active', tab==='edit');
|
||||
document.getElementById('tab-sticker').classList.toggle('active', tab==='sticker');
|
||||
document.getElementById('common-fields').style.display = (tab==='edit'||tab==='sticker') ? 'none' : 'contents';
|
||||
document.getElementById('row-image-only').style.display = tab==='image' ? 'flex' : 'none';
|
||||
document.getElementById('row-video-only').style.display = tab==='video' ? 'flex' : 'none';
|
||||
document.getElementById('row-edit-only').style.display = tab==='edit' ? 'flex' : 'none';
|
||||
document.getElementById('row-sticker-only').style.display = tab==='sticker' ? 'flex' : 'none';
|
||||
document.getElementById('gen-btn').textContent = genVerb();
|
||||
if(tab==='image'){
|
||||
document.getElementById('f-width').value=1024; document.getElementById('f-height').value=1024;
|
||||
document.getElementById('f-width').value=1024; document.getElementById('f-height').value=768;
|
||||
document.getElementById('f-quality').value='fast';
|
||||
onQualityChange();
|
||||
} else {
|
||||
} else if(tab==='video'){
|
||||
document.getElementById('f-width').value=704; document.getElementById('f-height').value=480;
|
||||
document.getElementById('f-steps').value=40; document.getElementById('f-guidance').value=3.0;
|
||||
document.getElementById('hint-text').textContent='LTX-Video 로컬 생성 · 보통 30~90초, 시간이 걸립니다';
|
||||
} else if(tab==='edit'){
|
||||
document.getElementById('hint-text').textContent='사진을 업로드하고 스타일을 골라주세요 · 대부분 2~10초, 카툰/캐리커쳐는 SDXL로 그려서 15~40초 정도 걸려요';
|
||||
} else {
|
||||
document.getElementById('hint-text').textContent='사진 업로드 → 스타일 변환 → 배경 제거(정교한 모델이라 20~30초) → 문구별 스티커 생성까지 한 번에 · 보통 40~70초, 카툰/캐리커쳐는 SDXL이라 더 걸려요';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,15 +690,170 @@ function startTimer(){
|
||||
genStartTime=Date.now();
|
||||
const btn=document.getElementById('gen-btn');
|
||||
btn.disabled=true;
|
||||
const verb = genVerb();
|
||||
genTimer=setInterval(()=>{
|
||||
const s=((Date.now()-genStartTime)/1000).toFixed(0);
|
||||
btn.textContent='생성 중... ('+s+'초)';
|
||||
btn.textContent=verb+' 중... ('+s+'초)';
|
||||
},500);
|
||||
}
|
||||
function stopTimer(){
|
||||
clearInterval(genTimer);
|
||||
document.getElementById('gen-btn').disabled=false;
|
||||
document.getElementById('gen-btn').textContent='생성';
|
||||
document.getElementById('gen-btn').textContent = genVerb();
|
||||
}
|
||||
|
||||
function runGenerate(){
|
||||
if(currentTab==='edit') runEdit();
|
||||
else if(currentTab==='sticker') runStickerSet();
|
||||
else generate();
|
||||
}
|
||||
|
||||
// tabSuffix '1' = 사진편집 탭 (uploads-grid / uploadedPhotoPath), '2' = 이모티콘 탭 (uploads-grid2 / uploadedStickerPath)
|
||||
async function loadUploadsGrid(tabSuffix){
|
||||
const gridId = tabSuffix==='1' ? 'uploads-grid' : 'uploads-grid2';
|
||||
const selected = tabSuffix==='1' ? uploadsSelected1 : uploadsSelected2;
|
||||
const prefix = 'up'+tabSuffix;
|
||||
try{
|
||||
const r=await fetch('/api/imagegen/uploads',{headers:authH()});
|
||||
const d=await r.json();
|
||||
const grid=document.getElementById(gridId);
|
||||
grid.innerHTML='';
|
||||
selected.clear();
|
||||
const sa=document.getElementById(prefix+'-selall'); if(sa) sa.checked=false;
|
||||
updateGridToolbar(prefix, selected);
|
||||
for(const item of (d.items||[])){
|
||||
const el=document.createElement('div');
|
||||
el.className='st-gitem';
|
||||
el.innerHTML='<input type="checkbox" class="st-gchk">'
|
||||
+'<img src="'+item.url+'">'
|
||||
+'<a class="st-gdl" href="'+item.url+'" download="'+item.name+'" title="다운로드">⬇</a>'
|
||||
+'<button class="st-gdel" title="삭제">✕</button>';
|
||||
wireGridItem(el, item.name, selected, prefix);
|
||||
el.querySelector('.st-gdl').addEventListener('click', ev=>ev.stopPropagation());
|
||||
el.querySelector('img').addEventListener('click', ()=>{
|
||||
if(tabSuffix==='1'){
|
||||
uploadedPhotoPath=item.path;
|
||||
document.getElementById('photo-dropzone').innerHTML='<img src="'+item.url+'">';
|
||||
} else {
|
||||
uploadedStickerPath=item.path;
|
||||
document.getElementById('sticker-dropzone').innerHTML='<img src="'+item.url+'">';
|
||||
}
|
||||
});
|
||||
el.querySelector('.st-gdel').addEventListener('click', async (ev)=>{
|
||||
ev.stopPropagation();
|
||||
if(!confirm(item.name+' 삭제할까요?'))return;
|
||||
await fetch('/api/imagegen/uploads/'+encodeURIComponent(item.name),{method:'DELETE',headers:authH()});
|
||||
loadUploadsGrid(tabSuffix);
|
||||
});
|
||||
grid.appendChild(el);
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
let uploadedPhotoPath=null;
|
||||
async function onPhotoSelected(file){
|
||||
if(!file) return;
|
||||
const zone=document.getElementById('photo-dropzone');
|
||||
zone.textContent='업로드 중...';
|
||||
uploadedPhotoPath=null;
|
||||
try{
|
||||
const form=new FormData();
|
||||
form.append('image', file);
|
||||
const r=await fetch('/api/upload/image',{method:'POST',headers:authH(),body:form});
|
||||
const d=await r.json();
|
||||
if(!r.ok || !d.success){
|
||||
zone.textContent='업로드 실패: '+(d.error||'알 수 없는 오류');
|
||||
return;
|
||||
}
|
||||
uploadedPhotoPath=d.path;
|
||||
zone.innerHTML='<img src="'+d.url+'">';
|
||||
loadUploadsGrid('1');
|
||||
}catch(e){
|
||||
zone.textContent='업로드 실패: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStrengthField(styleSelectId, fieldId){
|
||||
const isCartoon = document.getElementById(styleSelectId).value==='cartoon';
|
||||
document.getElementById(fieldId).style.display = isCartoon ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
async function runEdit(){
|
||||
if(!uploadedPhotoPath){alert('먼저 사진을 업로드해주세요');return;}
|
||||
const style=document.getElementById('f-style').value;
|
||||
const body={path:uploadedPhotoPath, style};
|
||||
if(style==='cartoon') body.strength=parseFloat(document.getElementById('f-strength').value);
|
||||
const resultArea=document.getElementById('result-area');
|
||||
resultArea.innerHTML='<div style="text-align:center"><div class="st-spinner"></div><div class="st-status">스타일 변환 중...</div></div>';
|
||||
startTimer();
|
||||
try{
|
||||
const r=await fetch('/api/imagegen/edit-image',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
|
||||
const d=await r.json();
|
||||
stopTimer();
|
||||
if(!r.ok || !d.success){
|
||||
resultArea.innerHTML='<div class="st-error">변환 실패: '+(d.error||'알 수 없는 오류')+'</div>';
|
||||
return;
|
||||
}
|
||||
resultArea.innerHTML=resultMediaHtml(d.url, 'image', 'edited_'+Date.now()+'.png');
|
||||
loadGallery();
|
||||
}catch(e){
|
||||
stopTimer();
|
||||
resultArea.innerHTML='<div class="st-error">요청 실패: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
||||
|
||||
let uploadedStickerPath=null;
|
||||
async function onStickerPhotoSelected(file){
|
||||
if(!file) return;
|
||||
const zone=document.getElementById('sticker-dropzone');
|
||||
zone.textContent='업로드 중...';
|
||||
uploadedStickerPath=null;
|
||||
try{
|
||||
const form=new FormData();
|
||||
form.append('image', file);
|
||||
const r=await fetch('/api/upload/image',{method:'POST',headers:authH(),body:form});
|
||||
const d=await r.json();
|
||||
if(!r.ok || !d.success){
|
||||
zone.textContent='업로드 실패: '+(d.error||'알 수 없는 오류');
|
||||
return;
|
||||
}
|
||||
uploadedStickerPath=d.path;
|
||||
zone.innerHTML='<img src="'+d.url+'">';
|
||||
loadUploadsGrid('2');
|
||||
}catch(e){
|
||||
zone.textContent='업로드 실패: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function runStickerSet(){
|
||||
if(!uploadedStickerPath){alert('먼저 사진을 업로드해주세요');return;}
|
||||
const style=document.getElementById('f-style2').value;
|
||||
const phrases=document.getElementById('f-phrases').value.split('\n').map(s=>s.trim()).filter(Boolean).slice(0,12);
|
||||
if(!phrases.length){alert('문구를 한 줄 이상 입력해주세요');return;}
|
||||
const body={path:uploadedStickerPath, style, phrases};
|
||||
if(style==='cartoon') body.strength=parseFloat(document.getElementById('f-strength2').value);
|
||||
const resultArea=document.getElementById('result-area');
|
||||
resultArea.innerHTML='<div style="text-align:center"><div class="st-spinner"></div><div class="st-status">이모티콘 세트 만드는 중... (스타일 변환 → 배경 제거 → 문구 '+phrases.length+'개)</div></div>';
|
||||
startTimer();
|
||||
try{
|
||||
const r=await fetch('/api/imagegen/make-sticker-set',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
|
||||
const d=await r.json();
|
||||
stopTimer();
|
||||
if(!r.ok || !d.success){
|
||||
resultArea.innerHTML='<div class="st-error">생성 실패: '+(d.error||'알 수 없는 오류')+'</div>';
|
||||
return;
|
||||
}
|
||||
resultArea.innerHTML='<div class="st-sticker-grid">'+d.stickers.map((s,i)=>
|
||||
'<div class="st-sticker-tile"><img src="'+s.url+'">'
|
||||
+'<a class="st-sticker-dl" href="'+s.url+'" download="sticker_'+(i+1)+'.png" title="다운로드" onclick="event.stopPropagation()">⬇</a>'
|
||||
+'<div class="st-sticker-cap">'+escapeHtml(s.phrase)+'</div></div>'
|
||||
).join('')+'</div>';
|
||||
}catch(e){
|
||||
stopTimer();
|
||||
resultArea.innerHTML='<div class="st-error">요청 실패: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function generate(){
|
||||
@@ -234,9 +894,9 @@ async function generate(){
|
||||
return;
|
||||
}
|
||||
if(currentTab==='image'){
|
||||
resultArea.innerHTML='<img src="'+d.url+'" alt="generated">';
|
||||
resultArea.innerHTML=resultMediaHtml(d.url, 'image', 'generated_'+Date.now()+'.png');
|
||||
} else {
|
||||
resultArea.innerHTML='<video src="'+d.url+'" controls autoplay loop></video>';
|
||||
resultArea.innerHTML=resultMediaHtml(d.url, 'video', 'generated_'+Date.now()+'.mp4');
|
||||
}
|
||||
loadGallery();
|
||||
}catch(e){
|
||||
@@ -251,21 +911,27 @@ async function loadGallery(){
|
||||
const d=await r.json();
|
||||
const grid=document.getElementById('gallery-grid');
|
||||
grid.innerHTML='';
|
||||
gallerySelected.clear();
|
||||
const sa=document.getElementById('gal-selall'); if(sa) sa.checked=false;
|
||||
updateGridToolbar('gal', gallerySelected);
|
||||
for(const item of (d.items||[])){
|
||||
const el=document.createElement('div');
|
||||
el.className='st-gitem';
|
||||
const media = item.type==='image'
|
||||
? '<img src="'+item.url+'">'
|
||||
: '<video src="'+item.url+'" muted></video>';
|
||||
el.innerHTML=media+'<span class="st-gtype">'+(item.type==='image'?'🖼️':'🎬')+'</span><button class="st-gdel" title="삭제">✕</button>';
|
||||
el.innerHTML='<input type="checkbox" class="st-gchk">'+media
|
||||
+'<span class="st-gtype">'+(item.type==='image'?'🖼️':'🎬')+'</span>'
|
||||
+'<a class="st-gdl" href="'+item.url+'" download="'+item.name+'" title="다운로드">⬇</a>'
|
||||
+'<button class="st-gdel" title="삭제">✕</button>';
|
||||
wireGridItem(el, item.name, gallerySelected, 'gal');
|
||||
el.querySelector('.st-gdl').addEventListener('click', ev=>ev.stopPropagation());
|
||||
el.querySelector('.st-gitem > img, .st-gitem > video')?.addEventListener('click',()=>{
|
||||
document.getElementById('result-area').innerHTML = item.type==='image'
|
||||
? '<img src="'+item.url+'">' : '<video src="'+item.url+'" controls autoplay loop></video>';
|
||||
document.getElementById('result-area').innerHTML = resultMediaHtml(item.url, item.type, item.name);
|
||||
});
|
||||
el.addEventListener('click',(ev)=>{ if(['BUTTON','INPUT','A'].includes(ev.target.tagName))return;
|
||||
document.getElementById('result-area').innerHTML = resultMediaHtml(item.url, item.type, item.name);
|
||||
});
|
||||
el.addEventListener('click',(ev)=>{ if(ev.target.tagName!=='BUTTON'){
|
||||
document.getElementById('result-area').innerHTML = item.type==='image'
|
||||
? '<img src="'+item.url+'">' : '<video src="'+item.url+'" controls autoplay loop></video>';
|
||||
}});
|
||||
el.querySelector('.st-gdel').addEventListener('click', async (ev)=>{
|
||||
ev.stopPropagation();
|
||||
if(!confirm(item.name+' 삭제할까요?'))return;
|
||||
@@ -277,9 +943,47 @@ async function loadGallery(){
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function openZoom(el){
|
||||
const modal=document.getElementById('zoom-modal');
|
||||
const img=document.getElementById('zoom-img');
|
||||
const vid=document.getElementById('zoom-video');
|
||||
if(el.tagName==='VIDEO'){
|
||||
vid.src=el.currentSrc||el.src; vid.style.display='block'; img.style.display='none';
|
||||
vid.play().catch(()=>{});
|
||||
} else {
|
||||
img.src=el.currentSrc||el.src; img.style.display='block'; vid.style.display='none'; vid.pause();
|
||||
}
|
||||
modal.classList.add('show');
|
||||
}
|
||||
function closeZoom(){
|
||||
document.getElementById('zoom-modal').classList.remove('show');
|
||||
document.getElementById('zoom-video').pause();
|
||||
}
|
||||
|
||||
let lastResultTap=0;
|
||||
function handleResultDblTap(ev){
|
||||
const t=ev.target;
|
||||
if(t.tagName!=='IMG'&&t.tagName!=='VIDEO')return;
|
||||
const now=Date.now();
|
||||
if(now-lastResultTap<350){
|
||||
ev.preventDefault();
|
||||
openZoom(t);
|
||||
lastResultTap=0;
|
||||
} else {
|
||||
lastResultTap=now;
|
||||
}
|
||||
}
|
||||
document.getElementById('result-area').addEventListener('touchend', handleResultDblTap);
|
||||
document.getElementById('result-area').addEventListener('dblclick', (ev)=>{
|
||||
const t=ev.target;
|
||||
if(t.tagName==='IMG'||t.tagName==='VIDEO') openZoom(t);
|
||||
});
|
||||
|
||||
(async function init(){
|
||||
const ok=await checkAuth();if(!ok)return;
|
||||
loadGallery();
|
||||
loadUploadsGrid('1');
|
||||
loadUploadsGrid('2');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user