auto-git:
[add] README.md [add] default.png [add] equirect_hdr_icon_512-Wiederhergestellt.png [add] generate_equirect.py [add] icon.png [add] index.html [add] package-lock.json [add] package.json [add] public/ [add] requirements.txt [add] run.sh [add] src-tauri/ [add] src/ [add] vite.config.js
This commit is contained in:
BIN
default.png
Normal file
BIN
default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
BIN
equirect_hdr_icon_512-Wiederhergestellt.png
Normal file
BIN
equirect_hdr_icon_512-Wiederhergestellt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
609
generate_equirect.py
Normal file
609
generate_equirect.py
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate an equirectangular HDRI image using Diffusers.
|
||||||
|
Optional upscaling:
|
||||||
|
--upscale topaz → legacy Topaz Photo AI CLI (if installed)
|
||||||
|
--upscale realesrgan → open Real-ESRGAN in-Python upscaler (pip install realesrgan==0.3.0 basicsr opencv-python)
|
||||||
|
Default flow: prompt in → equirectangular PNG out. Add --seam-inpaint to patch the horizontal wrap seam.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import gc
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import torch
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from diffusers import (
|
||||||
|
StableDiffusionPipeline,
|
||||||
|
StableDiffusionXLPipeline,
|
||||||
|
DPMSolverMultistepScheduler,
|
||||||
|
EulerDiscreteScheduler,
|
||||||
|
EulerAncestralDiscreteScheduler,
|
||||||
|
HeunDiscreteScheduler,
|
||||||
|
DDIMScheduler,
|
||||||
|
StableDiffusionInpaintPipeline,
|
||||||
|
AutoencoderKL,
|
||||||
|
UNet2DConditionModel,
|
||||||
|
)
|
||||||
|
from huggingface_hub import hf_hub_download
|
||||||
|
|
||||||
|
# Default panorama model. You can override via --model-path.
|
||||||
|
# Uses the SDXL 360 diffusion checkpoint from ProGamerGov (single-file safetensors).
|
||||||
|
MODEL_PATH = "ProGamerGov/sdxl-360-diffusion"
|
||||||
|
BASE_SDXL_MODEL = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||||
|
INPAINT_MODEL = "Lykon/dreamshaper-8-inpainting"
|
||||||
|
TOPAZ_CLI = "/Applications/Topaz Photo AI.app/Contents/MacOS/Topaz Photo AI"
|
||||||
|
REALESRGAN_MODEL = "RealESRGAN_x4plus.pth"
|
||||||
|
REALESRGAN_SCALE = 4 # x4 model for full upscale
|
||||||
|
# Recommended VAE for SDXL checkpoints
|
||||||
|
SDXL_VAE = "stabilityai/sdxl-vae"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_name(prompt: str) -> str:
|
||||||
|
base = prompt.strip().lower()
|
||||||
|
base = re.sub(r"\s+", "_", base)
|
||||||
|
base = re.sub(r"[^a-z0-9_]+", "", base)
|
||||||
|
return base or "env"
|
||||||
|
|
||||||
|
|
||||||
|
def next_filename(output_dir: str, base: str, width: int, height: int) -> str:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
i = 1
|
||||||
|
while True:
|
||||||
|
fname = f"{base}-{i}-{width}x{height}.png"
|
||||||
|
candidate = os.path.join(output_dir, fname)
|
||||||
|
if not os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
def shift_image(img: Image.Image, shift: int) -> Image.Image:
|
||||||
|
w, h = img.size
|
||||||
|
out = Image.new("RGB", (w, h))
|
||||||
|
out.paste(img.crop((shift, 0, w, h)), (0, 0))
|
||||||
|
out.paste(img.crop((0, 0, shift, h)), (w - shift, 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def create_mask(width: int, height: int, mask_w: int) -> Image.Image:
|
||||||
|
mask = Image.new("L", (width, height), 0)
|
||||||
|
draw = ImageDraw.Draw(mask)
|
||||||
|
left = (width - mask_w) // 2
|
||||||
|
draw.rectangle([left, 0, left + mask_w, height], fill=255)
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def unshift_image(img: Image.Image, shift: int) -> Image.Image:
|
||||||
|
w, h = img.size
|
||||||
|
out = Image.new("RGB", (w, h))
|
||||||
|
out.paste(img.crop((w - shift, 0, w, h)), (0, 0))
|
||||||
|
out.paste(img.crop((0, 0, w - shift, h)), (shift, 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def select_device() -> str:
|
||||||
|
if torch.backends.mps.is_available():
|
||||||
|
return "mps"
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def clear_torch_cache(device: str | None = None) -> None:
|
||||||
|
gc.collect()
|
||||||
|
if device == "cuda" and torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
elif device == "mps" and torch.backends.mps.is_available() and hasattr(torch, "mps"):
|
||||||
|
try:
|
||||||
|
torch.mps.synchronize()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
empty_cache = getattr(torch.mps, "empty_cache", None)
|
||||||
|
if empty_cache is not None:
|
||||||
|
empty_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_latents_to_image(vae: AutoencoderKL, latents: torch.Tensor, device: str) -> Image.Image:
|
||||||
|
print("→ Decoding latent image with standalone VAE…")
|
||||||
|
if hasattr(vae, "enable_tiling"):
|
||||||
|
vae.enable_tiling()
|
||||||
|
if hasattr(vae, "enable_slicing"):
|
||||||
|
vae.enable_slicing()
|
||||||
|
|
||||||
|
vae.to(device)
|
||||||
|
vae.eval()
|
||||||
|
vae_dtype = next(vae.parameters()).dtype
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
latents = latents.to(device=device, dtype=vae_dtype)
|
||||||
|
has_latents_mean = hasattr(vae.config, "latents_mean") and vae.config.latents_mean is not None
|
||||||
|
has_latents_std = hasattr(vae.config, "latents_std") and vae.config.latents_std is not None
|
||||||
|
if has_latents_mean and has_latents_std:
|
||||||
|
latent_channels = len(vae.config.latents_mean)
|
||||||
|
latents_mean = (
|
||||||
|
torch.tensor(vae.config.latents_mean)
|
||||||
|
.view(1, latent_channels, 1, 1)
|
||||||
|
.to(latents.device, latents.dtype)
|
||||||
|
)
|
||||||
|
latents_std = (
|
||||||
|
torch.tensor(vae.config.latents_std)
|
||||||
|
.view(1, latent_channels, 1, 1)
|
||||||
|
.to(latents.device, latents.dtype)
|
||||||
|
)
|
||||||
|
latents = latents * latents_std / vae.config.scaling_factor + latents_mean
|
||||||
|
else:
|
||||||
|
latents = latents / vae.config.scaling_factor
|
||||||
|
|
||||||
|
decoded = vae.decode(latents, return_dict=False)[0]
|
||||||
|
decoded = (decoded / 2 + 0.5).clamp(0, 1)
|
||||||
|
image = decoded[0].detach().cpu().permute(1, 2, 0).float().numpy()
|
||||||
|
|
||||||
|
del decoded, latents
|
||||||
|
clear_torch_cache(device)
|
||||||
|
return Image.fromarray((image * 255).round().astype("uint8"))
|
||||||
|
|
||||||
|
|
||||||
|
def run_topaz(input_path: str, tempdir: str) -> str:
|
||||||
|
print("→ Upscaling with Topaz Photo AI CLI…")
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
result = os.system(f'"{TOPAZ_CLI}" --cli "{input_path}" -o "{tempdir}"')
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise RuntimeError(f"Topaz invocation failed: {e}") from e
|
||||||
|
if result != 0:
|
||||||
|
raise RuntimeError("Topaz CLI returned non-zero exit code")
|
||||||
|
upscaled_files = sorted(
|
||||||
|
[os.path.join(tempdir, f) for f in os.listdir(tempdir) if f.lower().endswith('.png')],
|
||||||
|
key=os.path.getmtime,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
if not upscaled_files:
|
||||||
|
raise RuntimeError("Topaz produced no PNG output")
|
||||||
|
print(f"→ Upscaled file: {upscaled_files[0]}")
|
||||||
|
return upscaled_files[0]
|
||||||
|
|
||||||
|
|
||||||
|
def run_realesrgan(
|
||||||
|
input_image: Image.Image,
|
||||||
|
tempdir: str,
|
||||||
|
scale: int = 4,
|
||||||
|
model_path: str = REALESRGAN_MODEL,
|
||||||
|
progress_cb=None
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
# Compatibility shim for newer torchvision where functional_tensor moved/renamed
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
import torchvision.transforms._functional_tensor as _ft # type: ignore
|
||||||
|
sys.modules.setdefault("torchvision.transforms.functional_tensor", _ft)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
from realesrgan import RealESRGANer
|
||||||
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
|
import cv2
|
||||||
|
import types
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise RuntimeError(
|
||||||
|
"Real-ESRGAN dependencies missing. Install with: pip install realesrgan==0.3.0 basicsr opencv-python torchvision"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
device = select_device()
|
||||||
|
is_sdxl = "sdxl" in model_path.lower()
|
||||||
|
if not model_path or not os.path.exists(model_path):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Real-ESRGAN model not found at {model_path!r}. "
|
||||||
|
"Place RealESRGAN_x4plus.pth next to this script or update REALESRGAN_MODEL."
|
||||||
|
)
|
||||||
|
img_bgr = cv2.cvtColor(np.array(input_image), cv2.COLOR_RGB2BGR)
|
||||||
|
print(f"→ Upscaling with Real-ESRGAN (x{scale}) on {device}…")
|
||||||
|
model = RRDBNet(
|
||||||
|
num_in_ch=3,
|
||||||
|
num_out_ch=3,
|
||||||
|
num_feat=64,
|
||||||
|
num_block=23,
|
||||||
|
num_grow_ch=32,
|
||||||
|
scale=scale,
|
||||||
|
)
|
||||||
|
upsampler = RealESRGANer(
|
||||||
|
model_path=model_path,
|
||||||
|
scale=scale,
|
||||||
|
model=model,
|
||||||
|
tile=64, # aggressive tiling to keep memory & runtime manageable
|
||||||
|
tile_pad=10,
|
||||||
|
pre_pad=0,
|
||||||
|
half=False, # keep full precision for CPU/MPS
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wrap tile processing to surface progress per tile.
|
||||||
|
def tile_process_with_progress(self):
|
||||||
|
batch, channel, height, width = self.img.shape
|
||||||
|
output_height = height * self.scale
|
||||||
|
output_width = width * self.scale
|
||||||
|
output_shape = (batch, channel, output_height, output_width)
|
||||||
|
|
||||||
|
self.output = self.img.new_zeros(output_shape)
|
||||||
|
tiles_x = math.ceil(width / self.tile_size)
|
||||||
|
tiles_y = math.ceil(height / self.tile_size)
|
||||||
|
total_tiles = max(1, tiles_x * tiles_y)
|
||||||
|
|
||||||
|
for y in range(tiles_y):
|
||||||
|
for x in range(tiles_x):
|
||||||
|
ofs_x = x * self.tile_size
|
||||||
|
ofs_y = y * self.tile_size
|
||||||
|
input_start_x = ofs_x
|
||||||
|
input_end_x = min(ofs_x + self.tile_size, width)
|
||||||
|
input_start_y = ofs_y
|
||||||
|
input_end_y = min(ofs_y + self.tile_size, height)
|
||||||
|
|
||||||
|
input_start_x_pad = max(input_start_x - self.tile_pad, 0)
|
||||||
|
input_end_x_pad = min(input_end_x + self.tile_pad, width)
|
||||||
|
input_start_y_pad = max(input_start_y - self.tile_pad, 0)
|
||||||
|
input_end_y_pad = min(input_end_y + self.tile_pad, height)
|
||||||
|
|
||||||
|
input_tile_width = input_end_x - input_start_x
|
||||||
|
input_tile_height = input_end_y - input_start_y
|
||||||
|
tile_idx = y * tiles_x + x + 1
|
||||||
|
input_tile = self.img[:, :, input_start_y_pad:input_end_y_pad, input_start_x_pad:input_end_x_pad]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with torch.no_grad():
|
||||||
|
output_tile = self.model(input_tile)
|
||||||
|
except RuntimeError as error:
|
||||||
|
print('Error', error)
|
||||||
|
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("upscale", tile_idx, total_tiles)
|
||||||
|
|
||||||
|
output_start_x = input_start_x * self.scale
|
||||||
|
output_end_x = input_end_x * self.scale
|
||||||
|
output_start_y = input_start_y * self.scale
|
||||||
|
output_end_y = input_end_y * self.scale
|
||||||
|
|
||||||
|
output_start_x_tile = (input_start_x - input_start_x_pad) * self.scale
|
||||||
|
output_end_x_tile = output_start_x_tile + input_tile_width * self.scale
|
||||||
|
output_start_y_tile = (input_start_y - input_start_y_pad) * self.scale
|
||||||
|
output_end_y_tile = output_start_y_tile + input_tile_height * self.scale
|
||||||
|
|
||||||
|
self.output[:, :, output_start_y:output_end_y,
|
||||||
|
output_start_x:output_end_x] = output_tile[:, :, output_start_y_tile:output_end_y_tile,
|
||||||
|
output_start_x_tile:output_end_x_tile]
|
||||||
|
|
||||||
|
if progress_cb:
|
||||||
|
upsampler.tile_process = types.MethodType(tile_process_with_progress, upsampler)
|
||||||
|
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("upscale", 0, 1)
|
||||||
|
sr_img, _ = upsampler.enhance(img_bgr, outscale=scale)
|
||||||
|
sr_img = Image.fromarray(cv2.cvtColor(sr_img, cv2.COLOR_BGR2RGB))
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("upscale", 1, 1)
|
||||||
|
out_path = os.path.join(tempdir, f"realesrgan_x{scale}.png")
|
||||||
|
sr_img.save(out_path)
|
||||||
|
print(f"→ Real-ESRGAN output: {out_path}")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
prompt: str,
|
||||||
|
output_path: str,
|
||||||
|
work_dir: str,
|
||||||
|
upscale: str = "none",
|
||||||
|
model_path: str = MODEL_PATH,
|
||||||
|
base_model: str = BASE_SDXL_MODEL,
|
||||||
|
vae_model: str = SDXL_VAE,
|
||||||
|
steps: int = 25,
|
||||||
|
guidance: float = 4.5,
|
||||||
|
scheduler: str | None = None,
|
||||||
|
width: int = 1024,
|
||||||
|
height: int = 512,
|
||||||
|
seam_inpaint: bool = False,
|
||||||
|
) -> str:
|
||||||
|
# Normalize common aliases that 404 on HF
|
||||||
|
aliases = {
|
||||||
|
"proximasan/sdxl-360-diffusion": "ProGamerGov/sdxl-360-diffusion",
|
||||||
|
"proximasan": "ProGamerGov/sdxl-360-diffusion",
|
||||||
|
}
|
||||||
|
model_path = aliases.get(model_path, model_path)
|
||||||
|
|
||||||
|
device = select_device()
|
||||||
|
is_sdxl = "sdxl" in model_path.lower()
|
||||||
|
scale = guidance # keep inpaint guidance in sync with cfg guidance
|
||||||
|
enable_upscale = bool(upscale and upscale != "none")
|
||||||
|
|
||||||
|
os.makedirs(work_dir, exist_ok=True)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(dir=work_dir) as tempdir:
|
||||||
|
print(f"→ Using tempdir: {tempdir}")
|
||||||
|
|
||||||
|
gen_pipe = None
|
||||||
|
load_errors: list[str] = []
|
||||||
|
vae = None
|
||||||
|
if is_sdxl:
|
||||||
|
try:
|
||||||
|
vae = AutoencoderKL.from_pretrained(vae_model, subfolder="vae", torch_dtype=torch.float32)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
load_errors.append(f"vae: {e}")
|
||||||
|
|
||||||
|
# Try native Diffusers repo
|
||||||
|
try:
|
||||||
|
if is_sdxl:
|
||||||
|
pipe_kwargs = {"torch_dtype": torch.float32}
|
||||||
|
if vae is not None:
|
||||||
|
pipe_kwargs["vae"] = vae
|
||||||
|
|
||||||
|
# Some SDXL repos only ship a UNet (e.g., sdxl-360); in that case load SDXL base
|
||||||
|
# and swap the UNet to keep the rest of the components consistent.
|
||||||
|
unet_only = False
|
||||||
|
if model_path and model_path.endswith("sdxl-360-diffusion"):
|
||||||
|
try:
|
||||||
|
hf_hub_download(model_path, "unet/config.json")
|
||||||
|
unet_only = True
|
||||||
|
except Exception:
|
||||||
|
unet_only = False
|
||||||
|
|
||||||
|
if unet_only:
|
||||||
|
base_pipe = StableDiffusionXLPipeline.from_pretrained(
|
||||||
|
base_model,
|
||||||
|
**pipe_kwargs
|
||||||
|
).to(device)
|
||||||
|
unet = UNet2DConditionModel.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
subfolder="unet",
|
||||||
|
torch_dtype=torch.float32
|
||||||
|
).to(device)
|
||||||
|
base_pipe.unet = unet
|
||||||
|
gen_pipe = base_pipe
|
||||||
|
else:
|
||||||
|
gen_pipe = StableDiffusionXLPipeline.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
**pipe_kwargs
|
||||||
|
).to(device)
|
||||||
|
else:
|
||||||
|
gen_pipe = StableDiffusionPipeline.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
torch_dtype=torch.float32
|
||||||
|
).to(device)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
load_errors.append(f"from_pretrained: {e}")
|
||||||
|
|
||||||
|
# Fallback: single-file SDXL checkpoint from the repo
|
||||||
|
if gen_pipe is None:
|
||||||
|
ckpt_candidates = [
|
||||||
|
"sdxl_360_diffusion.safetensors",
|
||||||
|
"sdxl_360_diffusion_unet.safetensors",
|
||||||
|
"model.safetensors",
|
||||||
|
]
|
||||||
|
last_err = None
|
||||||
|
for fname in ckpt_candidates:
|
||||||
|
try:
|
||||||
|
ckpt = hf_hub_download(model_path, fname)
|
||||||
|
pipe_kwargs = {"torch_dtype": torch.float32}
|
||||||
|
if vae is not None:
|
||||||
|
pipe_kwargs["vae"] = vae
|
||||||
|
gen_pipe = StableDiffusionXLPipeline.from_single_file(
|
||||||
|
ckpt,
|
||||||
|
**pipe_kwargs
|
||||||
|
).to(device)
|
||||||
|
break
|
||||||
|
except Exception as e2: # noqa: BLE001
|
||||||
|
last_err = e2
|
||||||
|
load_errors.append(f"{fname}: {e2}")
|
||||||
|
if gen_pipe is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to load model '{model_path}'. "
|
||||||
|
"Ensure the repo/path exists (e.g., ProGamerGov/sdxl-360-diffusion) and "
|
||||||
|
"install accelerate for low_cpu_mem_usage: pip install accelerate. "
|
||||||
|
f"Errors: {load_errors}"
|
||||||
|
) from (last_err or Exception("No pipeline loaded"))
|
||||||
|
if gen_pipe.vae is None and is_sdxl:
|
||||||
|
try:
|
||||||
|
vae = vae or AutoencoderKL.from_pretrained(
|
||||||
|
SDXL_VAE,
|
||||||
|
subfolder="vae",
|
||||||
|
torch_dtype=torch.float32
|
||||||
|
)
|
||||||
|
gen_pipe.vae = vae.to(device)
|
||||||
|
gen_pipe.to(device)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
load_errors.append(f"vae-fallback: {e}")
|
||||||
|
raise RuntimeError(
|
||||||
|
"Loaded SDXL pipeline without a VAE; failed to attach the SDXL VAE. "
|
||||||
|
f"Errors: {load_errors}"
|
||||||
|
) from e
|
||||||
|
# Optionally override scheduler; otherwise keep the pipeline default (Euler for SDXL base).
|
||||||
|
if scheduler:
|
||||||
|
sched_kind = scheduler.lower()
|
||||||
|
sched_cfg = gen_pipe.scheduler.config
|
||||||
|
if sched_kind in {"dpmsolver", "dpmsolver++"}:
|
||||||
|
gen_pipe.scheduler = DPMSolverMultistepScheduler.from_config(sched_cfg)
|
||||||
|
elif sched_kind in {"dpmsolver-sde", "dpmsolver_sde"}:
|
||||||
|
gen_pipe.scheduler = DPMSolverMultistepScheduler.from_config(
|
||||||
|
sched_cfg,
|
||||||
|
algorithm_type="sde-dpmsolver++"
|
||||||
|
)
|
||||||
|
elif sched_kind in {"euler"}:
|
||||||
|
gen_pipe.scheduler = EulerDiscreteScheduler.from_config(sched_cfg)
|
||||||
|
elif sched_kind in {"euler_a", "euler-ancestral", "euler-ancestral-discrete"}:
|
||||||
|
gen_pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(sched_cfg)
|
||||||
|
elif sched_kind in {"heun"}:
|
||||||
|
gen_pipe.scheduler = HeunDiscreteScheduler.from_config(sched_cfg)
|
||||||
|
elif sched_kind in {"ddim"}:
|
||||||
|
gen_pipe.scheduler = DDIMScheduler.from_config(sched_cfg)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported scheduler '{scheduler}'. "
|
||||||
|
"Try one of: euler, euler_a, heun, ddim, dpmsolver, dpmsolver-sde."
|
||||||
|
)
|
||||||
|
gen_pipe.enable_attention_slicing()
|
||||||
|
if is_sdxl and vae is not None and hasattr(gen_pipe, "enable_vae_tiling"):
|
||||||
|
gen_pipe.enable_vae_tiling()
|
||||||
|
|
||||||
|
def progress_cb(phase: str, current: int, total: int):
|
||||||
|
payload = {
|
||||||
|
"phase": phase,
|
||||||
|
"current": current,
|
||||||
|
"total": total,
|
||||||
|
"upscale": enable_upscale,
|
||||||
|
"seamInpaint": seam_inpaint,
|
||||||
|
}
|
||||||
|
print(f"PROGRESS {json.dumps(payload)}", flush=True)
|
||||||
|
|
||||||
|
print("→ Generating equirectangular HDRI…")
|
||||||
|
progress_cb("gen", 0, steps)
|
||||||
|
image = gen_pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=guidance,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
callback_steps=1,
|
||||||
|
callback=lambda step, timestep, kwargs: progress_cb("gen", step + 1, steps),
|
||||||
|
).images[0]
|
||||||
|
|
||||||
|
gen_path = os.path.join(tempdir, f"base_{width}x{height}.png")
|
||||||
|
image.save(gen_path)
|
||||||
|
print(f"→ Saved initial image to {gen_path}")
|
||||||
|
|
||||||
|
seamless_path = os.path.join(tempdir, os.path.basename(output_path))
|
||||||
|
if seam_inpaint:
|
||||||
|
shift_amt = width // 2
|
||||||
|
mask_w = width // 8
|
||||||
|
|
||||||
|
shifted = shift_image(image, shift_amt)
|
||||||
|
mask = create_mask(width, height, mask_w)
|
||||||
|
|
||||||
|
inpaint_pipe = StableDiffusionInpaintPipeline.from_pretrained(
|
||||||
|
INPAINT_MODEL,
|
||||||
|
torch_dtype=torch.float32
|
||||||
|
).to(device)
|
||||||
|
inpaint_pipe.enable_attention_slicing()
|
||||||
|
|
||||||
|
print("→ Inpainting seam for seamless tiling…")
|
||||||
|
progress_cb("inpaint", 0, steps)
|
||||||
|
inpainted = inpaint_pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
image=shifted,
|
||||||
|
mask_image=mask,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=scale,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
callback_steps=1,
|
||||||
|
callback=lambda step, timestep, kwargs: progress_cb("inpaint", step + 1, steps),
|
||||||
|
).images[0]
|
||||||
|
|
||||||
|
inpainted = unshift_image(inpainted, shift_amt)
|
||||||
|
inpainted.save(seamless_path)
|
||||||
|
print(f"→ Crafted seamless image: {seamless_path}")
|
||||||
|
final_source = inpainted
|
||||||
|
else:
|
||||||
|
image.save(seamless_path)
|
||||||
|
print(f"→ Using raw output (seam inpaint disabled): {seamless_path}")
|
||||||
|
final_source = image
|
||||||
|
|
||||||
|
final_path = seamless_path
|
||||||
|
|
||||||
|
if upscale and upscale != "none":
|
||||||
|
try:
|
||||||
|
if upscale is True or upscale == "topaz":
|
||||||
|
final_path = run_topaz(seamless_path, tempdir)
|
||||||
|
elif upscale == "realesrgan":
|
||||||
|
final_path = run_realesrgan(
|
||||||
|
final_source,
|
||||||
|
tempdir,
|
||||||
|
scale=REALESRGAN_SCALE,
|
||||||
|
model_path=REALESRGAN_MODEL,
|
||||||
|
progress_cb=progress_cb
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown upscale option '{upscale}'")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"Upscaling failed ({upscale}); keeping seamless image: {e}")
|
||||||
|
|
||||||
|
shutil.move(final_path, output_path)
|
||||||
|
try:
|
||||||
|
with Image.open(output_path) as _im:
|
||||||
|
print(f"→ Final image written to {output_path} [{_im.size[0]}x{_im.size[1]}]")
|
||||||
|
except Exception:
|
||||||
|
print(f"→ Final image written to {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate an equirectangular HDRI image"
|
||||||
|
)
|
||||||
|
parser.add_argument('--prompt', required=True, help='Text prompt for generation')
|
||||||
|
parser.add_argument('--output', help='Output filename (PNG)')
|
||||||
|
parser.add_argument('--output-dir', default='output', help='Directory for outputs (default: output)')
|
||||||
|
parser.add_argument('--work-dir', default=os.path.dirname(os.path.abspath(__file__)), help='Working directory for temp files')
|
||||||
|
parser.add_argument(
|
||||||
|
'--upscale',
|
||||||
|
choices=['none', 'topaz', 'realesrgan'],
|
||||||
|
default='realesrgan',
|
||||||
|
help='Optional upscaler: none, topaz (legacy), realesrgan (open source; default)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--model-path',
|
||||||
|
default=MODEL_PATH,
|
||||||
|
help='Diffusers model id or local path (default: ProGamerGov/sdxl-360-diffusion)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--base-model',
|
||||||
|
default=BASE_SDXL_MODEL,
|
||||||
|
help='SDXL base pipeline used when the model only provides a UNet (default: stabilityai/stable-diffusion-xl-base-1.0)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--vae-model',
|
||||||
|
default=SDXL_VAE,
|
||||||
|
help='VAE repo/path to load for SDXL models (default: stabilityai/sdxl-vae; try madebyollin/sdxl-vae-fp16-fix on Mac)'
|
||||||
|
)
|
||||||
|
parser.add_argument('--steps', type=int, default=25, help='Number of inference steps (default: 25)')
|
||||||
|
parser.add_argument('--guidance', type=float, default=4.5, help='CFG guidance scale (default: 4.5)')
|
||||||
|
parser.add_argument('--width', type=int, default=1024, help='Output width (default: 1024)')
|
||||||
|
parser.add_argument('--height', type=int, default=512, help='Output height (default: 512)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--seam-inpaint',
|
||||||
|
action='store_true',
|
||||||
|
help='Patch the horizontal wrap seam by shifting, inpainting the center seam, then shifting back'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--scheduler',
|
||||||
|
choices=['euler', 'euler_a', 'heun', 'ddim', 'dpmsolver', 'dpmsolver-sde'],
|
||||||
|
help='Sampler/scheduler override; default uses the pipeline scheduler (Euler for SDXL base)'
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base = sanitize_name(args.prompt)
|
||||||
|
target = args.output or next_filename(args.output_dir, base, args.width, args.height)
|
||||||
|
output_abs = os.path.abspath(target)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result_path = generate(
|
||||||
|
args.prompt,
|
||||||
|
output_abs,
|
||||||
|
args.work_dir,
|
||||||
|
upscale=args.upscale,
|
||||||
|
model_path=args.model_path,
|
||||||
|
base_model=args.base_model,
|
||||||
|
vae_model=args.vae_model,
|
||||||
|
steps=args.steps,
|
||||||
|
guidance=args.guidance,
|
||||||
|
scheduler=args.scheduler,
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
seam_inpaint=args.seam_inpaint,
|
||||||
|
)
|
||||||
|
print(result_path)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"Generation failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Skymap Generator</title>
|
||||||
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1183
package-lock.json
generated
Normal file
1183
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "skymap-gen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^1.5.0",
|
||||||
|
"three": "^0.165.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^1.5.9",
|
||||||
|
"vite": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/Mittel (default).jpeg
Normal file
BIN
public/Mittel (default).jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
public/default.png
Normal file
BIN
public/default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
16
requirements.txt
Normal file
16
requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
torch==2.9.1
|
||||||
|
torchvision==0.24.1
|
||||||
|
diffusers==0.27.2
|
||||||
|
transformers==4.41.2
|
||||||
|
huggingface_hub==0.23.4
|
||||||
|
realesrgan==0.3.0
|
||||||
|
basicsr==1.4.2
|
||||||
|
opencv-python==4.12.0.88
|
||||||
|
Pillow==12.0.0
|
||||||
|
numpy==2.2.6
|
||||||
|
gfpgan==1.3.8
|
||||||
|
facexlib==0.3.0
|
||||||
|
scipy==1.15.3
|
||||||
|
scikit-image==0.25.2
|
||||||
|
tqdm==4.67.1
|
||||||
|
pyyaml==6.0.3
|
||||||
35
run.sh
Executable file
35
run.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
# Choose a Python; prefer 3.10 to match the tested venv.
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-}"
|
||||||
|
if [[ -z "${PYTHON_BIN}" ]]; then
|
||||||
|
if command -v python3.10 >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python3.10"
|
||||||
|
else
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
VENV_DIR="$ROOT_DIR/.venv"
|
||||||
|
if [[ ! -d "$VENV_DIR" ]]; then
|
||||||
|
echo "Creating venv at $VENV_DIR using $PYTHON_BIN"
|
||||||
|
"$PYTHON_BIN" -m venv "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
|
echo "Installing Python requirements..."
|
||||||
|
pip install --upgrade pip >/dev/null
|
||||||
|
pip install -r "$ROOT_DIR/requirements.txt"
|
||||||
|
|
||||||
|
if [[ ! -d "$ROOT_DIR/node_modules" ]]; then
|
||||||
|
echo "Installing npm dependencies..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting Tauri dev (npm run tauri dev)..."
|
||||||
|
npm run tauri dev
|
||||||
3989
src-tauri/Cargo.lock
generated
Normal file
3989
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
src-tauri/Cargo.toml
Normal file
13
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "skymap-gen"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Skymap"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tauri = { version = "1.5", features = [ "fs-read-file", "fs-read-dir"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "1.5", features = [] }
|
||||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
386
src-tauri/src/main.rs
Normal file
386
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{BufRead, BufReader, Read};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::{async_runtime, Env, State, Window};
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct Paths {
|
||||||
|
project_root: PathBuf,
|
||||||
|
active_generation_pid: Arc<Mutex<Option<u32>>>,
|
||||||
|
cancel_generation_requested: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MapInfo {
|
||||||
|
path: String,
|
||||||
|
filename: String,
|
||||||
|
modified: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GenerateResult {
|
||||||
|
output_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GenSettings {
|
||||||
|
steps: Option<u32>,
|
||||||
|
width: Option<u32>,
|
||||||
|
height: Option<u32>,
|
||||||
|
guidance: Option<f32>,
|
||||||
|
scheduler: Option<String>,
|
||||||
|
upscale: Option<String>,
|
||||||
|
seam_inpaint: Option<bool>,
|
||||||
|
model_path: Option<String>,
|
||||||
|
base_model: Option<String>,
|
||||||
|
vae_model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_root() -> Result<PathBuf, String> {
|
||||||
|
let cwd = std::env::current_dir().map_err(|e| format!("Failed to resolve current dir: {e}"))?;
|
||||||
|
for ancestor in cwd.ancestors() {
|
||||||
|
if ancestor.join("generate_equirect.py").exists() {
|
||||||
|
return Ok(ancestor.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Could not find project root (generate_equirect.py not found in ancestors)".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_dir(root: &Path) -> Result<PathBuf, String> {
|
||||||
|
let mut dir = root.to_path_buf();
|
||||||
|
dir.push("output");
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create output dir: {e}"))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script_path(root: &Path) -> Result<PathBuf, String> {
|
||||||
|
let candidate = root.join("generate_equirect.py");
|
||||||
|
if candidate.exists() {
|
||||||
|
return Ok(candidate);
|
||||||
|
}
|
||||||
|
Err("generate_equirect.py not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the Python binary to run: prefer project-local venv to ensure deps are present.
|
||||||
|
fn python_binary(root: &Path) -> PathBuf {
|
||||||
|
let venv_py = root.join(".venv").join("bin").join("python");
|
||||||
|
if venv_py.exists() {
|
||||||
|
return venv_py;
|
||||||
|
}
|
||||||
|
PathBuf::from("python3")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_maps(state: State<Paths>) -> Result<Vec<MapInfo>, String> {
|
||||||
|
let dir = output_dir(&state.project_root)?;
|
||||||
|
let mut maps = Vec::new();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(maps);
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(&dir).map_err(|e| format!("Failed to read output dir: {e}"))? {
|
||||||
|
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s.eq_ignore_ascii_case("png"))
|
||||||
|
!= Some(true)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = entry
|
||||||
|
.metadata()
|
||||||
|
.map_err(|e| format!("Metadata error: {e}"))?;
|
||||||
|
let modified = metadata
|
||||||
|
.modified()
|
||||||
|
.map(|m| {
|
||||||
|
m.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
maps.push(MapInfo {
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
filename,
|
||||||
|
modified,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
maps.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||||
|
Ok(maps)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_map(path: String, state: State<Paths>) -> Result<(), String> {
|
||||||
|
let dir = output_dir(&state.project_root)?
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("Failed to resolve output dir: {e}"))?;
|
||||||
|
let candidate = PathBuf::from(path);
|
||||||
|
let resolved = candidate
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("Failed to resolve map path: {e}"))?;
|
||||||
|
|
||||||
|
if !resolved.starts_with(&dir) {
|
||||||
|
return Err("Refusing to delete a file outside the output directory".to_string());
|
||||||
|
}
|
||||||
|
if resolved
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s.eq_ignore_ascii_case("png"))
|
||||||
|
!= Some(true)
|
||||||
|
{
|
||||||
|
return Err("Only PNG maps can be deleted".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(&resolved).map_err(|e| format!("Failed to delete map: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminate_process(pid: u32) -> Result<(), String> {
|
||||||
|
#[cfg(target_family = "unix")]
|
||||||
|
{
|
||||||
|
Command::new("kill")
|
||||||
|
.arg("-TERM")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to request cancellation: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_family = "windows")]
|
||||||
|
{
|
||||||
|
Command::new("taskkill")
|
||||||
|
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("Failed to request cancellation: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cancel_generation(state: State<Paths>) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.cancel_generation_requested
|
||||||
|
.store(true, Ordering::SeqCst);
|
||||||
|
let pid = *state
|
||||||
|
.active_generation_pid
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Failed to read active generation state".to_string())?;
|
||||||
|
|
||||||
|
match pid {
|
||||||
|
Some(0) => Ok(()),
|
||||||
|
Some(pid) => terminate_process(pid),
|
||||||
|
None => Err("No generation is running".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn generate_map(
|
||||||
|
window: Window,
|
||||||
|
prompt: String,
|
||||||
|
settings: Option<GenSettings>,
|
||||||
|
state: State<'_, Paths>,
|
||||||
|
) -> Result<GenerateResult, String> {
|
||||||
|
if prompt.trim().is_empty() {
|
||||||
|
return Err("Prompt must not be empty".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt_clone = prompt.clone();
|
||||||
|
let root = state.project_root.clone();
|
||||||
|
let cfg = settings.unwrap_or_default();
|
||||||
|
let steps = cfg.steps.unwrap_or(25);
|
||||||
|
let width = cfg.width.unwrap_or(1536);
|
||||||
|
let height = cfg.height.unwrap_or(768);
|
||||||
|
let guidance = cfg.guidance.unwrap_or(6.5);
|
||||||
|
let scheduler = cfg.scheduler.unwrap_or_else(|| "dpmsolver-sde".to_string());
|
||||||
|
let upscale = cfg.upscale.unwrap_or_else(|| "none".to_string());
|
||||||
|
let seam_inpaint = cfg.seam_inpaint.unwrap_or(false);
|
||||||
|
let model_path = cfg
|
||||||
|
.model_path
|
||||||
|
.unwrap_or_else(|| "proximasan/sdxl-360-diffusion".to_string());
|
||||||
|
let base_model = cfg
|
||||||
|
.base_model
|
||||||
|
.unwrap_or_else(|| "stabilityai/stable-diffusion-xl-base-1.0".to_string());
|
||||||
|
let vae_model = cfg
|
||||||
|
.vae_model
|
||||||
|
.unwrap_or_else(|| "madebyollin/sdxl-vae-fp16-fix".to_string());
|
||||||
|
let active_generation_pid = state.active_generation_pid.clone();
|
||||||
|
let cancel_generation_requested = state.cancel_generation_requested.clone();
|
||||||
|
let window = window.clone();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut active = active_generation_pid
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Failed to update active generation state".to_string())?;
|
||||||
|
if active.is_some() {
|
||||||
|
return Err("Generation is already running".to_string());
|
||||||
|
}
|
||||||
|
*active = Some(0);
|
||||||
|
cancel_generation_requested.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
async_runtime::spawn_blocking(move || {
|
||||||
|
let result = (|| {
|
||||||
|
let out_dir = output_dir(&root)?;
|
||||||
|
let script = script_path(&root)?;
|
||||||
|
let python = python_binary(&root);
|
||||||
|
|
||||||
|
let mut cmd = Command::new(python);
|
||||||
|
cmd.current_dir(&root)
|
||||||
|
.arg(script.as_os_str())
|
||||||
|
.arg("--prompt")
|
||||||
|
.arg(prompt_clone)
|
||||||
|
.arg("--output-dir")
|
||||||
|
.arg(&out_dir)
|
||||||
|
.arg("--work-dir")
|
||||||
|
.arg(root.to_string_lossy().to_string())
|
||||||
|
.arg("--upscale")
|
||||||
|
.arg(upscale)
|
||||||
|
.arg("--steps")
|
||||||
|
.arg(steps.to_string())
|
||||||
|
.arg("--guidance")
|
||||||
|
.arg(guidance.to_string())
|
||||||
|
.arg("--width")
|
||||||
|
.arg(width.to_string())
|
||||||
|
.arg("--height")
|
||||||
|
.arg(height.to_string())
|
||||||
|
.arg("--scheduler")
|
||||||
|
.arg(scheduler)
|
||||||
|
.arg("--model-path")
|
||||||
|
.arg(model_path)
|
||||||
|
.arg("--base-model")
|
||||||
|
.arg(base_model)
|
||||||
|
.arg("--vae-model")
|
||||||
|
.arg(vae_model)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
if seam_inpaint {
|
||||||
|
cmd.arg("--seam-inpaint");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to start generator: {e}"))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut active = active_generation_pid
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Failed to update active generation state".to_string())?;
|
||||||
|
*active = Some(child.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
if cancel_generation_requested.load(Ordering::SeqCst) {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| "Failed to capture generator stdout".to_string())?;
|
||||||
|
let stderr = child
|
||||||
|
.stderr
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| "Failed to capture generator stderr".to_string())?;
|
||||||
|
|
||||||
|
// Consume stderr in a side thread to avoid blocking.
|
||||||
|
let mut stderr_reader = BufReader::new(stderr);
|
||||||
|
let stderr_handle = std::thread::spawn(move || {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let _ = stderr_reader.read_to_string(&mut buf);
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut last_path: Option<String> = None;
|
||||||
|
let mut reader = BufReader::new(stdout);
|
||||||
|
let mut line = String::new();
|
||||||
|
while reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|e| format!("Failed to read generator output: {e}"))?
|
||||||
|
> 0
|
||||||
|
{
|
||||||
|
let trimmed = line.trim_end().to_string();
|
||||||
|
if let Some(json_str) = trimmed.strip_prefix("PROGRESS ") {
|
||||||
|
if let Ok(val) = serde_json::from_str::<Value>(json_str) {
|
||||||
|
let _ = window.emit("gen-progress", val);
|
||||||
|
}
|
||||||
|
} else if !trimmed.is_empty() {
|
||||||
|
last_path = Some(trimmed.clone());
|
||||||
|
}
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.map_err(|e| format!("Failed to wait for generator: {e}"))?;
|
||||||
|
let stderr_output = stderr_handle.join().unwrap_or_default();
|
||||||
|
|
||||||
|
if cancel_generation_requested.load(Ordering::SeqCst) {
|
||||||
|
return Err("Generation cancelled".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("Generator failed: {stderr_output}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_line =
|
||||||
|
last_path.ok_or_else(|| "Generator did not return a path".to_string())?;
|
||||||
|
let path = PathBuf::from(path_line.trim());
|
||||||
|
let resolved = if path.is_absolute() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
out_dir.join(path)
|
||||||
|
};
|
||||||
|
Ok(GenerateResult {
|
||||||
|
output_path: resolved.to_string_lossy().to_string(),
|
||||||
|
})
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Ok(mut active) = active_generation_pid.lock() {
|
||||||
|
*active = None;
|
||||||
|
}
|
||||||
|
cancel_generation_requested.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Generation task failed to join: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let context = tauri::generate_context!();
|
||||||
|
let env = Env::default();
|
||||||
|
// Try to locate the project root by walking up from cwd; if missing, fall back to Tauri resource dir.
|
||||||
|
let root = discover_root()
|
||||||
|
.or_else(|_| {
|
||||||
|
tauri::api::path::resource_dir(context.package_info(), &env)
|
||||||
|
.ok_or_else(|| "Could not locate project root or resource dir".to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
|
||||||
|
let state = Paths {
|
||||||
|
project_root: root,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(state)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
list_maps,
|
||||||
|
delete_map,
|
||||||
|
cancel_generation,
|
||||||
|
generate_map
|
||||||
|
])
|
||||||
|
.run(context)
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
38
src-tauri/tauri.conf.json
Normal file
38
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"package": {
|
||||||
|
"productName": "SkymapGen",
|
||||||
|
"version": "0.1.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devPath": "http://localhost:5173",
|
||||||
|
"distDir": "../dist"
|
||||||
|
},
|
||||||
|
"tauri": {
|
||||||
|
"allowlist": {
|
||||||
|
"fs": {
|
||||||
|
"readFile": true,
|
||||||
|
"readDir": true,
|
||||||
|
"scope": ["**"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"identifier": "com.skymap.gen",
|
||||||
|
"resources": ["../generate_equirect.py", "../default.png"]
|
||||||
|
},
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Skymap Generator",
|
||||||
|
"fullscreen": false,
|
||||||
|
"resizable": true,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
695
src/main.js
Normal file
695
src/main.js
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
import './style.css';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri';
|
||||||
|
import { readBinaryFile } from '@tauri-apps/api/fs';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
app.innerHTML = `
|
||||||
|
<div id="canvas-container"></div>
|
||||||
|
<div id="thumb-dock">
|
||||||
|
<div id="thumb-header">Maps</div>
|
||||||
|
<div id="thumb-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="settings-panel" class="hidden">
|
||||||
|
<div class="settings-header">
|
||||||
|
<div>
|
||||||
|
<div class="settings-title">Generation Settings</div>
|
||||||
|
<div class="settings-sub">Defaults tuned for SDXL 360</div>
|
||||||
|
</div>
|
||||||
|
<button id="settings-close" aria-label="Close settings">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label>Steps
|
||||||
|
<input type="number" id="steps-input" min="1" max="80" />
|
||||||
|
</label>
|
||||||
|
<label>Guidance
|
||||||
|
<input type="number" id="guidance-input" step="0.1" min="0" max="20" />
|
||||||
|
</label>
|
||||||
|
<label>Width
|
||||||
|
<input type="number" id="width-input" min="256" max="4096" step="64" />
|
||||||
|
</label>
|
||||||
|
<label>Height
|
||||||
|
<input type="number" id="height-input" min="256" max="4096" step="64" />
|
||||||
|
</label>
|
||||||
|
<label>Scheduler
|
||||||
|
<select id="scheduler-input">
|
||||||
|
<option value="dpmsolver-sde">DPM++ 2M SDE</option>
|
||||||
|
<option value="dpmsolver">DPM++ 2M</option>
|
||||||
|
<option value="euler">Euler</option>
|
||||||
|
<option value="euler_a">Euler Ancestral</option>
|
||||||
|
<option value="heun">Heun</option>
|
||||||
|
<option value="ddim">DDIM</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Upscale
|
||||||
|
<select id="upscale-input">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="realesrgan">Real-ESRGAN</option>
|
||||||
|
<option value="topaz">Topaz</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-check">Seam Inpainting
|
||||||
|
<input type="checkbox" id="seam-inpaint-input" />
|
||||||
|
</label>
|
||||||
|
<label>Model Path
|
||||||
|
<input type="text" id="model-path-input" />
|
||||||
|
</label>
|
||||||
|
<label>Base Model
|
||||||
|
<input type="text" id="base-model-input" />
|
||||||
|
</label>
|
||||||
|
<label>VAE
|
||||||
|
<input type="text" id="vae-model-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button id="settings-reset">Reset defaults</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="progress-overlay">
|
||||||
|
<div id="progress-ring">
|
||||||
|
<div id="progress-fill"></div>
|
||||||
|
<div id="progress-text">...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="prompt-bar">
|
||||||
|
<input id="prompt-input" type="text" placeholder="Describe the environment..." />
|
||||||
|
<button id="settings-btn" title="Generation settings">Settings</button>
|
||||||
|
<button id="generate-btn">Generate</button>
|
||||||
|
</div>
|
||||||
|
<div id="status"></div>
|
||||||
|
<div id="delete-confirm" class="hidden" role="dialog" aria-modal="true" aria-labelledby="delete-confirm-title">
|
||||||
|
<div id="delete-confirm-card">
|
||||||
|
<div id="delete-confirm-title">Delete map?</div>
|
||||||
|
<div id="delete-confirm-body">This will remove <span id="delete-confirm-filename"></span> from disk.</div>
|
||||||
|
<div id="delete-confirm-actions">
|
||||||
|
<button id="delete-confirm-cancel" type="button">Cancel</button>
|
||||||
|
<button id="delete-confirm-delete" type="button">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const canvasContainer = document.getElementById('canvas-container');
|
||||||
|
const promptInput = document.getElementById('prompt-input');
|
||||||
|
const generateBtn = document.getElementById('generate-btn');
|
||||||
|
const settingsBtn = document.getElementById('settings-btn');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const thumbList = document.getElementById('thumb-list');
|
||||||
|
const thumbDock = document.getElementById('thumb-dock');
|
||||||
|
const deleteConfirm = document.getElementById('delete-confirm');
|
||||||
|
const deleteConfirmFilename = document.getElementById('delete-confirm-filename');
|
||||||
|
const deleteConfirmCancel = document.getElementById('delete-confirm-cancel');
|
||||||
|
const deleteConfirmDelete = document.getElementById('delete-confirm-delete');
|
||||||
|
const progressOverlay = document.getElementById('progress-overlay');
|
||||||
|
const progressFill = document.getElementById('progress-fill');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const settingsPanel = document.getElementById('settings-panel');
|
||||||
|
const settingsClose = document.getElementById('settings-close');
|
||||||
|
const settingsReset = document.getElementById('settings-reset');
|
||||||
|
const stepsInput = document.getElementById('steps-input');
|
||||||
|
const guidanceInput = document.getElementById('guidance-input');
|
||||||
|
const widthInput = document.getElementById('width-input');
|
||||||
|
const heightInput = document.getElementById('height-input');
|
||||||
|
const schedulerInput = document.getElementById('scheduler-input');
|
||||||
|
const upscaleInput = document.getElementById('upscale-input');
|
||||||
|
const seamInpaintInput = document.getElementById('seam-inpaint-input');
|
||||||
|
const modelPathInput = document.getElementById('model-path-input');
|
||||||
|
const baseModelInput = document.getElementById('base-model-input');
|
||||||
|
const vaeModelInput = document.getElementById('vae-model-input');
|
||||||
|
|
||||||
|
// Three.js setup
|
||||||
|
let renderer, scene, camera, controls, skyMesh;
|
||||||
|
const defaultTextureUrl = '/default.png';
|
||||||
|
const skyFadeDurationMs = 1000;
|
||||||
|
let skyTransition = null;
|
||||||
|
let skyTextureRequestId = 0;
|
||||||
|
let autoSpin = true;
|
||||||
|
let lastInteraction = Date.now();
|
||||||
|
const minZoomDistance = 0.2;
|
||||||
|
const maxZoomDistance = 20;
|
||||||
|
let desiredDistance = 1;
|
||||||
|
const zoomLerpFactor = 0.1;
|
||||||
|
const zoomVec = new THREE.Vector3();
|
||||||
|
let progressState = {
|
||||||
|
upscale: null,
|
||||||
|
seamInpaint: null,
|
||||||
|
phases: {
|
||||||
|
gen: null,
|
||||||
|
inpaint: null,
|
||||||
|
upscale: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let progressUnlisten = null;
|
||||||
|
let currentMapPath = null;
|
||||||
|
let deleteConfirmResolve = null;
|
||||||
|
let generationRunning = false;
|
||||||
|
let cancelRequested = false;
|
||||||
|
const defaultSettings = {
|
||||||
|
steps: 25,
|
||||||
|
width: 1536,
|
||||||
|
height: 768,
|
||||||
|
guidance: 6.5,
|
||||||
|
scheduler: 'dpmsolver-sde',
|
||||||
|
upscale: 'none',
|
||||||
|
seamInpaint: false,
|
||||||
|
modelPath: 'proximasan/sdxl-360-diffusion',
|
||||||
|
baseModel: 'stabilityai/stable-diffusion-xl-base-1.0',
|
||||||
|
vaeModel: 'madebyollin/sdxl-vae-fp16-fix',
|
||||||
|
};
|
||||||
|
let currentSettings = { ...defaultSettings };
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
statusEl.textContent = msg || '';
|
||||||
|
statusEl.style.opacity = msg ? '0.98' : '0';
|
||||||
|
if (msg) {
|
||||||
|
clearTimeout(setStatus._timer);
|
||||||
|
setStatus._timer = setTimeout(() => {
|
||||||
|
statusEl.style.opacity = '0';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTexture(url) {
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
loader.load(
|
||||||
|
url,
|
||||||
|
tex => {
|
||||||
|
tex.mapping = THREE.EquirectangularReflectionMapping;
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
resolve(tex);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
err => reject(err)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSkyFromUrl(url) {
|
||||||
|
const requestId = ++skyTextureRequestId;
|
||||||
|
try {
|
||||||
|
const texture = await loadTexture(url);
|
||||||
|
if (requestId !== skyTextureRequestId) {
|
||||||
|
texture.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transitionSkyToTexture(texture);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load texture', e);
|
||||||
|
if (requestId === skyTextureRequestId) {
|
||||||
|
setStatus('Failed to load map texture');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSkyTexture(texture) {
|
||||||
|
const previousTexture = skyMesh.material.map;
|
||||||
|
skyMesh.material.map = texture;
|
||||||
|
skyMesh.material.needsUpdate = true;
|
||||||
|
|
||||||
|
if (previousTexture && previousTexture !== texture) {
|
||||||
|
previousTexture.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSkyTransition(transition) {
|
||||||
|
scene.remove(transition.mesh);
|
||||||
|
transition.material.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSkyTransition(transition) {
|
||||||
|
if (!transition || skyTransition !== transition) return;
|
||||||
|
removeSkyTransition(transition);
|
||||||
|
skyTransition = null;
|
||||||
|
replaceSkyTexture(transition.texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionSkyToTexture(texture) {
|
||||||
|
if (!skyMesh.material.map) {
|
||||||
|
replaceSkyTexture(texture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skyTransition) {
|
||||||
|
finishSkyTransition(skyTransition);
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
side: THREE.BackSide,
|
||||||
|
color: 0xffffff,
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(skyMesh.geometry, material);
|
||||||
|
mesh.rotation.copy(skyMesh.rotation);
|
||||||
|
mesh.renderOrder = skyMesh.renderOrder + 1;
|
||||||
|
scene.add(mesh);
|
||||||
|
|
||||||
|
skyTransition = {
|
||||||
|
mesh,
|
||||||
|
material,
|
||||||
|
texture,
|
||||||
|
start: performance.now(),
|
||||||
|
duration: skyFadeDurationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSkyTransition(now) {
|
||||||
|
if (!skyTransition) return;
|
||||||
|
|
||||||
|
const progress = Math.min(1, (now - skyTransition.start) / skyTransition.duration);
|
||||||
|
skyTransition.material.opacity = THREE.MathUtils.smoothstep(progress, 0, 1);
|
||||||
|
skyTransition.mesh.rotation.copy(skyMesh.rotation);
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
finishSkyTransition(skyTransition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initScene() {
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||||
|
camera.position.set(0.1, 0, 0.1);
|
||||||
|
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
canvasContainer.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
desiredDistance = camera.position.length();
|
||||||
|
|
||||||
|
const geometry = new THREE.SphereGeometry(50, 64, 32);
|
||||||
|
const material = new THREE.MeshBasicMaterial({ side: THREE.BackSide, color: 0xffffff });
|
||||||
|
skyMesh = new THREE.Mesh(geometry, material);
|
||||||
|
scene.add(skyMesh);
|
||||||
|
|
||||||
|
controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enablePan = false;
|
||||||
|
controls.enableZoom = false; // we implement custom smooth zooming
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
controls.rotateSpeed = 0.3;
|
||||||
|
controls.autoRotate = false;
|
||||||
|
controls.autoRotateSpeed = 0.15;
|
||||||
|
controls.minDistance = 0.05;
|
||||||
|
controls.maxDistance = 20;
|
||||||
|
|
||||||
|
controls.addEventListener('start', () => {
|
||||||
|
autoSpin = false;
|
||||||
|
lastInteraction = Date.now();
|
||||||
|
});
|
||||||
|
controls.addEventListener('end', () => {
|
||||||
|
lastInteraction = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
renderer.domElement.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY;
|
||||||
|
// Scale desired distance exponentially for smooth feel
|
||||||
|
const factor = Math.exp(delta * 0.0015);
|
||||||
|
desiredDistance = THREE.MathUtils.clamp(desiredDistance * factor, minZoomDistance, maxZoomDistance);
|
||||||
|
lastInteraction = Date.now();
|
||||||
|
autoSpin = false;
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
if (!renderer || !camera) return;
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
const now = performance.now();
|
||||||
|
const idle = Date.now() - lastInteraction > 2000;
|
||||||
|
if (idle) autoSpin = true;
|
||||||
|
if (autoSpin) {
|
||||||
|
skyMesh.rotation.y += 0.0008;
|
||||||
|
}
|
||||||
|
updateSkyTransition(now);
|
||||||
|
// Smooth zoom toward desired distance
|
||||||
|
const target = controls.target;
|
||||||
|
const currentDistance = camera.position.distanceTo(target);
|
||||||
|
const nextDistance = THREE.MathUtils.lerp(currentDistance, desiredDistance, zoomLerpFactor);
|
||||||
|
zoomVec.copy(camera.position).sub(target).normalize().multiplyScalar(nextDistance).add(target);
|
||||||
|
camera.position.copy(zoomVec);
|
||||||
|
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathToObjectUrl(path) {
|
||||||
|
const data = await readBinaryFile(path);
|
||||||
|
const blob = new Blob([new Uint8Array(data)], { type: 'image/png' });
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetProgressState() {
|
||||||
|
progressState = {
|
||||||
|
upscale: null,
|
||||||
|
seamInpaint: null,
|
||||||
|
phases: {
|
||||||
|
gen: null,
|
||||||
|
inpaint: null,
|
||||||
|
upscale: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeProgress() {
|
||||||
|
const upscaleOn = progressState.upscale === true;
|
||||||
|
const seamOn = progressState.seamInpaint === true;
|
||||||
|
const weights = {
|
||||||
|
gen: seamOn || upscaleOn ? 0.5 : 1,
|
||||||
|
inpaint: seamOn ? 0.5 : 0,
|
||||||
|
upscale: upscaleOn ? 0.5 : 0,
|
||||||
|
};
|
||||||
|
if (seamOn && upscaleOn) {
|
||||||
|
weights.gen = 0.25;
|
||||||
|
weights.inpaint = 0.25;
|
||||||
|
}
|
||||||
|
const frac = phase => {
|
||||||
|
if (!phase || !phase.total) return 0;
|
||||||
|
const pct = phase.current / phase.total;
|
||||||
|
return Math.max(0, Math.min(1, pct));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
frac(progressState.phases.gen) * weights.gen +
|
||||||
|
frac(progressState.phases.inpaint) * weights.inpaint +
|
||||||
|
frac(progressState.phases.upscale) * weights.upscale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgressDisplay(pct) {
|
||||||
|
if (!progressOverlay) return;
|
||||||
|
const clamped = Math.max(0, Math.min(1, pct || 0));
|
||||||
|
const deg = clamped * 100;
|
||||||
|
progressFill.style.background = `conic-gradient(#ffffff ${deg}%, rgba(255,255,255,0.12) ${deg}%)`;
|
||||||
|
progressText.textContent = `${Math.round(clamped * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProgress() {
|
||||||
|
resetProgressState();
|
||||||
|
if (progressOverlay) {
|
||||||
|
progressOverlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
updateProgressDisplay(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopProgress() {
|
||||||
|
updateProgressDisplay(1);
|
||||||
|
if (progressOverlay) {
|
||||||
|
progressOverlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshThumbnails(selectedPath) {
|
||||||
|
let maps;
|
||||||
|
try {
|
||||||
|
maps = await invoke('list_maps');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setStatus('Failed to read map list');
|
||||||
|
thumbDock.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thumbList.innerHTML = '';
|
||||||
|
if (!maps || maps.length === 0) {
|
||||||
|
thumbDock.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thumbDock.style.display = 'block';
|
||||||
|
|
||||||
|
for (let idx = 0; idx < maps.length; idx++) {
|
||||||
|
const item = maps[idx];
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'thumb-item';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'thumb-delete';
|
||||||
|
deleteBtn.type = 'button';
|
||||||
|
deleteBtn.textContent = '×';
|
||||||
|
deleteBtn.title = 'Delete map';
|
||||||
|
deleteBtn.setAttribute('aria-label', `Delete ${item.filename}`);
|
||||||
|
deleteBtn.addEventListener('click', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
await deleteMap(item, el);
|
||||||
|
});
|
||||||
|
let fileUrl;
|
||||||
|
try {
|
||||||
|
fileUrl = await pathToObjectUrl(item.path);
|
||||||
|
img.src = fileUrl;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load thumbnail', err);
|
||||||
|
img.alt = 'Failed to load';
|
||||||
|
}
|
||||||
|
el.appendChild(img);
|
||||||
|
el.appendChild(deleteBtn);
|
||||||
|
el.title = item.filename;
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
if (fileUrl) {
|
||||||
|
currentMapPath = item.path;
|
||||||
|
setSkyFromUrl(fileUrl);
|
||||||
|
setStatus(`Loaded ${item.filename}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
thumbList.appendChild(el);
|
||||||
|
|
||||||
|
if (fileUrl) {
|
||||||
|
if (idx === 0 && !selectedPath) {
|
||||||
|
currentMapPath = item.path;
|
||||||
|
setSkyFromUrl(fileUrl);
|
||||||
|
setStatus(`Showing ${item.filename}`);
|
||||||
|
}
|
||||||
|
if (selectedPath && selectedPath === item.path) {
|
||||||
|
currentMapPath = item.path;
|
||||||
|
setSkyFromUrl(fileUrl);
|
||||||
|
setStatus(`Showing ${item.filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMap(item, tileEl) {
|
||||||
|
if (!item?.path) return;
|
||||||
|
const confirmed = await requestDeleteConfirmation(item.filename);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('delete_map', { path: item.path });
|
||||||
|
const wasCurrent = currentMapPath === item.path;
|
||||||
|
tileEl?.remove();
|
||||||
|
setStatus(`Deleted ${item.filename}`);
|
||||||
|
|
||||||
|
if (thumbList.children.length === 0) {
|
||||||
|
thumbDock.style.display = 'none';
|
||||||
|
currentMapPath = null;
|
||||||
|
await setSkyFromUrl(defaultTextureUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasCurrent) {
|
||||||
|
thumbList.querySelector('.thumb-item')?.click();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setStatus('Failed to delete map');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDeleteConfirmation(filename) {
|
||||||
|
if (deleteConfirmResolve) {
|
||||||
|
closeDeleteConfirmation(false);
|
||||||
|
}
|
||||||
|
deleteConfirmFilename.textContent = `"${filename}"`;
|
||||||
|
deleteConfirm.classList.remove('hidden');
|
||||||
|
deleteConfirmDelete.focus();
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
deleteConfirmResolve = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteConfirmation(result) {
|
||||||
|
deleteConfirm.classList.add('hidden');
|
||||||
|
if (deleteConfirmResolve) {
|
||||||
|
const resolve = deleteConfirmResolve;
|
||||||
|
deleteConfirmResolve = null;
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateMap() {
|
||||||
|
if (generationRunning) {
|
||||||
|
await cancelGeneration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prompt = promptInput.value.trim();
|
||||||
|
if (!prompt) {
|
||||||
|
setStatus('Please enter a prompt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = {
|
||||||
|
steps: Number(stepsInput.value) || defaultSettings.steps,
|
||||||
|
guidance: Number(guidanceInput.value) || defaultSettings.guidance,
|
||||||
|
width: Number(widthInput.value) || defaultSettings.width,
|
||||||
|
height: Number(heightInput.value) || defaultSettings.height,
|
||||||
|
scheduler: schedulerInput.value || defaultSettings.scheduler,
|
||||||
|
upscale: upscaleInput.value || defaultSettings.upscale,
|
||||||
|
seamInpaint: seamInpaintInput.checked,
|
||||||
|
modelPath: modelPathInput.value.trim() || defaultSettings.modelPath,
|
||||||
|
baseModel: baseModelInput.value.trim() || defaultSettings.baseModel,
|
||||||
|
vaeModel: vaeModelInput.value.trim() || defaultSettings.vaeModel,
|
||||||
|
};
|
||||||
|
currentSettings = settings;
|
||||||
|
generationRunning = true;
|
||||||
|
cancelRequested = false;
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.textContent = 'Cancel';
|
||||||
|
setStatus('Generating...');
|
||||||
|
startProgress();
|
||||||
|
try {
|
||||||
|
const result = await invoke('generate_map', { prompt, settings });
|
||||||
|
const outputPath = result.outputPath || result.output_path || result;
|
||||||
|
if (outputPath) {
|
||||||
|
const fileUrl = await pathToObjectUrl(outputPath);
|
||||||
|
await refreshThumbnails(outputPath);
|
||||||
|
currentMapPath = outputPath;
|
||||||
|
await setSkyFromUrl(fileUrl);
|
||||||
|
setStatus('New environment loaded');
|
||||||
|
} else {
|
||||||
|
setStatus('Generation finished, but no output path reported');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setStatus(e === 'Generation cancelled' ? 'Generation cancelled' : (typeof e === 'string' ? e : 'Generation failed'));
|
||||||
|
} finally {
|
||||||
|
stopProgress();
|
||||||
|
generationRunning = false;
|
||||||
|
cancelRequested = false;
|
||||||
|
generateBtn.textContent = 'Generate';
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelGeneration() {
|
||||||
|
if (!generationRunning || cancelRequested) return;
|
||||||
|
cancelRequested = true;
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.textContent = 'Cancelling...';
|
||||||
|
setStatus('Cancelling generation...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('cancel_generation');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
cancelRequested = false;
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.textContent = 'Cancel';
|
||||||
|
setStatus(typeof e === 'string' ? e : 'Failed to cancel generation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupUI() {
|
||||||
|
generateBtn.addEventListener('click', () => {
|
||||||
|
if (generationRunning) {
|
||||||
|
cancelGeneration();
|
||||||
|
} else {
|
||||||
|
generateMap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
promptInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !generationRunning) generateMap();
|
||||||
|
});
|
||||||
|
settingsBtn.addEventListener('click', () => {
|
||||||
|
settingsPanel.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
settingsClose.addEventListener('click', () => {
|
||||||
|
settingsPanel.classList.add('hidden');
|
||||||
|
});
|
||||||
|
settingsReset.addEventListener('click', () => {
|
||||||
|
applySettings(defaultSettings);
|
||||||
|
});
|
||||||
|
deleteConfirmCancel.addEventListener('click', () => {
|
||||||
|
closeDeleteConfirmation(false);
|
||||||
|
});
|
||||||
|
deleteConfirmDelete.addEventListener('click', () => {
|
||||||
|
closeDeleteConfirmation(true);
|
||||||
|
});
|
||||||
|
deleteConfirm.addEventListener('click', (event) => {
|
||||||
|
if (event.target === deleteConfirm) {
|
||||||
|
closeDeleteConfirmation(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && !deleteConfirm.classList.contains('hidden')) {
|
||||||
|
closeDeleteConfirmation(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupProgressEvents() {
|
||||||
|
try {
|
||||||
|
progressUnlisten = await listen('gen-progress', (event) => {
|
||||||
|
const data = event?.payload || {};
|
||||||
|
if (typeof data.upscale === 'boolean') {
|
||||||
|
progressState.upscale = data.upscale;
|
||||||
|
}
|
||||||
|
if (typeof data.seamInpaint === 'boolean') {
|
||||||
|
progressState.seamInpaint = data.seamInpaint;
|
||||||
|
}
|
||||||
|
if (data.phase && typeof data.current === 'number' && typeof data.total === 'number') {
|
||||||
|
progressState.phases[data.phase] = {
|
||||||
|
current: data.current,
|
||||||
|
total: data.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
updateProgressDisplay(computeProgress());
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to bind progress listener', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
initScene();
|
||||||
|
setupUI();
|
||||||
|
applySettings(defaultSettings);
|
||||||
|
await setupProgressEvents();
|
||||||
|
await setSkyFromUrl(defaultTextureUrl);
|
||||||
|
await refreshThumbnails();
|
||||||
|
setStatus('Ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(cfg) {
|
||||||
|
currentSettings = { ...cfg };
|
||||||
|
stepsInput.value = cfg.steps;
|
||||||
|
guidanceInput.value = cfg.guidance;
|
||||||
|
widthInput.value = cfg.width;
|
||||||
|
heightInput.value = cfg.height;
|
||||||
|
schedulerInput.value = cfg.scheduler;
|
||||||
|
upscaleInput.value = cfg.upscale;
|
||||||
|
seamInpaintInput.checked = Boolean(cfg.seamInpaint);
|
||||||
|
modelPathInput.value = cfg.modelPath;
|
||||||
|
baseModelInput.value = cfg.baseModel;
|
||||||
|
vaeModelInput.value = cfg.vaeModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
493
src/style.css
Normal file
493
src/style.css
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
:root {
|
||||||
|
--overlay-bg: rgba(255, 255, 255, 0.14);
|
||||||
|
--text-color: #f7f7f7;
|
||||||
|
--dock-bg: rgba(18, 18, 24, 0.78);
|
||||||
|
--dock-hover: rgba(34, 34, 46, 0.92);
|
||||||
|
--input-bg: rgba(245, 245, 245, 0.82);
|
||||||
|
--input-text: #1f2933;
|
||||||
|
--border: rgba(255, 255, 255, 0.22);
|
||||||
|
--glass-bg: rgba(255,255,255,0.12);
|
||||||
|
--glass-bg-strong: rgba(255,255,255,0.18);
|
||||||
|
--glass-border: rgba(255,255,255,0.3);
|
||||||
|
--glass-shadow: 0 20px 50px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body, html, #app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b0c10;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: "Inter", "SF Pro Display", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
#status,
|
||||||
|
#thumb-dock,
|
||||||
|
#settings-panel,
|
||||||
|
#delete-confirm-card,
|
||||||
|
#progress-text {
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,0.72), 0 0 12px rgba(0,0,0,0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-input {
|
||||||
|
width: min(48vw, 720px);
|
||||||
|
min-width: 220px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255,255,255,0.16);
|
||||||
|
color: #fdfefe;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.35), 0 4px 14px rgba(0,0,0,0.18);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#prompt-input:focus {
|
||||||
|
border-color: rgba(255,255,255,0.5);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.45), 0 6px 18px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
#generate-btn {
|
||||||
|
border: none;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
color: #f5f6fb;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.2s ease;
|
||||||
|
box-shadow: 0 8px 18px rgba(0,0,0,0.14), inset 0 1px 0 rgba(255,255,255,0.35);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#generate-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#generate-btn:not(:disabled):hover { transform: translateY(-1px); }
|
||||||
|
#generate-btn:not(:disabled):active { transform: translateY(0); }
|
||||||
|
|
||||||
|
#settings-btn {
|
||||||
|
border: 1px solid rgba(255,255,255,0.32);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: #f5f6fb;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.2s ease;
|
||||||
|
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.2);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-btn:hover { transform: translateY(-1px); }
|
||||||
|
#settings-btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
#settings-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 680px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
padding: 18px 18px 14px;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-panel.hidden { display: none; }
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-close {
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.22);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid input,
|
||||||
|
.settings-grid select {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: rgba(255,255,255,0.14);
|
||||||
|
color: #f9fafc;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.22);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid .settings-check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 43px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.18);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid .settings-check input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: rgba(255,255,255,0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid input:focus,
|
||||||
|
.settings-grid select:focus {
|
||||||
|
border-color: rgba(255,255,255,0.52);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.32), 0 0 0 3px rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-reset {
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.2);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
position: fixed;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 200px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm.hidden { display: none; }
|
||||||
|
|
||||||
|
#delete-confirm-card {
|
||||||
|
width: min(360px, calc(100vw - 36px));
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
padding: 18px;
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-body {
|
||||||
|
color: rgba(255,255,255,0.76);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-filename {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-actions button {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
padding: 9px 13px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.2);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-cancel {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-delete {
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-confirm-actions button:focus-visible {
|
||||||
|
outline: 2px solid rgba(255,255,255,0.86);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress overlay */
|
||||||
|
#progress-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-ring {
|
||||||
|
width: 90vmin;
|
||||||
|
height: 90vmin;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(#ffffff 0%, rgba(255,255,255,0.08) 0%);
|
||||||
|
/* Ring thickness is outer - inner. Make it very thin (about 1% of radius). */
|
||||||
|
mask: radial-gradient(farthest-side, transparent 99%, black 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-text {
|
||||||
|
position: relative;
|
||||||
|
color: #f5f6fb;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-shadow: 0 1px 8px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#thumb-dock {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
left: 0;
|
||||||
|
width: 120px;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-right: 1px solid rgba(255,255,255,0.22);
|
||||||
|
border-radius: 0 12px 12px 0;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.45);
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#thumb-dock:hover { background: rgba(255,255,255,0.14); }
|
||||||
|
|
||||||
|
#thumb-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #d5d6e0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#thumb-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(80vh - 44px);
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for dock */
|
||||||
|
#thumb-list::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
#thumb-list::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
#thumb-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255,255,255,0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
#thumb-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-item {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: visible;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.12s ease;
|
||||||
|
background: #15171f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.16);
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 14px rgba(0,0,0,0.16), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-delete:focus-visible {
|
||||||
|
outline: 1px solid rgba(255,255,255,0.9);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-item:hover {
|
||||||
|
transform: translateY(-1px) scale(1.01);
|
||||||
|
box-shadow: 0 10px 26px rgba(0,0,0,0.26);
|
||||||
|
border-color: rgba(255,255,255,0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
#thumb-dock { display: none; }
|
||||||
|
#prompt-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
bottom: 18px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
#prompt-input { width: 78vw; }
|
||||||
|
#generate-btn { width: 100%; }
|
||||||
|
#settings-btn { width: 100%; }
|
||||||
|
#status { bottom: 120px; }
|
||||||
|
}
|
||||||
14
vite.config.js
Normal file
14
vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
envPrefix: ['VITE_', 'TAURI_'],
|
||||||
|
build: {
|
||||||
|
target: ['es2021', 'chrome100', 'safari13'],
|
||||||
|
outDir: 'dist'
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user