Skip to content

Commit 6e30d44

Browse files
authored
Merge branch 'main' into fix/zh-main-translation-corrections
2 parents 1864de6 + 8f68be5 commit 6e30d44

19 files changed

Lines changed: 1663 additions & 105 deletions

packages/shared-frontend-utils/src/formatUtil.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
33
import {
44
appendWorkflowJsonExt,
55
ensureWorkflowSuffix,
6+
getFilePathSeparatorVariants,
67
getFilenameDetails,
78
getMediaTypeFromFilename,
89
getPathDetails,
910
highlightQuery,
1011
isCivitaiModelUrl,
1112
isPreviewableMediaType,
13+
joinFilePath,
1214
truncateFilename
1315
} from './formatUtil'
1416

@@ -299,6 +301,42 @@ describe('formatUtil', () => {
299301
})
300302
})
301303

304+
describe('joinFilePath', () => {
305+
it('joins subfolder and filename with normalized slash separators', () => {
306+
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
307+
'nested/folder/child/file.png'
308+
)
309+
})
310+
311+
it('trims boundary separators without changing the filename body', () => {
312+
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
313+
'nested/folder/file.png'
314+
)
315+
})
316+
317+
it('returns the normalized filename when no subfolder is provided', () => {
318+
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
319+
})
320+
321+
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
322+
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
323+
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
324+
})
325+
})
326+
327+
describe('getFilePathSeparatorVariants', () => {
328+
it('returns slash and backslash variants for nested paths', () => {
329+
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
330+
'nested/folder/file.png',
331+
'nested\\folder\\file.png'
332+
])
333+
})
334+
335+
it('returns a single value when no separator is present', () => {
336+
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
337+
})
338+
})
339+
302340
describe('appendWorkflowJsonExt', () => {
303341
it('appends .app.json when isApp is true', () => {
304342
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')

packages/shared-frontend-utils/src/formatUtil.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
256256
}
257257
}
258258

259+
export function joinFilePath(
260+
subfolder: string | null | undefined,
261+
filename: string | null | undefined
262+
): string {
263+
const normalizedSubfolder = normalizeFilePathSeparators(
264+
subfolder ?? ''
265+
).replace(/^\/+|\/+$/g, '')
266+
const normalizedFilename = normalizeFilePathSeparators(
267+
filename ?? ''
268+
).replace(/^\/+/g, '')
269+
if (!normalizedSubfolder) return normalizedFilename
270+
if (!normalizedFilename) return normalizedSubfolder
271+
return `${normalizedSubfolder}/${normalizedFilename}`
272+
}
273+
274+
export function getFilePathSeparatorVariants(filepath: string): string[] {
275+
const slashPath = normalizeFilePathSeparators(filepath)
276+
const backslashPath = slashPath.replace(/\//g, '\\')
277+
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
278+
}
279+
280+
function normalizeFilePathSeparators(filepath: string): string {
281+
return filepath.replace(/[\\/]+/g, '/')
282+
}
283+
259284
/**
260285
* Parses a filepath into its filename and subfolder components.
261286
*
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
274299
} {
275300
if (!filepath?.trim()) return { filename: '', subfolder: '' }
276301

277-
const normalizedPath = filepath
278-
.replace(/[\\/]+/g, '/') // Normalize path separators
302+
const normalizedPath = normalizeFilePathSeparators(filepath)
279303
.replace(/^\//, '') // Remove leading slash
280304
.replace(/\/$/, '') // Remove trailing slash
281305

src/composables/graph/useErrorClearingHooks.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
543543
}
544544
])
545545
const verifySpy = vi
546-
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
546+
.spyOn(missingMediaScan, 'verifyMediaCandidates')
547547
.mockImplementation(async (candidates) => {
548548
for (const c of candidates) c.isMissing = true
549549
})
@@ -686,7 +686,7 @@ describe('realtime verification staleness guards', () => {
686686
let resolveVerify: (() => void) | undefined
687687
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
688688
const verifySpy = vi
689-
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
689+
.spyOn(missingMediaScan, 'verifyMediaCandidates')
690690
.mockImplementation(async (candidates) => {
691691
await verifyPromise
692692
for (const c of candidates) c.isMissing = true

src/composables/graph/useErrorClearingHooks.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
2929
import {
3030
scanNodeMediaCandidates,
31-
verifyCloudMediaCandidates
31+
verifyMediaCandidates
3232
} from '@/platform/missingMedia/missingMediaScan'
3333
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
3434
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -209,8 +209,8 @@ function scanSingleNodeErrors(node: LGraphNode): void {
209209
if (confirmedMedia.length) {
210210
useMissingMediaStore().addMissingMedia(confirmedMedia)
211211
}
212-
// Cloud media scans always return isMissing: undefined pending
213-
// verification against the input-assets list.
212+
// Cloud media scans return pending for asset verification. OSS scans only
213+
// return pending for generated output/temp media.
214214
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
215215
if (pendingMedia.length) {
216216
void verifyAndAddPendingMedia(pendingMedia)
@@ -282,7 +282,7 @@ async function verifyAndAddPendingMedia(
282282
): Promise<void> {
283283
const rootGraphAtScan = app.rootGraph
284284
try {
285-
await verifyCloudMediaCandidates(pending)
285+
await verifyMediaCandidates(pending, { isCloud })
286286
if (app.rootGraph !== rootGraphAtScan) return
287287
const verified = pending.filter(
288288
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)

src/extensions/core/load3d.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import type {
99
CameraState
1010
} from '@/extensions/core/load3d/interfaces'
1111
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
12-
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
12+
import {
13+
LOAD3D_NONE_MODEL,
14+
SUPPORTED_EXTENSIONS_ACCEPT
15+
} from '@/extensions/core/load3d/constants'
1316
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
1417
import { t } from '@/i18n'
1518
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -290,13 +293,9 @@ useExtensionService().registerExtension({
290293
)
291294

292295
node.addWidget('button', 'clear', 'clear', () => {
293-
useLoad3d(node).waitForLoad3d((load3d) => {
294-
load3d.clearModel()
295-
})
296-
297296
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
298297
if (modelWidget) {
299-
modelWidget.value = ''
298+
modelWidget.value = LOAD3D_NONE_MODEL
300299
}
301300
})
302301

src/extensions/core/load3d/Load3DConfiguration.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,3 +594,91 @@ describe('Load3DConfiguration.configure forwards persisted + settings to load3d'
594594
expect(load3d.setLightIntensity).toHaveBeenCalledWith(9)
595595
})
596596
})
597+
598+
describe('Load3DConfiguration "none" model handling', () => {
599+
let load3d: Load3d
600+
let loadModelSpy: ReturnType<typeof vi.fn>
601+
let clearModelSpy: ReturnType<typeof vi.fn>
602+
603+
function makeLoad3dMock(): Load3d {
604+
loadModelSpy = vi.fn().mockResolvedValue(undefined)
605+
clearModelSpy = vi.fn()
606+
return {
607+
loadModel: loadModelSpy,
608+
clearModel: clearModelSpy,
609+
setUpDirection: vi.fn(),
610+
setMaterialMode: vi.fn(),
611+
setTargetSize: vi.fn(),
612+
setCameraState: vi.fn(),
613+
toggleGrid: vi.fn(),
614+
setBackgroundColor: vi.fn(),
615+
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
616+
setBackgroundRenderMode: vi.fn(),
617+
toggleCamera: vi.fn(),
618+
setFOV: vi.fn(),
619+
setLightIntensity: vi.fn(),
620+
setHDRIIntensity: vi.fn(),
621+
setHDRIAsBackground: vi.fn(),
622+
setHDRIEnabled: vi.fn(),
623+
emitModelReady: vi.fn()
624+
} as unknown as Load3d
625+
}
626+
627+
async function flush() {
628+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
629+
}
630+
631+
beforeEach(() => {
632+
load3d = makeLoad3dMock()
633+
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
634+
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
635+
})
636+
637+
afterEach(() => {
638+
vi.restoreAllMocks()
639+
})
640+
641+
it('does not load or clear a model when the initial widget value is "none"', async () => {
642+
const config = new Load3DConfiguration(load3d)
643+
config.configure({
644+
modelWidget: { value: 'none' } as unknown as IBaseWidget,
645+
loadFolder: 'input'
646+
})
647+
await flush()
648+
649+
expect(loadModelSpy).not.toHaveBeenCalled()
650+
expect(clearModelSpy).not.toHaveBeenCalled()
651+
})
652+
653+
it('clears the model (and skips loadModel) when the widget value changes to "none"', async () => {
654+
const config = new Load3DConfiguration(load3d)
655+
const widget = { value: 'model.glb' } as unknown as IBaseWidget
656+
config.configure({ modelWidget: widget, loadFolder: 'input' })
657+
await flush()
658+
659+
loadModelSpy.mockClear()
660+
clearModelSpy.mockClear()
661+
662+
widget.value = 'none'
663+
await flush()
664+
665+
expect(clearModelSpy).toHaveBeenCalledTimes(1)
666+
expect(loadModelSpy).not.toHaveBeenCalled()
667+
})
668+
669+
it('loads a model when the widget value transitions from "none" to a real path', async () => {
670+
const config = new Load3DConfiguration(load3d)
671+
const widget = { value: 'none' } as unknown as IBaseWidget
672+
config.configure({ modelWidget: widget, loadFolder: 'input' })
673+
await flush()
674+
675+
expect(loadModelSpy).not.toHaveBeenCalled()
676+
677+
widget.value = 'model.glb'
678+
await flush()
679+
680+
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
681+
silentOnNotFound: false
682+
})
683+
})
684+
})

src/extensions/core/load3d/Load3DConfiguration.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { LOAD3D_NONE_MODEL } from '@/extensions/core/load3d/constants'
12
import Load3d from '@/extensions/core/load3d/Load3d'
23
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
34
import type {
@@ -109,7 +110,7 @@ class Load3DConfiguration {
109110
cameraState,
110111
silentOnNotFound
111112
)
112-
if (modelWidget.value) {
113+
if (modelWidget.value && modelWidget.value !== LOAD3D_NONE_MODEL) {
113114
void onModelWidgetUpdate(modelWidget.value)
114115
}
115116

@@ -280,7 +281,10 @@ class Load3DConfiguration {
280281
) {
281282
let isFirstLoad = true
282283
return async (value: string | number | boolean | object) => {
283-
if (!value) return
284+
if (!value || value === LOAD3D_NONE_MODEL) {
285+
this.load3d.clearModel()
286+
return
287+
}
284288

285289
const { filename, folder } = parseAnnotatedFilename(
286290
value as string,

src/extensions/core/load3d/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@ export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
2222
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
2323
...SUPPORTED_HDRI_EXTENSIONS
2424
].join(',')
25+
26+
export const LOAD3D_NONE_MODEL = 'none'

src/platform/assets/services/assetService.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -480,12 +480,27 @@ function createAssetService() {
480480
includePublic: boolean = true,
481481
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
482482
): Promise<AssetItem[]> {
483-
const data = await handleAssetRequest(
483+
const data = await getAssetsPageByTag(tag, includePublic, {
484+
limit,
485+
offset,
486+
signal
487+
})
488+
489+
return data.assets
490+
}
491+
492+
/**
493+
* Gets one paginated asset response filtered by a specific tag.
494+
*/
495+
async function getAssetsPageByTag(
496+
tag: string,
497+
includePublic: boolean = true,
498+
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
499+
): Promise<AssetResponse> {
500+
return await handleAssetRequest(
484501
{ includeTags: [tag], limit, offset, includePublic, signal },
485502
`assets for tag ${tag}`
486503
)
487-
488-
return data.assets
489504
}
490505

491506
/**
@@ -511,16 +526,11 @@ function createAssetService() {
511526
while (true) {
512527
if (signal?.aborted) throw createAbortError()
513528

514-
const data = await handleAssetRequest(
515-
{
516-
includeTags: [tag],
517-
limit: pageSize,
518-
offset,
519-
includePublic,
520-
signal
521-
},
522-
`assets for tag ${tag}`
523-
)
529+
const data = await getAssetsPageByTag(tag, includePublic, {
530+
limit: pageSize,
531+
offset,
532+
signal
533+
})
524534
const batch = data.assets
525535
if (batch.length === 0) {
526536
return assets
@@ -935,6 +945,7 @@ function createAssetService() {
935945
getAssetsForNodeType,
936946
getAssetDetails,
937947
getAssetsByTag,
948+
getAssetsPageByTag,
938949
getAllAssetsByTag,
939950
getInputAssetsIncludingPublic,
940951
invalidateInputAssetsIncludingPublic,

0 commit comments

Comments
 (0)