Init LittleWhiteBox
This commit is contained in:
331
modules/novel-draw/image-live-effect.js
Normal file
331
modules/novel-draw/image-live-effect.js
Normal file
@@ -0,0 +1,331 @@
|
||||
// image-live-effect.js
|
||||
// Live Photo - 柔和分区 + 亮度感知
|
||||
|
||||
import { extensionFolderPath } from "../../core/constants.js";
|
||||
|
||||
let PIXI = null;
|
||||
let pixiLoading = null;
|
||||
const activeEffects = new Map();
|
||||
|
||||
async function ensurePixi() {
|
||||
if (PIXI) return PIXI;
|
||||
if (pixiLoading) return pixiLoading;
|
||||
|
||||
pixiLoading = new Promise((resolve, reject) => {
|
||||
if (window.PIXI) { PIXI = window.PIXI; resolve(PIXI); return; }
|
||||
const script = document.createElement('script');
|
||||
script.src = `${extensionFolderPath}/libs/pixi.min.js`;
|
||||
script.onload = () => { PIXI = window.PIXI; resolve(PIXI); };
|
||||
script.onerror = () => reject(new Error('PixiJS 加载失败'));
|
||||
// eslint-disable-next-line no-unsanitized/method
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return pixiLoading;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 着色器 - 柔和分区 + 亮度感知
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec2 aVertexPosition;
|
||||
attribute vec2 aTextureCoord;
|
||||
uniform mat3 projectionMatrix;
|
||||
varying vec2 vTextureCoord;
|
||||
void main() {
|
||||
gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
|
||||
vTextureCoord = aTextureCoord;
|
||||
}`;
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
varying vec2 vTextureCoord;
|
||||
uniform sampler2D uSampler;
|
||||
uniform float uTime;
|
||||
uniform float uIntensity;
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
|
||||
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x),
|
||||
f.y
|
||||
);
|
||||
}
|
||||
|
||||
float zone(float v, float start, float end) {
|
||||
return smoothstep(start, start + 0.08, v) * (1.0 - smoothstep(end - 0.08, end, v));
|
||||
}
|
||||
|
||||
float skinDetect(vec4 color) {
|
||||
float brightness = dot(color.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float warmth = color.r - color.b;
|
||||
return smoothstep(0.3, 0.6, brightness) * smoothstep(0.0, 0.15, warmth);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vTextureCoord;
|
||||
float v = uv.y;
|
||||
float u = uv.x;
|
||||
float centerX = abs(u - 0.5);
|
||||
|
||||
vec4 baseColor = texture2D(uSampler, uv);
|
||||
float skin = skinDetect(baseColor);
|
||||
|
||||
vec2 offset = vec2(0.0);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🛡️ 头部保护 (Y: 0 ~ 0.30)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float headLock = 1.0 - smoothstep(0.0, 0.30, v);
|
||||
float headDampen = mix(1.0, 0.05, headLock);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🫁 全局呼吸
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float breath = sin(uTime * 0.8) * 0.004;
|
||||
offset += (uv - 0.5) * breath * headDampen;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 👙 胸部区域 (Y: 0.35 ~ 0.55) - 呼吸起伏
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float chestZone = zone(v, 0.35, 0.55);
|
||||
float chestCenter = 1.0 - smoothstep(0.0, 0.35, centerX);
|
||||
float chestStrength = chestZone * chestCenter;
|
||||
|
||||
float breathRhythm = sin(uTime * 1.0) * 0.6 + sin(uTime * 2.0) * 0.4;
|
||||
|
||||
// 纵向起伏
|
||||
float chestY = breathRhythm * 0.010 * (1.0 + skin * 0.7);
|
||||
offset.y += chestY * chestStrength * uIntensity;
|
||||
|
||||
// 横向微扩
|
||||
float chestX = breathRhythm * 0.005 * (u - 0.5);
|
||||
offset.x += chestX * chestStrength * uIntensity * (1.0 + skin * 0.4);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🍑 腰臀区域 (Y: 0.55 ~ 0.75) - 轻微摇摆
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float hipZone = zone(v, 0.55, 0.75);
|
||||
float hipCenter = 1.0 - smoothstep(0.0, 0.4, centerX);
|
||||
float hipStrength = hipZone * hipCenter;
|
||||
|
||||
// 左右轻晃
|
||||
float hipSway = sin(uTime * 0.6) * 0.008;
|
||||
offset.x += hipSway * hipStrength * uIntensity * (1.0 + skin * 0.4);
|
||||
|
||||
// 微弱弹动
|
||||
float hipBounce = sin(uTime * 1.0 + 0.3) * 0.006;
|
||||
offset.y += hipBounce * hipStrength * uIntensity * (1.0 + skin * 0.6);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 👗 底部区域 (Y: 0.75+) - 轻微飘动
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float bottomZone = smoothstep(0.73, 0.80, v);
|
||||
float bottomStrength = bottomZone * (v - 0.75) * 2.5;
|
||||
|
||||
float bottomWave = sin(uTime * 1.2 + u * 5.0) * 0.012;
|
||||
offset.x += bottomWave * bottomStrength * uIntensity;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🌊 环境流动 - 极轻微
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float ambient = noise(uv * 2.5 + uTime * 0.15) * 0.003;
|
||||
offset.x += ambient * headDampen * uIntensity;
|
||||
offset.y += noise(uv * 3.0 - uTime * 0.12) * 0.002 * headDampen * uIntensity;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 应用偏移
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
vec2 finalUV = clamp(uv + offset, 0.001, 0.999);
|
||||
|
||||
gl_FragColor = texture2D(uSampler, finalUV);
|
||||
}`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Live 效果类
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class ImageLiveEffect {
|
||||
constructor(container, imageSrc) {
|
||||
this.container = container;
|
||||
this.imageSrc = imageSrc;
|
||||
this.app = null;
|
||||
this.sprite = null;
|
||||
this.filter = null;
|
||||
this.canvas = null;
|
||||
this.running = false;
|
||||
this.destroyed = false;
|
||||
this.startTime = Date.now();
|
||||
this.intensity = 1.0;
|
||||
this._boundAnimate = this.animate.bind(this);
|
||||
}
|
||||
|
||||
async init() {
|
||||
const wrap = this.container.querySelector('.xb-nd-img-wrap');
|
||||
const img = this.container.querySelector('img');
|
||||
if (!wrap || !img) return false;
|
||||
|
||||
const rect = img.getBoundingClientRect();
|
||||
this.width = Math.round(rect.width);
|
||||
this.height = Math.round(rect.height);
|
||||
if (this.width < 50 || this.height < 50) return false;
|
||||
|
||||
try {
|
||||
this.app = new PIXI.Application({
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
backgroundAlpha: 0,
|
||||
resolution: 1,
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
this.canvas = document.createElement('div');
|
||||
this.canvas.className = 'xb-nd-live-canvas';
|
||||
this.canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:1;pointer-events:none;';
|
||||
this.app.view.style.cssText = 'width:100%;height:100%;display:block;';
|
||||
this.canvas.appendChild(this.app.view);
|
||||
wrap.appendChild(this.canvas);
|
||||
|
||||
const texture = await this.loadTexture(this.imageSrc);
|
||||
if (!texture || this.destroyed) { this.destroy(); return false; }
|
||||
|
||||
this.sprite = new PIXI.Sprite(texture);
|
||||
this.sprite.width = this.width;
|
||||
this.sprite.height = this.height;
|
||||
|
||||
this.filter = new PIXI.Filter(VERTEX_SHADER, FRAGMENT_SHADER, {
|
||||
uTime: 0,
|
||||
uIntensity: this.intensity,
|
||||
});
|
||||
this.sprite.filters = [this.filter];
|
||||
this.app.stage.addChild(this.sprite);
|
||||
|
||||
img.style.opacity = '0';
|
||||
this.container.classList.add('mode-live');
|
||||
this.start();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[Live] init error:', e);
|
||||
this.destroy();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
loadTexture(src) {
|
||||
return new Promise((resolve) => {
|
||||
if (this.destroyed) { resolve(null); return; }
|
||||
try {
|
||||
const texture = PIXI.Texture.from(src);
|
||||
if (texture.baseTexture.valid) resolve(texture);
|
||||
else {
|
||||
texture.baseTexture.once('loaded', () => resolve(texture));
|
||||
texture.baseTexture.once('error', () => resolve(null));
|
||||
}
|
||||
} catch { resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running || this.destroyed) return;
|
||||
this.running = true;
|
||||
this.app.ticker.add(this._boundAnimate);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
this.app?.ticker?.remove(this._boundAnimate);
|
||||
}
|
||||
|
||||
animate() {
|
||||
if (this.destroyed || !this.filter) return;
|
||||
this.filter.uniforms.uTime = (Date.now() - this.startTime) / 1000;
|
||||
}
|
||||
|
||||
setIntensity(value) {
|
||||
this.intensity = Math.max(0, Math.min(2, value));
|
||||
if (this.filter) this.filter.uniforms.uIntensity = this.intensity;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.stop();
|
||||
this.container?.classList.remove('mode-live');
|
||||
const img = this.container?.querySelector('img');
|
||||
if (img) img.style.opacity = '';
|
||||
this.canvas?.remove();
|
||||
this.app?.destroy(true, { children: true, texture: false });
|
||||
this.app = null;
|
||||
this.sprite = null;
|
||||
this.filter = null;
|
||||
this.canvas = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// API
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function toggleLiveEffect(container) {
|
||||
const existing = activeEffects.get(container);
|
||||
const btn = container.querySelector('.xb-nd-live-btn');
|
||||
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
activeEffects.delete(container);
|
||||
btn?.classList.remove('active');
|
||||
return false;
|
||||
}
|
||||
|
||||
btn?.classList.add('loading');
|
||||
|
||||
try {
|
||||
await ensurePixi();
|
||||
const img = container.querySelector('img');
|
||||
if (!img?.src) { btn?.classList.remove('loading'); return false; }
|
||||
|
||||
const effect = new ImageLiveEffect(container, img.src);
|
||||
const success = await effect.init();
|
||||
btn?.classList.remove('loading');
|
||||
|
||||
if (success) {
|
||||
activeEffects.set(container, effect);
|
||||
btn?.classList.add('active');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('[Live] failed:', e);
|
||||
btn?.classList.remove('loading');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyLiveEffect(container) {
|
||||
const effect = activeEffects.get(container);
|
||||
if (effect) {
|
||||
effect.destroy();
|
||||
activeEffects.delete(container);
|
||||
container.querySelector('.xb-nd-live-btn')?.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyAllLiveEffects() {
|
||||
activeEffects.forEach(e => e.destroy());
|
||||
activeEffects.clear();
|
||||
}
|
||||
|
||||
export function isLiveActive(container) {
|
||||
return activeEffects.has(container);
|
||||
}
|
||||
|
||||
export function getEffect(container) {
|
||||
return activeEffects.get(container);
|
||||
}
|
||||
Reference in New Issue
Block a user