diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index 6b1e5364..b8061270 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -19,19 +19,77 @@ function escapeHtml(value) { .replace(/'/g, '''); } +function extractExtension(value) { + if (value == null) { + return ''; + } + const targets = []; + const stringValue = String(value); + if (stringValue) { + targets.push(stringValue); + stringValue.split(/[?&=]/).forEach(fragment => { + if (fragment) { + targets.push(fragment); + } + }); + } + + for (const target of targets) { + let candidate = target; + try { + candidate = decodeURIComponent(candidate); + } catch (error) { + // ignoring malformed sequences, fallback to raw value + } + const lastDot = candidate.lastIndexOf('.'); + if (lastDot === -1) { + continue; + } + const extension = candidate.slice(lastDot).toLowerCase(); + if (extension.includes('/') || extension.includes('\\')) { + continue; + } + return extension; + } + + return ''; +} + function isVideoUrl(url) { if (!url || typeof url !== 'string') { return false; } + + const candidates = new Set(); + const addCandidate = value => { + if (value == null) { + return; + } + const stringValue = String(value); + if (!stringValue) { + return; + } + candidates.add(stringValue); + }; + + addCandidate(url); + try { const parsed = new URL(url, window.location.origin); - const pathname = parsed.pathname || ''; - const extension = pathname.slice(pathname.lastIndexOf('.')).toLowerCase(); - return VIDEO_EXTENSIONS.includes(extension); + addCandidate(parsed.pathname); + parsed.searchParams.forEach(value => addCandidate(value)); } catch (error) { - const normalized = url.split('?')[0].toLowerCase(); - return VIDEO_EXTENSIONS.some(ext => normalized.endsWith(ext)); + // ignore parse errors and rely on fallbacks below } + + for (const candidate of candidates) { + const extension = extractExtension(candidate); + if (extension && VIDEO_EXTENSIONS.includes(extension)) { + return true; + } + } + + return false; } function formatDateLabel(value) { diff --git a/tests/frontend/components/modelVersionsTab.media.test.js b/tests/frontend/components/modelVersionsTab.media.test.js new file mode 100644 index 00000000..b8b3a287 --- /dev/null +++ b/tests/frontend/components/modelVersionsTab.media.test.js @@ -0,0 +1,149 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + MODEL_VERSIONS_MODULE, + API_FACTORY_MODULE, + DOWNLOAD_MANAGER_MODULE, + UI_HELPERS_MODULE, + STATE_MODULE, + I18N_HELPERS_MODULE, + UTILS_MODULE, +} = vi.hoisted(() => ({ + MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname, +})); + +vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({ + downloadManager: { + downloadVersionWithDefaults: vi.fn(), + }, +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), +})); + +const stateMock = { global: { settings: { autoplay_on_hover: false } } }; +vi.mock(STATE_MODULE, () => ({ + state: stateMock, +})); + +vi.mock(I18N_HELPERS_MODULE, () => ({ + translate: vi.fn((_, __, fallback) => fallback ?? ''), +})); + +vi.mock(UTILS_MODULE, () => ({ + formatFileSize: vi.fn(() => '1 MB'), +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + getModelApiClient: vi.fn(), +})); + +describe('ModelVersionsTab media rendering', () => { + let getModelApiClient; + let fetchModelUpdateVersions; + + beforeEach(async () => { + vi.resetModules(); + document.body.innerHTML = ` +
+
+
+
+
+ `; + stateMock.global.settings.autoplay_on_hover = false; + ({ getModelApiClient } = await import(API_FACTORY_MODULE)); + fetchModelUpdateVersions = vi.fn(); + getModelApiClient.mockReturnValue({ + fetchModelUpdateVersions, + setModelUpdateIgnore: vi.fn(), + setVersionUpdateIgnore: vi.fn(), + deleteModel: vi.fn(), + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders video preview when preview URL references a video file in a query parameter', async () => { + const previewUrl = '/api/lm/previews?path=%2Fhome%2Fexample%2Fvideo-preview.mp4'; + fetchModelUpdateVersions.mockResolvedValue({ + success: true, + record: { + shouldIgnore: false, + inLibraryVersionIds: [2], + versions: [ + { + versionId: 2, + name: 'v1.0', + previewUrl, + baseModel: 'SDXL', + sizeBytes: 1024, + isInLibrary: true, + shouldIgnore: false, + }, + ], + }, + }); + + const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE); + const controller = initVersionsTab({ + modalId: 'model-versions-modal', + modelType: 'loras', + modelId: 123, + currentVersionId: null, + }); + + await controller.load(); + + const videoElement = document.querySelector('.version-media video'); + expect(videoElement).toBeTruthy(); + expect(videoElement?.getAttribute('src')).toBe(previewUrl); + expect(document.querySelector('.version-media img')).toBeFalsy(); + }); + + it('renders image preview when preview URL does not reference a video', async () => { + const previewUrl = '/api/lm/previews?path=%2Fhome%2Fexample%2Fpreview-image.png'; + fetchModelUpdateVersions.mockResolvedValue({ + success: true, + record: { + shouldIgnore: false, + inLibraryVersionIds: [3], + versions: [ + { + versionId: 3, + name: 'v1.1', + previewUrl, + baseModel: 'SDXL', + sizeBytes: 2048, + isInLibrary: true, + shouldIgnore: false, + }, + ], + }, + }); + + const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE); + const controller = initVersionsTab({ + modalId: 'model-versions-modal', + modelType: 'loras', + modelId: 456, + currentVersionId: null, + }); + + await controller.load(); + + const imageElement = document.querySelector('.version-media img'); + expect(imageElement).toBeTruthy(); + expect(imageElement?.getAttribute('src')).toBe(previewUrl); + expect(document.querySelector('.version-media video')).toBeFalsy(); + }); +});