| import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| import type { AvatarResult } from './types' |
| import type { FramingType, WizardMeta } from './galleryTypes' |
| import type { ViewAngle, ViewResultMap, ViewTimestampMap } from './viewPack' |
| import type { AvatarSettings } from './types' |
| import { buildAnglePrompt, buildIdentityLockSuffix, buildVisualDescriptorsFromMeta, compressOutfitForAngle, extractVisualDescriptors, getGarmentEmphasis, getViewAngleOption, resolveAngleTuning, sanitiseBasePromptForAngle, stripDescriptorDuplicates, VIEW_ANGLE_OPTIONS } from './viewPack' |
|
|
| export interface GenerateViewParams { |
| referenceImageUrl: string |
| angle: ViewAngle |
| characterPrompt?: string |
| basePrompt?: string |
| checkpointOverride?: string |
| seed?: number |
| |
| framingType?: FramingType |
| |
| avatarSettings?: AvatarSettings |
| |
| |
| |
| wizardMeta?: WizardMeta |
| } |
|
|
| interface OutfitGenerateResult { |
| results: AvatarResult[] |
| warnings: string[] |
| } |
|
|
| |
| |
| |
| const CACHE_PREFIX = 'hp_viewpack_' |
|
|
| interface CachedViewPack { |
| results: ViewResultMap |
| timestamps: ViewTimestampMap |
| } |
|
|
| function cacheKeyFor(key: string): string { |
| return `${CACHE_PREFIX}${key}` |
| } |
|
|
| function loadCached(key: string | undefined): CachedViewPack { |
| if (!key) return { results: {}, timestamps: {} } |
| try { |
| const raw = localStorage.getItem(cacheKeyFor(key)) |
| if (raw) { |
| const parsed = JSON.parse(raw) |
| |
| if (parsed && typeof parsed === 'object' && 'results' in parsed) { |
| return parsed as CachedViewPack |
| } |
| |
| return { results: parsed as ViewResultMap, timestamps: {} } |
| } |
| } catch { } |
| return { results: {}, timestamps: {} } |
| } |
|
|
| function saveCached(key: string | undefined, results: ViewResultMap, timestamps: ViewTimestampMap): void { |
| if (!key) return |
| try { |
| const hasEntries = Object.keys(results).length > 0 |
| if (hasEntries) { |
| const data: CachedViewPack = { results, timestamps } |
| localStorage.setItem(cacheKeyFor(key), JSON.stringify(data)) |
| } else { |
| localStorage.removeItem(cacheKeyFor(key)) |
| } |
| } catch { } |
| } |
|
|
| function removeCached(key: string | undefined): void { |
| if (!key) return |
| try { localStorage.removeItem(cacheKeyFor(key)) } catch { } |
| } |
|
|
| |
| |
| |
|
|
| |
| async function commitViewImage( |
| base: string, |
| headers: Record<string, string>, |
| comfyUrl: string, |
| oldUrl?: string, |
| ): Promise<string> { |
| try { |
| const res = await fetch(`${base}/v1/viewpack/commit`, { |
| method: 'POST', |
| headers, |
| body: JSON.stringify({ comfy_url: comfyUrl, old_url: oldUrl }), |
| }) |
| if (res.ok) { |
| const data = await res.json() |
| if (data.ok && data.url) return data.url |
| } |
| } catch { } |
| return comfyUrl |
| } |
|
|
| |
| function deleteViewImages( |
| base: string, |
| headers: Record<string, string>, |
| urls: string[], |
| ): void { |
| |
| const durable = urls.filter((u) => u.startsWith('/files/')) |
| if (durable.length === 0) return |
| fetch(`${base}/v1/viewpack/delete`, { |
| method: 'POST', |
| headers, |
| body: JSON.stringify({ urls: durable }), |
| }).catch(() => { }) |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function useViewPackGeneration(backendUrl: string, apiKey?: string, cacheKey?: string) { |
| const [resultsByAngle, setResultsByAngle] = useState<ViewResultMap>(() => loadCached(cacheKey).results) |
| const [timestampsByAngle, setTimestampsByAngle] = useState<ViewTimestampMap>(() => loadCached(cacheKey).timestamps) |
| const [loadingAngles, setLoadingAngles] = useState<Partial<Record<ViewAngle, boolean>>>({}) |
| const [warnings, setWarnings] = useState<string[]>([]) |
| const [error, setError] = useState<string | null>(null) |
|
|
| |
| const resultsRef = useRef(resultsByAngle) |
| resultsRef.current = resultsByAngle |
|
|
| |
| |
| |
| |
| |
| const [prevKey, setPrevKey] = useState(cacheKey) |
| const timestampsRef = useRef(timestampsByAngle) |
| timestampsRef.current = timestampsByAngle |
|
|
| if (prevKey !== cacheKey) { |
| |
| saveCached(prevKey, resultsByAngle, timestampsRef.current) |
|
|
| |
| const loaded = loadCached(cacheKey) |
| setPrevKey(cacheKey) |
| setResultsByAngle(loaded.results) |
| setTimestampsByAngle(loaded.timestamps) |
| setLoadingAngles({}) |
| setWarnings([]) |
| setError(null) |
| } |
|
|
| |
| useEffect(() => { |
| saveCached(cacheKey, resultsByAngle, timestampsByAngle) |
| }, [cacheKey, resultsByAngle, timestampsByAngle]) |
|
|
| const anyLoading = useMemo(() => Object.values(loadingAngles).some(Boolean), [loadingAngles]) |
|
|
| |
| const makeHeaders = useCallback((): Record<string, string> => { |
| const h: Record<string, string> = { 'Content-Type': 'application/json' } |
| if (apiKey) h['x-api-key'] = apiKey |
| return h |
| }, [apiKey]) |
|
|
| const generateAngle = useCallback(async (params: GenerateViewParams) => { |
| const base = (backendUrl || '').replace(/\/+$/, '') |
| const headers = makeHeaders() |
|
|
| const angleMeta = getViewAngleOption(params.angle) |
| const rawBase = params.basePrompt?.trim() || 'portrait photograph' |
|
|
| |
| |
| |
| |
| const basePrompt = sanitiseBasePromptForAngle(rawBase, params.angle) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const tunableAngle = params.angle !== 'front' ? params.angle as 'left' | 'right' | 'back' : null |
| const angleTuning = tunableAngle ? resolveAngleTuning(tunableAngle, params.avatarSettings) : null |
|
|
| |
| |
| const metaDescriptors = buildVisualDescriptorsFromMeta(params.wizardMeta) |
| const visualDescriptors = metaDescriptors || extractVisualDescriptors(rawBase) |
|
|
| |
| |
| |
| const cleanedBase = stripDescriptorDuplicates(basePrompt, visualDescriptors) |
|
|
| |
| |
| |
| |
| const outfitTokens = compressOutfitForAngle(cleanedBase, params.angle) |
| const garmentEmphasis = getGarmentEmphasis(params.angle, cleanedBase) |
|
|
| const viewPrompt = [ |
| buildAnglePrompt(params.angle, params.framingType, params.avatarSettings), |
| visualDescriptors, |
| outfitTokens, |
| garmentEmphasis, |
| buildIdentityLockSuffix(params.framingType), |
| ].filter(Boolean).join(', ') |
|
|
| |
| const negParts = [ |
| 'lowres, blurry, bad anatomy, deformed, extra fingers, missing fingers, bad hands, disfigured face, watermark, text, multiple people, duplicate', |
| ] |
| if (angleMeta.negativePrompt) { |
| negParts.push(angleMeta.negativePrompt) |
| } |
| const negativePrompt = negParts.join(', ') |
|
|
| setLoadingAngles((current) => ({ ...current, [params.angle]: true })) |
| setError(null) |
|
|
| try { |
| const res = await fetch(`${base}/v1/avatars/outfits`, { |
| method: 'POST', |
| headers, |
| body: JSON.stringify({ |
| reference_image_url: params.referenceImageUrl, |
| outfit_prompt: viewPrompt, |
| |
| |
| |
| |
| |
| |
| negative_prompt: negativePrompt, |
| count: 1, |
| generation_mode: angleMeta.generationMode || 'identity', |
| checkpoint_override: params.checkpointOverride, |
| seed: params.seed, |
| |
| denoise_override: angleTuning?.denoise ?? angleMeta.denoise, |
| |
| |
| |
| target_orientation: |
| (params.angle === 'left' && (params.avatarSettings?.autoMirrorLeft ?? true)) || |
| (params.angle === 'right' && (params.avatarSettings?.autoMirrorRight ?? false)) |
| ? params.angle |
| : undefined, |
| }), |
| }) |
|
|
| if (!res.ok) { |
| const text = await res.text().catch(() => '') |
| throw new Error(`View generation failed: ${res.status} ${text}`) |
| } |
|
|
| const data: OutfitGenerateResult = await res.json() |
| const first = data.results?.[0] |
| if (!first) throw new Error('View generation returned no images') |
|
|
| |
| const oldUrl = resultsRef.current[params.angle]?.url |
| const durableUrl = await commitViewImage(base, headers, first.url, oldUrl) |
|
|
| const tagged: AvatarResult = { |
| ...first, |
| url: durableUrl, |
| metadata: { |
| ...(first.metadata || {}), |
| view_angle: params.angle, |
| view_prompt: viewPrompt, |
| view_negative: negativePrompt, |
| }, |
| } |
|
|
| setResultsByAngle((current) => ({ ...current, [params.angle]: tagged })) |
| setTimestampsByAngle((current) => ({ ...current, [params.angle]: Date.now() })) |
| if (data.warnings?.length) { |
| setWarnings((current) => [...current, ...data.warnings]) |
| } |
| return tagged |
| } catch (err) { |
| const message = err instanceof Error ? err.message : 'View generation failed' |
| setError(message) |
| throw err |
| } finally { |
| setLoadingAngles((current) => ({ ...current, [params.angle]: false })) |
| } |
| }, [backendUrl, apiKey, makeHeaders]) |
|
|
| |
| const deleteAngle = useCallback((angle: ViewAngle) => { |
| |
| const existing = resultsRef.current[angle] |
| if (existing?.url) { |
| const base = (backendUrl || '').replace(/\/+$/, '') |
| deleteViewImages(base, makeHeaders(), [existing.url]) |
| } |
|
|
| setResultsByAngle((current) => { |
| const next = { ...current } |
| delete next[angle] |
| return next |
| }) |
| setTimestampsByAngle((current) => { |
| const next = { ...current } |
| delete next[angle] |
| return next |
| }) |
| }, [backendUrl, makeHeaders]) |
|
|
| |
| const reset = useCallback(() => { |
| |
| const allUrls = Object.values(resultsRef.current) |
| .map((r) => r?.url) |
| .filter((u): u is string => Boolean(u)) |
| if (allUrls.length > 0) { |
| const base = (backendUrl || '').replace(/\/+$/, '') |
| deleteViewImages(base, makeHeaders(), allUrls) |
| } |
|
|
| setResultsByAngle({}) |
| setTimestampsByAngle({}) |
| setLoadingAngles({}) |
| setWarnings([]) |
| setError(null) |
| removeCached(cacheKey) |
| }, [cacheKey, backendUrl, makeHeaders]) |
|
|
| const missingAngles = useCallback((existing?: ViewResultMap) => { |
| const source = existing || resultsByAngle |
| return VIEW_ANGLE_OPTIONS.filter((item) => !source[item.id]).map((item) => item.id) |
| }, [resultsByAngle]) |
|
|
| return { |
| resultsByAngle, |
| timestampsByAngle, |
| loadingAngles, |
| anyLoading, |
| warnings, |
| error, |
| generateAngle, |
| deleteAngle, |
| reset, |
| missingAngles, |
| } |
| } |
|
|