Compare commits

...

2 Commits

Author SHA1 Message Date
Will Miao 20417797e8 fix(download): accept UNet and Diffusion Model file types from CivitAI
- Prefer file type (UNet/Diffusion Model) over baseModel name when
  deciding whether a checkpoint routes to the unet folder
- Add UNet to backend primary file type whitelist
- Add Krea 2 to DIFFUSION_MODEL_BASE_MODELS
- Include UNet/Diffusion Model files in frontend file selection UI
- Use actual file type from CivitAI in download params instead of
  hardcoded 'Model'
2026-06-27 08:56:11 +08:00
Will Miao 004c69b9ef fix(marquee): use document coordinates, add auto-scroll, support VirtualScroller off-screen cards
- Convert marquee selection from viewport to document coordinates so
  scrolling during a drag no longer deselects off-screen cards.
- Add RAF-based auto-scroll when dragging near viewport edges.
- Compute off-screen card positions from VirtualScroller layout
  parameters instead of relying on DOM queries.
2026-06-27 08:21:21 +08:00
4 changed files with 200 additions and 41 deletions
+18 -4
View File
@@ -1288,10 +1288,24 @@ class DownloadManager:
"download_id": download_id, "download_id": download_id,
} }
# Check if this checkpoint should be treated as a diffusion model based on baseModel # Check if this checkpoint should be treated as a diffusion model
# Priority: (1) any file has type "UNet" or "Diffusion Model",
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
is_diffusion_model = False is_diffusion_model = False
if model_type == "checkpoint": if model_type == "checkpoint":
if base_model_value in DIFFUSION_MODEL_BASE_MODELS: # Check file types first (more direct signal from CivitAI)
version_files = version_info.get("files", [])
for f in version_files:
f_type = f.get("type", "")
if f_type in ("UNet", "Diffusion Model"):
is_diffusion_model = True
logger.info(
f"File type '{f_type}' detected, routing checkpoint to unet folder"
)
break
# Fallback to baseModel name check
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True is_diffusion_model = True
logger.info( logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder" f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
@@ -1420,7 +1434,7 @@ class DownloadManager:
f f
for f in files for f in files
if f.get("primary") if f.get("primary")
and f.get("type") in ("Model", "Negative", "Diffusion Model") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
), ),
None, None,
) )
@@ -1451,7 +1465,7 @@ class DownloadManager:
( (
f f
for f in files for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model") if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
), ),
None, None,
) )
+2
View File
@@ -147,6 +147,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Qwen", "Qwen",
"ZImageBase", "ZImageBase",
"ZImageTurbo", "ZImageTurbo",
# Krea 2 — loaded via UNETLoader in ComfyUI
"Krea 2",
] ]
) )
+176 -33
View File
@@ -21,6 +21,7 @@ export class BulkManager {
this.isMarqueeActive = false; this.isMarqueeActive = false;
this.isDragging = false; this.isDragging = false;
this.marqueeStart = { x: 0, y: 0 }; this.marqueeStart = { x: 0, y: 0 };
this.marqueeStartDoc = { x: 0, y: 0 }; // Marquee start in document coordinates
this.marqueeElement = null; this.marqueeElement = null;
this.initialSelectedModels = new Set(); this.initialSelectedModels = new Set();
@@ -29,6 +30,11 @@ export class BulkManager {
this.mouseDownTime = 0; this.mouseDownTime = 0;
this.mouseDownPosition = { x: 0, y: 0 }; this.mouseDownPosition = { x: 0, y: 0 };
// Auto-scroll properties for marquee
this.lastClientX = 0;
this.lastClientY = 0;
this.autoScrollRaf = null;
// Model type specific action configurations // Model type specific action configurations
this.actionConfig = { this.actionConfig = {
[MODEL_TYPES.LORA]: { [MODEL_TYPES.LORA]: {
@@ -168,7 +174,10 @@ export class BulkManager {
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => { eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
if (this.isMarqueeActive) { if (this.isMarqueeActive) {
this.lastClientX = e.clientX;
this.lastClientY = e.clientY;
this.updateMarqueeSelection(e); this.updateMarqueeSelection(e);
this.startAutoScroll();
} else if (this.mouseDownTime && !this.isDragging) { } else if (this.mouseDownTime && !this.isDragging) {
// Check if we've moved enough to consider it a drag // Check if we've moved enough to consider it a drag
const dx = e.clientX - this.mouseDownPosition.x; const dx = e.clientX - this.mouseDownPosition.x;
@@ -237,6 +246,7 @@ export class BulkManager {
* Clean up event handlers * Clean up event handlers
*/ */
cleanup() { cleanup() {
this.stopAutoScroll();
eventManager.removeAllHandlersForSource('bulkManager-keyboard'); eventManager.removeAllHandlersForSource('bulkManager-keyboard');
eventManager.removeAllHandlersForSource('bulkManager-marquee-start'); eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
eventManager.removeAllHandlersForSource('bulkManager-marquee-move'); eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
@@ -1727,10 +1737,15 @@ export class BulkManager {
* @param {boolean} isDragging - Whether this is triggered from a drag operation * @param {boolean} isDragging - Whether this is triggered from a drag operation
*/ */
startMarqueeSelection(e, isDragging = false) { startMarqueeSelection(e, isDragging = false) {
// Store initial mouse position // Store initial mouse position (viewport coordinates for visual element)
this.marqueeStart.x = this.mouseDownPosition.x; this.marqueeStart.x = this.mouseDownPosition.x;
this.marqueeStart.y = this.mouseDownPosition.y; this.marqueeStart.y = this.mouseDownPosition.y;
// Store initial mouse position in document coordinates (for logical selection)
const container = document.querySelector('.page-content');
this.marqueeStartDoc.x = this.mouseDownPosition.x + (container?.scrollLeft || 0);
this.marqueeStartDoc.y = this.mouseDownPosition.y + (container?.scrollTop || 0);
// Store initial selection state // Store initial selection state
this.initialSelectedModels = new Set(state.selectedModels); this.initialSelectedModels = new Set(state.selectedModels);
@@ -1776,46 +1791,67 @@ export class BulkManager {
*/ */
updateMarqueeSelection(e) { updateMarqueeSelection(e) {
if (!this.marqueeElement) return; if (!this.marqueeElement) return;
this.updateMarqueeSelectionFromPosition(e.clientX, e.clientY);
const currentX = e.clientX;
const currentY = e.clientY;
// Calculate rectangle bounds
const left = Math.min(this.marqueeStart.x, currentX);
const top = Math.min(this.marqueeStart.y, currentY);
const width = Math.abs(currentX - this.marqueeStart.x);
const height = Math.abs(currentY - this.marqueeStart.y);
// Update marquee element position and size
this.marqueeElement.style.left = left + 'px';
this.marqueeElement.style.top = top + 'px';
this.marqueeElement.style.width = width + 'px';
this.marqueeElement.style.height = height + 'px';
// Check which cards intersect with marquee
this.updateCardSelection(left, top, left + width, top + height);
} }
/** /**
* Update card selection based on marquee bounds * Update marquee from raw client coordinates (used by both mousemove and auto-scroll loop)
*/ */
updateCardSelection(left, top, right, bottom) { updateMarqueeSelectionFromPosition(clientX, clientY) {
const cards = document.querySelectorAll('.model-card'); if (!this.marqueeElement) return;
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
// Current position in document coordinates
const currentDocX = clientX + scrollX;
const currentDocY = clientY + scrollY;
// Calculate marquee rectangle in document coordinates
const docLeft = Math.min(this.marqueeStartDoc.x, currentDocX);
const docTop = Math.min(this.marqueeStartDoc.y, currentDocY);
const docRight = Math.max(this.marqueeStartDoc.x, currentDocX);
const docBottom = Math.max(this.marqueeStartDoc.y, currentDocY);
// Update visual marquee element (position: fixed, so subtract scroll offset)
this.marqueeElement.style.left = (docLeft - scrollX) + 'px';
this.marqueeElement.style.top = (docTop - scrollY) + 'px';
this.marqueeElement.style.width = (docRight - docLeft) + 'px';
this.marqueeElement.style.height = (docBottom - docTop) + 'px';
// Check which cards intersect with marquee
this.updateCardSelection(docLeft, docTop, docRight, docBottom);
}
/**
* Update card selection based on marquee bounds (document coordinates).
* Uses dual detection: DOM cards for visible ones + VirtualScroller layout for off-screen cards.
*/
updateCardSelection(docLeft, docTop, docRight, docBottom) {
const vs = state.virtualScroller;
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
const newSelection = new Set(this.initialSelectedModels); const newSelection = new Set(this.initialSelectedModels);
const visibleFilepaths = new Set();
cards.forEach(card => { // Step 1: Process visible DOM cards using getBoundingClientRect + scroll offset
const rect = card.getBoundingClientRect(); document.querySelectorAll('.model-card').forEach(card => {
// Check if card intersects with marquee rectangle
const intersects = !(rect.right < left ||
rect.left > right ||
rect.bottom < top ||
rect.top > bottom);
const filepath = card.dataset.filepath; const filepath = card.dataset.filepath;
if (!filepath) return;
visibleFilepaths.add(filepath);
const rect = card.getBoundingClientRect();
const cardLeft = rect.left + scrollX;
const cardTop = rect.top + scrollY;
const cardRight = rect.right + scrollX;
const cardBottom = rect.bottom + scrollY;
const intersects = !(cardRight < docLeft || cardLeft > docRight ||
cardBottom < docTop || cardTop > docBottom);
if (intersects) { if (intersects) {
// Add to selection if intersecting
newSelection.add(filepath); newSelection.add(filepath);
card.classList.add('selected'); card.classList.add('selected');
@@ -1825,12 +1861,43 @@ export class BulkManager {
this.updateMetadataCacheFromCard(filepath, card); this.updateMetadataCacheFromCard(filepath, card);
} }
} else if (!this.initialSelectedModels.has(filepath)) { } else if (!this.initialSelectedModels.has(filepath)) {
// Remove from selection if not intersecting and wasn't initially selected
newSelection.delete(filepath); newSelection.delete(filepath);
card.classList.remove('selected'); card.classList.remove('selected');
} }
}); });
// Step 2: Process off-screen cards via VirtualScroller layout calculation.
// Since VirtualScroller removes off-screen DOM elements, we compute
// each card's position from its index and the VS layout parameters.
if (vs?.gridElement && vs.items && vs.columnsCount > 0) {
const gridRect = vs.gridElement.getBoundingClientRect();
// Grid origin in scroll-container content coordinates
const originX = gridRect.left + scrollX;
const originY = gridRect.top + scrollY;
for (let i = 0; i < vs.items.length; i++) {
const filepath = vs.items[i]?.file_path;
if (!filepath || visibleFilepaths.has(filepath)) continue;
const row = Math.floor(i / vs.columnsCount);
const col = i % vs.columnsCount;
const cLeft = originX + col * (vs.itemWidth + vs.columnGap);
const cTop = originY + (vs.containerPaddingTop || 0) + row * (vs.itemHeight + (vs.rowGap || 0));
const cRight = cLeft + vs.itemWidth;
const cBottom = cTop + vs.itemHeight;
const intersects = !(cRight < docLeft || cLeft > docRight ||
cBottom < docTop || cTop > docBottom);
if (intersects) {
newSelection.add(filepath);
} else if (!this.initialSelectedModels.has(filepath)) {
newSelection.delete(filepath);
}
}
}
// Update global selection state // Update global selection state
state.selectedModels = newSelection; state.selectedModels = newSelection;
@@ -1849,6 +1916,9 @@ export class BulkManager {
this.isDragging = false; this.isDragging = false;
this.mouseDownTime = 0; this.mouseDownTime = 0;
// Stop any active auto-scroll
this.stopAutoScroll();
// Update event manager state // Update event manager state
eventManager.setState('marqueeActive', false); eventManager.setState('marqueeActive', false);
@@ -1874,6 +1944,79 @@ export class BulkManager {
// Clear initial selection state // Clear initial selection state
this.initialSelectedModels.clear(); this.initialSelectedModels.clear();
} }
/**
* Start auto-scroll loop when mouse approaches viewport edge during marquee
*/
startAutoScroll() {
if (this.autoScrollRaf) return;
this.autoScrollLoop();
}
/**
* Stop auto-scroll loop
*/
stopAutoScroll() {
if (this.autoScrollRaf) {
cancelAnimationFrame(this.autoScrollRaf);
this.autoScrollRaf = null;
}
}
/**
* Auto-scroll loop: scrolls the page when mouse is near viewport edges
* and re-evaluates marquee selection after each scroll.
*/
autoScrollLoop() {
if (!this.isMarqueeActive) {
this.autoScrollRaf = null;
return;
}
const container = document.querySelector('.page-content');
if (!container) {
this.autoScrollRaf = null;
return;
}
const MARGIN = 30; // Px from edge to trigger scroll
const BASE_SPEED = 12; // Pixels per frame at edge boundary
const MAX_SPEED = 40; // Maximum scroll speed
const rect = container.getBoundingClientRect();
let dx = 0;
let dy = 0;
// Vertical auto-scroll - speed increases the further the cursor is past the edge
if (this.lastClientY !== undefined) {
if (this.lastClientY < rect.top + MARGIN) {
const dist = Math.max(0, (rect.top + MARGIN) - this.lastClientY);
dy = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
} else if (this.lastClientY > rect.bottom - MARGIN) {
const dist = Math.max(0, this.lastClientY - (rect.bottom - MARGIN));
dy = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
}
}
// Horizontal auto-scroll
if (this.lastClientX !== undefined) {
if (this.lastClientX < rect.left + MARGIN) {
const dist = Math.max(0, (rect.left + MARGIN) - this.lastClientX);
dx = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
} else if (this.lastClientX > rect.right - MARGIN) {
const dist = Math.max(0, this.lastClientX - (rect.right - MARGIN));
dx = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
}
}
if (dx !== 0 || dy !== 0) {
container.scrollBy(dx, dy);
// Re-evaluate marquee selection with the new scroll position
this.updateMarqueeSelectionFromPosition(this.lastClientX, this.lastClientY);
this.autoScrollRaf = requestAnimationFrame(() => this.autoScrollLoop());
} else {
this.autoScrollRaf = null;
}
}
} }
export const bulkManager = new BulkManager(); export const bulkManager = new BulkManager();
+4 -4
View File
@@ -351,7 +351,7 @@ export class DownloadManager {
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
// Count model-type files per version // Count model-type files per version
const modelFiles = (version.files || []).filter(f => f.type === 'Model'); const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {}; const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
const fileSize = version.modelSizeKB ? const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) : (version.modelSizeKB / 1024).toFixed(2) :
@@ -478,7 +478,7 @@ export class DownloadManager {
if (!version) return; if (!version) return;
this.currentVersion = version; this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => f.type === 'Model'); const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
document.getElementById('versionStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'none';
document.getElementById('fileSelectionStep').style.display = 'block'; document.getElementById('fileSelectionStep').style.display = 'block';
@@ -534,7 +534,7 @@ export class DownloadManager {
const version = this.currentVersion; const version = this.currentVersion;
if (!version) return; if (!version) return;
const modelFiles = (version.files || []).filter(f => f.type === 'Model'); const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value); this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
document.getElementById('fileSelectionStep').style.display = 'none'; document.getElementById('fileSelectionStep').style.display = 'none';
@@ -954,7 +954,7 @@ export class DownloadManager {
} }
if (!this.isBatchMode) { if (!this.isBatchMode) {
const fileParams = this.selectedFile ? { const fileParams = this.selectedFile ? {
type: 'Model', type: this.selectedFile.type || 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor', format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full', size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp, fp: this.selectedFile.metadata?.fp,