Merge pull request #48 in YR/star-web-components from gauss-canvas to master

* commit 'b6d1047169261cd4fdf1d138abf5f7b8780c9bad':
  TASK: #113126 - add gauss blur component
This commit is contained in:
汪昌棋 2022-09-27 15:20:17 +08:00
commit bb6692cd37
11 changed files with 700 additions and 3 deletions

View File

@ -24,3 +24,4 @@
- add function for dragging icon into container
- add homescreen function for storing apps' order
- fix bugs of container
- add gauss blur component

View File

@ -8,7 +8,7 @@
content="width=device-width, user-scalable=no, initial-scale=1.0"
/>
<title>Star Web Components</title>
<script type="module" src="./start-element.js"></script>
<script type="module" src="./star-element.js"></script>
</head>
<body>
<!-- main -->

View File

@ -21,6 +21,11 @@
"description": "StarWeb组件"
}
},
"permissions": {
"settings": {
"access": "readwrite"
}
},
"core": true,
"version": "0.0.1"
}

View File

@ -0,0 +1,62 @@
## 高斯模糊小组件
小组件为解决用 `Css` 属性进行高斯模糊后,浏览器卡顿的问题。原理是用一个 `canvas` 将根据不同 `sigma` 值进行高斯模糊处理后的图展示出来。
该组件的模糊算法当 `sigma` 越高时 ,其处理模糊的速度越快,越低时处理速度越慢,然而 `sigma` 越低,模糊程度越小,与原图越类似,可以用降低分辨率的方法替换模糊算法。因此组件设定了一个门槛值 `threshold` ,使用者可以根据机器性能和图片质量选择门槛值的高低。
因为模糊算法耗时受 `sigma` 值和图片分辨率的影响,无法准确掌控,因此为了不阻塞主线程的渲染工作,模糊算法需要放入 `Web worker` 中运行。
### 属性
- `src`: 要高斯模糊的图片URL
- `sigma`: 高斯模糊程度系数,除了第一次传值时不会有模糊渐变,之后传值时图片模糊会呈渐变,如若不需要动画则调用方法 `showImmediately` 并传值
- `threshold`: 当 `sigma` 大于该值时组件采用模糊算法否则采用降低分辨率的方法模糊图片默认值为1
- `during`: 模糊渐变的最长时间,单位为 `ms`,默认值为 500
- `bezier`: 模糊渐变的贝塞尔系数,接受参数为一个有四个数字元素的数组,默认值为 `[0.19, 1, 0.22, 1]`
### 使用
注意,请不要使用跨域图片资源,否则无法转化为 `ImageData` 进行模糊计算
```html
<gauss-canvas src="./test.png" sigma="1"></gauss-canvas>
```
```js
import "@star-web-element/gauss"
const canvas = document.querySelector('gauss-canvas');
canvas.addEventListener('click', () => {
// 会有模糊渐变
canvas.sigma ^= 10
})
canvas.addEventListener('mousedown', handleEvent);
canvas.addEventListener('mousemove', handleEvent);
canvas.addEventListener('mouseup', handleEvent);
let mouseData = {
start: 0,
moveDistance: 0,
}
function handleEvent(evt) {
switch (evt.type) {
case 'mousedown':
mouseData.start = evt.clientY;
break;
case ' mousemove':
if (mouseData.start) {
mouseData.moveDistance = evt.clientY - mouseData.start;
const conHeight = canvas.parentElement.offsetHeight;
const targetSigma = (mouseData.moveDistance / conHeight) * 10;
// 不会有模糊渐变
canvas.showImmediately(targetSigma);
}
break;
case 'mouseup':
mouseData.start = mouseData.moveDistance = 0;
break;
}
}
```

View File

@ -0,0 +1,287 @@
import {html, css, LitElement} from 'lit'
import {customElement, property, query} from 'lit/decorators.js'
import workerUrl from './worker'
enum ErrorType {
IMAGE_LOAD_FAILED = 'Image load failed!',
IMAGE_NOT_PREPARED = 'The Image resource is not completely prepared!',
IMAGE_CROS_ERROR = 'The Image resource crosses domain!',
CANVAS_PUTIMAGEDATA_ERROR = 'Canvas putImageData failed!',
}
const cubic_bezier = (
p0: number,
p1: number,
p2: number,
p3: number,
t: number
): number => {
return (
p0 * Math.pow(1 - t, 3) +
3 * p1 * t * Math.pow(1 - t, 2) +
3 * p2 * t * t * (1 - t) +
p3 * Math.pow(t, 3)
)
}
@customElement('gauss-canvas')
export default class GaussCanvas extends LitElement {
@query('#display') display!: HTMLCanvasElement
@property({type: String})
get src() {
return this._src
}
set src(value) {
if (value && value !== this._src) {
const loadCb = () => {
this._src = value
}
const errCb = () => {
this._img.src = this._src
this._img.removeEventListener('load', loadCb)
}
this._img.addEventListener('error', errCb, {once: true})
this._img.addEventListener('load', loadCb, {once: true})
this._img.src = value
}
}
@property() threshold: number = 1
@property() transition: boolean = false
@property()
get sigma() {
return this._sigma
}
set sigma(value) {
if (value < 0) {
value = 0
}
if (this._sigma == -1) {
this._sigma = Number(value)
this.show(value)
} else if (value !== this._sigma && this._targetSigma == -1) {
this._targetSigma = value
this.openAnimation()
} else {
console.info('changed target sigma')
}
}
@property({type: Number}) bezier: [number, number, number, number] = [
0.19, 1, 0.22, 1,
]
@property() during = 500
readonly _img: HTMLImageElement = document.createElement('img')
_loadStatus: boolean = false
_imgWidth: number = 0
_imgHeight: number = 0
readonly _trCanvas: HTMLCanvasElement = document.createElement('canvas')
readonly _trCtx = this._trCanvas.getContext('2d')!
_imageData: ImageData | undefined
_src: string = ''
_ctx!: CanvasRenderingContext2D
_sigma: number = -1
_targetSigma: number = -1
_worker: Worker | undefined
_id!: number
disWidth: number = 0
disHeight: number = 0
constructor(url: string) {
super()
this._img.addEventListener('error', this.loadError)
this._img.addEventListener('load', this.loadSuccess)
this._id = new Date().getTime()
this.src = url
}
loadError = () => {
this._loadStatus = false
this._imgWidth = this._imgHeight = 0
console.error(ErrorType.IMAGE_LOAD_FAILED)
}
loadSuccess = () => {
this._loadStatus = true
this._imgHeight = this._img.height
this._imgWidth = this._img.width
this.show(this.sigma)
}
drawWorker = (
sigma: number,
imageData: ImageData,
canvas: HTMLCanvasElement,
draw: Function
) => {
if (sigma > this.threshold) {
if (!this._worker) {
this._worker = new Worker(workerUrl)
this._worker.addEventListener('message', (evt) => {
draw(evt.data.data, evt.data.id)
})
}
this._worker.postMessage({
imageData,
sigma,
id: this._id,
height: canvas.height,
width: canvas.width,
})
this.isFormatting = true
} else {
draw(imageData, this._id)
this.cb?.()
}
}
cb: Function | undefined
isFormatting: boolean = false
show = (sigma: number, cb?: Function) => {
this.cb = cb
return new Promise((res, rej) => {
if (!this._loadStatus) {
if (!this._img.src.includes(this._src)) {
this._img.src = this._src
}
return
}
if (this.isFormatting) {
return res(void 0)
}
let ratio: number = sigma
if (sigma == 0) {
ratio = 1
} else if (sigma < this.threshold) {
ratio = 2 * this.threshold - 1
}
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
canvas.height = this._img.height / ratio
canvas.width = this._img.width / ratio
ctx.drawImage(
this._img,
0,
0,
this._img.width,
this._img.height,
0,
0,
canvas.width,
canvas.height
)
const draw = (imageData: ImageData, dataId: number) => {
if (dataId == this._id) {
if (this.cb) {
this.cb?.()
}
// +100 为了清除拖影
this.disHeight = this.display.height = imageData.height /* + 100 */
this.disWidth = this.display.width = imageData.width
this._ctx.putImageData(imageData, 0, 0)
this.isFormatting = false
res(void 0)
}
}
try {
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
this.drawWorker(sigma, imageData, canvas, draw)
} catch (error: any) {
if (error.code == 18) {
return rej(ErrorType.IMAGE_CROS_ERROR)
}
return rej(ErrorType.CANVAS_PUTIMAGEDATA_ERROR)
}
})
}
showImmediately(sigma: number) {
return new Promise((res) => {
this._sigma = sigma
this.show(sigma).then(res)
})
}
_startTime!: number
animation: Function | undefined
openAnimation() {
this._startTime = new Date().getTime()
const getCurSigma = () => {
const t = (new Date().getTime() - this._startTime) / this.during
let ratio = cubic_bezier(...this.bezier, t)
if (ratio > 0.95) {
return this._targetSigma
}
const result = (this._targetSigma - this._sigma) * ratio + this._sigma
return result > 0 ? result : 0
}
let changeSigma = () => {
this._sigma = getCurSigma()
if (this._targetSigma !== this._sigma) {
return true
} else {
return false
}
}
this.animation = () => {
if (changeSigma()) {
// console.time(`sigma: ${this._sigma}`)
this.show(this._sigma, () => {
// console.timeEnd(`sigma: ${this._sigma}`)
// @ts-ignore
requestAnimationFrame(this.animation!)
})
} else {
this._targetSigma = -1
}
}
this.animation()
}
protected firstUpdated() {
this._ctx = this.display.getContext('2d')!
}
render() {
return html`
<canvas id="display"></canvas>
`
}
static styles = css`
:host {
display: block;
overflow: hidden;
}
canvas {
display: block;
margin: auto;
height: calc(100%);
width: 100%;
}
`
}
declare global {
interface HTMLElementTagNameMap {
'gauss-canvas': GaussCanvas
}
}

View File

@ -0,0 +1,25 @@
{
"name": "@star-web-components/gauss",
"version": "0.0.1",
"description": "",
"type": "module",
"main": "./index.js",
"module": "./index.js",
"exports": {
".": {
"default": "./index.js"
},
"./index": {
"default": "./index.js"
},
"./index.js": {
"default": "./index.js"
},
"./icons/index.js": {
"default": "./icons/index.js"
},
"./package.json": "./package.json"
},
"author": "",
"license": "ISC"
}

View File

@ -0,0 +1,181 @@
// import {
// cubic_bezier,
// fastBlur,
// genSrcAndDest,
// mergeChannels,
// genKernelsForGaussian,
// _fastBlur,
// hFastMotionBlur,
// vFastMotionBlur,
// } from './utils'
// @ts-ignore
const workCode = () => {
function genKernelsForGaussian(sigma: number, n: number) {
const wIdeal = Math.sqrt((12 * Math.pow(sigma, 2)) / n + 1)
const sizes = []
let wl = Math.floor(wIdeal)
if (wl % 2 === 0) {
wl--
}
const wu = wl + 2
let m =
(12 * Math.pow(sigma, 2) - n * Math.pow(wl, 2) - 4 * n * wl - 3 * n) /
(-4 * wl - 4)
m = Math.round(m)
for (let i = 0; i < n; i++) {
sizes.push(i < m ? wl : wu)
}
return sizes
}
function genSrcAndDest(data: Uint8ClampedArray) {
const dataInt8 = new Uint8ClampedArray(data.buffer)
const simpleChannelLength = dataInt8.length / 4
const r = new Uint8ClampedArray(simpleChannelLength)
const g = new Uint8ClampedArray(simpleChannelLength)
const b = new Uint8ClampedArray(simpleChannelLength)
const a = new Uint8ClampedArray(simpleChannelLength)
const _r = new Uint8ClampedArray(simpleChannelLength)
const _g = new Uint8ClampedArray(simpleChannelLength)
const _b = new Uint8ClampedArray(simpleChannelLength)
const _a = new Uint8ClampedArray(simpleChannelLength)
for (let i = 0; i < simpleChannelLength; i++) {
_r[i] = r[i] = dataInt8[i * 4]
_g[i] = g[i] = dataInt8[i * 4 + 1]
_b[i] = b[i] = dataInt8[i * 4 + 2]
_a[i] = a[i] = dataInt8[i * 4 + 3]
}
return {src: [r, g, b, a], dest: [_r, _g, _b, _a]}
}
function mergeChannels([r, g, b, a]: Uint8ClampedArray[]) {
const simpleChannelLength = r.length
const data = new Uint8ClampedArray(simpleChannelLength * 4)
for (let i = 0; i < simpleChannelLength; i++) {
data[4 * i] = r[i]
data[4 * i + 1] = g[i]
data[4 * i + 2] = b[i]
data[4 * i + 3] = a[i]
}
return data
}
function hFastMotionBlur(
src: Uint8ClampedArray,
dest: Uint8ClampedArray,
width: number,
height: number,
radius: number
) {
for (let i = 0; i < height; i++) {
let accumulation = radius * src[i * width]
for (let j = 0; j <= radius; j++) {
accumulation += src[i * width + j]
}
dest[i * width] = Math.round(accumulation / (2 * radius + 1))
for (let j = 1; j < width; j++) {
const left = Math.max(0, j - radius - 1)
const right = Math.min(width - 1, j + radius)
accumulation =
accumulation + (src[i * width + right] - src[i * width + left])
dest[i * width + j] = Math.round(accumulation / (2 * radius + 1))
}
}
}
function vFastMotionBlur(
src: Uint8ClampedArray,
dest: Uint8ClampedArray,
width: number,
height: number,
radius: number
) {
for (let i = 0; i < width; i++) {
let accumulation = radius * src[i]
for (let j = 0; j <= radius; j++) {
accumulation += src[j * width + i]
}
dest[i] = Math.round(accumulation / (2 * radius + 1))
for (let j = 1; j < height; j++) {
const top = Math.max(0, j - radius - 1)
const bottom = Math.min(height - 1, j + radius)
accumulation =
accumulation + src[bottom * width + i] - src[top * width + i]
dest[j * width + i] = Math.round(accumulation / (2 * radius + 1))
}
}
}
function _fastBlur(
src: Uint8ClampedArray,
dest: Uint8ClampedArray,
width: number,
height: number,
radius: number
) {
hFastMotionBlur(dest, src, width, height, radius)
vFastMotionBlur(src, dest, width, height, radius)
}
function fastBlur(
src: Uint8ClampedArray,
dest: Uint8ClampedArray,
width: number,
height: number,
sigma: number
) {
const boxes = genKernelsForGaussian(sigma, 3)
for (let i = 0; i < src.length; i++) {
dest[i] = src[i]
}
_fastBlur(src, dest, width, height, (boxes[0] - 1) / 2)
_fastBlur(src, dest, width, height, (boxes[1] - 1) / 2)
_fastBlur(src, dest, width, height, (boxes[2] - 1) / 2)
return dest
}
onmessage = (evt) => {
postMessage({data: blurCanvas(evt.data), id: evt.data.id})
}
let blurCanvas = ({
imageData,
height,
width,
sigma,
}: {
imageData: ImageData
sigma: number
height: number
width: number
}) => {
const {src: srcRgba, dest: destRgba} = genSrcAndDest(imageData.data)
for (let i = 0; i < 3; i++) {
fastBlur(srcRgba[i], destRgba[i], width, height, sigma)
}
const destData = mergeChannels(destRgba)
imageData.data.set(destData)
return imageData
}
}
const transfer = () => {
let codeStr: string = ''
codeStr += `(${workCode.toString()})()`
return codeStr
}
let workBlob = new Blob([transfer()])
export default URL.createObjectURL(workBlob)

View File

@ -1,5 +1,5 @@
import {html, LitElement, css} from 'lit'
import {customElement, property} from 'lit/decorators.js'
import {customElement} from 'lit/decorators.js'
import '../../../components/button/button'
import '../../../components/overlay/active-overlay'
import {OverlayStack} from '../../../components/overlay/overlay-stack'

View File

@ -0,0 +1,127 @@
import {html, css, LitElement, CSSResultGroup} from 'lit'
import {customElement, property, query} from 'lit/decorators.js'
import '../../../components/gauss_canvas/index'
import GaussCanvas from '../../../components/gauss_canvas/index'
@customElement('panel-gauss')
export class GaussBlur extends LitElement {
@query('#container') container!: HTMLDivElement
@query('gauss-canvas') canvas!: GaussCanvas
// _src = '/src/test/panels/gauss_canvas/big.jpeg'
@property() _src!: string
// 'https://fanyiapp.cdn.bcebos.com/cms/image/cfacf96e5beb2a8444e016b96fb96ab6.jpg'
@property({type: Number}) sigma: number = 2
@property({type: Number}) topPositionSigma: number = 20
@property({type: Number}) bottomPositionSigma: number = 2
_moveFlag: boolean = false
_offsetY: number = 0
_start: number = 0
_moveDistance: number = 0
set offsetY(value: number) {
this._offsetY = -value
this.canvas.display.style.transform = `translateY(${value}px)`
}
handleEvent = (evt: TouchEvent | MouseEvent) => {
switch (evt.type) {
case 'touchstart':
case 'mousedown':
this._moveFlag = false
if (evt instanceof MouseEvent) {
this._start = evt.clientY
} else {
this._start = evt.touches[0].pageY
}
break
case 'touchmove':
case 'mousemove':
if (this._start) {
if (evt instanceof MouseEvent) {
this._moveDistance = evt.clientY - this._start
} else {
this._moveDistance = evt.touches[0].pageY - this._start
}
if (this._moveDistance > 0) this._moveDistance = 0
requestAnimationFrame(() => {
this.offsetY = this._moveDistance
this.changeSigma()
})
}
this._moveFlag = true
break
case 'touchend':
case 'mouseup':
this._start = this._moveDistance = 0
break
case 'click':
!this._moveFlag && (this.canvas.sigma ^= 10)
break
}
}
/**
* change sigma fowllowing this.offsetY
*/
changeSigma() {
const conHeight = this.container.offsetHeight
const targetSigma =
(this._offsetY / conHeight) *
(this.topPositionSigma - this.bottomPositionSigma) +
this.bottomPositionSigma
this.canvas.showImmediately(targetSigma)
}
firstUpdated() {
;(window as any).panel = this
this.addEventListener('touchstart', this)
this.addEventListener('touchmove', this)
this.addEventListener('touchend', this)
this.addEventListener('mousedown', this)
this.addEventListener('mousemove', this)
this.addEventListener('mouseup', this)
this.canvas.addEventListener('click', this)
}
handleInputFile(evt: Event) {
const imgfile = (evt.target as HTMLInputElement).files?.[0]
if (imgfile) {
this._src = URL.createObjectURL(imgfile)
}
}
render() {
return html`
<div id="container">
<input type="file" @change=${this.handleInputFile} />
<gauss-canvas src=${this._src} sigma=${this.sigma}></gauss-canvas>
</div>
`
}
static styles?: CSSResultGroup | undefined = css`
#container {
height: 100vh;
width: 100vw;
will-change: transform;
overflow: hidden;
}
input {
position: absolute;
top: 0;
left: 0;
}
gauss-canvas {
height: 100vh;
width: 100vw;
}
`
}
declare global {
interface HTMLElementTagNameMap {
'panel-gauss': GaussBlur
}
}

View File

@ -1,5 +1,5 @@
import {html, LitElement, css, CSSResultArray} from 'lit'
import {customElement, property} from 'lit/decorators.js'
import {customElement} from 'lit/decorators.js'
import '../../../components/button/button'
import '../../../components/ul/ul'
import '../../../components/li//li'

View File

@ -14,6 +14,7 @@ import './card/card'
import './indicators/indicators'
import './indicators/home-indicator'
import './blur/use-blur'
import './gauss_canvas/gauss-blur'
import './button/button'
import './container/container'
import './radio/radio'
@ -258,6 +259,14 @@ export class PanelRoot extends LitElement {
href="#blur"
></star-li>
<hr />
<star-li
type=${LiType.ICON_LABEL}
label="高斯模糊"
icon="achievement"
iconcolor="gold"
href="#gauss"
></star-li>
<hr />
<star-li
type=${LiType.ICON_LABEL}
label="主屏"