mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-20 23:13:40 +00:00
Compare commits
7 Commits
fix/linear
...
feat/hdr-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f41c2ee27d | ||
|
|
c4db198875 | ||
|
|
040e490f02 | ||
|
|
90c523b4a3 | ||
|
|
1f759a758c | ||
|
|
44557fd138 | ||
|
|
90210292d7 |
@@ -5,7 +5,6 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
@@ -42,7 +41,6 @@ setup((app) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export class BaseDialog {
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,11 @@ export class BuilderSaveAsHelper {
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
// The icon-only X carries an aria-label, while the footer Close button
|
||||
// is named by its text — getByLabel only matches the former.
|
||||
this.dismissButton = this.successDialog.getByLabel('Close', {
|
||||
exact: true
|
||||
})
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
|
||||
@@ -38,7 +38,6 @@ export const TestIds = {
|
||||
settings: 'settings-dialog',
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
|
||||
@@ -99,15 +99,15 @@ async function mockShareableAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
|
||||
* auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
const dialogs = page.getByRole('dialog')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await mask.count()) === 0) break
|
||||
if ((await dialogs.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await mask
|
||||
await dialogs
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
|
||||
@@ -612,18 +612,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
|
||||
// around each individual gesture. Holding the modifiers down across all
|
||||
// three drags plus the intervening screenshot assertions could saturate the
|
||||
// main thread and stall a single mouse.move step past the test timeout, and
|
||||
// a mid-test failure would leave the modifiers stuck down. Releasing per
|
||||
// gesture matches the robust pattern used in canvasSettings.spec.ts.
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
{ x: 10, y: 280 },
|
||||
{ x: 10, y: 220 }
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-default-ctrl-shift.png'
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
})
|
||||
|
||||
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
|
||||
|
||||
@@ -44,16 +44,32 @@ describe('GlobalDialog renderer branching', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
it('renders the Reka branch when renderer is omitted (default)', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
key: 'renderer-default',
|
||||
title: 'Default renderer dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-escape-hatch',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'primevue' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Dialog migration regression net: the showConfirmDialog helper must open
|
||||
* its dialog through the Reka renderer with zeroed section padding (the
|
||||
* Confirm* sections carry their own). Catches accidental reverts of the
|
||||
* Phase 6 renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
|
||||
describe('showConfirmDialog Reka renderer opt-in', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
|
||||
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
|
||||
showConfirmDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('md')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards the confirm section components and caller props', () => {
|
||||
showConfirmDialog({
|
||||
key: 'confirm-test',
|
||||
headerProps: { title: 'Title' },
|
||||
props: { promptText: 'Prompt' },
|
||||
footerProps: { confirmText: 'Delete' }
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('confirm-test')
|
||||
expect(args.headerComponent).toBe(ConfirmHeader)
|
||||
expect(args.component).toBe(ConfirmBody)
|
||||
expect(args.footerComponent).toBe(ConfirmFooter)
|
||||
expect(args.headerProps).toEqual({ title: 'Title' })
|
||||
expect(args.props).toEqual({ promptText: 'Prompt' })
|
||||
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
@@ -11,7 +12,9 @@ interface ConfirmDialogOptions {
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
export function showConfirmDialog(
|
||||
options: ConfirmDialogOptions = {}
|
||||
): DialogInstance {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
@@ -23,11 +26,13 @@ export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
renderer: 'reka',
|
||||
size: 'md',
|
||||
// Confirm sections carry their own padding — zero out the dialog
|
||||
// chrome padding, like the PrimeVue `pt` overrides did.
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0',
|
||||
footerClass: 'p-0'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
258
src/components/hdr/HdrViewerContent.vue
Normal file
258
src/components/hdr/HdrViewerContent.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="flex size-full bg-base-background">
|
||||
<div class="relative flex-1">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="absolute size-full"
|
||||
data-testid="hdr-viewer-canvas"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="viewer.loading.value"
|
||||
class="absolute inset-0 flex items-center justify-center text-base-foreground"
|
||||
>
|
||||
{{ $t('g.loading') }}...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="viewer.error.value"
|
||||
role="alert"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--image-off] size-12" />
|
||||
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.pixel.value"
|
||||
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
|
||||
data-testid="hdr-pixel-readout"
|
||||
>
|
||||
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
|
||||
<div>
|
||||
{{ formatNum(viewer.pixel.value.r) }}
|
||||
{{ formatNum(viewer.pixel.value.g) }}
|
||||
{{ formatNum(viewer.pixel.value.b) }}
|
||||
<template v-if="viewer.pixel.value.a !== null">
|
||||
{{ formatNum(viewer.pixel.value.a) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
|
||||
<input
|
||||
v-model.number="viewer.exposureStops.value"
|
||||
type="range"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
:aria-label="$t('hdrViewer.exposure')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
@click="viewer.normalizeExposure"
|
||||
>
|
||||
{{ $t('hdrViewer.normalizeExposure') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.channel') }}</label>
|
||||
<select
|
||||
v-model="viewer.channel.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.channel')"
|
||||
>
|
||||
<option v-for="mode in channelModes" :key="mode" :value="mode">
|
||||
{{ channelLabels[mode] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
|
||||
<select
|
||||
v-model="viewer.gamut.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.sourceGamut')"
|
||||
>
|
||||
<option v-for="name in gamutNames" :key="name" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-dither"
|
||||
v-model="viewer.dither.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-dither" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.dither') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-clip"
|
||||
v-model="viewer.clipWarnings.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-clip" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.clipWarnings') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="histogramPath" class="space-y-2 p-2">
|
||||
<label>{{ $t('hdrViewer.histogram') }}</label>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
|
||||
>
|
||||
<path
|
||||
:d="histogramPath"
|
||||
:class="histogramColorClass"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.5"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.stats.value"
|
||||
class="space-y-1 p-2 text-xs tabular-nums"
|
||||
>
|
||||
<div v-if="viewer.dimensions.value" class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.resolution') }}</span>
|
||||
<span>{{ viewer.dimensions.value }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.min') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.min) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.max') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.max) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.mean') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.stdDev') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.nanCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.nan') }}</span>
|
||||
<span>{{ viewer.stats.value.nanCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.infCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.inf') }}</span>
|
||||
<span>{{ viewer.stats.value.infCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
|
||||
{{ $t('hdrViewer.fitView') }}
|
||||
</Button>
|
||||
<Button variant="secondary" class="flex-1" @click="handleDownload">
|
||||
{{ $t('g.downloadImage') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ChannelMode } from '@/composables/useHdrViewer'
|
||||
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
|
||||
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
|
||||
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
|
||||
const { imageUrl } = defineProps<{ imageUrl: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const viewer = useHdrViewer()
|
||||
const gamutNames = GAMUT_NAMES
|
||||
const channelModes = CHANNEL_MODES
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
const exposureLabel = computed(() => {
|
||||
const value = viewer.exposureStops.value
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
|
||||
)
|
||||
|
||||
const histogramColorClass = computed(() => {
|
||||
switch (viewer.channel.value) {
|
||||
case 'r':
|
||||
return 'text-red-500'
|
||||
case 'g':
|
||||
return 'text-green-500'
|
||||
case 'b':
|
||||
return 'text-blue-500'
|
||||
default:
|
||||
return 'text-base-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
|
||||
rgb: t('hdrViewer.channels.rgb'),
|
||||
r: t('hdrViewer.channels.r'),
|
||||
g: t('hdrViewer.channels.g'),
|
||||
b: t('hdrViewer.channels.b'),
|
||||
a: t('hdrViewer.channels.a'),
|
||||
luminance: t('hdrViewer.channels.luminance')
|
||||
}))
|
||||
|
||||
function formatNum(value: number): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
|
||||
? value.toExponential(3)
|
||||
: value.toFixed(4)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadFile(toFullResolutionUrl(imageUrl))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
|
||||
})
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
@@ -15,7 +15,14 @@ export interface PositionConfig {
|
||||
scale?: number
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
|
||||
interface UseAbsolutePositionReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updatePosition: (config: PositionConfig) => void
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(
|
||||
options: { useTransform?: boolean } = {}
|
||||
): UseAbsolutePositionReturn {
|
||||
const { useTransform = false } = options
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Rect {
|
||||
@@ -28,7 +28,26 @@ interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
interface UseDomClippingReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updateClipPath: (
|
||||
element: HTMLElement,
|
||||
canvasElement: HTMLCanvasElement,
|
||||
isSelected: boolean,
|
||||
selectedArea?: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
}
|
||||
) => void
|
||||
}
|
||||
|
||||
export function useDomClipping(
|
||||
options: ClippingOptions = {}
|
||||
): UseDomClippingReturn {
|
||||
const style = ref<CSSProperties>({})
|
||||
const { margin = 4 } = options
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { ComputedRef, CSSProperties, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
@@ -8,10 +8,23 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
const PREVIEW_WIDTH = 200
|
||||
const PREVIEW_MARGIN = 16
|
||||
|
||||
interface UseNodePreviewAndDragReturn {
|
||||
previewRef: Ref<HTMLElement | null>
|
||||
isHovered: Ref<boolean>
|
||||
isDragging: Ref<boolean>
|
||||
showPreview: ComputedRef<boolean>
|
||||
nodePreviewStyle: Ref<CSSProperties>
|
||||
sidebarLocation: ComputedRef<'left' | 'right'>
|
||||
handleMouseEnter: (e: MouseEvent) => void
|
||||
handleMouseLeave: () => void
|
||||
handleDragStart: (e: DragEvent) => void
|
||||
handleDragEnd: (e: DragEvent) => void
|
||||
}
|
||||
|
||||
export function useNodePreviewAndDrag(
|
||||
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
|
||||
panelRef?: Ref<HTMLElement | null>
|
||||
) {
|
||||
): UseNodePreviewAndDragReturn {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
|
||||
@@ -9,19 +9,13 @@ export const useQueueClearHistoryDialog = () => {
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: 'bg-transparent',
|
||||
style: 'padding: 0'
|
||||
}
|
||||
}
|
||||
// The content draws its own panel — neutralize the chrome box.
|
||||
contentClass: 'w-fit max-w-90 border-none bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
446
src/composables/useHdrViewer.ts
Normal file
446
src/composables/useHdrViewer.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import * as THREE from 'three'
|
||||
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
|
||||
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
|
||||
import {
|
||||
detectGamutFromChromaticities,
|
||||
gamutToSrgbMatrix
|
||||
} from '@/renderer/hdr/colorGamut'
|
||||
import {
|
||||
HDR_VIEWER_FRAGMENT_SHADER,
|
||||
HDR_VIEWER_VERTEX_SHADER
|
||||
} from '@/renderer/hdr/hdrViewerShader'
|
||||
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
|
||||
import {
|
||||
computeChannelHistograms,
|
||||
computeImageStats
|
||||
} from '@/renderer/hdr/hdrStats'
|
||||
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
|
||||
|
||||
const MIN_ZOOM = 0.05
|
||||
const MAX_ZOOM = 64
|
||||
|
||||
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
|
||||
|
||||
export const CHANNEL_MODES: ChannelMode[] = [
|
||||
'rgb',
|
||||
'r',
|
||||
'g',
|
||||
'b',
|
||||
'a',
|
||||
'luminance'
|
||||
]
|
||||
|
||||
const CHANNEL_INDEX: Record<ChannelMode, number> = {
|
||||
rgb: 0,
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3,
|
||||
a: 4,
|
||||
luminance: 5
|
||||
}
|
||||
|
||||
export interface PixelReadout {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number | null
|
||||
}
|
||||
|
||||
interface ExrTexData {
|
||||
header?: { chromaticities?: ChromaticityCoords }
|
||||
}
|
||||
|
||||
function createLoader(url: string) {
|
||||
const filename = getImageFilenameFromUrl(url)
|
||||
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
|
||||
const loader = new EXRLoader()
|
||||
loader.setDataType(THREE.FloatType)
|
||||
return loader
|
||||
}
|
||||
|
||||
function makeReader(
|
||||
data: ArrayLike<number>,
|
||||
type: THREE.TextureDataType
|
||||
): (index: number) => number {
|
||||
if (type === THREE.HalfFloatType) {
|
||||
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
|
||||
}
|
||||
return (index) => data[index]
|
||||
}
|
||||
|
||||
function loadHdrTexture(
|
||||
url: string
|
||||
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
createLoader(url).load(
|
||||
url,
|
||||
(texture, texData) => {
|
||||
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
|
||||
resolve({
|
||||
texture,
|
||||
gamut: detectGamutFromChromaticities(chromaticities)
|
||||
})
|
||||
},
|
||||
undefined,
|
||||
reject
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function useHdrViewer() {
|
||||
const exposureStops = ref(0)
|
||||
const dither = ref(true)
|
||||
const clipWarnings = ref(false)
|
||||
const gamut = ref<GamutName>('sRGB')
|
||||
const channel = ref<ChannelMode>('rgb')
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const dimensions = ref<string | null>(null)
|
||||
const stats = ref<ImageStats | null>(null)
|
||||
const histograms = shallowRef<ChannelHistograms | null>(null)
|
||||
const pixel = ref<PixelReadout | null>(null)
|
||||
|
||||
const histogram = computed<Uint32Array | null>(() => {
|
||||
const channelHistograms = histograms.value
|
||||
if (!channelHistograms) return null
|
||||
switch (channel.value) {
|
||||
case 'r':
|
||||
return channelHistograms.r
|
||||
case 'g':
|
||||
return channelHistograms.g
|
||||
case 'b':
|
||||
return channelHistograms.b
|
||||
case 'a':
|
||||
return channelHistograms.a
|
||||
default:
|
||||
return channelHistograms.luminance
|
||||
}
|
||||
})
|
||||
|
||||
const containerRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.OrthographicCamera | null = null
|
||||
let material: THREE.ShaderMaterial | null = null
|
||||
let mesh: THREE.Mesh | null = null
|
||||
let texture: THREE.Texture | null = null
|
||||
let imageAspect = 1
|
||||
let frameRequested = false
|
||||
|
||||
let readSample: ((index: number) => number) | null = null
|
||||
let imageWidth = 0
|
||||
let imageHeight = 0
|
||||
let imageChannels = 4
|
||||
|
||||
const raycaster = new THREE.Raycaster()
|
||||
const pointerNdc = new THREE.Vector2()
|
||||
|
||||
function requestRender() {
|
||||
if (!renderer || frameRequested) return
|
||||
frameRequested = true
|
||||
requestAnimationFrame(() => {
|
||||
frameRequested = false
|
||||
if (renderer && scene && camera) renderer.render(scene, camera)
|
||||
})
|
||||
}
|
||||
|
||||
function containerSize() {
|
||||
const el = containerRef.value
|
||||
return {
|
||||
width: el?.clientWidth || 1,
|
||||
height: el?.clientHeight || 1
|
||||
}
|
||||
}
|
||||
|
||||
function updateProjection() {
|
||||
if (!camera) return
|
||||
const { width, height } = containerSize()
|
||||
const halfH = 0.5
|
||||
const halfW = (0.5 * width) / height
|
||||
camera.left = -halfW
|
||||
camera.right = halfW
|
||||
camera.top = halfH
|
||||
camera.bottom = -halfH
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
if (!camera) return
|
||||
const { width, height } = containerSize()
|
||||
const containerAspect = width / height
|
||||
camera.zoom = Math.min(1, containerAspect / imageAspect)
|
||||
camera.position.set(0, 0, 1)
|
||||
camera.updateProjectionMatrix()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function applyUniforms() {
|
||||
if (!material) return
|
||||
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
|
||||
material.uniforms.uDither.value = dither.value
|
||||
material.uniforms.uClipWarnings.value = clipWarnings.value
|
||||
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
|
||||
const m = gamutToSrgbMatrix(gamut.value)
|
||||
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
|
||||
m[0],
|
||||
m[1],
|
||||
m[2],
|
||||
m[3],
|
||||
m[4],
|
||||
m[5],
|
||||
m[6],
|
||||
m[7],
|
||||
m[8]
|
||||
)
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function buildScene() {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
|
||||
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
renderer.setPixelRatio(window.devicePixelRatio)
|
||||
renderer.setClearColor(0x0a0a0a, 1)
|
||||
|
||||
scene = new THREE.Scene()
|
||||
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
|
||||
camera.position.set(0, 0, 1)
|
||||
|
||||
material = new THREE.ShaderMaterial({
|
||||
glslVersion: THREE.GLSL3,
|
||||
vertexShader: HDR_VIEWER_VERTEX_SHADER,
|
||||
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
|
||||
uniforms: {
|
||||
uImage: { value: null },
|
||||
uGamutToSRGB: { value: new THREE.Matrix3() },
|
||||
uGain: { value: 1 },
|
||||
uChannel: { value: 0 },
|
||||
uDither: { value: true },
|
||||
uClipWarnings: { value: false },
|
||||
uClipRange: { value: new THREE.Vector2(0, 1) }
|
||||
}
|
||||
})
|
||||
|
||||
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
|
||||
scene.add(mesh)
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!renderer) return
|
||||
const { width, height } = containerSize()
|
||||
renderer.setSize(width, height, false)
|
||||
updateProjection()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function setTexture(loaded: THREE.DataTexture) {
|
||||
if (!material || !mesh) return
|
||||
loaded.colorSpace = THREE.LinearSRGBColorSpace
|
||||
loaded.minFilter = THREE.LinearFilter
|
||||
loaded.magFilter = THREE.LinearFilter
|
||||
loaded.needsUpdate = true
|
||||
|
||||
const { width, height, data } = loaded.image
|
||||
texture = loaded
|
||||
imageAspect = width / height
|
||||
mesh.scale.set(imageAspect, 1, 1)
|
||||
material.uniforms.uImage.value = loaded
|
||||
dimensions.value = `${width} x ${height}`
|
||||
|
||||
if (!data) return
|
||||
imageWidth = width
|
||||
imageHeight = height
|
||||
imageChannels = data.length / (width * height)
|
||||
readSample = makeReader(data, loaded.type)
|
||||
stats.value = computeImageStats(readSample, data.length, imageChannels)
|
||||
histograms.value = computeChannelHistograms(
|
||||
readSample,
|
||||
data.length,
|
||||
imageChannels
|
||||
)
|
||||
}
|
||||
|
||||
async function mount(container: HTMLElement, url: string) {
|
||||
containerRef.value = container
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
buildScene()
|
||||
container.appendChild(renderer!.domElement)
|
||||
renderer!.domElement.classList.add('block', 'size-full')
|
||||
resize()
|
||||
applyUniforms()
|
||||
attachInteractions(renderer!.domElement)
|
||||
|
||||
const { texture: loaded, gamut: detectedGamut } =
|
||||
await loadHdrTexture(url)
|
||||
if (!material || !mesh) {
|
||||
loaded.dispose()
|
||||
return
|
||||
}
|
||||
gamut.value = detectedGamut
|
||||
setTexture(loaded)
|
||||
applyUniforms()
|
||||
fitView()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
dispose()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExposure() {
|
||||
const max = stats.value?.max ?? 0
|
||||
exposureStops.value = max > 0 ? -Math.log2(max) : 0
|
||||
}
|
||||
|
||||
function attachInteractions(canvas: HTMLCanvasElement) {
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false })
|
||||
canvas.addEventListener('pointerdown', onPointerDown)
|
||||
canvas.addEventListener('pointermove', onHoverMove)
|
||||
canvas.addEventListener('pointerleave', onHoverLeave)
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
if (!camera) return
|
||||
event.preventDefault()
|
||||
const factor = Math.exp(-event.deltaY * 0.001)
|
||||
const nextZoom = THREE.MathUtils.clamp(
|
||||
camera.zoom * factor,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
camera.zoom = nextZoom
|
||||
camera.updateProjectionMatrix()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
|
||||
null
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (!camera) return
|
||||
dragStart = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
camX: camera.position.x,
|
||||
camY: camera.position.y
|
||||
}
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!camera || !dragStart) return
|
||||
const { height } = containerSize()
|
||||
const worldPerPixel = 1 / (height * camera.zoom)
|
||||
camera.position.x =
|
||||
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
|
||||
camera.position.y =
|
||||
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragStart = null
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onHoverMove(event: PointerEvent) {
|
||||
if (!camera || !mesh || !renderer || dragStart || !readSample) return
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
raycaster.setFromCamera(pointerNdc, camera)
|
||||
const hit = raycaster.intersectObject(mesh)[0]
|
||||
if (!hit?.uv) {
|
||||
pixel.value = null
|
||||
return
|
||||
}
|
||||
const col = THREE.MathUtils.clamp(
|
||||
Math.floor(hit.uv.x * imageWidth),
|
||||
0,
|
||||
imageWidth - 1
|
||||
)
|
||||
const row = THREE.MathUtils.clamp(
|
||||
Math.floor(hit.uv.y * imageHeight),
|
||||
0,
|
||||
imageHeight - 1
|
||||
)
|
||||
const base = (row * imageWidth + col) * imageChannels
|
||||
pixel.value = {
|
||||
x: col,
|
||||
y: imageHeight - 1 - row,
|
||||
r: readSample(base),
|
||||
g: readSample(base + 1),
|
||||
b: readSample(base + 2),
|
||||
a: imageChannels === 4 ? readSample(base + 3) : null
|
||||
}
|
||||
}
|
||||
|
||||
function onHoverLeave() {
|
||||
pixel.value = null
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
|
||||
if (renderer) {
|
||||
renderer.domElement.removeEventListener('wheel', onWheel)
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
|
||||
renderer.domElement.removeEventListener('pointermove', onHoverMove)
|
||||
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
|
||||
renderer.forceContextLoss()
|
||||
renderer.domElement.dispatchEvent(
|
||||
new Event('webglcontextlost', { bubbles: true, cancelable: true })
|
||||
)
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
}
|
||||
texture?.dispose()
|
||||
material?.dispose()
|
||||
mesh?.geometry.dispose()
|
||||
|
||||
renderer = null
|
||||
scene = null
|
||||
camera = null
|
||||
material = null
|
||||
mesh = null
|
||||
texture = null
|
||||
readSample = null
|
||||
}
|
||||
|
||||
useResizeObserver(containerRef, resize)
|
||||
|
||||
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
|
||||
|
||||
onUnmounted(dispose)
|
||||
|
||||
return {
|
||||
exposureStops,
|
||||
dither,
|
||||
clipWarnings,
|
||||
gamut,
|
||||
channel,
|
||||
loading,
|
||||
error,
|
||||
dimensions,
|
||||
stats,
|
||||
histogram,
|
||||
pixel,
|
||||
mount,
|
||||
dispose,
|
||||
fitView,
|
||||
normalizeExposure
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
type ResizeDirection =
|
||||
export type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
@@ -17,6 +17,17 @@ type ResizeDirection =
|
||||
| 'sw'
|
||||
| 'se'
|
||||
|
||||
export interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 8
|
||||
const CORNER_SIZE = 10
|
||||
/** Minimum crop width/height in source image pixel space. */
|
||||
@@ -264,17 +275,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
height: `${cropHeight.value * scaleFactor.value}px`
|
||||
}))
|
||||
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
|
||||
|
||||
const allResizeHandles = computed<ResizeHandle[]>(() => {
|
||||
|
||||
@@ -36,6 +36,15 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
options?.afterClose?.()
|
||||
},
|
||||
initialCategory
|
||||
},
|
||||
// The template browser is a wide layout. Without an explicit size the
|
||||
// Reka DialogContent falls back to size 'md' (max-w-xl), clipping the
|
||||
// filter bar so the Clear Filters button lands outside the viewport.
|
||||
// Size it like the other large dialogs (Settings/Manager).
|
||||
dialogComponentProps: {
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[80vh] rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,8 +154,10 @@ export const i18n = createI18n({
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te, d } = i18n.global
|
||||
const { tm } = i18n.global
|
||||
export const t: (typeof i18n.global)['t'] = i18n.global.t
|
||||
export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
|
||||
@@ -387,6 +387,35 @@
|
||||
"collapseAll": "Collapse all",
|
||||
"expandAll": "Expand all"
|
||||
},
|
||||
"hdrViewer": {
|
||||
"title": "HDR Viewer",
|
||||
"openInHdrViewer": "Open in HDR Viewer",
|
||||
"hdrImage": "HDR image",
|
||||
"failedToLoad": "Failed to load HDR image",
|
||||
"exposure": "Exposure",
|
||||
"normalizeExposure": "Auto exposure",
|
||||
"channel": "Channel",
|
||||
"channels": {
|
||||
"rgb": "RGB",
|
||||
"r": "R",
|
||||
"g": "G",
|
||||
"b": "B",
|
||||
"a": "Alpha",
|
||||
"luminance": "Luminance"
|
||||
},
|
||||
"sourceGamut": "Source gamut",
|
||||
"dither": "Dither",
|
||||
"clipWarnings": "Clip warnings",
|
||||
"fitView": "Fit",
|
||||
"histogram": "Histogram",
|
||||
"resolution": "Resolution",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"mean": "Mean",
|
||||
"stdDev": "Std dev",
|
||||
"nan": "NaN",
|
||||
"inf": "Inf"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Nodes Manager",
|
||||
"nodePackInfo": "Node Pack Info",
|
||||
@@ -3340,7 +3369,7 @@
|
||||
"mediaLabel": "{count} Media File | {count} Media Files",
|
||||
"modelsLabel": "{count} Model | {count} Models",
|
||||
"checkingAssets": "Checking media visibility…",
|
||||
"acknowledgeCheckbox": "I understand these media items will be published and made public",
|
||||
"acknowledgeCheckbox": "I understand anyone with the link can view these files",
|
||||
"inLibrary": "In library",
|
||||
"comfyHubTitle": "Upload to ComfyHub",
|
||||
"comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",
|
||||
|
||||
@@ -5,7 +5,6 @@ import { initializeApp } from 'firebase/app'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { createApp } from 'vue'
|
||||
@@ -133,7 +132,6 @@ app
|
||||
}
|
||||
}
|
||||
})
|
||||
.use(ConfirmationService)
|
||||
.use(ToastService)
|
||||
.use(pinia)
|
||||
.use(i18n)
|
||||
|
||||
@@ -144,7 +144,6 @@ import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import {
|
||||
formatDuration,
|
||||
@@ -158,7 +157,10 @@ import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '../utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
@@ -279,12 +281,15 @@ const formattedDuration = computed(() => {
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
const displayImageDimensions = computed(() =>
|
||||
resolveDisplayImageDimensions(asset, imageDimensions.value)
|
||||
)
|
||||
|
||||
// Get metadata info based on file kind
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
|
||||
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
|
||||
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
|
||||
if (fileKind.value === 'image' && displayImageDimensions.value) {
|
||||
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
|
||||
@@ -774,6 +774,10 @@ export function useMediaAssetActions() {
|
||||
onCancel: () => {
|
||||
resolve(false)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
|
||||
|
||||
// Contents bring their own width and padding — shrink-wrap the chrome and
|
||||
// zero the section padding (the PrimeVue `pt` overrides this replaces).
|
||||
const uploadDialogComponentProps = {
|
||||
renderer: 'reka',
|
||||
contentClass: 'w-fit max-w-[calc(100vw-1rem)]',
|
||||
headerClass: 'py-0 pl-0',
|
||||
bodyClass: 'p-0 overflow-y-hidden'
|
||||
} as const
|
||||
|
||||
export function useModelUpload(
|
||||
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
|
||||
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
|
||||
@@ -31,12 +40,7 @@ export function useModelUpload(
|
||||
key: 'upload-model-upgrade',
|
||||
headerComponent: UploadModelUpgradeModalHeader,
|
||||
component: UploadModelUpgradeModal,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0! overflow-y-hidden!'
|
||||
}
|
||||
}
|
||||
dialogComponentProps: uploadDialogComponentProps
|
||||
})
|
||||
} else {
|
||||
dialogStore.showDialog({
|
||||
@@ -49,12 +53,7 @@ export function useModelUpload(
|
||||
await onUploadSuccess?.(result)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0! overflow-y-hidden!'
|
||||
}
|
||||
}
|
||||
dialogComponentProps: uploadDialogComponentProps
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetMetadataDimensions,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getSourceName
|
||||
getSourceName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
@@ -417,4 +419,124 @@ describe('assetMetadataUtils', () => {
|
||||
expect(getAssetCardTitle(asset)).toBe('pretty.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetMetadataDimensions', () => {
|
||||
it('returns dimensions when width/height are positive integers', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
|
||||
expect(getAssetMetadataDimensions(asset)).toEqual({
|
||||
width: 1024,
|
||||
height: 768
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: 'NaN width', width: Number.NaN, height: 768 },
|
||||
{
|
||||
name: 'Infinity height',
|
||||
width: 1024,
|
||||
height: Number.POSITIVE_INFINITY
|
||||
},
|
||||
{ name: 'zero width', width: 0, height: 768 },
|
||||
{ name: 'negative height', width: 1024, height: -1 },
|
||||
{ name: 'fractional width', width: 1024.5, height: 768 },
|
||||
{ name: 'string width', width: '1024', height: 768 },
|
||||
{ name: 'missing width', width: undefined, height: 768 }
|
||||
])('returns undefined for invalid shape: $name', ({ width, height }) => {
|
||||
const asset = { ...mockAsset, metadata: { width, height } }
|
||||
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when metadata is absent', () => {
|
||||
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when asset itself is undefined', () => {
|
||||
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDisplayImageDimensions', () => {
|
||||
const rendered = { width: 512, height: 288 }
|
||||
|
||||
it('prefers server metadata dimensions over the rendered natural size', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1920, height: 1080 } }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers metadata even when a downscaled thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp',
|
||||
metadata: { width: 1920, height: 1080 }
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size when no thumbnail was shown (original served)', () => {
|
||||
const asset = { ...mockAsset }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size on OSS where thumbnail_url equals preview_url (full-res)', () => {
|
||||
const fullResUrl =
|
||||
'http://localhost:8188/view?filename=output.png&type=output'
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: fullResUrl,
|
||||
preview_url: fullResUrl
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('returns undefined (no label) when metadata is absent and a distinct downscaled thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp'
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses the fallback for an invalid metadata shape when a distinct thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp',
|
||||
metadata: { width: 0, height: 1080 }
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses the fallback when thumbnail_url is present but preview_url is absent', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp'
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size when metadata is invalid and no thumbnail guard applies', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 0, height: 1080 } }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('returns undefined when neither metadata nor a rendered size is available', () => {
|
||||
expect(
|
||||
resolveDisplayImageDimensions(mockAsset, undefined)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the rendered size when asset is undefined (no thumbnail to guard against)', () => {
|
||||
expect(resolveDisplayImageDimensions(undefined, rendered)).toEqual(
|
||||
rendered
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,6 +216,64 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
return getAssetDisplayFilename(asset)
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
|
||||
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
|
||||
* NaN, Infinity, 0, negatives, and fractional values.
|
||||
*/
|
||||
function isValidDimension(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original image dimensions from `asset.metadata.{width,height}`
|
||||
* when both pass shape validation, otherwise `undefined`. Callers should fall
|
||||
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
|
||||
* on runtimes that serve the original file but reports preview size on
|
||||
* runtimes that serve a downscaled preview.
|
||||
*/
|
||||
export function getAssetMetadataDimensions(
|
||||
asset: AssetItem | undefined
|
||||
): ImageDimensions | undefined {
|
||||
const w = asset?.metadata?.width
|
||||
const h = asset?.metadata?.height
|
||||
if (isValidDimension(w) && isValidDimension(h)) {
|
||||
return { width: w, height: h }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the image dimensions an asset card should display.
|
||||
*
|
||||
* Prefers the server-provided original dimensions from
|
||||
* {@link getAssetMetadataDimensions}. Only when those are absent does it fall
|
||||
* back to `renderedNaturalSize` — the natural size of the `<img>` the card
|
||||
* actually rendered — and only when that rendered image was the original file.
|
||||
*
|
||||
* A distinct `thumbnail_url` (one that differs from `preview_url`) means the
|
||||
* card rendered a downscaled preview, so `renderedNaturalSize` reflects the
|
||||
* preview's dimensions rather than the asset's. In that case this returns
|
||||
* `undefined` so the card shows no label rather than a wrong resolution.
|
||||
* On OSS, `thumbnail_url` and `preview_url` are the same URL (full-res),
|
||||
* so the guard correctly passes through `renderedNaturalSize`.
|
||||
*/
|
||||
export function resolveDisplayImageDimensions(
|
||||
asset: AssetItem | undefined,
|
||||
renderedNaturalSize: ImageDimensions | undefined
|
||||
): ImageDimensions | undefined {
|
||||
const fromMetadata = getAssetMetadataDimensions(asset)
|
||||
if (fromMetadata) return fromMetadata
|
||||
if (asset?.thumbnail_url && asset.thumbnail_url !== asset.preview_url)
|
||||
return undefined
|
||||
return renderedNaturalSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `hash` when present (cloud assets are hash-keyed
|
||||
|
||||
@@ -49,13 +49,9 @@ export const useSubscriptionDialog = () => {
|
||||
),
|
||||
props: { onClose: hide },
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(360px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'bg-transparent border-none rounded-none shadow-none'
|
||||
},
|
||||
content: { class: '!p-0 bg-transparent border-none shadow-none' }
|
||||
}
|
||||
renderer: 'reka',
|
||||
contentClass:
|
||||
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -89,16 +85,14 @@ export const useSubscriptionDialog = () => {
|
||||
component,
|
||||
props: useWorkspaceVariant ? workspaceProps : personalProps,
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent h-full'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
|
||||
}
|
||||
}
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
// The pricing tables host a PrimeVue Popover teleported to body.
|
||||
// Reka's modal mode traps focus and disables body pointer-events,
|
||||
// making the popover unclickable. Mirrors Settings/Manager.
|
||||
modal: false,
|
||||
contentClass:
|
||||
'w-[min(1328px,95vw)] max-w-[min(1328px,95vw)] sm:max-w-[min(1328px,95vw)] h-full max-h-[958px] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -122,16 +116,10 @@ export const useSubscriptionDialog = () => {
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(640px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[min(640px,95vw)] max-w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
@@ -66,11 +66,7 @@ export function useShareDialog() {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-144 max-w-full'
|
||||
}
|
||||
}
|
||||
contentClass: 'sm:max-w-144 rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,11 +110,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => resolve({ action: 'cancel' }),
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
|
||||
}
|
||||
}
|
||||
contentClass: 'sm:max-w-176 rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface Member {
|
||||
email: string
|
||||
joined_at: string
|
||||
role: WorkspaceRole
|
||||
// True when this member is the workspace's original owner/creator
|
||||
// (member.id == workspace.created_by_user_id). Gates the creator-only
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
|
||||
@@ -113,7 +113,11 @@
|
||||
button-variant="gradient"
|
||||
/>
|
||||
<Button
|
||||
v-if="showSubscribeAction && !isPersonalWorkspace"
|
||||
v-if="
|
||||
showSubscribeAction &&
|
||||
!isPersonalWorkspace &&
|
||||
(!isCancelled || permissions.canManageSubscriptionLifecycle)
|
||||
"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
|
||||
@@ -128,9 +128,10 @@
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<!-- Cancelled state: show only Resubscribe button -->
|
||||
<!-- Cancelled state: reactivation is original-owner-only. -->
|
||||
<template v-if="isCancelled">
|
||||
<Button
|
||||
v-if="permissions.canManageSubscriptionLifecycle"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
@@ -161,7 +162,7 @@
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isFreeTierPlan"
|
||||
v-if="!isFreeTierPlan && planMenuItems.length > 0"
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@@ -513,15 +514,23 @@ const subscriptionTierName = computed(() => {
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
const planMenuItems = computed(() => [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
}
|
||||
}
|
||||
])
|
||||
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
|
||||
// and the "more options" button is hidden (see template).
|
||||
const planMenuItems = computed(() =>
|
||||
permissions.value.canManageSubscriptionLifecycle
|
||||
? [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(
|
||||
subscription.value?.endDate ?? undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
|
||||
@@ -82,7 +82,8 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
name: 'Owner User',
|
||||
email: 'owner@example.com',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0)
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
})),
|
||||
filteredMembers: mockFilteredMembers,
|
||||
filteredPendingInvites: mockFilteredPendingInvites,
|
||||
@@ -153,6 +154,7 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ export function useMembersPanel() {
|
||||
name: userDisplayName.value ?? '',
|
||||
email: userEmail.value ?? '',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0)
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
}))
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -2,15 +2,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const mockActiveWorkspace = vi.hoisted(() => ({
|
||||
value: null as WorkspaceWithRole | null
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
activeWorkspace: null as WorkspaceWithRole | null,
|
||||
isCurrentUserOriginalOwner: false,
|
||||
ensureMembersLoaded: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
return mockStore.activeWorkspace
|
||||
},
|
||||
get isCurrentUserOriginalOwner() {
|
||||
return mockStore.isCurrentUserOriginalOwner
|
||||
},
|
||||
ensureMembersLoaded: mockStore.ensureMembersLoaded
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -46,14 +52,20 @@ async function loadComposable() {
|
||||
return module.useWorkspaceUI()
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
mockStore.activeWorkspace = null
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
mockStore.ensureMembersLoaded.mockReset()
|
||||
}
|
||||
|
||||
describe('useWorkspaceUI', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mockActiveWorkspace.value = null
|
||||
resetStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockActiveWorkspace.value = null
|
||||
resetStore()
|
||||
})
|
||||
|
||||
describe('when no active workspace', () => {
|
||||
@@ -71,7 +83,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('personal workspace', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = personalWorkspace
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
})
|
||||
|
||||
it('grants billing access but disables team management', async () => {
|
||||
@@ -119,7 +131,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as owner', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
})
|
||||
|
||||
it('grants full management permissions', async () => {
|
||||
@@ -159,7 +171,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as member', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkspace.value = teamMemberWorkspace
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
})
|
||||
|
||||
it('restricts management actions while allowing leave', async () => {
|
||||
@@ -195,9 +207,60 @@ describe('useWorkspaceUI', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Drives off the members-list self-row original-owner signal, surfaced by the
|
||||
// store getter `isCurrentUserOriginalOwner`.
|
||||
describe('subscription lifecycle (creator-only)', () => {
|
||||
it('grants lifecycle to the personal-workspace sole owner', async () => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
|
||||
})
|
||||
|
||||
it('grants lifecycle to a team owner who is the original owner', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = true
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscription).toBe(true)
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
|
||||
})
|
||||
|
||||
it('withholds lifecycle from a promoted (non-creator) team owner', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscription).toBe(true)
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed while the members list is still loading', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('withholds lifecycle from members', async () => {
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('delegates member loading to the store when a team workspace becomes active', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
await loadComposable()
|
||||
expect(mockStore.ensureMembersLoaded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not load members for a personal workspace', async () => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
await loadComposable()
|
||||
expect(mockStore.ensureMembersLoaded).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared instance', () => {
|
||||
it('returns the same composable state for multiple callers within a test', async () => {
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
const first = await loadComposable()
|
||||
const second = await loadComposable()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
@@ -14,6 +14,10 @@ interface WorkspacePermissions {
|
||||
canLeaveWorkspace: boolean
|
||||
canAccessWorkspaceMenu: boolean
|
||||
canManageSubscription: boolean
|
||||
// Creator-only subscription lifecycle: cancel / reactivate / downgrade.
|
||||
// Any owner has `canManageSubscription` (manage payment, top-up, change
|
||||
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
|
||||
canManageSubscriptionLifecycle: boolean
|
||||
canTopUp: boolean
|
||||
}
|
||||
|
||||
@@ -34,7 +38,8 @@ interface WorkspaceUIConfig {
|
||||
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
role: WorkspaceRole,
|
||||
isOriginalOwner: boolean
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
@@ -46,6 +51,8 @@ function getPermissions(
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: false,
|
||||
canManageSubscription: true,
|
||||
// Personal workspace is single-member: the user is the sole owner/creator.
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -60,6 +67,7 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: isOriginalOwner,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -74,6 +82,7 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
}
|
||||
@@ -145,8 +154,26 @@ function useWorkspaceUIInternal() {
|
||||
() => store.activeWorkspace?.role ?? 'owner'
|
||||
)
|
||||
|
||||
// The original-owner signal lives on the members-list self-row, so a team
|
||||
// workspace's members must be loaded before its lifecycle gate can resolve.
|
||||
// The store dedupes in-flight/already-loaded requests and logs failures;
|
||||
// until members arrive the getter fails closed.
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
() => {
|
||||
if (store.activeWorkspace?.type === 'team') {
|
||||
void store.ensureMembersLoaded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const permissions = computed<WorkspacePermissions>(() =>
|
||||
getPermissions(workspaceType.value, workspaceRole.value)
|
||||
getPermissions(
|
||||
workspaceType.value,
|
||||
workspaceRole.value,
|
||||
store.isCurrentUserOriginalOwner
|
||||
)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
|
||||
@@ -29,6 +29,15 @@ vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
|
||||
}))
|
||||
|
||||
// Mock current user (drives the original-owner self-row match by email)
|
||||
const mockCurrentUser = vi.hoisted(() => ({
|
||||
userEmail: { value: null as string | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ userEmail: mockCurrentUser.userEmail })
|
||||
}))
|
||||
|
||||
// Mock workspaceApi
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -122,6 +131,7 @@ describe('useTeamWorkspaceStore', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
sessionStorage.clear()
|
||||
mockCurrentUser.userEmail.value = null
|
||||
|
||||
// Reset workspaceAuthStore mock state
|
||||
mockWorkspaceAuthStore.currentWorkspace = null
|
||||
@@ -680,6 +690,193 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureMembersLoaded', () => {
|
||||
const memberRow = {
|
||||
id: 'user-1',
|
||||
name: 'Owner',
|
||||
email: 'owner@test.com',
|
||||
joined_at: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
function mockMembersResponse() {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [memberRow],
|
||||
pagination: { offset: 0, limit: 50, total: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
async function activateTeamWorkspace() {
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
return store
|
||||
}
|
||||
|
||||
it('loads members for a team workspace that is not yet loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
expect(store.members).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not load members again once loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dedupes concurrent calls into a single request', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await Promise.all([
|
||||
store.ensureMembersLoaded(),
|
||||
store.ensureMembersLoaded()
|
||||
])
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not load members for a personal workspace', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs a failed request and retries on the next call', async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mockWorkspaceApi.listMembers.mockRejectedValueOnce(new Error('boom'))
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
expect(store.members).toHaveLength(0)
|
||||
|
||||
mockMembersResponse()
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(2)
|
||||
expect(store.members).toHaveLength(1)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCurrentUserOriginalOwner', () => {
|
||||
async function loadTeamWithMembers(
|
||||
members: Array<{
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
joined_at: string
|
||||
is_original_owner?: boolean
|
||||
}>
|
||||
) {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
await store.fetchMembers()
|
||||
return store
|
||||
}
|
||||
|
||||
const ownerSelf = {
|
||||
id: 'user-1',
|
||||
name: 'Owner',
|
||||
email: 'owner@test.com',
|
||||
joined_at: '2024-01-01T00:00:00Z',
|
||||
role: 'owner' as const,
|
||||
is_original_owner: true
|
||||
}
|
||||
const promotedSelf = { ...ownerSelf, is_original_owner: false }
|
||||
|
||||
it('is true when the self-row is the original owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the self-row by email case-insensitively', async () => {
|
||||
mockCurrentUser.userEmail.value = 'OWNER@TEST.COM'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the self-row is a promoted (non-creator) owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const store = await loadTeamWithMembers([promotedSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the self-row omits is_original_owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const { is_original_owner: _omitted, ...selfWithoutFlag } = ownerSelf
|
||||
const store = await loadTeamWithMembers([selfWithoutFlag])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when no member row matches the current user', async () => {
|
||||
mockCurrentUser.userEmail.value = 'someone-else@test.com'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when members are not loaded', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the current user email is unknown', async () => {
|
||||
mockCurrentUser.userEmail.value = null
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('recomputes reactively when the self-row arrives after an empty read', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [ownerSelf],
|
||||
pagination: { offset: 0, limit: 50, total: 1 }
|
||||
})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
|
||||
await store.fetchMembers()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invite actions', () => {
|
||||
it('fetchPendingInvites updates active workspace invites', async () => {
|
||||
const mockInvites = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
@@ -21,6 +22,7 @@ export interface WorkspaceMember {
|
||||
email: string
|
||||
joinDate: Date
|
||||
role: 'owner' | 'member'
|
||||
isOriginalOwner: boolean
|
||||
}
|
||||
|
||||
export interface PendingInvite {
|
||||
@@ -49,7 +51,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
joinDate: new Date(member.joined_at),
|
||||
role: member.role
|
||||
role: member.role,
|
||||
isOriginalOwner: member.is_original_owner ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +149,18 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
() => activeWorkspace.value?.members ?? []
|
||||
)
|
||||
|
||||
// True when the current user is the active workspace's original owner,
|
||||
// resolved from the self-row of the loaded members list. Matches by email
|
||||
// (the stable current-user join key; member.id is a cloud user id, not the
|
||||
// Firebase uid). Fails closed when members are not loaded or no self-row
|
||||
// matches, so lifecycle gating stays hidden until the real signal arrives.
|
||||
const isCurrentUserOriginalOwner = computed(() => {
|
||||
const email = useCurrentUser().userEmail.value?.toLowerCase()
|
||||
if (!email) return false
|
||||
const selfRow = members.value.find((m) => m.email.toLowerCase() === email)
|
||||
return selfRow?.isOriginalOwner ?? false
|
||||
})
|
||||
|
||||
const pendingInvites = computed<PendingInvite[]>(
|
||||
() => activeWorkspace.value?.pendingInvites ?? []
|
||||
)
|
||||
@@ -507,6 +522,36 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
return members
|
||||
}
|
||||
|
||||
// Tracks which team workspaces have already loaded their members so the
|
||||
// lifecycle gate resolves without redundant or duplicate fetches.
|
||||
const loadedMemberWorkspaceIds = new Set<string>()
|
||||
let inFlightMembersWorkspaceId: string | null = null
|
||||
|
||||
/**
|
||||
* Load the active team workspace's members once. No-ops for personal or
|
||||
* already-loaded workspaces and dedupes concurrent calls. A failed request is
|
||||
* logged and leaves the workspace unloaded so a later call retries.
|
||||
*/
|
||||
async function ensureMembersLoaded(): Promise<void> {
|
||||
const workspaceId = activeWorkspaceId.value
|
||||
if (!workspaceId) return
|
||||
if (activeWorkspace.value?.type === 'personal') return
|
||||
if (loadedMemberWorkspaceIds.has(workspaceId)) return
|
||||
if (inFlightMembersWorkspaceId === workspaceId) return
|
||||
|
||||
inFlightMembersWorkspaceId = workspaceId
|
||||
try {
|
||||
await fetchMembers()
|
||||
loadedMemberWorkspaceIds.add(workspaceId)
|
||||
} catch (e) {
|
||||
console.error('Failed to load workspace members', e)
|
||||
} finally {
|
||||
if (inFlightMembersWorkspaceId === workspaceId) {
|
||||
inFlightMembersWorkspaceId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the current workspace.
|
||||
*/
|
||||
@@ -652,6 +697,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
ownedWorkspacesCount,
|
||||
canCreateWorkspace,
|
||||
members,
|
||||
isCurrentUserOriginalOwner,
|
||||
pendingInvites,
|
||||
totalMemberSlots,
|
||||
isInviteLimitReached,
|
||||
@@ -675,6 +721,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
|
||||
// Member Actions
|
||||
fetchMembers,
|
||||
ensureMembersLoaded,
|
||||
removeMember,
|
||||
|
||||
// Invite Actions
|
||||
|
||||
@@ -23,15 +23,23 @@
|
||||
total: imageUrls.length
|
||||
})
|
||||
"
|
||||
@click="openImageInGallery(index)"
|
||||
@click="handleGridClick(index)"
|
||||
>
|
||||
<img
|
||||
v-if="!isHdrImageUrl(imageUrls[index])"
|
||||
:src="url"
|
||||
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
|
||||
draggable="false"
|
||||
class="pointer-events-none size-full object-contain"
|
||||
@load="updateAspectRatio($event, index)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full flex-col items-center justify-center gap-1 text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--sun] size-6" />
|
||||
<span class="text-xs">{{ $t('hdrViewer.hdrImage') }}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -61,12 +69,31 @@
|
||||
</p>
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="showLoader && !imageError" class="size-full">
|
||||
<div
|
||||
v-if="showLoader && !imageError && !currentImageIsHdr"
|
||||
class="size-full"
|
||||
>
|
||||
<Skeleton class="size-full rounded-sm" />
|
||||
</div>
|
||||
<button
|
||||
v-if="!imageError && currentImageIsHdr"
|
||||
type="button"
|
||||
data-testid="hdr-open-button"
|
||||
class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-3 border-0 bg-transparent text-base-foreground"
|
||||
@click="openHdrViewer(currentImageUrl)"
|
||||
>
|
||||
<i class="icon-[lucide--sun] size-12" />
|
||||
<span class="text-sm">{{ $t('hdrViewer.hdrImage') }}</span>
|
||||
<span
|
||||
class="rounded-md bg-base-foreground px-3 py-1.5 text-sm text-base-background"
|
||||
>
|
||||
{{ $t('hdrViewer.openInHdrViewer') }}
|
||||
</span>
|
||||
<span class="text-xs">{{ getImageFilename(currentImageUrl) }}</span>
|
||||
</button>
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
v-if="!imageError && !currentImageIsHdr"
|
||||
data-testid="main-image"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
@@ -82,7 +109,7 @@
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
v-if="!hasMultipleImages && !imageError"
|
||||
v-if="!hasMultipleImages && !imageError && !currentImageIsHdr"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.editOrMaskImage')"
|
||||
:aria-label="$t('g.editOrMaskImage')"
|
||||
@@ -117,7 +144,7 @@
|
||||
|
||||
<!-- Image Dimensions (gallery mode only) -->
|
||||
<div
|
||||
v-if="viewMode === 'gallery'"
|
||||
v-if="viewMode === 'gallery' && !currentImageIsHdr"
|
||||
class="pt-2 text-center text-xs text-base-foreground"
|
||||
>
|
||||
<span
|
||||
@@ -178,7 +205,9 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { openHdrViewer } from '@/services/hdrViewerService'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { isHdrImageUrl } from '@/utils/hdrFormatUtil'
|
||||
import { getGridThumbnailUrl } from '@/utils/imageUtil'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -228,6 +257,7 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
)
|
||||
|
||||
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
|
||||
const currentImageIsHdr = computed(() => isHdrImageUrl(currentImageUrl.value))
|
||||
const gridImageUrls = computed(() => imageUrls.map(getGridThumbnailUrl))
|
||||
const hasMultipleImages = computed(() => imageUrls.length > 1)
|
||||
const imageAltText = computed(() =>
|
||||
@@ -333,6 +363,15 @@ async function openImageInGallery(index: number) {
|
||||
galleryPanelEl.value?.focus()
|
||||
}
|
||||
|
||||
function handleGridClick(index: number) {
|
||||
const url = imageUrls[index]
|
||||
if (isHdrImageUrl(url)) {
|
||||
openHdrViewer(url)
|
||||
return
|
||||
}
|
||||
void openImageInGallery(index)
|
||||
}
|
||||
|
||||
function getNavigationDotClass(index: number) {
|
||||
return cn(
|
||||
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',
|
||||
|
||||
90
src/renderer/hdr/colorGamut.test.ts
Normal file
90
src/renderer/hdr/colorGamut.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
GAMUT_NAMES,
|
||||
detectGamutFromChromaticities,
|
||||
gamutToSrgbMatrix
|
||||
} from './colorGamut'
|
||||
|
||||
const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1]
|
||||
|
||||
describe('gamutToSrgbMatrix', () => {
|
||||
it('returns identity for sRGB source', () => {
|
||||
expect(gamutToSrgbMatrix('sRGB')).toEqual(IDENTITY)
|
||||
})
|
||||
|
||||
it('matches the published linear Rec.2020 to sRGB matrix', () => {
|
||||
const expected = [
|
||||
1.6605, -0.5876, -0.0728, -0.1246, 1.1329, -0.0083, -0.0182, -0.1006,
|
||||
1.1187
|
||||
]
|
||||
const actual = gamutToSrgbMatrix('Rec.2020')
|
||||
for (let i = 0; i < 9; i++) {
|
||||
expect(actual[i]).toBeCloseTo(expected[i], 3)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps the Rec.2020 white point to equal-energy sRGB (rows sum to ~1)', () => {
|
||||
const m = gamutToSrgbMatrix('Rec.2020')
|
||||
for (let row = 0; row < 3; row++) {
|
||||
const sum = m[row * 3] + m[row * 3 + 1] + m[row * 3 + 2]
|
||||
expect(sum).toBeCloseTo(1, 3)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the supported gamut names', () => {
|
||||
expect(GAMUT_NAMES).toContain('sRGB')
|
||||
expect(GAMUT_NAMES).toContain('Rec.2020')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectGamutFromChromaticities', () => {
|
||||
it('falls back to sRGB when the attribute is absent', () => {
|
||||
expect(detectGamutFromChromaticities(undefined)).toBe('sRGB')
|
||||
})
|
||||
|
||||
it('detects Rec.2020 primaries from the EXR header', () => {
|
||||
expect(
|
||||
detectGamutFromChromaticities({
|
||||
redX: 0.708,
|
||||
redY: 0.292,
|
||||
greenX: 0.17,
|
||||
greenY: 0.797,
|
||||
blueX: 0.131,
|
||||
blueY: 0.046,
|
||||
whiteX: 0.3127,
|
||||
whiteY: 0.329
|
||||
})
|
||||
).toBe('Rec.2020')
|
||||
})
|
||||
|
||||
it('does not match Rec.2020 when the white point differs', () => {
|
||||
expect(
|
||||
detectGamutFromChromaticities({
|
||||
redX: 0.708,
|
||||
redY: 0.292,
|
||||
greenX: 0.17,
|
||||
greenY: 0.797,
|
||||
blueX: 0.131,
|
||||
blueY: 0.046,
|
||||
whiteX: 0.314,
|
||||
whiteY: 0.351
|
||||
})
|
||||
).toBe('sRGB')
|
||||
})
|
||||
|
||||
it('detects Rec.709/sRGB primaries from the EXR header', () => {
|
||||
expect(
|
||||
detectGamutFromChromaticities({
|
||||
redX: 0.64,
|
||||
redY: 0.33,
|
||||
greenX: 0.3,
|
||||
greenY: 0.6,
|
||||
blueX: 0.15,
|
||||
blueY: 0.06,
|
||||
whiteX: 0.3127,
|
||||
whiteY: 0.329
|
||||
})
|
||||
).toBe('sRGB')
|
||||
})
|
||||
})
|
||||
144
src/renderer/hdr/colorGamut.ts
Normal file
144
src/renderer/hdr/colorGamut.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
interface Chromaticities {
|
||||
red: readonly [number, number]
|
||||
green: readonly [number, number]
|
||||
blue: readonly [number, number]
|
||||
white: readonly [number, number]
|
||||
}
|
||||
|
||||
const D65: readonly [number, number] = [0.3127, 0.329]
|
||||
|
||||
const CHROMATICITIES = {
|
||||
sRGB: {
|
||||
red: [0.64, 0.33],
|
||||
green: [0.3, 0.6],
|
||||
blue: [0.15, 0.06],
|
||||
white: D65
|
||||
},
|
||||
'Rec.2020': {
|
||||
red: [0.708, 0.292],
|
||||
green: [0.17, 0.797],
|
||||
blue: [0.131, 0.046],
|
||||
white: D65
|
||||
}
|
||||
} satisfies Record<string, Chromaticities>
|
||||
|
||||
export type GamutName = keyof typeof CHROMATICITIES
|
||||
|
||||
export const GAMUT_NAMES = Object.keys(CHROMATICITIES) as GamutName[]
|
||||
|
||||
type Mat3 = readonly number[]
|
||||
|
||||
const IDENTITY: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]
|
||||
|
||||
function rgbToXyz(c: Chromaticities): Mat3 {
|
||||
const [rx, ry] = c.red
|
||||
const [gx, gy] = c.green
|
||||
const [bx, by] = c.blue
|
||||
const [wx, wy] = c.white
|
||||
|
||||
const xWhite = wx / wy
|
||||
const zWhite = (1 - wx - wy) / wy
|
||||
|
||||
const d = rx * (by - gy) + bx * (gy - ry) + gx * (ry - by)
|
||||
|
||||
const srN =
|
||||
xWhite * (by - gy) -
|
||||
gx * (by - 1 + by * (xWhite + zWhite)) +
|
||||
bx * (gy - 1 + gy * (xWhite + zWhite))
|
||||
const sgN =
|
||||
xWhite * (ry - by) +
|
||||
rx * (by - 1 + by * (xWhite + zWhite)) -
|
||||
bx * (ry - 1 + ry * (xWhite + zWhite))
|
||||
const sbN =
|
||||
xWhite * (gy - ry) -
|
||||
rx * (gy - 1 + gy * (xWhite + zWhite)) +
|
||||
gx * (ry - 1 + ry * (xWhite + zWhite))
|
||||
|
||||
const sr = srN / d
|
||||
const sg = sgN / d
|
||||
const sb = sbN / d
|
||||
|
||||
return [
|
||||
sr * rx,
|
||||
sg * gx,
|
||||
sb * bx,
|
||||
sr * ry,
|
||||
sg * gy,
|
||||
sb * by,
|
||||
sr * (1 - rx - ry),
|
||||
sg * (1 - gx - gy),
|
||||
sb * (1 - bx - by)
|
||||
]
|
||||
}
|
||||
|
||||
function multiply(a: Mat3, b: Mat3): Mat3 {
|
||||
const result = new Array<number>(9).fill(0)
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let col = 0; col < 3; col++) {
|
||||
let sum = 0
|
||||
for (let k = 0; k < 3; k++) sum += a[row * 3 + k] * b[k * 3 + col]
|
||||
result[row * 3 + col] = sum
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function invert(m: Mat3): Mat3 {
|
||||
const [a, b, c, d, e, f, g, h, i] = m
|
||||
const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
|
||||
if (det === 0) return IDENTITY
|
||||
|
||||
const invDet = 1 / det
|
||||
return [
|
||||
(e * i - f * h) * invDet,
|
||||
(c * h - b * i) * invDet,
|
||||
(b * f - c * e) * invDet,
|
||||
(f * g - d * i) * invDet,
|
||||
(a * i - c * g) * invDet,
|
||||
(c * d - a * f) * invDet,
|
||||
(d * h - e * g) * invDet,
|
||||
(b * g - a * h) * invDet,
|
||||
(a * e - b * d) * invDet
|
||||
]
|
||||
}
|
||||
|
||||
const SRGB_TO_XYZ = rgbToXyz(CHROMATICITIES.sRGB)
|
||||
const XYZ_TO_SRGB = invert(SRGB_TO_XYZ)
|
||||
|
||||
export function gamutToSrgbMatrix(gamut: GamutName): Mat3 {
|
||||
if (gamut === 'sRGB') return IDENTITY
|
||||
return multiply(XYZ_TO_SRGB, rgbToXyz(CHROMATICITIES[gamut]))
|
||||
}
|
||||
|
||||
export interface ChromaticityCoords {
|
||||
redX: number
|
||||
redY: number
|
||||
greenX: number
|
||||
greenY: number
|
||||
blueX: number
|
||||
blueY: number
|
||||
whiteX: number
|
||||
whiteY: number
|
||||
}
|
||||
|
||||
function matchesGamut(c: ChromaticityCoords, gamut: GamutName): boolean {
|
||||
const ref = CHROMATICITIES[gamut]
|
||||
const tol = 0.01
|
||||
return (
|
||||
Math.abs(c.redX - ref.red[0]) < tol &&
|
||||
Math.abs(c.redY - ref.red[1]) < tol &&
|
||||
Math.abs(c.greenX - ref.green[0]) < tol &&
|
||||
Math.abs(c.greenY - ref.green[1]) < tol &&
|
||||
Math.abs(c.blueX - ref.blue[0]) < tol &&
|
||||
Math.abs(c.blueY - ref.blue[1]) < tol &&
|
||||
Math.abs(c.whiteX - ref.white[0]) < tol &&
|
||||
Math.abs(c.whiteY - ref.white[1]) < tol
|
||||
)
|
||||
}
|
||||
|
||||
export function detectGamutFromChromaticities(
|
||||
c: ChromaticityCoords | undefined
|
||||
): GamutName {
|
||||
if (!c) return 'sRGB'
|
||||
return GAMUT_NAMES.find((name) => matchesGamut(c, name)) ?? 'sRGB'
|
||||
}
|
||||
91
src/renderer/hdr/hdrStats.test.ts
Normal file
91
src/renderer/hdr/hdrStats.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeChannelHistograms, computeImageStats } from './hdrStats'
|
||||
|
||||
function reader(values: number[]) {
|
||||
return (i: number) => values[i]
|
||||
}
|
||||
|
||||
describe('computeImageStats', () => {
|
||||
it('computes min/max/mean over RGB, skipping alpha', () => {
|
||||
const data = [0, 0.5, 1, 1, 0.2, 0.4, 0.6, 1]
|
||||
const stats = computeImageStats(reader(data), data.length, 4)
|
||||
expect(stats.min).toBe(0)
|
||||
expect(stats.max).toBe(1)
|
||||
expect(stats.mean).toBeCloseTo((0 + 0.5 + 1 + 0.2 + 0.4 + 0.6) / 6, 6)
|
||||
})
|
||||
|
||||
it('counts NaN and Inf and excludes them from min/max/mean', () => {
|
||||
const data = [0.5, NaN, Infinity, -Infinity, 0.25]
|
||||
const stats = computeImageStats(reader(data), data.length, 3)
|
||||
expect(stats.nanCount).toBe(1)
|
||||
expect(stats.infCount).toBe(2)
|
||||
expect(stats.min).toBe(0.25)
|
||||
expect(stats.max).toBe(0.5)
|
||||
expect(stats.mean).toBeCloseTo(0.375, 6)
|
||||
})
|
||||
|
||||
it('counts NaN/Inf in alpha but keeps alpha out of min/max/mean', () => {
|
||||
const data = [0.1, 0.2, 0.3, NaN, 0.4, 0.5, 0.6, Infinity]
|
||||
const stats = computeImageStats(reader(data), data.length, 4)
|
||||
expect(stats.nanCount).toBe(1)
|
||||
expect(stats.infCount).toBe(1)
|
||||
expect(stats.min).toBe(0.1)
|
||||
expect(stats.max).toBe(0.6)
|
||||
expect(stats.mean).toBeCloseTo((0.1 + 0.2 + 0.3 + 0.4 + 0.5 + 0.6) / 6, 6)
|
||||
})
|
||||
|
||||
it('reports HDR values above one', () => {
|
||||
const data = [2, 4, 8]
|
||||
const stats = computeImageStats(reader(data), data.length, 3)
|
||||
expect(stats.max).toBe(8)
|
||||
expect(stats.mean).toBeCloseTo(14 / 3, 6)
|
||||
})
|
||||
|
||||
it('returns zeros when there are no finite samples', () => {
|
||||
const data = [NaN, Infinity]
|
||||
const stats = computeImageStats(reader(data), data.length, 3)
|
||||
expect(stats).toEqual({
|
||||
min: 0,
|
||||
max: 0,
|
||||
mean: 0,
|
||||
stdDev: 0,
|
||||
nanCount: 1,
|
||||
infCount: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeChannelHistograms', () => {
|
||||
it('bins each channel independently', () => {
|
||||
const data = [0, 0.5, 1, 0.5, 0.5, 0.5]
|
||||
const hist = computeChannelHistograms(reader(data), data.length, 3, 4)
|
||||
expect(hist.r[0]).toBe(1)
|
||||
expect(hist.r[2]).toBe(1)
|
||||
expect(hist.g[2]).toBe(2)
|
||||
expect(hist.b[3]).toBe(1)
|
||||
expect(hist.a).toBeNull()
|
||||
})
|
||||
|
||||
it('builds an alpha histogram for RGBA data', () => {
|
||||
const data = [0, 0, 0, 1, 1, 1, 1, 0]
|
||||
const hist = computeChannelHistograms(reader(data), data.length, 4, 4)
|
||||
expect(hist.a).not.toBeNull()
|
||||
expect(hist.a![3]).toBe(1)
|
||||
expect(hist.a![0]).toBe(1)
|
||||
})
|
||||
|
||||
it('clamps HDR values above one into the last bin', () => {
|
||||
const data = [8, 8, 8]
|
||||
const hist = computeChannelHistograms(reader(data), data.length, 3, 4)
|
||||
expect(hist.luminance[3]).toBe(1)
|
||||
expect(hist.r[3]).toBe(1)
|
||||
})
|
||||
|
||||
it('skips NaN samples per channel', () => {
|
||||
const data = [NaN, 0.5, 0.5]
|
||||
const hist = computeChannelHistograms(reader(data), data.length, 3, 4)
|
||||
expect(hist.r.reduce((a, b) => a + b, 0)).toBe(0)
|
||||
expect(hist.g.reduce((a, b) => a + b, 0)).toBe(1)
|
||||
})
|
||||
})
|
||||
92
src/renderer/hdr/hdrStats.ts
Normal file
92
src/renderer/hdr/hdrStats.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export interface ImageStats {
|
||||
min: number
|
||||
max: number
|
||||
mean: number
|
||||
stdDev: number
|
||||
nanCount: number
|
||||
infCount: number
|
||||
}
|
||||
|
||||
export function computeImageStats(
|
||||
read: (index: number) => number,
|
||||
length: number,
|
||||
channels: number
|
||||
): ImageStats {
|
||||
let min = Infinity
|
||||
let max = -Infinity
|
||||
let sum = 0
|
||||
let sumSq = 0
|
||||
let count = 0
|
||||
let nanCount = 0
|
||||
let infCount = 0
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const value = read(i)
|
||||
if (Number.isNaN(value)) {
|
||||
nanCount++
|
||||
continue
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
infCount++
|
||||
continue
|
||||
}
|
||||
if (channels === 4 && i % channels === 3) continue
|
||||
if (value < min) min = value
|
||||
if (value > max) max = value
|
||||
sum += value
|
||||
sumSq += value * value
|
||||
count++
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return { min: 0, max: 0, mean: 0, stdDev: 0, nanCount, infCount }
|
||||
}
|
||||
|
||||
const mean = sum / count
|
||||
const variance = Math.max(0, sumSq / count - mean * mean)
|
||||
return { min, max, mean, stdDev: Math.sqrt(variance), nanCount, infCount }
|
||||
}
|
||||
|
||||
export interface ChannelHistograms {
|
||||
r: Uint32Array
|
||||
g: Uint32Array
|
||||
b: Uint32Array
|
||||
a: Uint32Array | null
|
||||
luminance: Uint32Array
|
||||
}
|
||||
|
||||
export function computeChannelHistograms(
|
||||
read: (index: number) => number,
|
||||
length: number,
|
||||
channels: number,
|
||||
bins = 256
|
||||
): ChannelHistograms {
|
||||
const last = bins - 1
|
||||
const r = new Uint32Array(bins)
|
||||
const g = new Uint32Array(bins)
|
||||
const b = new Uint32Array(bins)
|
||||
const luminance = new Uint32Array(bins)
|
||||
const a = channels === 4 ? new Uint32Array(bins) : null
|
||||
|
||||
const accumulate = (target: Uint32Array, value: number) => {
|
||||
if (Number.isNaN(value)) return
|
||||
const bin = Math.floor(Math.max(0, value) * bins)
|
||||
target[bin > last ? last : bin]++
|
||||
}
|
||||
|
||||
for (let i = 0; i + channels - 1 < length; i += channels) {
|
||||
const rv = read(i)
|
||||
const gv = channels >= 3 ? read(i + 1) : rv
|
||||
const bv = channels >= 3 ? read(i + 2) : rv
|
||||
accumulate(r, rv)
|
||||
accumulate(g, gv)
|
||||
accumulate(b, bv)
|
||||
if (a) accumulate(a, read(i + 3))
|
||||
accumulate(
|
||||
luminance,
|
||||
channels >= 3 ? 0.2126 * rv + 0.7152 * gv + 0.0722 * bv : rv
|
||||
)
|
||||
}
|
||||
|
||||
return { r, g, b, a, luminance }
|
||||
}
|
||||
79
src/renderer/hdr/hdrViewerShader.ts
Normal file
79
src/renderer/hdr/hdrViewerShader.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export const HDR_VIEWER_VERTEX_SHADER = `
|
||||
out vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export const HDR_VIEWER_FRAGMENT_SHADER = `
|
||||
in vec2 vUv;
|
||||
out vec4 frag_color;
|
||||
|
||||
uniform sampler2D uImage;
|
||||
uniform mat3 uGamutToSRGB;
|
||||
uniform float uGain;
|
||||
uniform int uChannel;
|
||||
uniform bool uDither;
|
||||
uniform bool uClipWarnings;
|
||||
uniform vec2 uClipRange;
|
||||
|
||||
float linearToS(float a) {
|
||||
float s = sign(a);
|
||||
a = abs(a);
|
||||
return s * (a < 0.0031308 ? 12.92 * a : 1.055 * pow(a, 1.0 / 2.4) - 0.055);
|
||||
}
|
||||
|
||||
vec3 linearToSRGB(vec3 c) {
|
||||
return vec3(linearToS(c.r), linearToS(c.g), linearToS(c.b));
|
||||
}
|
||||
|
||||
float ign(vec2 p) {
|
||||
return fract(52.9829189 * fract(0.06711056 * p.x + 0.00583715 * p.y));
|
||||
}
|
||||
|
||||
float tent(float r) {
|
||||
float rp = sqrt(2.0 * r);
|
||||
float rn = sqrt(2.0 * r + 1.0) - 1.0;
|
||||
return (r < 0.0) ? 0.5 * rn : 0.5 * rp;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 texel = texture(uImage, vUv);
|
||||
vec3 mapped = uGamutToSRGB * texel.rgb;
|
||||
|
||||
vec3 selected;
|
||||
if (uChannel == 1) selected = vec3(mapped.r);
|
||||
else if (uChannel == 2) selected = vec3(mapped.g);
|
||||
else if (uChannel == 3) selected = vec3(mapped.b);
|
||||
else if (uChannel == 4) selected = vec3(texel.a);
|
||||
else if (uChannel == 5)
|
||||
selected = vec3(dot(mapped, vec3(0.2126, 0.7152, 0.0722)));
|
||||
else selected = mapped;
|
||||
|
||||
vec3 exposed = selected * uGain;
|
||||
|
||||
vec3 display = linearToSRGB(exposed);
|
||||
|
||||
if (uDither) {
|
||||
float r = ign(gl_FragCoord.xy) - 0.5;
|
||||
display += vec3(tent(r) / 255.0);
|
||||
}
|
||||
|
||||
display = clamp(display, 0.0, 1.0);
|
||||
|
||||
if (uClipWarnings) {
|
||||
float zebra1 =
|
||||
mod(floor((gl_FragCoord.x + gl_FragCoord.y) / 8.0), 2.0) == 0.0 ? 0.0 : 1.0;
|
||||
float zebra2 =
|
||||
mod(floor((gl_FragCoord.x - gl_FragCoord.y) / 8.0), 2.0) == 0.0 ? 0.0 : 1.0;
|
||||
bvec3 over = greaterThan(exposed, vec3(uClipRange.y));
|
||||
bvec3 under = lessThan(exposed, vec3(uClipRange.x));
|
||||
display = mix(display, vec3(zebra1), vec3(over));
|
||||
display = mix(display, vec3(zebra2), vec3(under));
|
||||
}
|
||||
|
||||
frag_color = vec4(display, 1.0);
|
||||
}
|
||||
`
|
||||
@@ -79,4 +79,56 @@ describe('dialogService Reka renderer opt-in', () => {
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('lg')
|
||||
})
|
||||
|
||||
it("showTopUpCreditsDialog() sets renderer 'reka' with a transparent shrink-wrapped chrome", async () => {
|
||||
await useDialogService().showTopUpCreditsDialog()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('bg-transparent')
|
||||
})
|
||||
|
||||
it("showLayoutDialog() defaults to renderer 'reka' headless without pt", () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showLayoutDialog({
|
||||
key: 'layout-test',
|
||||
component: Component,
|
||||
props: {}
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('showLayoutDialog() lets callers override the defaults', () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showLayoutDialog({
|
||||
key: 'layout-override-test',
|
||||
component: Component,
|
||||
props: {},
|
||||
dialogComponentProps: { closable: false, contentClass: 'w-170' }
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.closable).toBe(false)
|
||||
expect(args.dialogComponentProps.contentClass).toBe('w-170')
|
||||
})
|
||||
|
||||
it("showSmallLayoutDialog() sets renderer 'reka' with zeroed section padding", () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showSmallLayoutDialog({
|
||||
key: 'small-layout-test',
|
||||
component: Component
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0 overflow-y-hidden')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,20 @@ const lazyCloudNotificationContent = () =>
|
||||
const lazyPublishDialog = () =>
|
||||
import('@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue')
|
||||
|
||||
/**
|
||||
* Shrink-wrap the Reka DialogContent around the content's intrinsic width,
|
||||
* like the auto-sized PrimeVue root it replaces.
|
||||
*/
|
||||
const HUG_CONTENT_CLASS =
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)]'
|
||||
|
||||
/**
|
||||
* Reka chrome for headless dialogs whose content draws its own panel
|
||||
* (background/border/rounding) — neutralize the DialogContent box and
|
||||
* shrink-wrap it around the content.
|
||||
*/
|
||||
const SELF_STYLED_PANEL_CONTENT_CLASS = `${HUG_CONTENT_CLASS} border-none bg-transparent shadow-none`
|
||||
|
||||
export type ConfirmationDialogType =
|
||||
| 'default'
|
||||
| 'overwrite'
|
||||
@@ -199,6 +213,8 @@ export const useDialogService = () => {
|
||||
},
|
||||
headerComponent: ComfyOrgHeader,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass: HUG_CONTENT_CLASS,
|
||||
closable: false,
|
||||
onClose: () => resolve(false)
|
||||
}
|
||||
@@ -222,6 +238,10 @@ export const useDialogService = () => {
|
||||
onSuccess: () => resolve(true)
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
// SignInContent is a fixed w-96 — size 'sm' (max-w-sm) leaves only
|
||||
// 352px after the body padding; hug the intrinsic width instead.
|
||||
contentClass: HUG_CONTENT_CLASS,
|
||||
closable: true,
|
||||
onClose: () => resolve(false)
|
||||
}
|
||||
@@ -327,12 +347,9 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -351,6 +368,10 @@ export const useDialogService = () => {
|
||||
props: {
|
||||
onSuccess: () =>
|
||||
dialogStore.closeDialog({ key: 'global-update-password' })
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass: HUG_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -380,20 +401,10 @@ export const useDialogService = () => {
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
}) {
|
||||
const layoutDefaultProps: DialogComponentProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden'
|
||||
},
|
||||
header: {
|
||||
class: 'p-0! hidden'
|
||||
},
|
||||
content: {
|
||||
class: 'p-0! m-0!'
|
||||
}
|
||||
}
|
||||
closable: true
|
||||
}
|
||||
|
||||
return dialogStore.showDialog({
|
||||
@@ -415,18 +426,15 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
...rest,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
closable: true,
|
||||
pt: {
|
||||
root: { class: 'bg-base-background border-border-default' },
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!p-0 overflow-y-hidden' },
|
||||
footer: { class: '!p-0' },
|
||||
pcCloseButton: {
|
||||
root: {
|
||||
class: '!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5'
|
||||
}
|
||||
}
|
||||
},
|
||||
// Contents bring their own width and separators — shrink-wrap the
|
||||
// chrome and zero the section padding.
|
||||
contentClass:
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-border-default',
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0 overflow-y-hidden',
|
||||
footerClass: 'p-0',
|
||||
...callerProps
|
||||
}
|
||||
})
|
||||
@@ -446,13 +454,10 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||
const workspaceDialogPt = {
|
||||
const workspaceDialogProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
} as const
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
@@ -465,7 +470,7 @@ export const useDialogService = () => {
|
||||
key: 'delete-workspace',
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,7 +484,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -498,7 +503,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -509,7 +514,7 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
key: 'leave-workspace',
|
||||
component,
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -520,7 +525,7 @@ export const useDialogService = () => {
|
||||
key: 'edit-workspace',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -532,7 +537,7 @@ export const useDialogService = () => {
|
||||
key: 'remove-member',
|
||||
component,
|
||||
props: { memberId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -543,7 +548,7 @@ export const useDialogService = () => {
|
||||
key: 'invite-member',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -555,7 +560,7 @@ export const useDialogService = () => {
|
||||
key: 'invite-member-upsell',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -567,7 +572,7 @@ export const useDialogService = () => {
|
||||
key: 'revoke-invite',
|
||||
component,
|
||||
props: { inviteId },
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
@@ -597,7 +602,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { cancelAt },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogPt
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -612,9 +617,8 @@ export const useDialogService = () => {
|
||||
props: {},
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
pt: {
|
||||
root: { class: 'w-170 max-h-[85vh]' }
|
||||
},
|
||||
contentClass:
|
||||
'w-170 max-w-[calc(100vw-1rem)] sm:max-w-[42.5rem] rounded-2xl overflow-hidden',
|
||||
onClose: () => resolve()
|
||||
}
|
||||
})
|
||||
@@ -628,12 +632,13 @@ export const useDialogService = () => {
|
||||
key,
|
||||
component: ComfyHubPublishDialog,
|
||||
props: {
|
||||
onClose: () => dialogStore.closeDialog({ key })
|
||||
onClose: () => dialogStore.closeDialog({ key }),
|
||||
// Falls through to the BaseModalLayout root — keeps the e2e
|
||||
// publish-dialog selector working without the PrimeVue pt hook.
|
||||
'data-testid': 'publish-dialog'
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
root: { 'data-testid': 'publish-dialog' }
|
||||
}
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
28
src/services/hdrViewerService.ts
Normal file
28
src/services/hdrViewerService.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import {
|
||||
getImageFilenameFromUrl,
|
||||
toFullResolutionUrl
|
||||
} from '@/utils/hdrFormatUtil'
|
||||
|
||||
const HdrViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/hdr/HdrViewerContent.vue')
|
||||
)
|
||||
|
||||
export function openHdrViewer(url: string) {
|
||||
const fullResUrl = toFullResolutionUrl(url)
|
||||
useDialogStore().showDialog({
|
||||
key: 'hdr-viewer',
|
||||
title: getImageFilenameFromUrl(fullResUrl) ?? t('hdrViewer.title'),
|
||||
component: HdrViewerContent,
|
||||
props: { imageUrl: fullResUrl },
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass: 'w-[80vw] h-[80vh] max-h-[80vh]',
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -21,10 +21,10 @@ type DialogPosition =
|
||||
| 'bottomright'
|
||||
|
||||
/**
|
||||
* Selects the dialog renderer used by `GlobalDialog`. `'primevue'` is the
|
||||
* current default and runs the legacy PrimeVue `Dialog` path. `'reka'` opts
|
||||
* into the Reka-UI primitive set under `src/components/ui/dialog/`. Migration
|
||||
* tracked in `temp/plans/adr-0009-dialog-reka-migration-DRAFT.md`.
|
||||
* Selects the dialog renderer used by `GlobalDialog`. `'reka'` (the default)
|
||||
* renders the Reka-UI primitive set under `src/components/ui/dialog/`.
|
||||
* `'primevue'` is the legacy PrimeVue `Dialog` escape hatch, kept only until
|
||||
* the branch is deleted in the Phase 6 cleanup (FE-578).
|
||||
*/
|
||||
type DialogRenderer = 'primevue' | 'reka'
|
||||
|
||||
@@ -201,6 +201,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
closable: true,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
renderer: 'reka' as DialogRenderer,
|
||||
...options.dialogComponentProps,
|
||||
maximized: false,
|
||||
onMaximize: () => {
|
||||
|
||||
60
src/utils/hdrFormatUtil.test.ts
Normal file
60
src/utils/hdrFormatUtil.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getImageFilenameFromUrl,
|
||||
isHdrImageFilename,
|
||||
isHdrImageUrl,
|
||||
toFullResolutionUrl
|
||||
} from './hdrFormatUtil'
|
||||
|
||||
describe('isHdrImageFilename', () => {
|
||||
it('detects exr and hdr regardless of case', () => {
|
||||
expect(isHdrImageFilename('render.exr')).toBe(true)
|
||||
expect(isHdrImageFilename('RENDER.EXR')).toBe(true)
|
||||
expect(isHdrImageFilename('env.hdr')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-hdr formats and empty input', () => {
|
||||
expect(isHdrImageFilename('image.png')).toBe(false)
|
||||
expect(isHdrImageFilename('image.webp')).toBe(false)
|
||||
expect(isHdrImageFilename(undefined)).toBe(false)
|
||||
expect(isHdrImageFilename('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getImageFilenameFromUrl', () => {
|
||||
it('reads the filename query parameter from a view url', () => {
|
||||
expect(
|
||||
getImageFilenameFromUrl('/api/view?filename=out.exr&type=output')
|
||||
).toBe('out.exr')
|
||||
})
|
||||
|
||||
it('falls back to the last path segment', () => {
|
||||
expect(getImageFilenameFromUrl('https://x.test/files/out.hdr')).toBe(
|
||||
'out.hdr'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isHdrImageUrl', () => {
|
||||
it('detects hdr outputs from view urls', () => {
|
||||
expect(isHdrImageUrl('/api/view?filename=scene.exr&type=output')).toBe(true)
|
||||
expect(isHdrImageUrl('/api/view?filename=scene.png&type=output')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toFullResolutionUrl', () => {
|
||||
it('strips the preview parameter', () => {
|
||||
expect(
|
||||
toFullResolutionUrl('/api/view?filename=out.exr&preview=webp;75&rand=1')
|
||||
).toBe('/api/view?filename=out.exr&rand=1')
|
||||
})
|
||||
|
||||
it('leaves urls without a preview parameter untouched', () => {
|
||||
expect(toFullResolutionUrl('/api/view?filename=out.exr')).toBe(
|
||||
'/api/view?filename=out.exr'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/utils/hdrFormatUtil.ts
Normal file
38
src/utils/hdrFormatUtil.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const HDR_EXTENSIONS = ['.exr', '.hdr'] as const
|
||||
|
||||
export function isHdrImageFilename(filename: string | undefined): boolean {
|
||||
if (!filename) return false
|
||||
const lower = filename.toLowerCase()
|
||||
return HDR_EXTENSIONS.some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
export function getImageFilenameFromUrl(url: string): string | undefined {
|
||||
if (!url) return undefined
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
return (
|
||||
parsed.searchParams.get('filename') ??
|
||||
parsed.pathname.split('/').pop() ??
|
||||
undefined
|
||||
)
|
||||
} catch {
|
||||
return url.split('/').pop()
|
||||
}
|
||||
}
|
||||
|
||||
export function isHdrImageUrl(url: string | undefined): boolean {
|
||||
if (!url) return false
|
||||
return isHdrImageFilename(getImageFilenameFromUrl(url))
|
||||
}
|
||||
|
||||
export function toFullResolutionUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
parsed.searchParams.delete('preview')
|
||||
return url.startsWith('http')
|
||||
? parsed.toString()
|
||||
: `${parsed.pathname}${parsed.search}`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user