import { defineStore } from 'pinia' import { computed, ref, shallowRef, toRaw, toValue } from 'vue' import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs' import type { APITaskType, JobListItem, TaskType } from '@/platform/remote/comfyui/jobs/jobTypes' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ResultItem, StatusWsMessageStatus, TaskOutput } from '@/schemas/apiSchema' import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil' import { api } from '@/scripts/api' import { parseTaskOutput } from '@/stores/resultItemParsing' import type { ComfyApp } from '@/scripts/app' import { useExtensionService } from '@/services/extensionService' import { getJobDetail } from '@/services/jobOutputCache' import { useNodeOutputStore } from '@/stores/nodeOutputStore' import { useExecutionStore } from '@/stores/executionStore' import { useSettingStore } from '@/platform/settings/settingStore' import { getMediaTypeFromFilename } from '@/utils/formatUtil' enum TaskItemDisplayStatus { Running = 'Running', Pending = 'Pending', Completed = 'Completed', Failed = 'Failed', Cancelled = 'Cancelled' } interface ResultItemInit extends ResultItem { nodeId: NodeId mediaType: string format?: string frame_rate?: number display_name?: string content?: string } export class ResultItemImpl { filename: string subfolder: string type: string nodeId: NodeId // 'audio' | 'images' | ... mediaType: string display_name?: string // VHS output specific fields format?: string frame_rate?: number // text specific field content?: string constructor(obj: ResultItemInit) { this.filename = obj.filename ?? '' this.subfolder = obj.subfolder ?? '' this.type = obj.type ?? '' this.nodeId = obj.nodeId this.mediaType = obj.mediaType this.display_name = obj.display_name this.format = obj.format this.frame_rate = obj.frame_rate this.content = obj.content } get urlParams(): URLSearchParams { const params = new URLSearchParams() params.set('filename', this.filename) params.set('type', this.type) params.set('subfolder', this.subfolder) if (this.format) { params.set('format', this.format) } if (this.frame_rate) { params.set('frame_rate', this.frame_rate.toString()) } return params } /** * VHS advanced preview URL. `/viewvideo` endpoint is provided by VHS node. * * `/viewvideo` always returns a webm file. */ get vhsAdvancedPreviewUrl(): string { return api.apiURL('/viewvideo?' + this.urlParams) } get url(): string { if (!this.filename) return '' return api.apiURL('/view?' + this.urlParams) } get previewUrl(): string { if (!this.isImage) return this.url const params = new URLSearchParams(this.urlParams) appendCloudResParam(params, this.filename) return api.apiURL('/view?' + params) } get urlWithTimestamp(): string { return `${this.url}&t=${+new Date()}` } get isVhsFormat(): boolean { return !!this.format && !!this.frame_rate } get htmlVideoType(): string | undefined { if (this.isWebm) { return 'video/webm' } if (this.isMp4) { return 'video/mp4' } if (this.filename.endsWith('.mov')) { return 'video/quicktime' } if (this.isVhsFormat) { if (this.format?.endsWith('webm')) { return 'video/webm' } if (this.format?.endsWith('mp4')) { return 'video/mp4' } } return undefined } get htmlAudioType(): string | undefined { if (this.isMp3) { return 'audio/mpeg' } if (this.isWav) { return 'audio/wav' } if (this.isOgg) { return 'audio/ogg' } if (this.isFlac) { return 'audio/flac' } return undefined } get isWebm(): boolean { return this.filename.endsWith('.webm') } get isMp4(): boolean { return this.filename.endsWith('.mp4') } get isVideoBySuffix(): boolean { return getMediaTypeFromFilename(this.filename) === 'video' } get isImageBySuffix(): boolean { return getMediaTypeFromFilename(this.filename) === 'image' } get isMp3(): boolean { return this.filename.endsWith('.mp3') } get isWav(): boolean { return this.filename.endsWith('.wav') } get isOgg(): boolean { return this.filename.endsWith('.ogg') } get isFlac(): boolean { return this.filename.endsWith('.flac') } get isAudioBySuffix(): boolean { return getMediaTypeFromFilename(this.filename) === 'audio' } get isVideo(): boolean { const isVideoByType = this.mediaType === 'video' || !!this.format?.startsWith('video/') return ( this.isVideoBySuffix || (isVideoByType && !this.isImageBySuffix && !this.isAudioBySuffix) ) } get isImage(): boolean { return ( this.isImageBySuffix || (this.mediaType === 'images' && !this.isVideoBySuffix && !this.isAudioBySuffix) ) } get isAudio(): boolean { const isAudioByType = this.mediaType === 'audio' || !!this.format?.startsWith('audio/') return ( this.isAudioBySuffix || (isAudioByType && !this.isImageBySuffix && !this.isVideoBySuffix) ) } get is3D(): boolean { return getMediaTypeFromFilename(this.filename) === '3D' } get isText(): boolean { return this.mediaType === 'text' } get supportsPreview(): boolean { return ( this.isImage || this.isVideo || this.isAudio || this.is3D || this.isText ) } static filterPreviewable( outputs: readonly ResultItemImpl[] ): ResultItemImpl[] { return outputs.filter((o) => o.supportsPreview) } static findByUrl(items: readonly ResultItemImpl[], url?: string): number { if (!url) return 0 const idx = items.findIndex((o) => o.url === url) return idx >= 0 ? idx : 0 } } export class TaskItemImpl { readonly job: JobListItem readonly outputs: TaskOutput readonly flatOutputs: ReadonlyArray constructor( job: JobListItem, outputs?: TaskOutput, flatOutputs?: ReadonlyArray ) { this.job = job // If no outputs provided but job has preview_output, create synthetic outputs // using the real nodeId and mediaType from the backend response const effectiveOutputs = outputs ?? (job.preview_output ? { [job.preview_output.nodeId]: { [job.preview_output.mediaType]: [job.preview_output] } } : {}) this.outputs = effectiveOutputs this.flatOutputs = flatOutputs ?? this.calculateFlatOutputs() } calculateFlatOutputs(): ReadonlyArray { if (!this.outputs) { return [] } return parseTaskOutput(this.outputs) } /** All outputs that support preview (images, videos, audio, 3D, text) */ get previewableOutputs(): readonly ResultItemImpl[] { return ResultItemImpl.filterPreviewable(this.flatOutputs) } get previewOutput(): ResultItemImpl | undefined { const previewable = this.previewableOutputs // Prefer the last saved media file (most recent result) over temp previews return ( previewable.findLast((output) => output.type === 'output') ?? previewable.at(-1) ) } // Derive taskType from job status get taskType(): TaskType { switch (this.job.status) { case 'in_progress': return 'Running' case 'pending': return 'Pending' default: return 'History' } } get apiTaskType(): APITaskType { switch (this.taskType) { case 'Running': case 'Pending': return 'queue' case 'History': return 'history' } } get key() { return this.jobId + this.displayStatus } get jobId() { return this.job.id } get outputsCount(): number | undefined { return this.job.outputs_count ?? undefined } get status() { return this.job.status } get errorMessage(): string | undefined { return this.job.execution_error?.exception_message ?? undefined } get executionError() { return this.job.execution_error ?? undefined } get workflowId(): string | undefined { return this.job.workflow_id ?? undefined } get createTime(): number { return this.job.create_time } get interrupted(): boolean { return ( this.job.status === 'failed' && this.job.execution_error?.exception_type === 'InterruptProcessingException' ) } get isHistory() { return this.taskType === 'History' } get isRunning() { return this.taskType === 'Running' } get displayStatus(): TaskItemDisplayStatus { switch (this.job.status) { case 'in_progress': return TaskItemDisplayStatus.Running case 'pending': return TaskItemDisplayStatus.Pending case 'completed': return TaskItemDisplayStatus.Completed case 'failed': return TaskItemDisplayStatus.Failed case 'cancelled': return TaskItemDisplayStatus.Cancelled } } get executionStartTimestamp() { return this.job.execution_start_time ?? undefined } get executionEndTimestamp() { return this.job.execution_end_time ?? undefined } get executionTime() { if (!this.executionStartTimestamp || !this.executionEndTimestamp) { return undefined } return this.executionEndTimestamp - this.executionStartTimestamp } get executionTimeInSeconds() { return this.executionTime !== undefined ? this.executionTime / 1000 : undefined } /** * Loads full outputs for tasks that only have preview data * Returns a new TaskItemImpl with full outputs and execution status */ public async loadFullOutputs(): Promise { // Only load for history tasks (caller checks outputsCount > 1) if (!this.isHistory) { return this } const jobDetail = await getJobDetail(this.jobId) if (!jobDetail?.outputs) { return this } // Create new TaskItemImpl with full outputs return new TaskItemImpl(this.job, jobDetail.outputs) } public async loadWorkflow(app: ComfyApp) { if (!this.isHistory) { return } // Single fetch for both workflow and outputs (with caching) const jobDetail = await getJobDetail(this.jobId) const workflowData = await extractWorkflow(jobDetail) if (!workflowData) { return } await app.loadGraphData(toRaw(workflowData)) // Use full outputs from job detail, or fall back to existing outputs const outputsToLoad = jobDetail?.outputs ?? this.outputs if (!outputsToLoad) { return } const nodeOutputsStore = useNodeOutputStore() const rawOutputs = toRaw(outputsToLoad) for (const nodeExecutionId in rawOutputs) { nodeOutputsStore.setNodeOutputsByExecutionId( nodeExecutionId, rawOutputs[nodeExecutionId] ) } useExtensionService().invokeExtensions( 'onNodeOutputsUpdated', app.nodeOutputs ) } public flatten(): TaskItemImpl[] { if (this.displayStatus !== TaskItemDisplayStatus.Completed) { return [this] } return this.flatOutputs.map( (output: ResultItemImpl, i: number) => new TaskItemImpl( { ...this.job, id: `${this.jobId}-${i}` }, { [output.nodeId]: { [output.mediaType]: [output] } }, [output] ) ) } } export const useQueueStore = defineStore('queue', () => { // Use shallowRef because TaskItemImpl instances are immutable and arrays are // replaced entirely (not mutated), so deep reactivity would waste performance const runningTasks = shallowRef([]) const pendingTasks = shallowRef([]) const historyTasks = shallowRef([]) const hasFetchedHistorySnapshot = ref(false) const maxHistoryItems = ref(64) const isLoading = ref(false) // Single-flight coalescing: at most one fetch in flight at a time. // If update() is called while a fetch is running, the call is coalesced // and a single re-fetch fires after the current one completes. // This prevents both request spam and UI starvation (where a rapid stream // of calls causes every response to be discarded by a stale-request guard). let inFlight = false let dirty = false const tasks = computed( () => [ ...pendingTasks.value, ...runningTasks.value, ...historyTasks.value ] as TaskItemImpl[] ) const flatTasks = computed(() => tasks.value.flatMap((task: TaskItemImpl) => task.flatten()) ) const lastJobHistoryPriority = computed(() => historyTasks.value.length ? historyTasks.value[0].job.priority : -1 ) const hasPendingTasks = computed(() => pendingTasks.value.length > 0) const activeJobsCount = computed( () => pendingTasks.value.length + runningTasks.value.length ) const update = async () => { if (inFlight) { dirty = true return } inFlight = true dirty = false isLoading.value = true try { const [queueResult, historyResult] = await Promise.allSettled([ api.getQueue({ throwOnError: true }), api.getHistory(maxHistoryItems.value) ]) if (queueResult.status === 'fulfilled') { const queue = queueResult.value // API returns pre-sorted data (sort_by=create_time&order=desc) runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job)) pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job)) const appearedTasks = [...pendingTasks.value, ...runningTasks.value] const executionStore = useExecutionStore() appearedTasks.forEach((task) => { const jobIdString = String(task.jobId) const workflowId = task.workflowId if (workflowId && jobIdString) { executionStore.registerJobWorkflowIdMapping(jobIdString, workflowId) } }) const activeJobIds = new Set([ ...queue.Running.map((j) => j.id), ...queue.Pending.map((j) => j.id) ]) executionStore.reconcileInitializingJobs(activeJobIds) } else { console.error('Failed to fetch queue:', queueResult.reason) } if (historyResult.status === 'fulfilled') { const history = historyResult.value const currentHistory = toValue(historyTasks) // Sort by create_time descending and limit to maxItems const sortedHistory = [...history] .sort((a, b) => b.create_time - a.create_time) .slice(0, toValue(maxHistoryItems)) // Reuse existing TaskItemImpl instances or create new // Must recreate if outputs_count changed (e.g., API started returning it) const existingByJobId = new Map( currentHistory.map((impl) => [impl.jobId, impl]) ) const nextHistoryTasks = sortedHistory.map((job) => { const existing = existingByJobId.get(job.id) if (!existing) return new TaskItemImpl(job) // Recreate if outputs_count changed to ensure lazy loading works if (existing.outputsCount !== (job.outputs_count ?? undefined)) { return new TaskItemImpl(job) } return existing }) const isHistoryUnchanged = nextHistoryTasks.length === currentHistory.length && nextHistoryTasks.every( (task, index) => task === currentHistory[index] ) if (!isHistoryUnchanged) { historyTasks.value = nextHistoryTasks } hasFetchedHistorySnapshot.value = true } else { console.error('Failed to fetch history:', historyResult.reason) } } finally { isLoading.value = false inFlight = false if (dirty) { void update() } } } const clear = async ( targets: ('queue' | 'history')[] = ['queue', 'history'] ) => { if (targets.length === 0) { return } await Promise.all(targets.map((type) => api.clearItems(type))) await update() } const deleteTask = async (task: TaskItemImpl) => { await api.deleteItem(task.apiTaskType, task.jobId) await update() } return { runningTasks, pendingTasks, historyTasks, hasFetchedHistorySnapshot, maxHistoryItems, isLoading, tasks, flatTasks, lastJobHistoryPriority, hasPendingTasks, activeJobsCount, update, clear, delete: deleteTask } }) export const useQueuePendingTaskCountStore = defineStore( 'queuePendingTaskCount', { state: () => ({ count: 0 }), actions: { update(e: CustomEvent) { this.count = e.detail?.exec_info?.queue_remaining || 0 } } } ) export type AutoQueueMode = | 'disabled' | 'change' | 'instant-idle' | 'instant-running' export const isInstantMode = ( mode: AutoQueueMode ): mode is 'instant-idle' | 'instant-running' => mode === 'instant-idle' || mode === 'instant-running' export const isInstantRunningMode = ( mode: AutoQueueMode ): mode is 'instant-running' => mode === 'instant-running' export const useQueueSettingsStore = defineStore('queueSettingsStore', { state: () => ({ mode: 'disabled' as AutoQueueMode, batchCount: 1 }) }) export const useQueueUIStore = defineStore('queueUIStore', () => { const settingStore = useSettingStore() const isOverlayExpanded = computed({ get: () => settingStore.get('Comfy.Queue.History.Expanded'), set: (value) => settingStore.set('Comfy.Queue.History.Expanded', value) }) function toggleOverlay() { isOverlayExpanded.value = !isOverlayExpanded.value } return { isOverlayExpanded, toggleOverlay } })