mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-04 12:20:50 +00:00
Compare commits
5 Commits
infinite-s
...
fix/loadim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a84f3ddbf | ||
|
|
8052cf7b60 | ||
|
|
727cc3ed87 | ||
|
|
1d14fd7230 | ||
|
|
b64aec26a2 |
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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' }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': '播放' },
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const MARKETING_FORMATS = ['avif', 'webp'] as const
|
||||
|
||||
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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('|')
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 201–202 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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user