Files
stable-diffusion.cpp-patch/examples/server/frontend/src/App.vue
T
2026-08-16 18:27:57 +07:00

924 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import CollapsibleSection from "./components/CollapsibleSection.vue";
import ImageDropzone from "./components/ImageDropzone.vue";
import StatusChip from "./components/StatusChip.vue";
import { cancelJob, getCapabilities, getJob, submitImageJob, submitVideoJob } from "./lib/api";
import { buildRequestBodyForMode, CACHE_MODES, createBlankForm, formFromCapabilities } from "./lib/form";
import { IMAGE_INPUTS, VIDEO_IMAGE_INPUTS } from "./lib/image-inputs";
import { assignImageEntries, clearImageEntries, filesToImageEntries, removeImageEntry } from "./lib/images";
import { createStoredRef, normalizePollIntervalMs } from "./lib/settings";
import type {
Capabilities,
GenerationForm,
GenerationMode,
ImageEntry,
ImageTarget,
Job,
SampleParams,
} from "./lib/types";
const baseUrl = createStoredRef<string>("sdcpp-webui-base-url", "", (value: unknown) => String(value || ""));
const pollIntervalMs = createStoredRef<number>("sdcpp-webui-poll-interval-ms", 100, normalizePollIntervalMs);
const activeTab = ref<"image" | "video" | "settings">("image");
const selectedGenerationTab = ref<"image" | "video">("image");
const generationMode = computed<GenerationMode>(() => selectedGenerationTab.value === "video" ? "video" : "image");
const lightboxOpen = ref(false);
const lightboxImageSrc = ref("");
const lightboxImageAlt = ref("");
const sectionState = reactive<Record<string, boolean>>({
sample: true,
sampleAdvanced: false,
guidance: true,
guidanceAdvanced: false,
highNoise: false,
highNoiseSample: true,
highNoiseGuidance: true,
conditioning: false,
auxiliaryImages: false,
lora: false,
vaeTiling: false,
cache: false,
});
const loadingCapabilities = ref(false);
const capabilitiesError = ref("");
const serviceOnline = ref(false);
const capabilities = ref<Capabilities | null>(null);
const currentJob = ref<Job | null>(null);
const selectedOutputIndex = ref(0);
const statusMessage = ref("");
const statusTone = ref("");
const form = reactive<GenerationForm>(createBlankForm());
let pollTimer = 0;
let elapsedTimer = 0;
const nowSeconds = ref(Date.now() / 1000);
const modelName = computed(() => {
const model = capabilities.value?.model;
return model?.stem || model?.name || "No model info";
});
const supportedModes = computed(() => capabilities.value?.supported_modes || []);
const supportsImageMode = computed(() => {
return !supportedModes.value.length || supportedModes.value.includes("img_gen");
});
const supportsVideoMode = computed(() => {
return !supportedModes.value.length || supportedModes.value.includes("vid_gen");
});
const selectedModeKey = computed<"img_gen" | "vid_gen">(() => generationMode.value === "video" ? "vid_gen" : "img_gen");
const currentJobModeKey = computed<"img_gen" | "vid_gen">(() => currentJob.value?.kind || selectedModeKey.value);
const selectedModeFeatures = computed(() => {
return capabilities.value?.features_by_mode?.[selectedModeKey.value] || {};
});
const currentJobFeatures = computed(() => {
return capabilities.value?.features_by_mode?.[currentJobModeKey.value] || selectedModeFeatures.value;
});
const imageOutputFormats = computed(() => {
return capabilities.value?.output_formats_by_mode?.img_gen || ["png", "jpeg"];
});
const videoOutputFormats = computed(() => {
return capabilities.value?.output_formats_by_mode?.vid_gen || ["webm", "avi"];
});
const outputFormats = computed(() => generationMode.value === "video" ? videoOutputFormats.value : imageOutputFormats.value);
const samplers = computed(() => capabilities.value?.samplers || ["default"]);
const schedulers = computed(() => capabilities.value?.schedulers || ["default"]);
const availableLoras = computed(() => capabilities.value?.loras || []);
const currentImageInputs = computed(() => generationMode.value === "video" ? VIDEO_IMAGE_INPUTS : IMAGE_INPUTS);
const gridImageInputs = computed(() => currentImageInputs.value.filter((input) => input.layout === "grid"));
const fullImageInputs = computed(() => currentImageInputs.value.filter((input) => input.layout === "full"));
const queueLimit = computed(() => capabilities.value?.limits?.max_queue_size ?? "unknown");
const canCancelQueued = computed(() => Boolean(currentJobFeatures.value.cancel_queued));
const canCancelGenerating = computed(() => Boolean(currentJobFeatures.value.cancel_generating));
const currentStatus = computed(() => currentJob.value?.status || "idle");
const currentJobKind = computed(() => currentJob.value?.kind || null);
const currentImages = computed(() => currentJobKind.value === "img_gen" ? currentJob.value?.result?.images || [] : []);
const selectedImage = computed(() => {
if (!currentImages.value.length) {
return null;
}
const index = Math.min(selectedOutputIndex.value, currentImages.value.length - 1);
const image = currentImages.value[index];
const format = currentJob.value?.result?.output_format || form.output_format || "png";
return `data:image/${format};base64,${image.b64_json}`;
});
const videoMimeType = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.mime_type || "" : "");
const videoFrameCount = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.frame_count || 0 : 0);
const videoFps = computed(() => currentJobKind.value === "vid_gen" ? currentJob.value?.result?.fps || 0 : 0);
const videoPreviewSrc = computed(() => {
if (currentJobKind.value !== "vid_gen" || !currentJob.value?.result?.b64_json) {
return null;
}
if (currentJob.value?.result?.output_format === "avi") {
return null;
}
if (!videoMimeType.value.startsWith("video/")) {
return null;
}
return `data:${videoMimeType.value};base64,${currentJob.value.result.b64_json}`;
});
const animatedVideoImageSrc = computed(() => {
if (currentJobKind.value !== "vid_gen" || !currentJob.value?.result?.b64_json) {
return null;
}
if (videoMimeType.value !== "image/webp") {
return null;
}
return `data:image/webp;base64,${currentJob.value.result.b64_json}`;
});
const previewImageSrc = computed(() => animatedVideoImageSrc.value || selectedImage.value);
const downloadableSrc = computed(() => {
const result = currentJob.value?.result;
if (currentJobKind.value === "vid_gen" && result?.b64_json && videoMimeType.value) {
return `data:${videoMimeType.value};base64,${result.b64_json}`;
}
return selectedImage.value;
});
const canCancelCurrentJob = computed(() => {
if (!currentJob.value) {
return false;
}
if (currentStatus.value === "queued") {
return canCancelQueued.value;
}
if (currentStatus.value === "generating") {
return canCancelGenerating.value;
}
return false;
});
const isJobRunning = computed(() => {
return currentStatus.value === "queued" || currentStatus.value === "generating";
});
function buildSampleSummary(sample: SampleParams): string {
const scheduler = sample.scheduler || "default";
const method = sample.sample_method || "default";
const steps = sample.sample_steps || 0;
const flowShift = sample.flow_shift === "" || sample.flow_shift == null
? "flow auto"
: `flow ${sample.flow_shift}`;
return `${scheduler} · ${flowShift} · ${method} · ${steps} steps`;
}
function buildGuidanceSummary(sample: SampleParams): string {
const cfg = sample.guidance.txt_cfg;
const distilled = sample.guidance.distilled_guidance;
return `cfg ${cfg} · distilled ${distilled}`;
}
const sampleSummary = computed(() => buildSampleSummary(form.sample_params));
const guidanceSummary = computed(() => buildGuidanceSummary(form.sample_params));
const highNoiseSummary = computed(() => {
return `moe ${formatSummaryNumber(form.moe_boundary)} · ${buildSampleSummary(form.high_noise_sample_params)} · ${buildGuidanceSummary(form.high_noise_sample_params)}`;
});
const loraSummary = computed(() => {
if (!form.lora.length) {
return "No LoRA";
}
return `${form.lora.length} configured`;
});
const imageInputsSummary = computed(() => {
const parts: string[] = [];
if (form.init_image) parts.push(generationMode.value === "video" ? "start" : "init");
if (generationMode.value === "image") {
if (form.mask_image) parts.push("mask");
if (form.control_image) parts.push("control");
if (form.ref_images.length) parts.push(`${form.ref_images.length} refs`);
} else {
if (form.end_image) parts.push("end");
if (form.control_frames.length) parts.push(`${form.control_frames.length} frames`);
}
return parts.length ? parts.join(" · ") : "No images";
});
const vaeTilingSummary = computed(() => {
if (!form.vae_tiling_params.enabled) {
return "Disabled";
}
return `${form.vae_tiling_params.tile_size_x}×${form.vae_tiling_params.tile_size_y} · overlap ${form.vae_tiling_params.target_overlap}`;
});
const cacheSummary = computed(() => {
const mode = form.cache.mode || "disabled";
if (mode === "disabled") {
return "Disabled";
}
const option = String(form.cache.option || "").trim();
return option ? `${mode} · ${option}` : mode;
});
const conditioningSummary = computed(() => {
const clipSkip = formatSummaryNumber(form.clip_skip, 0);
const strength = formatSummaryNumber(form.strength);
if (generationMode.value === "video") {
return `clip_skip ${clipSkip} · strength ${strength} · vace ${formatSummaryNumber(form.vace_strength)}`;
}
const controlStrength = formatSummaryNumber(form.control_strength);
return `clip_skip ${clipSkip} · img ${strength} · control ${controlStrength}`;
});
function defaultOutputFormatForMode(mode: GenerationMode): string {
if (mode === "video") {
return videoOutputFormats.value[0] || "webm";
}
return imageOutputFormats.value[0] || "png";
}
function ensureOutputFormatForMode(mode = generationMode.value): void {
const validFormats = mode === "video" ? videoOutputFormats.value : imageOutputFormats.value;
if (!validFormats.length) {
return;
}
if (!validFormats.includes(form.output_format)) {
form.output_format = defaultOutputFormatForMode(mode);
}
}
function setMessage(message: string, tone = ""): void {
statusMessage.value = message;
statusTone.value = tone;
}
function clearMessage(): void {
setMessage("", "");
}
function deepAssign(target: Record<string, any>, ...sources: Record<string, any>[]): Record<string, any> {
for (const source of sources) {
if (!source) continue;
for (const key of Object.keys(source)) {
const sv = source[key];
if (sv !== null && typeof sv === "object" && !Array.isArray(sv) &&
target[key] !== null && typeof target[key] === "object" && !Array.isArray(target[key])) {
deepAssign(target[key], sv);
} else {
target[key] = sv;
}
}
}
return target;
}
function applyForm(nextForm: GenerationForm): void {
deepAssign(form, createBlankForm(), nextForm);
}
async function refreshCapabilities(): Promise<void> {
loadingCapabilities.value = true;
capabilitiesError.value = "";
try {
const response = await getCapabilities(baseUrl.value);
capabilities.value = response;
serviceOnline.value = true;
applyForm(formFromCapabilities(response));
if (selectedGenerationTab.value === "image" && !supportsImageMode.value && supportsVideoMode.value) {
selectedGenerationTab.value = "video";
} else if (selectedGenerationTab.value === "video" && !supportsVideoMode.value && supportsImageMode.value) {
selectedGenerationTab.value = "image";
}
if (activeTab.value === "image" && !supportsImageMode.value && supportsVideoMode.value) {
activeTab.value = "video";
} else if (activeTab.value === "video" && !supportsVideoMode.value && supportsImageMode.value) {
activeTab.value = "image";
}
ensureOutputFormatForMode();
clearMessage();
} catch (error) {
capabilitiesError.value = error instanceof Error ? error.message : String(error);
serviceOnline.value = false;
setMessage(capabilitiesError.value, "error");
} finally {
loadingCapabilities.value = false;
}
}
function toggleSection(section: string): void {
sectionState[section] = !sectionState[section];
}
function selectGenerationMode(mode: GenerationMode): void {
if (mode === "image" && !supportsImageMode.value) {
setMessage("Current model only supports video generation.", "error");
return;
}
if (mode === "video" && !supportsVideoMode.value) {
setMessage("Current model only supports image generation.", "error");
return;
}
selectedGenerationTab.value = mode;
activeTab.value = mode;
ensureOutputFormatForMode(mode);
}
function stopPolling(): void {
if (pollTimer) {
window.clearTimeout(pollTimer);
pollTimer = 0;
}
}
function stopElapsedTimer(): void {
if (elapsedTimer) {
window.clearInterval(elapsedTimer);
elapsedTimer = 0;
}
}
function startElapsedTimer(): void {
stopElapsedTimer();
nowSeconds.value = Date.now() / 1000;
elapsedTimer = window.setInterval(() => {
nowSeconds.value = Date.now() / 1000;
}, 100);
}
async function pollJob(id: string): Promise<void> {
stopPolling();
try {
currentJob.value = await getJob(baseUrl.value, id);
serviceOnline.value = true;
if (currentStatus.value === "queued" || currentStatus.value === "generating") {
pollTimer = window.setTimeout(() => pollJob(id), normalizePollIntervalMs(pollIntervalMs.value));
clearMessage();
return;
}
stopElapsedTimer();
if (currentStatus.value === "completed") {
setMessage(currentJob.value?.kind === "vid_gen" ? "Video generation completed." : "Image generation completed.", "success");
return;
}
if (currentStatus.value === "cancelled") {
setMessage("Job cancelled.", "error");
return;
}
if (currentStatus.value === "failed") {
setMessage(currentJob.value?.error?.message || "Generation failed.", "error");
}
} catch (error) {
stopElapsedTimer();
serviceOnline.value = false;
setMessage(error instanceof Error ? error.message : String(error), "error");
}
}
async function generate(): Promise<void> {
try {
const request = buildRequestBodyForMode(generationMode.value, form);
clearMessage();
selectedOutputIndex.value = 0;
startElapsedTimer();
currentJob.value = generationMode.value === "video"
? await submitVideoJob(baseUrl.value, request)
: await submitImageJob(baseUrl.value, request);
await pollJob(currentJob.value.id);
} catch (error) {
stopElapsedTimer();
setMessage(error instanceof Error ? error.message : String(error), "error");
}
}
async function cancelCurrentJob(): Promise<void> {
if (!currentJob.value?.id) {
return;
}
try {
currentJob.value = await cancelJob(baseUrl.value, currentJob.value.id);
stopPolling();
stopElapsedTimer();
setMessage("Job cancelled.", "error");
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error), "error");
}
}
function addLora(): void {
form.lora.push({
path: availableLoras.value[0]?.path || "",
multiplier: 1,
is_high_noise: false,
});
}
function removeLora(index: number): void {
form.lora.splice(index, 1);
}
async function assignImages(target: ImageTarget, files: FileList): Promise<void> {
const images = await filesToImageEntries(files);
if (!images.length) {
return;
}
assignImageEntries(form, target, images);
}
function clearImage(target: ImageTarget): void {
clearImageEntries(form, target);
}
function getFormImage(target: ImageTarget): ImageEntry | null {
if (target === "ref_images" || target === "control_frames") return null;
return form[target];
}
function openImageEntry(image: ImageEntry | null): void {
openLightbox(image?.dataUrl, image?.name);
}
function removeCollectionImage(target: "ref_images" | "control_frames", index: number): void {
removeImageEntry(form, target, index);
}
function selectOutput(index: number): void {
selectedOutputIndex.value = index;
}
function formatUnixTime(seconds: number | undefined): string {
if (!seconds) {
return "No job";
}
return new Date(seconds * 1000).toLocaleString();
}
function formatElapsed(started: number | undefined, completed: number | undefined): string {
if (!started) {
return "Idle";
}
const end = completed || nowSeconds.value;
const total = Math.max(0, end - started);
if (total < 60) {
return `${total.toFixed(1)}s`;
}
const minutes = Math.floor(total / 60);
const seconds = total - minutes * 60;
return `${minutes}m ${seconds.toFixed(1)}s`;
}
function formatSummaryNumber(value: number, digits = 3): string {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return "0";
}
return Number(numeric.toFixed(digits)).toString();
}
function downloadSelected(): void {
if (!downloadableSrc.value) {
return;
}
const link = document.createElement("a");
link.href = downloadableSrc.value;
link.download = `${currentJob.value?.id || "output"}.${currentJob.value?.result?.output_format || form.output_format}`;
document.body.appendChild(link);
link.click();
link.remove();
}
function openLightbox(src: string | null | undefined = previewImageSrc.value, alt = "Expanded image"): void {
if (!src) {
return;
}
lightboxImageSrc.value = src;
lightboxImageAlt.value = alt;
lightboxOpen.value = true;
}
function closeLightbox(): void {
lightboxOpen.value = false;
lightboxImageSrc.value = "";
lightboxImageAlt.value = "";
}
async function onPaste(event: ClipboardEvent): Promise<void> {
if (!event.clipboardData?.files?.length) {
return;
}
await assignImages("init_image", event.clipboardData.files);
setMessage("Pasted image into init_image.", "success");
}
watch(generationMode, (mode) => {
ensureOutputFormatForMode(mode);
});
watch(imageOutputFormats, () => {
ensureOutputFormatForMode();
});
watch(videoOutputFormats, () => {
ensureOutputFormatForMode();
});
onMounted(() => {
window.addEventListener("paste", onPaste);
refreshCapabilities();
});
onBeforeUnmount(() => {
stopPolling();
stopElapsedTimer();
window.removeEventListener("paste", onPaste);
});
</script>
<template>
<div class="shell">
<header class="page-header panel">
<div class="page-header__top">
<div class="page-header__copy">
<div class="breadcrumb">
<span class="breadcrumb__org">stable-diffusion.cpp</span>
<span class="breadcrumb__slash">/</span>
<span>{{ modelName }}</span>
</div>
<h1 class="page-title">{{ modelName }}</h1>
<p class="page-description">
Native async image and video generation interface for the local `stable-diffusion.cpp` server.
</p>
</div>
<div class="page-header__meta">
<StatusChip :status="serviceOnline ? 'online' : 'offline'" :label="serviceOnline ? 'service online' : 'service unavailable'" />
<StatusChip :label="`queue ${queueLimit}`" />
<StatusChip :status="currentStatus" :label="currentStatus" />
</div>
</div>
<div class="page-tabs">
<div class="page-tabs__list">
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'image' }" type="button" @click="selectGenerationMode('image')" :disabled="!supportsImageMode">Image Generation</button>
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'video' }" type="button" @click="selectGenerationMode('video')" :disabled="!supportsVideoMode">Video Generation</button>
<button class="page-tab" :class="{ 'page-tab--active': activeTab === 'settings' }" type="button" @click="activeTab = 'settings'">Settings</button>
</div>
<div class="page-tabs__actions">
<button class="btn-secondary" type="button" @click="refreshCapabilities" :disabled="loadingCapabilities">{{ loadingCapabilities ? "Refreshing..." : "Refresh Server Info" }}</button>
</div>
</div>
<div v-if="activeTab === 'settings'" class="settings">
<div class="settings__grid">
<div class="field">
<label>Base URL</label>
<input v-model="baseUrl" placeholder="Leave blank to use same origin" />
</div>
<div class="field">
<label>Queue Limit</label>
<input :value="queueLimit" readonly />
</div>
<div class="field">
<label>Output Formats ({{ generationMode === "video" ? "video" : "image" }})</label>
<input :value="outputFormats.join(', ')" readonly />
</div>
<div class="field">
<label>Output Format</label>
<select v-model="form.output_format">
<option v-for="format in outputFormats" :key="format" :value="format">{{ format }}</option>
</select>
</div>
<div class="field">
<label>Output Compression</label>
<input v-model.number="form.output_compression" type="number" min="0" max="100" />
</div>
<div class="field">
<label>Job Poll Interval (ms)</label>
<input v-model.number="pollIntervalMs" type="number" min="1" step="1" />
</div>
</div>
<div v-if="capabilitiesError" class="status-message status-message--error">{{ capabilitiesError }}</div>
</div>
</header>
<div v-if="activeTab !== 'settings'" class="layout">
<section class="panel control-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">Input</h2>
</div>
</div>
<div class="prompt-card">
<div class="field--full">
<label>Prompt</label>
<textarea v-model="form.prompt" :placeholder="generationMode === 'video' ? 'Describe the motion, scene, and framing you want to generate' : 'Describe the image you want to generate'" />
</div>
<div class="field--full stack-top">
<label>Negative Prompt</label>
<textarea v-model="form.negative_prompt" placeholder="What should be excluded?" />
</div>
</div>
<div class="fields stack-top">
<div class="field"><label>Width</label><input v-model.number="form.width" type="number" min="64" /></div>
<div class="field"><label>Height</label><input v-model.number="form.height" type="number" min="64" /></div>
</div>
<div v-if="generationMode === 'image'" class="fields stack-top">
<div class="field"><label>Batch Count</label><input v-model.number="form.batch_count" type="number" min="1" /></div>
<div class="field"><label>Seed</label><input v-model.number="form.seed" type="number" /></div>
</div>
<div v-else class="fields stack-top">
<div class="field"><label>Video Frames</label><input v-model.number="form.video_frames" type="number" min="1" /></div>
<div class="field"><label>FPS</label><input v-model.number="form.fps" type="number" min="1" /></div>
</div>
<div v-if="generationMode === 'video'" class="field stack-top">
<label>Seed</label>
<input v-model.number="form.seed" type="number" />
</div>
<CollapsibleSection class="stack-top" eyebrow="Sample" :summary="sampleSummary" :open="sectionState.sample" variant="module" @toggle="toggleSection('sample')">
<div class="fields">
<div class="field">
<label>Scheduler</label>
<select v-model="form.sample_params.scheduler">
<option value="default">default</option>
<option v-for="scheduler in schedulers" :key="scheduler" :value="scheduler">{{ scheduler }}</option>
</select>
</div>
<div class="field"><label>Flow Shift</label><input v-model="form.sample_params.flow_shift" type="number" step="0.01" placeholder="blank = default" /></div>
<div class="field">
<label>Method</label>
<select v-model="form.sample_params.sample_method">
<option value="default">default</option>
<option v-for="sampler in samplers" :key="sampler" :value="sampler">{{ sampler }}</option>
</select>
</div>
<div class="field"><label>Steps</label><input v-model.number="form.sample_params.sample_steps" type="number" /></div>
</div>
<div class="sample-panel__extras">
<button class="module-card__link" type="button" @click="toggleSection('sampleAdvanced')">
{{ sectionState.sampleAdvanced ? "Hide extras" : "Show extras" }}
</button>
</div>
<div v-if="sectionState.sampleAdvanced" class="fields">
<div class="field"><label>Eta</label><input v-model="form.sample_params.eta" type="number" step="0.01" placeholder="blank = default" /></div>
<div class="field"><label>Shifted Timestep</label><input v-model.number="form.sample_params.shifted_timestep" type="number" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Guidance" :summary="guidanceSummary" :open="sectionState.guidance" variant="module" @toggle="toggleSection('guidance')">
<div class="fields">
<div class="field"><label>CFG Scale</label><input v-model.number="form.sample_params.guidance.txt_cfg" type="number" step="0.1" /></div>
<div class="field"><label>Distilled Guidance</label><input v-model.number="form.sample_params.guidance.distilled_guidance" type="number" step="0.1" /></div>
</div>
<div class="sample-panel__extras">
<button class="module-card__link" type="button" @click="toggleSection('guidanceAdvanced')">
{{ sectionState.guidanceAdvanced ? "Hide extras" : "Show extras" }}
</button>
</div>
<div v-if="sectionState.guidanceAdvanced" class="fields">
<div class="field"><label>Image CFG</label><input v-model="form.sample_params.guidance.img_cfg" type="number" step="0.1" placeholder="blank = follow text cfg" /></div>
<div class="field"><label>SLG Layers</label><input v-model="form.sample_params.guidance.slg_layers" placeholder="7,8,9" /></div>
<div class="field"><label>SLG Layer Start</label><input v-model.number="form.sample_params.guidance.layer_start" type="number" step="0.01" /></div>
<div class="field"><label>SLG Layer End</label><input v-model.number="form.sample_params.guidance.layer_end" type="number" step="0.01" /></div>
<div class="field"><label>SLG Scale</label><input v-model.number="form.sample_params.guidance.scale" type="number" step="0.01" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection v-if="generationMode === 'video'" class="stack-top" eyebrow="High Noise Pass" :summary="highNoiseSummary" :open="sectionState.highNoise" variant="module" @toggle="toggleSection('highNoise')">
<div class="field">
<label>MoE Boundary</label>
<input v-model.number="form.moe_boundary" type="number" step="0.001" />
</div>
<CollapsibleSection eyebrow="Sample" :summary="buildSampleSummary(form.high_noise_sample_params)" :open="sectionState.highNoiseSample" variant="plain" @toggle="toggleSection('highNoiseSample')">
<div class="fields">
<div class="field">
<label>Scheduler</label>
<select v-model="form.high_noise_sample_params.scheduler">
<option value="default">default</option>
<option v-for="scheduler in schedulers" :key="`high-noise-${scheduler}`" :value="scheduler">{{ scheduler }}</option>
</select>
</div>
<div class="field"><label>Flow Shift</label><input v-model="form.high_noise_sample_params.flow_shift" type="number" step="0.01" placeholder="blank = default" /></div>
<div class="field">
<label>Method</label>
<select v-model="form.high_noise_sample_params.sample_method">
<option value="default">default</option>
<option v-for="sampler in samplers" :key="`high-noise-${sampler}`" :value="sampler">{{ sampler }}</option>
</select>
</div>
<div class="field"><label>Steps</label><input v-model.number="form.high_noise_sample_params.sample_steps" type="number" /></div>
<div class="field"><label>Eta</label><input v-model="form.high_noise_sample_params.eta" type="number" step="0.01" placeholder="blank = auto" /></div>
<div class="field"><label>Shifted Timestep</label><input v-model.number="form.high_noise_sample_params.shifted_timestep" type="number" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Guidance" :summary="buildGuidanceSummary(form.high_noise_sample_params)" :open="sectionState.highNoiseGuidance" variant="plain" @toggle="toggleSection('highNoiseGuidance')">
<div class="fields">
<div class="field"><label>CFG Scale</label><input v-model.number="form.high_noise_sample_params.guidance.txt_cfg" type="number" step="0.1" /></div>
<div class="field"><label>Distilled Guidance</label><input v-model.number="form.high_noise_sample_params.guidance.distilled_guidance" type="number" step="0.1" /></div>
<div class="field"><label>Image CFG</label><input v-model="form.high_noise_sample_params.guidance.img_cfg" type="number" step="0.1" placeholder="blank = follow text cfg" /></div>
<div class="field"><label>SLG Layers</label><input v-model="form.high_noise_sample_params.guidance.slg_layers" placeholder="7,8,9" /></div>
<div class="field"><label>SLG Layer Start</label><input v-model.number="form.high_noise_sample_params.guidance.layer_start" type="number" step="0.01" /></div>
<div class="field"><label>SLG Layer End</label><input v-model.number="form.high_noise_sample_params.guidance.layer_end" type="number" step="0.01" /></div>
<div class="field"><label>SLG Scale</label><input v-model.number="form.high_noise_sample_params.guidance.scale" type="number" step="0.01" /></div>
</div>
</CollapsibleSection>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Conditioning" :summary="conditioningSummary" :open="sectionState.conditioning" @toggle="toggleSection('conditioning')">
<div class="fields">
<div class="field"><label>CLIP Skip</label><input v-model.number="form.clip_skip" type="number" /></div>
<div class="field"><label>Strength</label><input v-model.number="form.strength" type="number" step="0.01" /></div>
<div v-if="generationMode === 'image'" class="field"><label>Control Strength</label><input v-model.number="form.control_strength" type="number" step="0.01" /></div>
<div v-if="generationMode === 'video'" class="field"><label>VACE Strength</label><input v-model.number="form.vace_strength" type="number" step="0.01" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="LoRA" :summary="loraSummary" :open="sectionState.lora" @toggle="toggleSection('lora')">
<div v-if="!availableLoras.length" class="hint">No LoRA entries were returned by capabilities.</div>
<div v-else-if="!form.lora.length" class="hint">No LoRA overrides configured.</div>
<div v-else class="list-editor">
<div class="list-row list-row--header">
<div>LoRA</div>
<div>Multiplier</div>
<div>High Noise</div>
<div></div>
</div>
<div v-for="(item, index) in form.lora" :key="index" class="list-row">
<select v-model="item.path">
<option v-for="lora in availableLoras" :key="lora.path" :value="lora.path">
{{ lora.name }} ({{ lora.path }})
</option>
</select>
<input v-model.number="item.multiplier" type="number" step="0.1" />
<label class="checkbox list-row__checkbox">
<input v-model="item.is_high_noise" type="checkbox" />
<span>{{ item.is_high_noise ? "On" : "Off" }}</span>
</label>
<button class="btn-ghost" type="button" @click="removeLora(index)">Remove</button>
</div>
</div>
<div><button class="btn-ghost" type="button" @click="addLora" :disabled="!availableLoras.length">Add LoRA</button></div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Image Inputs" :summary="imageInputsSummary" :open="sectionState.auxiliaryImages" @toggle="toggleSection('auxiliaryImages')">
<div class="upload-grid">
<ImageDropzone
v-for="input in gridImageInputs"
:key="input.target"
:label="input.label"
:description="input.description"
:preview="getFormImage(input.target)"
@select="assignImages(input.target, $event)"
@clear="clearImage(input.target)"
@preview="openImageEntry($event)"
/>
</div>
<div v-for="input in fullImageInputs" :key="input.target">
<ImageDropzone
:label="input.label"
:description="input.description"
:preview="getFormImage(input.target)"
@select="assignImages(input.target, $event)"
@clear="clearImage(input.target)"
@preview="openImageEntry($event)"
/>
</div>
<div v-if="generationMode === 'image'" class="group">
<label>Reference Images</label>
<ImageDropzone
label="Reference Images"
description="Multiple reference images supported."
:items="form.ref_images"
multiple
@select="assignImages('ref_images', $event)"
@clear="clearImage('ref_images')"
/>
<div v-if="!form.ref_images.length" class="hint">No files selected.</div>
<div v-else class="file-list">
<div v-for="(item, index) in form.ref_images" :key="item.name + index" class="file-chip file-chip--preview">
<button class="file-chip__thumb-button" type="button" @click="openLightbox(item.dataUrl, item.name)">
<img class="file-chip__thumb" :src="item.dataUrl" :alt="item.name" />
</button>
<span class="file-chip__name">{{ item.name }}</span>
<button class="icon-button" type="button" @click="removeCollectionImage('ref_images', index)">Remove</button>
</div>
</div>
</div>
<div v-else class="group">
<label>Control Frames</label>
<ImageDropzone
label="Control Frames"
description="Upload ordered conditioning frames. The server preserves the array order."
:items="form.control_frames"
multiple
@select="assignImages('control_frames', $event)"
@clear="clearImage('control_frames')"
/>
<div v-if="!form.control_frames.length" class="hint">No files selected.</div>
<div v-else class="file-list">
<div v-for="(item, index) in form.control_frames" :key="item.name + index" class="file-chip file-chip--preview">
<button class="file-chip__thumb-button" type="button" @click="openLightbox(item.dataUrl, item.name)">
<img class="file-chip__thumb" :src="item.dataUrl" :alt="item.name" />
</button>
<span class="file-chip__name">{{ item.name }}</span>
<button class="icon-button" type="button" @click="removeCollectionImage('control_frames', index)">Remove</button>
</div>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="VAE Tiling" :summary="vaeTilingSummary" :open="sectionState.vaeTiling" @toggle="toggleSection('vaeTiling')">
<label class="checkbox"><input v-model="form.vae_tiling_params.enabled" type="checkbox" /><span>Enabled</span></label>
<div class="fields">
<div class="field"><label>Tile Size X</label><input v-model.number="form.vae_tiling_params.tile_size_x" type="number" /></div>
<div class="field"><label>Tile Size Y</label><input v-model.number="form.vae_tiling_params.tile_size_y" type="number" /></div>
</div>
<div class="field"><label>Target Overlap</label><input v-model.number="form.vae_tiling_params.target_overlap" type="number" step="0.01" /></div>
<div class="fields">
<div class="field"><label>Relative Size X</label><input v-model.number="form.vae_tiling_params.rel_size_x" type="number" step="0.01" /></div>
<div class="field"><label>Relative Size Y</label><input v-model.number="form.vae_tiling_params.rel_size_y" type="number" step="0.01" /></div>
</div>
</CollapsibleSection>
<CollapsibleSection class="stack-top" eyebrow="Cache" :summary="cacheSummary" :open="sectionState.cache" @toggle="toggleSection('cache')">
<div class="field">
<label>Mode</label>
<select v-model="form.cache.mode">
<option v-for="mode in CACHE_MODES" :key="mode" :value="mode">{{ mode }}</option>
</select>
</div>
<div class="field field--full"><label>Cache Option</label><input v-model="form.cache.option" placeholder="threshold=0.25,start=0.15,end=0.95" /></div>
<div class="field"><label>SCM Mask</label><input v-model="form.cache.scm_mask" /></div>
<label class="checkbox"><input v-model="form.cache.scm_policy_dynamic" type="checkbox" /><span>Dynamic SCM Policy</span></label>
</CollapsibleSection>
</section>
<section class="panel output-panel">
<div class="panel-header">
<div>
<h2 class="panel-title">Output</h2>
</div>
</div>
<div v-if="videoPreviewSrc" class="hero-frame hero-frame--media">
<video class="hero-frame__video" :src="videoPreviewSrc" controls autoplay loop muted playsinline />
</div>
<button v-else class="hero-frame hero-frame--button" type="button" :disabled="!previewImageSrc" @click="openLightbox(previewImageSrc, 'Generated output')">
<img v-if="previewImageSrc" :src="previewImageSrc" alt="Generated output" />
<div v-else class="hero-placeholder">
<h2>{{ generationMode === "video" ? "Generate Video" : "Generate Images" }}</h2>
<p>
{{ generationMode === "video"
? "Generated video or animated WebP output will appear here once the current job finishes."
: "Generated images will appear here once the current job finishes." }}
</p>
</div>
</button>
<div v-if="currentJobKind === 'vid_gen' && currentJob?.result?.output_format === 'avi'" class="hint stack-top">
Browser playback for AVI depends on codec support. Download the file if the preview cannot play.
</div>
<div class="metrics output-metrics">
<div class="metric">
<div class="metric__label">Status</div>
<div class="metric__value">{{ currentStatus }}</div>
</div>
<div class="metric">
<div class="metric__label">Queue</div>
<div class="metric__value">{{ currentJob?.queue_position ?? 0 }}</div>
</div>
<div class="metric">
<div class="metric__label">Created</div>
<div class="metric__value mono">{{ formatUnixTime(currentJob?.created) }}</div>
</div>
<div class="metric">
<div class="metric__label">Elapsed</div>
<div class="metric__value">{{ formatElapsed(currentJob?.started, currentJob?.completed) }}</div>
</div>
<div v-if="currentJobKind === 'vid_gen'" class="metric">
<div class="metric__label">FPS</div>
<div class="metric__value">{{ videoFps || "-" }}</div>
</div>
<div v-if="currentJobKind === 'vid_gen'" class="metric">
<div class="metric__label">Frames</div>
<div class="metric__value">{{ videoFrameCount || "-" }}</div>
</div>
</div>
<div v-if="statusMessage" class="status-message" :class="statusTone === 'error' ? 'status-message--error' : 'status-message--success'">{{ statusMessage }}</div>
<div v-else-if="currentJob?.error?.message" class="status-message status-message--error">{{ currentJob.error.message }}</div>
<div class="output-controls">
<button class="btn output-controls__primary" type="button" :disabled="isJobRunning" @click="generate">
{{ generationMode === "video" ? "Generate Video" : "Generate Image" }}
</button>
<div class="actions output-controls__secondary">
<button class="btn-secondary" type="button" :disabled="!downloadableSrc" @click="downloadSelected">Download</button>
<button class="btn-danger" type="button" :disabled="!canCancelCurrentJob" @click="cancelCurrentJob">Cancel</button>
</div>
</div>
<div v-if="currentImages.length > 1" class="thumb-row">
<button v-for="(image, index) in currentImages" :key="image.index" class="thumb" :class="{ 'thumb--active': index === selectedOutputIndex }" type="button" @click="selectOutput(index)">
<img :src="`data:image/${currentJob?.result?.output_format || 'png'};base64,${image.b64_json}`" :alt="`Output ${index + 1}`" />
</button>
</div>
</section>
</div>
<div v-if="lightboxOpen && lightboxImageSrc" class="lightbox" @click.self="closeLightbox">
<button class="lightbox__close" type="button" @click="closeLightbox">Close</button>
<img class="lightbox__image" :src="lightboxImageSrc" :alt="lightboxImageAlt" />
</div>
</div>
</template>