feat(stage-ui,stage-web): new colorsFromElement lib extracted

This commit is contained in:
Neko Ayaka
2025-12-20 23:13:05 +08:00
parent 4f914d854c
commit b8b22b3d84
5 changed files with 237 additions and 107 deletions
+2
View File
@@ -107,10 +107,12 @@
"dompurify": "^3.3.1",
"es-toolkit": "catalog:",
"gpuu": "^1.0.6",
"html2canvas": "^1.4.1",
"jszip": "^3.10.1",
"localforage": "^1.10.0",
"mediabunny": "^1.26.0",
"nanoid": "^5.1.6",
"node-vibrant": "^4.0.3",
"ofetch": "^1.5.1",
"pinia": "^3.0.4",
"pixi-filters": "4",
@@ -0,0 +1,182 @@
import type { Color } from 'culori'
import html2canvas from 'html2canvas'
import { average } from 'culori'
import { Vibrant } from 'node-vibrant/browser'
export type ColorFromElementMode = 'vibrant' | 'html2canvas' | 'both'
export interface ColorFromElementOptions {
/**
* Which extraction pipeline to run. Use `'both'` to mirror the devtools view.
* Defaults to `'both'`.
*/
mode?: ColorFromElementMode
/**
* Options for the Vibrant-based palette extraction.
*/
vibrant?: {
/**
* Optional override for the image source; falls back to the `img` element's `currentSrc/src`.
*/
imageSource?: string
/**
* Ratio (0-1) of the image height to sample from the top edge. Defaults to 0.2.
*/
sampleTopRatio?: number
}
/**
* Options for html2canvas-based sampling of the rendered element.
*/
html2canvas?: {
/**
* Region forwarded to html2canvas. Provide only what you need; defaults to the live element box.
*/
region?: {
x?: number
y?: number
width?: number
height?: number
}
/**
* How many pixels (height) to read from the captured canvas. Defaults to 20.
*/
sampleHeight?: number
/**
* Pixel stride when sampling the captured row. Defaults to 10 (i.e., every 10th pixel).
*/
sampleStride?: number
/**
* Canvas scale used by html2canvas. Defaults to 0.5.
*/
scale?: number
allowTaint?: boolean
useCORS?: boolean
backgroundColor?: string | null
logging?: boolean
}
}
export interface ColorFromElementResult {
vibrant?: {
palette: string[]
dominant?: string
}
html2canvas?: {
average?: string
canvas?: HTMLCanvasElement
}
}
export async function colorFromElement(element: HTMLElement, options: ColorFromElementOptions = {}): Promise<ColorFromElementResult> {
const mode = options.mode ?? 'both'
const result: ColorFromElementResult = {}
const shouldRunVibrant = mode === 'vibrant' || mode === 'both'
const shouldRunHtml2Canvas = mode === 'html2canvas' || mode === 'both'
if (shouldRunVibrant) {
const vibrantResult = await extractWithVibrant(element, options.vibrant)
result.vibrant = vibrantResult
}
if (shouldRunHtml2Canvas) {
const html2CanvasResult = await extractWithHtml2Canvas(element, options.html2canvas)
result.html2canvas = html2CanvasResult
}
return result
}
async function extractWithVibrant(element: HTMLElement, options: ColorFromElementOptions['vibrant']): Promise<ColorFromElementResult['vibrant']> {
const sampleTopRatio = options?.sampleTopRatio ?? 0.2
const imageSource = options?.imageSource ?? (element instanceof HTMLImageElement ? (element.currentSrc || element.src) : undefined)
if (!imageSource) {
return { palette: [], dominant: undefined }
}
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = imageSource
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('Failed to load image for Vibrant extraction'))
})
const cropHeight = Math.max(1, Math.floor(img.naturalHeight * sampleTopRatio))
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = cropHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, img.naturalWidth, cropHeight, 0, 0, img.naturalWidth, cropHeight)
}
const dataUrl = canvas.toDataURL()
const vibrant = new Vibrant(dataUrl)
const palette = await vibrant.getPalette()
const colors = Object.values(palette)
.map(color => color?.hex)
.filter((color): color is string => typeof color === 'string')
return {
palette: colors,
dominant: palette.Vibrant?.hex || palette.DarkVibrant?.hex || colors[0],
}
}
async function extractWithHtml2Canvas(element: HTMLElement, options: ColorFromElementOptions['html2canvas']): Promise<ColorFromElementResult['html2canvas']> {
const region = options?.region ?? {}
// NOTICE: Region defaults to the live element box; override when the rendered size differs (e.g., scaled canvases).
const captureWidth = region.width ?? element.offsetWidth
const captureHeight = region.height ?? element.offsetHeight
const canvas = await html2canvas(element, {
allowTaint: options?.allowTaint ?? true,
useCORS: options?.useCORS ?? true,
backgroundColor: options?.backgroundColor ?? null,
scale: options?.scale ?? 0.5,
logging: options?.logging ?? false,
width: captureWidth,
height: captureHeight,
x: region.x,
y: region.y,
})
const ctx = canvas.getContext('2d')
if (!ctx) {
return { canvas, average: undefined }
}
const sampleHeight = Math.max(1, Math.min(options?.sampleHeight ?? 20, canvas.height))
const sampleStride = Math.max(1, options?.sampleStride ?? 10)
const imageData = ctx.getImageData(0, 0, canvas.width, sampleHeight)
const colors: Color[] = []
for (let i = 0; i < imageData.data.length; i += 4 * sampleStride) {
const r = imageData.data[i]
const g = imageData.data[i + 1]
const b = imageData.data[i + 2]
const a = imageData.data[i + 3]
if (a > 0) {
colors.push({ mode: 'rgb', r, g, b })
}
}
if (colors.length === 0) {
return { canvas, average: undefined }
}
const averaged = average(colors as [Color, ...Color[]])
const averageColor = averaged ? `rgb(${averaged.r}, ${averaged.g}, ${averaged.b})` : undefined
return {
canvas,
average: averageColor,
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './audio/manager'
export * from './color-from-element'
export * from './workers/types'
export * from './workers/worker'