File size: 15,167 Bytes
23b413b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
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
  /** Framing type from the front view — angles will match this composition. */
  framingType?: FramingType
  /** Avatar settings — used to read per-angle tuning overrides (denoise, promptWeight). */
  avatarSettings?: AvatarSettings
  /** Structured appearance data from the creator wizard. When present, visual
   *  descriptors are built from exact values (e.g. "black hair, olive skin tone")
   *  instead of regex-guessing from the prompt — eliminating color drift. */
  wizardMeta?: WizardMeta
}

interface OutfitGenerateResult {
  results: AvatarResult[]
  warnings: string[]
}

// ---------------------------------------------------------------------------
// localStorage cache helpers
// ---------------------------------------------------------------------------
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)
      // Support old format (plain ViewResultMap) and new format (CachedViewPack)
      if (parsed && typeof parsed === 'object' && 'results' in parsed) {
        return parsed as CachedViewPack
      }
      // Legacy: plain ViewResultMap without timestamps
      return { results: parsed as ViewResultMap, timestamps: {} }
    }
  } catch { /* corrupt entry — ignore */ }
  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 { /* storage full — silently fail */ }
}

function removeCached(key: string | undefined): void {
  if (!key) return
  try { localStorage.removeItem(cacheKeyFor(key)) } catch { /* ignore */ }
}

// ---------------------------------------------------------------------------
// Backend helpers — commit & delete durable view-pack images
// ---------------------------------------------------------------------------

/** Commit a /comfy/view/ image to durable /files/ storage, optionally deleting the old one. */
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 { /* commit failed — fall back to ephemeral URL */ }
  return comfyUrl
}

/** Ask backend to delete one or more durable view-pack images. Fire-and-forget. */
function deleteViewImages(
  base: string,
  headers: Record<string, string>,
  urls: string[],
): void {
  // Only send /files/ URLs — /comfy/view/ URLs are ephemeral and managed by ComfyUI
  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(() => { /* best-effort cleanup */ })
}

// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------

/**
 * @param backendUrl  Backend base URL
 * @param apiKey      Optional API key
 * @param cacheKey    Unique key for localStorage persistence (e.g. characterId
 *                    or characterId+outfitId). When this changes the hook
 *                    loads the cached results for the new key.
 */
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)

  // Stable reference to current results for use inside callbacks
  const resultsRef = useRef(resultsByAngle)
  resultsRef.current = resultsByAngle

  // Track previous cacheKey so we can save before switching.
  // Uses React's "adjusting state from props" pattern: when cacheKey changes
  // we synchronously swap the cached data during render so there is never a
  // stale-data frame where angle thumbnails from the OLD source are shown
  // alongside the NEW source's front image.
  const [prevKey, setPrevKey] = useState(cacheKey)
  const timestampsRef = useRef(timestampsByAngle)
  timestampsRef.current = timestampsByAngle

  if (prevKey !== cacheKey) {
    // Save current in-memory results under the *previous* key
    saveCached(prevKey, resultsByAngle, timestampsRef.current)

    // Load the new key's cached data synchronously
    const loaded = loadCached(cacheKey)
    setPrevKey(cacheKey)
    setResultsByAngle(loaded.results)
    setTimestampsByAngle(loaded.timestamps)
    setLoadingAngles({})
    setWarnings([])
    setError(null)
  }

  // Auto-persist whenever results change
  useEffect(() => {
    saveCached(cacheKey, resultsByAngle, timestampsByAngle)
  }, [cacheKey, resultsByAngle, timestampsByAngle])

  const anyLoading = useMemo(() => Object.values(loadingAngles).some(Boolean), [loadingAngles])

  /** Build common fetch headers. */
  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'

    // Strip pose / camera / framing tokens that would contradict the angle
    // directive.  For 'front' this is a no-op (returns rawBase unchanged).
    // Keeps only outfit, appearance, and quality tokens so the clothing is
    // faithfully reproduced at every angle without front-bias contamination.
    const basePrompt = sanitiseBasePromptForAngle(rawBase, params.angle)

    // Build the positive prompt: angle-specific direction FIRST (highest weight),
    // then outfit description, then consistency suffix.
    // Angle directive is placed first so CLIP gives it the most attention weight.
    // The outfit description comes second — it's NOT duplicated via character_prompt
    // (we send character_prompt=undefined) to avoid tripling outfit tokens which
    // drowns out the angle instruction.
    //
    // The angle prompt and identity-lock suffix adapt to the front view's
    // framing type (half_body / mid_body / headshot) so the body range
    // matches across all angles — e.g. "head to waist" instead of
    // hardcoded "head to thighs".
    // Resolve per-angle tuning (denoise, promptWeight) from user settings or defaults
    const tunableAngle = params.angle !== 'front' ? params.angle as 'left' | 'right' | 'back' : null
    const angleTuning = tunableAngle ? resolveAngleTuning(tunableAngle, params.avatarSettings) : null

    // Prefer structured appearance data from wizardMeta (exact values, no guessing).
    // Fall back to regex extraction from the prompt for legacy items without meta.
    const metaDescriptors = buildVisualDescriptorsFromMeta(params.wizardMeta)
    const visualDescriptors = metaDescriptors || extractVisualDescriptors(rawBase)

    // Strip tokens from the base prompt that are already covered by the visual
    // descriptors (e.g. "brown hair", "light skin tone") plus non-visual boilerplate
    // (e.g. "European features baseline", "semi-realistic") to free CLIP budget.
    const cleanedBase = stripDescriptorDuplicates(basePrompt, visualDescriptors)

    // For non-front angles, compress the outfit to save CLIP tokens and add
    // angle-specific garment emphasis (e.g. "thong strap visible on hip" for
    // side views, "lace pattern visible from behind" for back view).
    // Front view uses the full uncompressed outfit (plenty of CLIP budget).
    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(', ')

    // Build the negative prompt: prevent front-facing bias from the reference latent
    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,
          // character_prompt intentionally omitted for view pack generation.
          // The outfit description is already in viewPrompt (via basePrompt).
          // Sending it again as character_prompt causes the backend to include
          // it a second time after _strip_outfit_tokens, tripling outfit token
          // weight and drowning out the angle directive.
          // Identity is preserved via the reference image + InstantID, not text.
          negative_prompt: negativePrompt,
          count: 1,
          generation_mode: angleMeta.generationMode || 'identity',
          checkpoint_override: params.checkpointOverride,
          seed: params.seed,
          // Denoise: user-tuned value (from settings) or built-in default.
          denoise_override: angleTuning?.denoise ?? angleMeta.denoise,
          // Per-direction mirror control: send target_orientation only when
          // the corresponding toggle is enabled in settings.
          // Left defaults ON (SD confuses left), right defaults OFF (reliable).
          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')

      // Commit to durable storage, deleting old image if regenerating
      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])

  /** Delete a single angle result (in-memory + cache + backend file). */
  const deleteAngle = useCallback((angle: ViewAngle) => {
    // Delete the durable file on the backend
    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])

  /** Clear all results for the current key (in-memory + cache + backend files). */
  const reset = useCallback(() => {
    // Collect all durable URLs and delete them on the backend
    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,
  }
}