Compare commits

..

5 Commits

Author SHA1 Message Date
bymyself
1a84f3ddbf fix: remove debug console.warn statements from preview hot paths 2026-05-03 21:56:00 -07:00
bymyself
8052cf7b60 fix: dirty root canvas for subgraph LoadImage preview + add debug logging 2026-05-03 21:56:00 -07:00
bymyself
727cc3ed87 fix: stabilize LoadImage subgraph E2E test for CI
Load default workflow and pan to node before context menu
interaction, matching the pattern used by other subgraph tests.
2026-05-03 21:56:00 -07:00
bymyself
1d14fd7230 test: add Playwright test for LoadImage subgraph preview promotion
Verifies that converting a LoadImage node to a subgraph produces the
$$canvas-image-preview pseudo-widget in the subgraph node's
proxyWidgets, confirming the fix in canvasImagePreviewTypes.ts.
2026-05-03 21:55:37 -07:00
bymyself
b64aec26a2 fix: add LoadImage and LoadVideo to canvas preview node types
LoadImage and LoadVideo were missing from CANVAS_IMAGE_PREVIEW_NODE_TYPES,
so their previews were never promoted to parent subgraph nodes. This caused
promoted preview images to not display on subgraph nodes in graph mode and
app mode when the interior node was a LoadImage or LoadVideo.
2026-05-03 21:53:31 -07:00
28 changed files with 170 additions and 938 deletions

View File

@@ -69,50 +69,6 @@ test.describe('Homepage @smoke', () => {
).toBeVisible()
})
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
await expect(cta).toBeVisible()
const [columnBox, ctaBox] = await Promise.all([
contentColumn.boundingBox(),
cta.boundingBox()
])
expect(columnBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
})
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const subheading = contentColumn.getByText(
/Videos & case studies from teams/i
)
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
const [subBox, ctaBox] = await Promise.all([
subheading.boundingBox(),
cta.boundingBox()
])
expect(subBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()

View File

@@ -1,83 +0,0 @@
# Website Scripts
## `refresh-ashby-snapshot.ts`
Pulls the latest job postings from Ashby and writes
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
GitHub Actions workflow; also runnable locally via
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.
## `process-videos.sh`
Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
frame for marketing videos using `ffmpeg`. Run **locally** before
uploading the outputs to `media.comfy.org`; this is not wired into CI.
```sh
apps/website/scripts/process-videos.sh \
./video-sources \
./dist/videos \
"640 960 1280 1920"
```
### Output
For each source video at `./video-sources/foo.mp4`, you get:
```text
foo-640.webm foo-640.mp4
foo-960.webm foo-960.mp4
foo-1280.webm foo-1280.mp4
foo-1920.webm foo-1920.mp4
foo-poster.jpg
```
The naming convention is enforced by `buildVideoSources()` in
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
emit `<source>` URLs.
### Pairing with `<SiteVideo>`
Once the assets are uploaded, render them with:
```vue
<SiteVideo
name="foo"
base-url="https://media.comfy.org/website/marketing"
:width="1280"
:formats="['webm', 'mp4']"
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
autoplay
loop
/>
```
### `<SiteVideo>` vs `<VideoPlayer>`
- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
autoplay marketing clips. No custom controls, no captions UI.
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
fullscreen, and caption toggles. Use this for content with subtitles or
user-driven playback.
If you need both responsive sources and the rich `VideoPlayer` chrome, the
two are not yet combined; either pick one or extend `VideoPlayer` to accept
a source list.
### Encoder choices
- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
required for Safari iOS.
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
the video shows something while loading.
### Why a single resolution per video
`<source media="...">` inside `<video>` is unreliable across browsers
(Safari ignores it). The simplest correct strategy is to ship one
well-sized resolution and let CSS scale it down on smaller viewports.
The script generates multiple widths so you can pick a different one
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
JavaScript-based selection later if metrics demand it.

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env bash
#
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
# for every source video in a given directory. Intended to be run locally
# before uploading the outputs to media.comfy.org.
#
# Usage:
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
#
# Example:
# apps/website/scripts/process-videos.sh \
# ./video-sources \
# ./dist/videos \
# "640 960 1280 1920"
#
# Defaults to widths "1280" if omitted.
#
# Output naming matches buildVideoSources() in src/utils/video.ts:
# <name>-<width>.webm
# <name>-<width>.mp4
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
#
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.
set -euo pipefail
if [[ $# -lt 2 ]]; then
cat <<USAGE >&2
Usage: $0 <input-dir> <output-dir> [widths]
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
USAGE
exit 64
fi
input_dir=$1
output_dir=$2
widths=${3:-1280}
for tool in ffmpeg ffprobe; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found on PATH" >&2
exit 127
fi
done
if [[ ! -d $input_dir ]]; then
echo "error: input dir not found: $input_dir" >&2
exit 66
fi
mkdir -p "$output_dir"
shopt -s nullglob nocaseglob
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
shopt -u nullglob nocaseglob
if [[ ${#sources[@]} -eq 0 ]]; then
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
exit 66
fi
for src in "${sources[@]}"; do
name=$(basename "$src")
name=${name%.*}
echo "==> $name"
for w in $widths; do
webm_out="$output_dir/${name}-${w}.webm"
mp4_out="$output_dir/${name}-${w}.mp4"
echo " encoding ${w}w VP9/WebM -> $webm_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
-c:a libopus -b:a 96k \
-f webm "$webm_out"
echo " encoding ${w}w H.264/MP4 -> $mp4_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$mp4_out"
done
poster_out="$output_dir/${name}-poster.jpg"
duration_raw=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true)
if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then
duration="$duration_raw"
else
duration=0
fi
if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then
poster_seek=1
else
poster_seek=0
fi
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
ffmpeg -y -hide_banner -loglevel error \
-ss "$poster_seek" -i "$src" \
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
-q:v 4 \
"$poster_out"
done
echo "done. upload contents of $output_dir to media.comfy.org."

View File

@@ -1,51 +0,0 @@
# Marketing Assets
Source images committed here are processed by Astro at build time and emitted
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).
## Usage
Drop a high-resolution source image (PNG or JPG) here, then render it with
Astro's built-in `<Picture>` component plus the shared defaults:
```astro
---
import { Picture } from 'astro:assets'
import {
MARKETING_FORMATS,
MARKETING_WIDTHS
} from '../utils/marketingImage'
import hero from '../assets/marketing/hero.png'
---
<Picture
src={hero}
alt="ComfyUI workflow preview"
formats={[...MARKETING_FORMATS]}
widths={[...MARKETING_WIDTHS]}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```
The component generates a `<picture>` element with `<source>` tags for AVIF
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
`dist/_website/` for long-term caching.
A custom Astro wrapper component is intentionally not provided: Astro's
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
a thin wrapper that mutates `widths` / `formats` impractical to type safely
without `as` casts. The shared constants give us the same consistency benefit
without that cost.
## When to use this vs. `media.comfy.org`
- **Use `src/assets/marketing/`** for static marketing images that are part of
page content (hero shots, product imagery, illustrations). Build-time
processing gives you AVIF/WebP variants automatically.
- **Use `media.comfy.org`** for video content, large/changing image libraries
(gallery), and anything shared across properties.
## Source image guidelines
- Provide the largest size you'll ever need (≥1920px wide).
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
- Astro will downscale; it will not upscale. Always supply at least 1920w.

View File

@@ -88,7 +88,7 @@ const contactColumn = {
{ label: t('footer.sales', locale), href: routes.contact },
{
label: t('footer.support', locale),
href: externalLinks.support,
href: externalLinks.discord,
external: true
},
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { buildVideoSources, videoKey } from '../../utils/video'
import type { VideoFormat } from '../../utils/video'
const {
name,
baseUrl,
width = 1280,
formats = ['webm', 'mp4'],
poster,
alt,
autoplay = false,
loop = false,
muted = autoplay,
controls = false,
preload = autoplay ? 'auto' : 'metadata',
containerClass,
videoClass
} = defineProps<{
name: string
baseUrl: string
width?: number
formats?: VideoFormat[]
poster?: string
alt?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
controls?: boolean
preload?: 'auto' | 'metadata' | 'none'
containerClass?: string
videoClass?: string
}>()
const sources = computed(() =>
buildVideoSources({ name, baseUrl, width, formats })
)
const remountKey = computed(() => videoKey(sources.value))
const decorative = computed(() => !alt && !controls)
</script>
<template>
<div :class="cn('relative', containerClass)">
<video
:key="remountKey"
:class="cn('size-full', videoClass)"
:poster
:preload
:autoplay
:loop
:muted
:controls
:aria-label="alt"
:aria-hidden="decorative ? true : undefined"
playsinline
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
</video>
</div>
</template>

View File

@@ -35,10 +35,7 @@ const routes = getRoutes(locale)
</div>
<!-- Right: content -->
<div
data-testid="case-study-content"
class="flex flex-col justify-between p-6 lg:flex-1"
>
<div class="flex flex-col justify-between p-6 lg:flex-1">
<div class="flex flex-col gap-8">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
@@ -55,8 +52,12 @@ const routes = getRoutes(locale)
</p>
</div>
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
<BrandButton :href="routes.customers" variant="outline">
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
:href="routes.customers"
variant="outline"
class="flex-1 text-center"
>
{{ t('caseStudy.seeAll', locale) }}
</BrandButton>
</div>

View File

@@ -101,9 +101,17 @@ const features: IncludedFeature[] = [
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<div>
<p class="text-primary-comfy-canvas text-sm font-medium">
{{ t(feature.titleKey, locale) }}
</p>
<span
v-if="feature.isComingSoon"
class="text-primary-comfy-yellow mt-1 inline-block text-xs"
>
{{ t('pricing.included.comingSoon', locale) }}
</span>
</div>
</div>
<!-- Description -->

View File

@@ -35,7 +35,6 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/api-reference/cloud',
github: 'https://github.com/Comfy-Org/ComfyUI',
platform: 'https://platform.comfy.org',
support: 'https://support.comfy.org/hc/en-us',
workflows: 'https://comfy.org/workflows',
youtube: 'https://www.youtube.com/@ComfyOrg'
} as const

View File

@@ -914,9 +914,9 @@ const translations = {
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
},
'cloud.faq.3.a': {
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1280,6 +1280,10 @@ const translations = {
en: 'Run multiple workflows in parallel to speed up your pipeline.',
'zh-CN': '并行运行多个工作流,加速你的流程。'
},
'pricing.included.comingSoon': {
en: 'coming soon',
'zh-CN': '即将推出'
},
// VideoPlayer
'player.play': { en: 'Play', 'zh-CN': '播放' },

View File

@@ -1,3 +0,0 @@
export const MARKETING_FORMATS = ['avif', 'webp'] as const
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const

View File

@@ -1,111 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildVideoSources, videoKey } from './video'
describe('buildVideoSources', () => {
it('builds a source per requested format', () => {
const sources = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/website/marketing',
width: 1280,
formats: ['webm', 'mp4']
})
expect(sources).toEqual([
{
src: 'https://media.comfy.org/website/marketing/hero-1280.webm',
type: 'video/webm',
format: 'webm'
},
{
src: 'https://media.comfy.org/website/marketing/hero-1280.mp4',
type: 'video/mp4',
format: 'mp4'
}
])
})
it('preserves caller-supplied format order', () => {
const sources = buildVideoSources({
name: 'clip',
baseUrl: 'https://cdn.example.com/v',
width: 960,
formats: ['mp4', 'webm']
})
expect(sources.map((s) => s.format)).toEqual(['mp4', 'webm'])
})
it('strips a single trailing slash from baseUrl', () => {
const sources = buildVideoSources({
name: 'reel',
baseUrl: 'https://media.comfy.org/website/marketing/',
width: 1920,
formats: ['webm']
})
expect(sources[0]?.src).toBe(
'https://media.comfy.org/website/marketing/reel-1920.webm'
)
})
it('returns an empty list when no formats are requested', () => {
const sources = buildVideoSources({
name: 'x',
baseUrl: 'https://example.com',
width: 640,
formats: []
})
expect(sources).toEqual([])
})
})
describe('videoKey', () => {
it('changes when the source URL list changes', () => {
const at1280 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const at640 = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 640,
formats: ['webm', 'mp4']
})
expect(videoKey(at1280)).not.toBe(videoKey(at640))
})
it('is stable across repeated calls with the same inputs', () => {
const args = {
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4'] as const
}
expect(
videoKey(buildVideoSources({ ...args, formats: [...args.formats] }))
).toBe(videoKey(buildVideoSources({ ...args, formats: [...args.formats] })))
})
it('reflects format-order changes', () => {
const webmFirst = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['webm', 'mp4']
})
const mp4First = buildVideoSources({
name: 'hero',
baseUrl: 'https://media.comfy.org/m',
width: 1280,
formats: ['mp4', 'webm']
})
expect(videoKey(webmFirst)).not.toBe(videoKey(mp4First))
})
})

View File

@@ -1,49 +0,0 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
/** @knipIgnoreUsedByStackedPR */
export type VideoSource = {
src: string
type: `video/${VideoFormat}`
format: VideoFormat
}
const MIME_TYPES: Record<VideoFormat, VideoSource['type']> = {
webm: 'video/webm',
mp4: 'video/mp4'
}
type BuildArgs = {
name: string
baseUrl: string
width: number
formats: VideoFormat[]
}
/**
* Expects assets named `${name}-${width}.${format}` under `${baseUrl}/`,
* matching the output of `apps/website/scripts/process-videos.sh`.
*/
export function buildVideoSources({
name,
baseUrl,
width,
formats
}: BuildArgs): VideoSource[] {
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
return formats.map((format) => ({
src: `${base}/${name}-${width}.${format}`,
type: MIME_TYPES[format],
format
}))
}
/**
* Stable identifier for a list of video sources, suitable as a Vue `key`.
* Browsers do not reload a `<video>` when nested `<source>` children change;
* keying the parent forces a remount when the source set changes.
*/
export function videoKey(sources: VideoSource[]): string {
return sources.map((s) => s.src).join('|')
}

View File

@@ -6,7 +6,8 @@ import { TestIds } from '@e2e/fixtures/selectors'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import {
getPromotedWidgetNames,
getPromotedWidgetCount
getPromotedWidgetCount,
getPseudoPreviewWidgets
} from '@e2e/fixtures/utils/promotedWidgets'
async function expectPromotedWidgetNamesToContain(
@@ -93,6 +94,34 @@ test.describe(
'filename_prefix'
)
})
test('LoadImage node gets $$canvas-image-preview pseudo-widget promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Add a LoadImage node and convert to subgraph programmatically
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const node = window.LiteGraph!.createNode('LoadImage')!
node.pos = [300, 300]
graph.add(node)
const { node: subgraphNode } = graph.convertToSubgraph(
new Set([node])
)
return String(subgraphNode.id)
})
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(
comfyPage,
subgraphNodeId
)
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
})
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {

View File

@@ -54,9 +54,6 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically

View File

@@ -13569,7 +13569,7 @@ export interface components {
stream: boolean | null;
};
/** @enum {string} */
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "gpt-5.5" | "gpt-5.5-pro" | "chatgpt-4o-latest";
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "chatgpt-4o-latest";
MoonvalleyTextToVideoInferenceParams: {
/**
* @description Height of the generated video in pixels

View File

@@ -98,7 +98,6 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
@@ -108,7 +107,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
@@ -121,7 +119,6 @@ const onClearHistoryFromMenu = (close: () => void) => {
}
const onToggleDockedJobHistory = async (close: () => void) => {
trackFeatureUsed()
close()
try {
@@ -141,7 +138,6 @@ const onToggleDockedJobHistory = async (close: () => void) => {
}
const onToggleRunProgressBar = async () => {
trackFeatureUsed()
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value

View File

@@ -13,7 +13,7 @@
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="onUpdateSelectedJobTab"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@@ -50,7 +50,6 @@ import type {
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -82,7 +81,6 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -97,11 +95,6 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
emit('update:selectedJobTab', value)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)

View File

@@ -66,7 +66,6 @@ import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -94,7 +93,6 @@ const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const {
totalPercentFormatted,
@@ -190,7 +188,6 @@ const {
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const jobId = item.taskRef?.jobId
if (!jobId) return
@@ -212,7 +209,6 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
@@ -228,12 +224,10 @@ const setExpanded = (expanded: boolean) => {
}
const viewAllJobs = () => {
trackFeatureUsed()
setExpanded(true)
}
const toggleAssetsSidebar = () => {
trackFeatureUsed()
sidebarTabStore.toggleSidebarTab('assets')
}
@@ -263,14 +257,12 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
trackFeatureUsed()
await openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
// Capture pending jobIds before clearing
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
@@ -283,7 +275,6 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const tasks = queueStore.runningTasks
const jobIds = tasks
.map((task) => task.jobId)
@@ -307,7 +298,6 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
})
const onClearHistoryFromMenu = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
</script>

View File

@@ -122,7 +122,6 @@ import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
const {
hideShowAssetsAction = false,
@@ -148,7 +147,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
@@ -172,7 +170,6 @@ const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
trackFeatureUsed()
selectWorkflowFilter(value)
close()
}
@@ -182,7 +179,6 @@ const selectSortMode = (value: JobSortMode) => {
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
trackFeatureUsed()
selectSortMode(value)
close()
}

View File

@@ -2,16 +2,15 @@
<SidebarTabTemplate :title="$t('queue.jobHistory')">
<template #alt-title>
<div class="ml-auto flex shrink-0 items-center">
<JobHistoryActionsMenu @clear-history="onClearHistory" />
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
</div>
</template>
<template #header>
<div class="flex flex-col gap-2 pb-1">
<div class="px-3 py-2">
<JobFilterTabs
:selected-job-tab="selectedJobTab"
v-model:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="onUpdateSelectedJobTab"
/>
</div>
<JobFilterActions
@@ -82,14 +81,13 @@ import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem, JobTab } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -106,17 +104,6 @@ const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay')
const onClearHistory = () => {
trackFeatureUsed()
showQueueClearHistoryDialog()
}
const onUpdateSelectedJobTab = (value: JobTab) => {
trackFeatureUsed()
selectedJobTab.value = value
}
const {
selectedJobTab,
selectedWorkflowFilter,
@@ -158,7 +145,6 @@ const activeQueueSummary = computed(() => {
})
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
trackFeatureUsed()
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
@@ -174,7 +160,6 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
@@ -209,12 +194,10 @@ const { jobMenuEntries, cancelJob } = useJobMenu(
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
await cancelJob(item)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
trackFeatureUsed()
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})

View File

@@ -4,7 +4,9 @@ export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
'GLSLShader',
'LoadImage',
'LoadVideo'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {

View File

@@ -203,6 +203,28 @@ describe('getPromotableWidgets', () => {
).toBe(true)
})
it('adds virtual canvas preview widget for LoadImage nodes', () => {
const node = new LGraphNode('LoadImage')
node.type = 'LoadImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for LoadVideo nodes', () => {
const node = new LGraphNode('LoadVideo')
node.type = 'LoadVideo'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
@@ -271,6 +293,25 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for LoadImage nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const loadImageNode = new LGraphNode('LoadImage')
loadImageNode.type = 'LoadImage'
subgraph.add(loadImageNode)
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(loadImageNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the

View File

@@ -633,14 +633,6 @@ export function useMediaAssetActions() {
)
if (hasOutputAssets) {
const succeededOutputIds = assetArray
.filter(
(a, i) =>
getAssetType(a) === 'output' &&
results[i].status === 'fulfilled'
)
.map((a) => a.id)
assetsStore.removeHistoryItems(succeededOutputIds)
await assetsStore.updateHistory()
}
if (hasInputAssets) {

View File

@@ -11,12 +11,6 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
triggerThreshold: 3,
delayMs: 5000
},
'queue-progress-overlay': {
featureId: 'queue-progress-overlay',
typeformId: 'HZ5saxry',
triggerThreshold: 16,
delayMs: 5000
},
'error-panel': {
featureId: 'error-panel',
typeformId: 'iFp4p4mV',

View File

@@ -110,6 +110,13 @@ export const useImageUploadWidget = () => {
isAnimated
})
node.graph?.setDirtyCanvas(true)
// When this node is inside a subgraph, also dirty the root canvas so
// the parent SubgraphNode redraws and picks up the new preview via
// its onDrawBackground → updatePreviews loop.
const rootGraph = node.graph?.rootGraph
if (rootGraph && rootGraph !== node.graph) {
rootGraph.setDirtyCanvas(true)
}
}
// On load if we have a value then render the image

View File

@@ -426,93 +426,6 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('Deletion', () => {
it('should remove items by id from history', async () => {
const mockHistory = Array.from({ length: 5 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(5)
store.removeHistoryItems(['prompt_1', 'prompt_3'])
expect(store.historyAssets).toHaveLength(3)
const ids = store.historyAssets.map((a) => a.id)
expect(ids).not.toContain('prompt_1')
expect(ids).not.toContain('prompt_3')
})
it('should adjust pagination offset after deletion', async () => {
const mockHistory = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
// Delete 3 items — offset shifts from 200 to 197 so the next page
// request lines up with the server's post-deletion cursor and we don't
// skip the rows that backfilled into the old page-1 boundary.
store.removeHistoryItems(['prompt_1', 'prompt_3', 'prompt_5'])
expect(store.historyAssets).toHaveLength(197)
const nextBatch = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(nextBatch)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 197 })
})
it('should compose deletion offset with drift on next loadMore', async () => {
// Initial page 1 (200 items: prompt_0 .. prompt_199)
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
// 3 net-new items merge in via a refresh — drift becomes 3.
const newJobs = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(7000 + i)
)
const refreshedPage1 = [...newJobs, ...page1.slice(0, 197)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
// Delete 2 loaded items — historyOffset should drop from 200 to 198.
store.removeHistoryItems(['prompt_10', 'prompt_20'])
// Next loadMore offset must compose deletion + drift:
// adjustedOffset = (200 - 2) + 3 = 201
// If historyOffset is not decremented the request becomes 203 and the
// server rows at the new positions 201202 are silently skipped.
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 201 })
})
it('should allow re-inserting a removed item on next updateHistory', async () => {
const mockHistory = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
store.removeHistoryItems(['prompt_1'])
expect(store.historyAssets).toHaveLength(2)
// Simulate the item reappearing from the server (e.g. delete failed)
vi.mocked(api.getHistory).mockResolvedValueOnce(mockHistory)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(3)
})
})
describe('Error Handling', () => {
it('should preserve existing data when loadMore fails', async () => {
// First successful load - full batch
@@ -629,142 +542,6 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
describe('Incremental Update', () => {
it('should preserve loaded items when updateHistory is called after pagination', async () => {
// Use indices 100-299 for page1 so there's room for a "newer" item
const page1 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(100 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(300 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(store.historyAssets).toHaveLength(400)
// Index 0 → Date.now() - 0ms, which is newer than index 100+
const newJob = createMockJobItem(0)
const refreshedPage1 = [newJob, ...page1.slice(0, 199)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(401)
expect(store.historyAssets[0].id).toBe('prompt_0')
})
it('should not duplicate items when updateHistory fetches overlapping data', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([...page1])
await store.updateHistory()
expect(store.historyAssets).toHaveLength(200)
})
it('should adjust offset for loadMore after new items are prepended', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const newJobs = Array.from({ length: 5 }, (_, i) =>
createMockJobItem(5000 + i)
)
const refreshedPage1 = [...newJobs, ...page1.slice(0, 195)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 205 })
})
it('should preserve drift counter when loadMore fails', async () => {
const page1 = Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
vi.mocked(api.getHistory).mockResolvedValueOnce(page1)
await store.updateHistory()
const newJobs = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(6000 + i)
)
const refreshedPage1 = [...newJobs, ...page1.slice(0, 197)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshedPage1)
await store.updateHistory()
// loadMore fails — drift counter should be preserved
vi.mocked(api.getHistory).mockRejectedValueOnce(new Error('fail'))
await store.loadMoreHistory()
// Retry loadMore — should still include the drift adjustment
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 203 })
})
it('should re-enable pagination when history grows past one page', async () => {
// Initial load with fewer than BATCH_SIZE items — pagination exhausted
const smallPage = Array.from({ length: 50 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(smallPage)
await store.updateHistory()
expect(store.hasMoreHistory).toBe(false)
// Many new jobs arrive — first page is now full (BATCH_SIZE items)
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(7000 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(fullPage)
await store.updateHistory()
expect(store.hasMoreHistory).toBe(true)
})
it('should not accumulate drift when pagination is exhausted', async () => {
const smallPage = Array.from({ length: 50 }, (_, i) =>
createMockJobItem(i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(smallPage)
await store.updateHistory()
// Subsequent update with a few new items, still under BATCH_SIZE
const newJobs = Array.from({ length: 3 }, (_, i) =>
createMockJobItem(8000 + i)
)
const refreshed = [...newJobs, ...smallPage.slice(0, 47)]
vi.mocked(api.getHistory).mockResolvedValueOnce(refreshed)
await store.updateHistory()
// Re-enable pagination with a full page
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(9000 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(fullPage)
await store.updateHistory()
// loadMore should use offset 200 (no drift accumulated while exhausted)
const page2 = Array.from({ length: 200 }, (_, i) =>
createMockJobItem(200 + i)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(page2)
await store.loadMoreHistory()
expect(api.getHistory).toHaveBeenLastCalledWith(200, { offset: 200 })
})
})
describe('jobDetailView Support', () => {
it('should include outputCount and allOutputs in user_metadata', async () => {
const mockHistory = Array.from({ length: 5 }, (_, i) =>

View File

@@ -94,7 +94,7 @@ export const useAssetsStore = defineStore('assets', () => {
// Track assets currently being deleted (for loading overlay)
const deletingAssetIds = shallowReactive(new Set<string>())
function setAssetDeleting(assetId: string, isDeleting: boolean) {
const setAssetDeleting = (assetId: string, isDeleting: boolean) => {
if (isDeleting) {
deletingAssetIds.add(assetId)
} else {
@@ -102,7 +102,7 @@ export const useAssetsStore = defineStore('assets', () => {
}
}
function isAssetDeleting(assetId: string): boolean {
const isAssetDeleting = (assetId: string): boolean => {
return deletingAssetIds.has(assetId)
}
@@ -110,7 +110,6 @@ export const useAssetsStore = defineStore('assets', () => {
const historyOffset = ref(0)
const hasMoreHistory = ref(true)
const isLoadingMore = ref(false)
const prependedSinceLastLoad = ref(0)
const allHistoryItems = ref<AssetItem[]>([])
@@ -140,124 +139,67 @@ export const useAssetsStore = defineStore('assets', () => {
}
/**
* Insert an asset into the sorted list at the correct position (newest first).
* Skips duplicates already tracked in loadedIds.
* @returns true if the asset was inserted, false if it was a duplicate.
* Fetch history assets with pagination support
* @param loadMore - true for pagination (append), false for initial load (replace)
*/
function insertAssetSorted(asset: AssetItem): boolean {
if (loadedIds.has(asset.id)) return false
loadedIds.add(asset.id)
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
// Reset state for initial load
if (!loadMore) {
historyOffset.value = 0
hasMoreHistory.value = true
allHistoryItems.value = []
loadedIds.clear()
}
const assetTime = new Date(asset.created_at ?? 0).getTime()
// Sort: newer first; ties broken by lexicographically larger id first
// so insertion order is stable across repeated merges.
const insertIndex = allHistoryItems.value.findIndex((item) => {
const itemTime = new Date(item.created_at ?? 0).getTime()
if (itemTime !== assetTime) return itemTime < assetTime
return item.id < asset.id
// Fetch from server with offset
const history = await api.getHistory(BATCH_SIZE, {
offset: historyOffset.value
})
if (insertIndex === -1) {
allHistoryItems.value.push(asset)
} else {
allHistoryItems.value.splice(insertIndex, 0, asset)
}
return true
}
/**
* Remove items from the local view (used after the server confirmed the
* delete). Decrements historyOffset by the count of removed loaded items so
* the canonical caller flow — `removeHistoryItems(ids)` then
* `await updateHistory()` then `loadMoreHistory()` — produces the correct
* server offset:
*
* After deletion, the refreshed first page that `updateHistory()` fetches
* gets backfilled with items that previously sat just past the page-1
* boundary. Those backfilled items legitimately count as drift. If we
* leave `historyOffset` untouched, `loadMoreHistory()` then computes
* `historyOffset + drift` which double-counts the deletion: it adds the
* backfilled items on top of the original cursor and skips that many
* unseen rows on the next page.
*/
function removeHistoryItems(ids: string[]) {
const idSet = new Set(ids)
const removedCount = allHistoryItems.value.filter((item) =>
idSet.has(item.id)
).length
allHistoryItems.value = allHistoryItems.value.filter(
(item) => !idSet.has(item.id)
)
ids.forEach((id) => loadedIds.delete(id))
historyOffset.value = Math.max(0, historyOffset.value - removedCount)
historyAssets.value = allHistoryItems.value
}
function trimToMaxItems() {
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
const removed = allHistoryItems.value.splice(MAX_HISTORY_ITEMS)
removed.forEach((item) => loadedIds.delete(item.id))
}
}
/**
* Fetch the first page of history. On initial load (empty state), replaces
* all items. On subsequent calls, incrementally merges new items without
* clearing existing pagination state — fixing the "disappearing assets" bug.
*/
async function fetchHistoryIncremental(): Promise<void> {
const history = await api.getHistory(BATCH_SIZE, { offset: 0 })
// Convert JobListItems to AssetItems
const newAssets = mapHistoryToAssets(history)
if (allHistoryItems.value.length === 0) {
if (loadMore) {
// Filter out duplicates and insert in sorted order
for (const asset of newAssets) {
if (loadedIds.has(asset.id)) {
continue // Skip duplicates
}
loadedIds.add(asset.id)
// Find insertion index to maintain sorted order (newest first)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
// Asset is oldest, append to end
allHistoryItems.value.push(asset)
} else {
// Insert at the correct position
allHistoryItems.value.splice(insertIndex, 0, asset)
}
}
} else {
// Initial load: replace all
allHistoryItems.value = newAssets
newAssets.forEach((asset) => loadedIds.add(asset.id))
historyOffset.value = BATCH_SIZE
hasMoreHistory.value = history.length === BATCH_SIZE
} else {
let newCount = 0
for (const asset of newAssets) {
if (insertAssetSorted(asset)) newCount++
}
if (hasMoreHistory.value) {
prependedSinceLastLoad.value += newCount
} else if (history.length === BATCH_SIZE) {
hasMoreHistory.value = true
prependedSinceLastLoad.value = 0
}
}
trimToMaxItems()
}
/**
* Fetch the next page of history for infinite scroll pagination.
* Adjusts offset to account for items prepended since the last page load.
*/
async function fetchHistoryNextPage(): Promise<void> {
const driftAtRequest = prependedSinceLastLoad.value
const adjustedOffset = historyOffset.value + driftAtRequest
const history = await api.getHistory(BATCH_SIZE, {
offset: adjustedOffset
})
const newAssets = mapHistoryToAssets(history)
// Subtract only the drift captured at request time so concurrent
// updateHistory() increments during the in-flight fetch are preserved.
prependedSinceLastLoad.value = Math.max(
0,
prependedSinceLastLoad.value - driftAtRequest
)
for (const asset of newAssets) {
insertAssetSorted(asset)
}
historyOffset.value = adjustedOffset + BATCH_SIZE
// Update pagination state
historyOffset.value += BATCH_SIZE
hasMoreHistory.value = history.length === BATCH_SIZE
trimToMaxItems()
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
// Clean up Set
removed.forEach((item) => loadedIds.delete(item.id))
}
return allHistoryItems.value
}
const historyAssets = ref<AssetItem[]>([])
@@ -265,19 +207,18 @@ export const useAssetsStore = defineStore('assets', () => {
const historyError = ref<unknown>(null)
/**
* Load or refresh history assets. On first call, performs a full load.
* On subsequent calls (e.g. after job completion), incrementally merges
* new items without clearing existing pagination state.
* Initial load of history assets
*/
async function updateHistory() {
const updateHistory = async () => {
historyLoading.value = true
historyError.value = null
try {
await fetchHistoryIncremental()
await fetchHistoryAssets(false)
historyAssets.value = allHistoryItems.value
} catch (err) {
console.error('Error fetching history assets:', err)
historyError.value = err
// Keep existing data when error occurs
if (!historyAssets.value.length) {
historyAssets.value = []
}
@@ -289,18 +230,20 @@ export const useAssetsStore = defineStore('assets', () => {
/**
* Load more history items (infinite scroll)
*/
async function loadMoreHistory() {
const loadMoreHistory = async () => {
// Guard: prevent concurrent loads and check if more items available
if (!hasMoreHistory.value || isLoadingMore.value) return
isLoadingMore.value = true
historyError.value = null
try {
await fetchHistoryNextPage()
await fetchHistoryAssets(true)
historyAssets.value = allHistoryItems.value
} catch (err) {
console.error('Error loading more history:', err)
historyError.value = err
// Keep existing data when error occurs (consistent with updateHistory)
if (!historyAssets.value.length) {
historyAssets.value = []
}
@@ -804,7 +747,6 @@ export const useAssetsStore = defineStore('assets', () => {
updateInputs,
updateHistory,
loadMoreHistory,
removeHistoryItems,
// Input mapping helpers
inputAssetsByFilename,