Skip to content

Commit b8b22b3

Browse files
committed
feat(stage-ui,stage-web): new colorsFromElement lib extracted
1 parent 4f914d8 commit b8b22b3

File tree

5 files changed

+235
-105
lines changed

5 files changed

+235
-105
lines changed

apps/stage-web/src/pages/devtools/background-gradient-blending.vue

Lines changed: 40 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
<script setup lang="ts">
2-
import type { Color } from 'culori'
3-
4-
import html2canvas from 'html2canvas'
5-
2+
import { colorFromElement } from '@proj-airi/stage-ui/libs'
63
import { BasicInputFile } from '@proj-airi/ui'
7-
import { average } from 'culori'
8-
import { Vibrant } from 'node-vibrant/browser'
94
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
105
116
import defaultBackgroundImage from '../../assets/backgrounds/fairy-forest.e17cbc2774.ko-fi.com.avif'
127
138
import { useThemeColor } from '../../composables/theme-color'
14-
159
// Reactive state
1610
const isCapturing = ref(false)
1711
const extractedColors = ref<string[]>([])
@@ -53,113 +47,60 @@ const topBar = computed(() => {
5347
return ''
5448
})
5549
56-
// Color extraction from image
57-
async function extractColorsFromImage() {
58-
if (images.value.length === 0) {
50+
async function refreshColors() {
51+
if (!imageRef.value || images.value.length === 0) {
5952
return
6053
}
6154
6255
try {
6356
isCapturing.value = true
6457
65-
// Load the image
66-
const img = new window.Image()
67-
img.crossOrigin = 'anonymous'
68-
img.src = images.value[0]
69-
70-
await new Promise((resolve, reject) => {
71-
img.onload = resolve
72-
img.onerror = reject
73-
})
74-
75-
// Create a canvas and draw only the top portion
76-
const cropHeight = Math.floor(img.naturalHeight * 0.2) // top 20% of the image
77-
const canvas = document.createElement('canvas')
78-
canvas.width = img.naturalWidth
79-
canvas.height = cropHeight
80-
const ctx = canvas.getContext('2d')
81-
if (ctx) {
82-
ctx.drawImage(img, 0, 0, img.naturalWidth, cropHeight, 0, 0, img.naturalWidth, cropHeight)
58+
if (imageRef.value instanceof HTMLImageElement && !imageRef.value.complete) {
59+
await new Promise<void>((resolve, reject) => {
60+
imageRef.value?.addEventListener('load', () => resolve(), { once: true })
61+
imageRef.value?.addEventListener('error', () => reject(new Error('Image failed to load')), { once: true })
62+
})
8363
}
8464
85-
// Convert canvas to data URL and use Vibrant on the cropped region
86-
const dataUrl = canvas.toDataURL()
87-
const vibrant = new Vibrant(dataUrl)
88-
const palette = await vibrant.getPalette()
89-
90-
const colors = Object.values(palette)
91-
.map(color => color?.hex)
92-
.filter((color): color is string => typeof color === 'string')
93-
94-
extractedColors.value = colors
95-
dominantColor.value = palette.Vibrant?.hex || palette.DarkVibrant?.hex || colors[0]
96-
97-
// Update PWA theme color
98-
await updateThemeColor()
99-
}
100-
catch (error) {
101-
console.error('Color extraction failed:', error)
102-
}
103-
finally {
104-
isCapturing.value = false
105-
}
106-
}
107-
108-
// Capture top edge colors using html2canvas
109-
async function captureTopEdgeColors() {
110-
if (!imageRef.value)
111-
return
112-
113-
try {
114-
isCapturing.value = true
115-
116-
// Capture the background element
117-
const canvas = await html2canvas(imageRef.value, {
118-
allowTaint: true,
119-
useCORS: true,
120-
backgroundColor: null,
121-
scale: 0.5, // Reduce quality for performance
122-
height: 100, // Only capture top portion
123-
width: imageRef.value.offsetWidth,
124-
logging: false,
65+
const result = await colorFromElement(imageRef.value, {
66+
mode: 'both',
67+
vibrant: {
68+
imageSource: images.value[0],
69+
sampleTopRatio: 0.2,
70+
},
71+
html2canvas: {
72+
region: {
73+
x: 0,
74+
y: 0,
75+
width: imageRef.value.offsetWidth,
76+
height: 100,
77+
},
78+
sampleHeight: 20,
79+
sampleStride: 10,
80+
scale: 0.5,
81+
backgroundColor: null,
82+
allowTaint: true,
83+
useCORS: true,
84+
},
12585
})
12686
127-
// Display captured canvas for debugging
128-
if (canvasRef.value) {
87+
extractedColors.value = result.vibrant?.palette ?? []
88+
dominantColor.value = result.vibrant?.dominant ?? ''
89+
topEdgeColors.value = result.html2canvas?.average ?? ''
90+
91+
if (canvasRef.value && result.html2canvas?.canvas) {
12992
const ctx = canvasRef.value.getContext('2d')
13093
if (ctx) {
131-
canvasRef.value.width = canvas.width
132-
canvasRef.value.height = canvas.height
133-
ctx.drawImage(canvas, 0, 0)
94+
canvasRef.value.width = result.html2canvas.canvas.width
95+
canvasRef.value.height = result.html2canvas.canvas.height
96+
ctx.drawImage(result.html2canvas.canvas, 0, 0)
13497
}
13598
}
13699
137-
// Sample colors from top edge
138-
const ctx = canvas.getContext('2d')
139-
if (ctx) {
140-
const imageData = ctx.getImageData(0, 0, canvas.width, 20) // Top 20px
141-
const colors: Color[] = []
142-
143-
// Sample every 10th pixel to get representative colors
144-
for (let i = 0; i < imageData.data.length; i += 40) { // RGBA = 4 bytes, sample every 10 pixels
145-
const r = imageData.data[i]
146-
const g = imageData.data[i + 1]
147-
const b = imageData.data[i + 2]
148-
const a = imageData.data[i + 3]
149-
150-
if (a > 0) { // Skip transparent pixels
151-
colors.push({ mode: 'rgb', r, g, b })
152-
}
153-
}
154-
155-
if (colors.length > 0) {
156-
const c = average(colors as [Color, ...Color[]])
157-
topEdgeColors.value = `rgb(${c.r}, ${c.g}, ${c.b})`
158-
}
159-
}
100+
await updateThemeColor()
160101
}
161102
catch (error) {
162-
console.error('Canvas capture failed:', error)
103+
console.error('Color extraction failed:', error)
163104
}
164105
finally {
165106
isCapturing.value = false
@@ -169,14 +110,12 @@ async function captureTopEdgeColors() {
169110
// Auto-extract colors on mount
170111
onMounted(async () => {
171112
await nextTick()
172-
await extractColorsFromImage()
173-
await captureTopEdgeColors()
113+
await refreshColors()
174114
})
175115
176116
watch(images, async () => {
177117
await nextTick()
178-
await extractColorsFromImage()
179-
await captureTopEdgeColors()
118+
await refreshColors()
180119
})
181120
182121
onUnmounted(() => {

packages/stage-ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,12 @@
107107
"dompurify": "^3.3.1",
108108
"es-toolkit": "catalog:",
109109
"gpuu": "^1.0.6",
110+
"html2canvas": "^1.4.1",
110111
"jszip": "^3.10.1",
111112
"localforage": "^1.10.0",
112113
"mediabunny": "^1.26.0",
113114
"nanoid": "^5.1.6",
115+
"node-vibrant": "^4.0.3",
114116
"ofetch": "^1.5.1",
115117
"pinia": "^3.0.4",
116118
"pixi-filters": "4",
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import type { Color } from 'culori'
2+
3+
import html2canvas from 'html2canvas'
4+
5+
import { average } from 'culori'
6+
import { Vibrant } from 'node-vibrant/browser'
7+
8+
export type ColorFromElementMode = 'vibrant' | 'html2canvas' | 'both'
9+
10+
export interface ColorFromElementOptions {
11+
/**
12+
* Which extraction pipeline to run. Use `'both'` to mirror the devtools view.
13+
* Defaults to `'both'`.
14+
*/
15+
mode?: ColorFromElementMode
16+
/**
17+
* Options for the Vibrant-based palette extraction.
18+
*/
19+
vibrant?: {
20+
/**
21+
* Optional override for the image source; falls back to the `img` element's `currentSrc/src`.
22+
*/
23+
imageSource?: string
24+
/**
25+
* Ratio (0-1) of the image height to sample from the top edge. Defaults to 0.2.
26+
*/
27+
sampleTopRatio?: number
28+
}
29+
/**
30+
* Options for html2canvas-based sampling of the rendered element.
31+
*/
32+
html2canvas?: {
33+
/**
34+
* Region forwarded to html2canvas. Provide only what you need; defaults to the live element box.
35+
*/
36+
region?: {
37+
x?: number
38+
y?: number
39+
width?: number
40+
height?: number
41+
}
42+
/**
43+
* How many pixels (height) to read from the captured canvas. Defaults to 20.
44+
*/
45+
sampleHeight?: number
46+
/**
47+
* Pixel stride when sampling the captured row. Defaults to 10 (i.e., every 10th pixel).
48+
*/
49+
sampleStride?: number
50+
/**
51+
* Canvas scale used by html2canvas. Defaults to 0.5.
52+
*/
53+
scale?: number
54+
allowTaint?: boolean
55+
useCORS?: boolean
56+
backgroundColor?: string | null
57+
logging?: boolean
58+
}
59+
}
60+
61+
export interface ColorFromElementResult {
62+
vibrant?: {
63+
palette: string[]
64+
dominant?: string
65+
}
66+
html2canvas?: {
67+
average?: string
68+
canvas?: HTMLCanvasElement
69+
}
70+
}
71+
72+
export async function colorFromElement(element: HTMLElement, options: ColorFromElementOptions = {}): Promise<ColorFromElementResult> {
73+
const mode = options.mode ?? 'both'
74+
const result: ColorFromElementResult = {}
75+
76+
const shouldRunVibrant = mode === 'vibrant' || mode === 'both'
77+
const shouldRunHtml2Canvas = mode === 'html2canvas' || mode === 'both'
78+
79+
if (shouldRunVibrant) {
80+
const vibrantResult = await extractWithVibrant(element, options.vibrant)
81+
result.vibrant = vibrantResult
82+
}
83+
84+
if (shouldRunHtml2Canvas) {
85+
const html2CanvasResult = await extractWithHtml2Canvas(element, options.html2canvas)
86+
result.html2canvas = html2CanvasResult
87+
}
88+
89+
return result
90+
}
91+
92+
async function extractWithVibrant(element: HTMLElement, options: ColorFromElementOptions['vibrant']): Promise<ColorFromElementResult['vibrant']> {
93+
const sampleTopRatio = options?.sampleTopRatio ?? 0.2
94+
const imageSource = options?.imageSource ?? (element instanceof HTMLImageElement ? (element.currentSrc || element.src) : undefined)
95+
96+
if (!imageSource) {
97+
return { palette: [], dominant: undefined }
98+
}
99+
100+
const img = new Image()
101+
img.crossOrigin = 'anonymous'
102+
img.src = imageSource
103+
104+
await new Promise<void>((resolve, reject) => {
105+
img.onload = () => resolve()
106+
img.onerror = () => reject(new Error('Failed to load image for Vibrant extraction'))
107+
})
108+
109+
const cropHeight = Math.max(1, Math.floor(img.naturalHeight * sampleTopRatio))
110+
const canvas = document.createElement('canvas')
111+
canvas.width = img.naturalWidth
112+
canvas.height = cropHeight
113+
const ctx = canvas.getContext('2d')
114+
if (ctx) {
115+
ctx.drawImage(img, 0, 0, img.naturalWidth, cropHeight, 0, 0, img.naturalWidth, cropHeight)
116+
}
117+
118+
const dataUrl = canvas.toDataURL()
119+
const vibrant = new Vibrant(dataUrl)
120+
const palette = await vibrant.getPalette()
121+
122+
const colors = Object.values(palette)
123+
.map(color => color?.hex)
124+
.filter((color): color is string => typeof color === 'string')
125+
126+
return {
127+
palette: colors,
128+
dominant: palette.Vibrant?.hex || palette.DarkVibrant?.hex || colors[0],
129+
}
130+
}
131+
132+
async function extractWithHtml2Canvas(element: HTMLElement, options: ColorFromElementOptions['html2canvas']): Promise<ColorFromElementResult['html2canvas']> {
133+
const region = options?.region ?? {}
134+
// NOTICE: Region defaults to the live element box; override when the rendered size differs (e.g., scaled canvases).
135+
const captureWidth = region.width ?? element.offsetWidth
136+
const captureHeight = region.height ?? element.offsetHeight
137+
138+
const canvas = await html2canvas(element, {
139+
allowTaint: options?.allowTaint ?? true,
140+
useCORS: options?.useCORS ?? true,
141+
backgroundColor: options?.backgroundColor ?? null,
142+
scale: options?.scale ?? 0.5,
143+
logging: options?.logging ?? false,
144+
width: captureWidth,
145+
height: captureHeight,
146+
x: region.x,
147+
y: region.y,
148+
})
149+
150+
const ctx = canvas.getContext('2d')
151+
if (!ctx) {
152+
return { canvas, average: undefined }
153+
}
154+
155+
const sampleHeight = Math.max(1, Math.min(options?.sampleHeight ?? 20, canvas.height))
156+
const sampleStride = Math.max(1, options?.sampleStride ?? 10)
157+
const imageData = ctx.getImageData(0, 0, canvas.width, sampleHeight)
158+
const colors: Color[] = []
159+
160+
for (let i = 0; i < imageData.data.length; i += 4 * sampleStride) {
161+
const r = imageData.data[i]
162+
const g = imageData.data[i + 1]
163+
const b = imageData.data[i + 2]
164+
const a = imageData.data[i + 3]
165+
166+
if (a > 0) {
167+
colors.push({ mode: 'rgb', r, g, b })
168+
}
169+
}
170+
171+
if (colors.length === 0) {
172+
return { canvas, average: undefined }
173+
}
174+
175+
const averaged = average(colors as [Color, ...Color[]])
176+
const averageColor = averaged ? `rgb(${averaged.r}, ${averaged.g}, ${averaged.b})` : undefined
177+
178+
return {
179+
canvas,
180+
average: averageColor,
181+
}
182+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './audio/manager'
2+
export * from './color-from-element'
23
export * from './workers/types'
34
export * from './workers/worker'

0 commit comments

Comments
 (0)