mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
refactor(ambilight): [PKG-AMBILIGHT-03] split typed lifecycle and recover native canvas sampling
This commit is contained in:
1 parent
43f0e9a8c6
commit
6caee44dbf
25 files changed
+1680
-306
No files matched your search
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=t(),define(function(){return t.artplayerPluginAmbilight})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAmbilight=t()}(this,function(){"use strict";return function(e={}){return t=>{const{$video:n}=t.template,{createElement:r,addClass:i,setStyles:o}=t.constructor.utils,{blur:a="50px",opacity:l=.5,frequency:u=10,duration:f=.3}=e,s=r("div"),d=((c=s).innerHTML=Array.from({length:9}).fill("<div></div>").join(""),Array.from(c.children));var c,m,p,g;!function(e,t){i(e,"artplayer-plugin-ambilight"),t.parentNode.insertBefore(e,t),o(e,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"})}(s,n),m=a,p=l,g=f,d.forEach(e=>{o(e,{opacity:p,filter:`blur(${m})`,transition:`background-color ${g}s ease`})});const y=function(e,n,i){const o=r("canvas"),a=o.getContext("2d");o.width=3,o.height=3;let l=0;return function r(){const o=performance.now();if(o-l<1e3/i||!t.playing)return void(h=requestAnimationFrame(r));l=o;const u=e.videoWidth/3,f=e.videoHeight/3,s=[[0,0],[u,0],[2*u,0],[0,f],[u,f],[2*u,f],[0,2*f],[u,2*f],[2*u,2*f]].map(([t,n])=>function(t,n,r,i){a.drawImage(e,t,n,r,i,0,0,1,1);const[o,l,u]=a.getImageData(0,0,1,1).data;return`rgb(${o}, ${l}, ${u})`}(t,n,u,f));n.forEach((e,t)=>{e.style.backgroundColor=s[t]}),h=requestAnimationFrame(r)}}(n,d,u);let h=null;function b(){h||y()}function v(){h&&(cancelAnimationFrame(h),h=null)}return t.on("ready",b),t.on("destroy",v),{name:"artplayerPluginAmbilight",start:b,stop:v}}}});
|
||||
!function(t,n){"object"===typeof exports&&"undefined"!==typeof module?module.exports=n():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=n(),define(function(){return t.artplayerPluginAmbilight})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAmbilight=n()}(this,function(){"use strict";function t(t,n,e){let r="stopped",o=null,i=0,l=!1,u=0;function c(){const s=i,f=()=>"running"===r&&s===i;l=!0;try{const r=performance.now();if(r-u<1e3/t||!n()||!f())return;u=r,e(f)}finally{l=!1,"running"===r&&null===o&&function(){const t=i;try{o=requestAnimationFrame(()=>{"running"===r&&t===i&&(o=null,c())})}catch(n){throw r="stopped",i++,n}}()}}return{start:function(){"stopped"===r&&(r="running",i++,l||c())},stop:function(){if("destroyed"===r)return;r="stopped",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)},destroy:function(){if("destroyed"===r)return;r="destroyed",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)}}}return function(n={}){return e=>{let r=e,o=e.isDestroy,i=null,l=null,u=null;const c=[],s={name:"artplayerPluginAmbilight",start(){o||u?.start()},stop(){o||u?.stop()}};if(o)return r=null,s;function f(){const t=[u?.destroy,l?.destroy,i?.destroy];u=null,l=null,i=null;const n=[];for(const r of t)try{r?.()}catch(e){n.push(e)}if(n.length)throw n[0]}function a(){if(o)return;o=!0;const t=r;r=null;const n=[];for(const[r,o]of c.splice(0))try{t?.off(r,o)}catch(e){n.push(e)}try{f()}catch(e){n.push(e)}if(n.length)throw n[0]}function d(t,n){c.push([t,n]),r?.on(t,n)}try{if(d("destroy",a),o)return s;const{$video:c}=e.template,h=e.constructor.utils,{blur:y="50px",opacity:p=.5,frequency:g=10,duration:m=.3}=n;if(o)return s;if(i=function(t){const n=t.createElement("div");n.innerHTML=Array.from({length:9}).fill("<div></div>").join("");const e=Array.from(n.children);return{mount:function(r,o,i){if(i()&&(t.addClass(n,"artplayer-plugin-ambilight"),i()&&(r.parentNode.insertBefore(n,r),i()))){t.setStyles(n,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"});for(const n of e){if(!i())return;t.setStyles(n,{opacity:o.opacity,filter:`blur(${o.blur})`,transition:`background-color ${o.duration}s ease`})}}},render:function(t,n){for(let r=0;r<t.length;r++){if(!n())return;const o=e[r],i=t[r];o&&void 0!==i&&(o.style.backgroundColor=i)}},destroy:function(){try{n.parentNode?.removeChild(n)}finally{e.length=0}}}}(h),o||i.mount(c,{blur:y,opacity:p,duration:m},()=>!o),o)return f(),s;const b=h.createElement("canvas");if(o)return b.width=0,b.height=0,s;if(l=function(t,n,e){let r;try{r=t.getContext("2d"),t.width=3,t.height=3}catch(l){throw t.width=0,t.height=0,l}let o=!1,i=!1;return{read:function(l){if(o||!l())return null;const u=n.videoWidth,c=n.videoHeight;if(!Number.isFinite(u)||!Number.isFinite(c)||u<=0||c<=0)return null;const s=u/3,f=c/3,a=[];try{if(i){const n=e();if(o||!l())return n.width=0,n.height=0,null;if(r=null,r=(t=n).getContext("2d"),o||!l())return t.width=0,t.height=0,null;t.width=3,t.height=3,i=!1}if(!r)return null;for(let t=0;t<3;t++)for(let e=0;e<3;e++){if(o||!l())return null;if(r.drawImage(n,e*s,t*f,s,f,0,0,1,1),o||!l())return null;const[i,u,c]=r.getImageData(0,0,1,1).data;a.push(`rgb(${i}, ${u}, ${c})`)}return a}catch{return o||(i=!0,t.width=0,t.height=0),null}},destroy:function(){o||(o=!0,t.width=0,t.height=0)}}}(b,c,()=>h.createElement("canvas")),o)return f(),s;u=t(g,()=>Boolean(r?.playing),t=>{const n=l?.read(t);n&&t()&&i?.render(n,t)}),d("ready",s.start),o&&f()}catch(h){try{a()}catch{}try{f()}catch{}throw h}return s}}});
|
||||
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=t(),define(function(){return t.artplayerPluginAmbilight})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAmbilight=t()}(this,function(){"use strict";return function(e={}){return t=>{const{$video:n}=t.template,{createElement:r,addClass:i,setStyles:o}=t.constructor.utils,{blur:a="50px",opacity:l=.5,frequency:u=10,duration:f=.3}=e,s=r("div"),d=((c=s).innerHTML=Array.from({length:9}).fill("<div></div>").join(""),Array.from(c.children));var c,m,p,g;!function(e,t){i(e,"artplayer-plugin-ambilight"),t.parentNode.insertBefore(e,t),o(e,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"})}(s,n),m=a,p=l,g=f,d.forEach(e=>{o(e,{opacity:p,filter:`blur(${m})`,transition:`background-color ${g}s ease`})});const y=function(e,n,i){const o=r("canvas"),a=o.getContext("2d");o.width=3,o.height=3;let l=0;return function r(){const o=performance.now();if(o-l<1e3/i||!t.playing)return void(h=requestAnimationFrame(r));l=o;const u=e.videoWidth/3,f=e.videoHeight/3,s=[[0,0],[u,0],[2*u,0],[0,f],[u,f],[2*u,f],[0,2*f],[u,2*f],[2*u,2*f]].map(([t,n])=>function(t,n,r,i){a.drawImage(e,t,n,r,i,0,0,1,1);const[o,l,u]=a.getImageData(0,0,1,1).data;return`rgb(${o}, ${l}, ${u})`}(t,n,u,f));n.forEach((e,t)=>{e.style.backgroundColor=s[t]}),h=requestAnimationFrame(r)}}(n,d,u);let h=null;function b(){h||y()}function v(){h&&(cancelAnimationFrame(h),h=null)}return t.on("ready",b),t.on("destroy",v),{name:"artplayerPluginAmbilight",start:b,stop:v}}}});
|
||||
!function(t,n){"object"===typeof exports&&"undefined"!==typeof module?module.exports=n():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=n(),define(function(){return t.artplayerPluginAmbilight})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAmbilight=n()}(this,function(){"use strict";function t(t,n,e){let r="stopped",o=null,i=0,l=!1,u=0;function c(){const s=i,f=()=>"running"===r&&s===i;l=!0;try{const r=performance.now();if(r-u<1e3/t||!n()||!f())return;u=r,e(f)}finally{l=!1,"running"===r&&null===o&&function(){const t=i;try{o=requestAnimationFrame(()=>{"running"===r&&t===i&&(o=null,c())})}catch(n){throw r="stopped",i++,n}}()}}return{start:function(){"stopped"===r&&(r="running",i++,l||c())},stop:function(){if("destroyed"===r)return;r="stopped",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)},destroy:function(){if("destroyed"===r)return;r="destroyed",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)}}}return function(n={}){return e=>{let r=e,o=e.isDestroy,i=null,l=null,u=null;const c=[],s={name:"artplayerPluginAmbilight",start(){o||null==u||u.start()},stop(){o||null==u||u.stop()}};if(o)return r=null,s;function f(){const t=[null==u?void 0:u.destroy,null==l?void 0:l.destroy,null==i?void 0:i.destroy];u=null,l=null,i=null;const n=[];for(const r of t)try{null==r||r()}catch(e){n.push(e)}if(n.length)throw n[0]}function a(){if(o)return;o=!0;const t=r;r=null;const n=[];for(const[r,o]of c.splice(0))try{null==t||t.off(r,o)}catch(e){n.push(e)}try{f()}catch(e){n.push(e)}if(n.length)throw n[0]}function d(t,n){c.push([t,n]),null==r||r.on(t,n)}try{if(d("destroy",a),o)return s;const{$video:c}=e.template,h=e.constructor.utils,{blur:y="50px",opacity:p=.5,frequency:g=10,duration:m=.3}=n;if(o)return s;if(i=function(t){const n=t.createElement("div");n.innerHTML=Array.from({length:9}).fill("<div></div>").join("");const e=Array.from(n.children);return{mount:function(r,o,i){if(i()&&(t.addClass(n,"artplayer-plugin-ambilight"),i()&&(r.parentNode.insertBefore(n,r),i()))){t.setStyles(n,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"});for(const n of e){if(!i())return;t.setStyles(n,{opacity:o.opacity,filter:`blur(${o.blur})`,transition:`background-color ${o.duration}s ease`})}}},render:function(t,n){for(let r=0;r<t.length;r++){if(!n())return;const o=e[r],i=t[r];o&&void 0!==i&&(o.style.backgroundColor=i)}},destroy:function(){var t;try{null==(t=n.parentNode)||t.removeChild(n)}finally{e.length=0}}}}(h),o||i.mount(c,{blur:y,opacity:p,duration:m},()=>!o),o)return f(),s;const b=h.createElement("canvas");if(o)return b.width=0,b.height=0,s;if(l=function(t,n,e){let r;try{r=t.getContext("2d"),t.width=3,t.height=3}catch(l){throw t.width=0,t.height=0,l}let o=!1,i=!1;return{read:function(l){if(o||!l())return null;const u=n.videoWidth,c=n.videoHeight;if(!Number.isFinite(u)||!Number.isFinite(c)||u<=0||c<=0)return null;const s=u/3,f=c/3,a=[];try{if(i){const n=e();if(o||!l())return n.width=0,n.height=0,null;if(r=null,r=(t=n).getContext("2d"),o||!l())return t.width=0,t.height=0,null;t.width=3,t.height=3,i=!1}if(!r)return null;for(let t=0;t<3;t++)for(let e=0;e<3;e++){if(o||!l())return null;if(r.drawImage(n,e*s,t*f,s,f,0,0,1,1),o||!l())return null;const[i,u,c]=r.getImageData(0,0,1,1).data;a.push(`rgb(${i}, ${u}, ${c})`)}return a}catch(d){return o||(i=!0,t.width=0,t.height=0),null}},destroy:function(){o||(o=!0,t.width=0,t.height=0)}}}(b,c,()=>h.createElement("canvas")),o)return f(),s;u=t(g,()=>Boolean(null==r?void 0:r.playing),t=>{const n=null==l?void 0:l.read(t);n&&t()&&(null==i||i.render(n,t))}),d("ready",s.start),o&&f()}catch(h){try{a()}catch(y){}try{f()}catch(y){}throw h}return s}}});
|
||||
@@ -4,99 +4,299 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
function createColorSampler(canvas, video, createCanvas) {
|
||||
let context;
|
||||
try {
|
||||
context = canvas.getContext("2d");
|
||||
canvas.width = 3;
|
||||
canvas.height = 3;
|
||||
} catch (error) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
throw error;
|
||||
}
|
||||
let destroyed = false;
|
||||
let replaceCanvas = false;
|
||||
function read(active) {
|
||||
if (destroyed || !active())
|
||||
return null;
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
||||
return null;
|
||||
const w = width / 3;
|
||||
const h = height / 3;
|
||||
const colors = [];
|
||||
try {
|
||||
if (replaceCanvas) {
|
||||
const replacement = createCanvas();
|
||||
if (destroyed || !active()) {
|
||||
replacement.width = 0;
|
||||
replacement.height = 0;
|
||||
return null;
|
||||
}
|
||||
canvas = replacement;
|
||||
context = null;
|
||||
context = canvas.getContext("2d");
|
||||
if (destroyed || !active()) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return null;
|
||||
}
|
||||
canvas.width = 3;
|
||||
canvas.height = 3;
|
||||
replaceCanvas = false;
|
||||
}
|
||||
if (!context)
|
||||
return null;
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let column = 0; column < 3; column++) {
|
||||
if (destroyed || !active())
|
||||
return null;
|
||||
context.drawImage(video, column * w, row * h, w, h, 0, 0, 1, 1);
|
||||
if (destroyed || !active())
|
||||
return null;
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||
colors.push(`rgb(${r}, ${g}, ${b})`);
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
} catch {
|
||||
if (!destroyed) {
|
||||
replaceCanvas = true;
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function destroy() {
|
||||
if (destroyed)
|
||||
return;
|
||||
destroyed = true;
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
return { read, destroy };
|
||||
}
|
||||
function createFrameLoop(frequency, playing, update) {
|
||||
let state = "stopped";
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
let inFrame = false;
|
||||
let lastUpdateTime = 0;
|
||||
function schedule() {
|
||||
const current = generation;
|
||||
try {
|
||||
pending = requestAnimationFrame(() => {
|
||||
if (state !== "running" || current !== generation)
|
||||
return;
|
||||
pending = null;
|
||||
tick();
|
||||
});
|
||||
} catch (error) {
|
||||
state = "stopped";
|
||||
generation++;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function tick() {
|
||||
const current = generation;
|
||||
const active = () => state === "running" && current === generation;
|
||||
inFrame = true;
|
||||
try {
|
||||
const now = performance.now();
|
||||
if (now - lastUpdateTime < 1e3 / frequency || !playing() || !active())
|
||||
return;
|
||||
lastUpdateTime = now;
|
||||
update(active);
|
||||
} finally {
|
||||
inFrame = false;
|
||||
if (state === "running" && pending === null)
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
function start() {
|
||||
if (state !== "stopped")
|
||||
return;
|
||||
state = "running";
|
||||
generation++;
|
||||
if (!inFrame)
|
||||
tick();
|
||||
}
|
||||
function stop() {
|
||||
if (state === "destroyed")
|
||||
return;
|
||||
state = "stopped";
|
||||
generation++;
|
||||
const frame = pending;
|
||||
pending = null;
|
||||
if (frame !== null)
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
function destroy() {
|
||||
if (state === "destroyed")
|
||||
return;
|
||||
state = "destroyed";
|
||||
generation++;
|
||||
const frame = pending;
|
||||
pending = null;
|
||||
if (frame !== null)
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
return { start, stop, destroy };
|
||||
}
|
||||
function createAmbilightView(utils) {
|
||||
const element = utils.createElement("div");
|
||||
element.innerHTML = Array.from({ length: 9 }).fill("<div></div>").join("");
|
||||
const items = Array.from(element.children);
|
||||
function mount(video, option, active) {
|
||||
if (!active())
|
||||
return;
|
||||
utils.addClass(element, "artplayer-plugin-ambilight");
|
||||
if (!active())
|
||||
return;
|
||||
video.parentNode.insertBefore(element, video);
|
||||
if (!active())
|
||||
return;
|
||||
utils.setStyles(element, { position: "absolute", top: 0, left: 0, zIndex: 9, inset: 0, display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gridTemplateRows: "1fr 1fr 1fr" });
|
||||
for (const item of items) {
|
||||
if (!active())
|
||||
return;
|
||||
utils.setStyles(item, { opacity: option.opacity, filter: `blur(${option.blur})`, transition: `background-color ${option.duration}s ease` });
|
||||
}
|
||||
}
|
||||
function render(colors, active) {
|
||||
for (let index = 0; index < colors.length; index++) {
|
||||
if (!active())
|
||||
return;
|
||||
const item = items[index];
|
||||
const color = colors[index];
|
||||
if (item && color !== void 0)
|
||||
item.style.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
function destroy() {
|
||||
try {
|
||||
element.parentNode?.removeChild(element);
|
||||
} finally {
|
||||
items.length = 0;
|
||||
}
|
||||
}
|
||||
return { mount, render, destroy };
|
||||
}
|
||||
function artplayerPluginAmbilight(option = {}) {
|
||||
return (art) => {
|
||||
const { $video } = art.template;
|
||||
const { createElement, addClass, setStyles } = art.constructor.utils;
|
||||
const { blur = "50px", opacity = 0.5, frequency = 10, duration = 0.3 } = option;
|
||||
const $ambilight = createElement("div");
|
||||
const gridItems = createGridItems($ambilight);
|
||||
setupAmbilight($ambilight, $video);
|
||||
setupGridItems(gridItems, blur, opacity, duration);
|
||||
const updateColors = createColorUpdater($video, gridItems, frequency);
|
||||
let animationFrameId = null;
|
||||
function start() {
|
||||
if (!animationFrameId) {
|
||||
updateColors();
|
||||
}
|
||||
}
|
||||
function stop() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
}
|
||||
art.on("ready", start);
|
||||
art.on("destroy", stop);
|
||||
return {
|
||||
let host = art;
|
||||
let closed = art.isDestroy;
|
||||
let view = null;
|
||||
let sampler = null;
|
||||
let loop = null;
|
||||
const subscriptions = [];
|
||||
const result = {
|
||||
name: "artplayerPluginAmbilight",
|
||||
start,
|
||||
stop
|
||||
};
|
||||
function createGridItems($ambilight2) {
|
||||
$ambilight2.innerHTML = Array.from({ length: 9 }).fill("<div></div>").join("");
|
||||
return Array.from($ambilight2.children);
|
||||
}
|
||||
function setupAmbilight($ambilight2, $video2) {
|
||||
addClass($ambilight2, "artplayer-plugin-ambilight");
|
||||
$video2.parentNode.insertBefore($ambilight2, $video2);
|
||||
setStyles($ambilight2, {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 9,
|
||||
inset: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gridTemplateRows: "1fr 1fr 1fr"
|
||||
});
|
||||
}
|
||||
function setupGridItems(gridItems2, blur2, opacity2, duration2) {
|
||||
gridItems2.forEach(($item) => {
|
||||
setStyles($item, {
|
||||
opacity: opacity2,
|
||||
filter: `blur(${blur2})`,
|
||||
transition: `background-color ${duration2}s ease`
|
||||
});
|
||||
});
|
||||
}
|
||||
function createColorUpdater($video2, gridItems2, frequency2) {
|
||||
const canvas = createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.width = 3;
|
||||
canvas.height = 3;
|
||||
function getAverageColor(x, y, w, h) {
|
||||
ctx.drawImage($video2, x, y, w, h, 0, 0, 1, 1);
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
start() {
|
||||
if (!closed)
|
||||
loop?.start();
|
||||
},
|
||||
stop() {
|
||||
if (!closed)
|
||||
loop?.stop();
|
||||
}
|
||||
let lastUpdateTime = 0;
|
||||
return function updateColors2() {
|
||||
const now = performance.now();
|
||||
if (now - lastUpdateTime < 1e3 / frequency2 || !art.playing) {
|
||||
animationFrameId = requestAnimationFrame(updateColors2);
|
||||
return;
|
||||
}
|
||||
lastUpdateTime = now;
|
||||
const w = $video2.videoWidth / 3;
|
||||
const h = $video2.videoHeight / 3;
|
||||
const colors = [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[2 * w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
[2 * w, h],
|
||||
[0, 2 * h],
|
||||
[w, 2 * h],
|
||||
[2 * w, 2 * h]
|
||||
].map(([x, y]) => getAverageColor(x, y, w, h));
|
||||
gridItems2.forEach(($item, index) => {
|
||||
$item.style.backgroundColor = colors[index];
|
||||
});
|
||||
animationFrameId = requestAnimationFrame(updateColors2);
|
||||
};
|
||||
};
|
||||
if (closed) {
|
||||
host = null;
|
||||
return result;
|
||||
}
|
||||
function releaseResources() {
|
||||
const releases = [loop?.destroy, sampler?.destroy, view?.destroy];
|
||||
loop = null;
|
||||
sampler = null;
|
||||
view = null;
|
||||
const failures = [];
|
||||
for (const release of releases) {
|
||||
try {
|
||||
release?.();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0];
|
||||
}
|
||||
function destroy() {
|
||||
if (closed)
|
||||
return;
|
||||
closed = true;
|
||||
const current = host;
|
||||
host = null;
|
||||
const failures = [];
|
||||
for (const [event, callback] of subscriptions.splice(0)) {
|
||||
try {
|
||||
current?.off(event, callback);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
releaseResources();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0];
|
||||
}
|
||||
function listen(event, callback) {
|
||||
subscriptions.push([event, callback]);
|
||||
host?.on(event, callback);
|
||||
}
|
||||
try {
|
||||
listen("destroy", destroy);
|
||||
if (closed)
|
||||
return result;
|
||||
const { $video } = art.template;
|
||||
const utils = art.constructor.utils;
|
||||
const { blur = "50px", opacity = 0.5, frequency = 10, duration = 0.3 } = option;
|
||||
if (closed)
|
||||
return result;
|
||||
view = createAmbilightView(utils);
|
||||
if (!closed)
|
||||
view.mount($video, { blur, opacity, duration }, () => !closed);
|
||||
if (closed) {
|
||||
releaseResources();
|
||||
return result;
|
||||
}
|
||||
const canvas = utils.createElement("canvas");
|
||||
if (closed) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return result;
|
||||
}
|
||||
sampler = createColorSampler(canvas, $video, () => utils.createElement("canvas"));
|
||||
if (closed) {
|
||||
releaseResources();
|
||||
return result;
|
||||
}
|
||||
loop = createFrameLoop(frequency, () => Boolean(host?.playing), (valid) => {
|
||||
const colors = sampler?.read(valid);
|
||||
if (colors && valid())
|
||||
view?.render(colors, valid);
|
||||
});
|
||||
listen("ready", result.start);
|
||||
if (closed)
|
||||
releaseResources();
|
||||
} catch (error) {
|
||||
try {
|
||||
destroy();
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
releaseResources();
|
||||
} catch {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
export {
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@
|
||||
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
|
||||
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
"test:unit": "node --test test/ambilight.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
|
||||
"test:unit": "node --test test/ambilight.test.js test/ambilight-lifecycle.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
|
||||
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
|
||||
"test": "yarn test:node && yarn test:baseline",
|
||||
"test:browser": "playwright test",
|
||||
@@ -70,7 +70,7 @@
|
||||
"check:impact": "node refactor/scripts/impact.mjs",
|
||||
"check:contracts": "node refactor/scripts/contracts.mjs",
|
||||
"test:contracts": "node refactor/scripts/run-contracts.mjs",
|
||||
"test:ambilight": "node --test test/ambilight.test.js"
|
||||
"test:ambilight": "node --test test/ambilight.test.js test/ambilight-lifecycle.test.js"
|
||||
},
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Ambilight maintenance
|
||||
|
||||
Read the frozen published contracts in `refactor/baselines/ambilight-contract.md`
|
||||
before changing the public factory or distribution. Source is strict TypeScript;
|
||||
the existing public declaration remains the input contract until task 04.
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/index.ts` | Factory, ready/destroy subscriptions, construction rollback, terminal ownership |
|
||||
| `src/view.ts` | Nine-cell grid, historical placement/styles, atomic color rendering, owned DOM removal |
|
||||
| `src/sampler.ts` | Native video dimensions, nine region-to-pixel samples, failed canvas replacement |
|
||||
| `src/scheduler.ts` | Frequency gate, native RAF ownership, generation checks for reentrant stop/start |
|
||||
|
||||
The registration function reads options. `ready` calls `start`; stop cancels the
|
||||
pending frame but retains the grid and its colors. A paused player retains one
|
||||
frame loop without sampling. Core destruction closes the public methods, detaches
|
||||
subscriptions, cancels the frame (including ID zero), releases canvas storage and
|
||||
removes the grid. Attaching to an already destroyed host is inert.
|
||||
|
||||
Cleanup attempts every resource even if another release throws. Construction
|
||||
preserves the primary error. Synchronous callbacks from custom host utilities can
|
||||
destroy the host during setup or sampling: check the terminal state after each
|
||||
external boundary. A frame generation prevents cancelled callbacks and reentrant
|
||||
stop/start from publishing obsolete colors or creating duplicate loops.
|
||||
|
||||
The nine colors are collected before rendering. Missing contexts and invalid
|
||||
dimensions do not throw. A draw/read exception discards that sample, zeros the old
|
||||
canvas and replaces it on the next eligible sample. The replacement is necessary:
|
||||
Firefox's real canvas retained SecurityError after a width reset, while a fresh
|
||||
canvas could read the recovered same-origin video. Retries remain frequency-gated;
|
||||
with a permanently cross-origin source this allocates at most one replacement per
|
||||
eligible sample. No bypass of canvas origin restrictions is attempted.
|
||||
|
||||
Compatibility constraints: keep numeric style arguments, nine row-major samples,
|
||||
blur/opacity/frequency/duration defaults, synchronous start/stop returns, option
|
||||
reading at registration, and the old frequency comparison (including NaN and
|
||||
Infinity behavior). The historical zIndex option remains ignored (style is 9).
|
||||
Do not conflate old declaration differences with newly authorized API changes.
|
||||
|
||||
Validation: `yarn test:ambilight`, package strict typecheck, normal package build,
|
||||
and `yarn test:browser test/browser/ambilight-lifecycle.spec.js`. Historical tests
|
||||
in `test/ambilight.test.js` and `test/browser/ambilight.spec.js` intentionally retain
|
||||
the old failures. Candidate tests must remain separate. The browser suite runs real
|
||||
video/canvas with a lexical native RAF observer across published cores 5.1.7/5.4.0
|
||||
and candidate core in Chromium/Firefox/WebKit. Proxy/device, packed consumer and
|
||||
public type acceptance remain tasks 04-06; see `refactor/ambilight-validation.md`.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
ambilight plugin for ArtPlayer
|
||||
|
||||
`start()` and `stop()` are synchronous. The plugin starts on the player's `ready`
|
||||
event; `stop()` retains its last colors. Destroying the player removes the plugin's
|
||||
grid and frame loop, and previously retained methods then do nothing. Canvas read
|
||||
failures are skipped and retried, allowing a later readable source to recover.
|
||||
|
||||
Internal modules and compatibility tests are documented in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
## Demo
|
||||
|
||||
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-ambilight/index.js&example=ambilight)
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=t(),define(function(){return t.artplayerPluginAmbilight})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAmbilight=t()}(this,function(){"use strict";return function(e={}){return t=>{const{$video:n}=t.template,{createElement:r,addClass:i,setStyles:o}=t.constructor.utils,{blur:a="50px",opacity:l=.5,frequency:u=10,duration:f=.3}=e,s=r("div"),d=((c=s).innerHTML=Array.from({length:9}).fill("<div></div>").join(""),Array.from(c.children));var c,m,p,g;!function(e,t){i(e,"artplayer-plugin-ambilight"),t.parentNode.insertBefore(e,t),o(e,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"})}(s,n),m=a,p=l,g=f,d.forEach(e=>{o(e,{opacity:p,filter:`blur(${m})`,transition:`background-color ${g}s ease`})});const y=function(e,n,i){const o=r("canvas"),a=o.getContext("2d");o.width=3,o.height=3;let l=0;return function r(){const o=performance.now();if(o-l<1e3/i||!t.playing)return void(h=requestAnimationFrame(r));l=o;const u=e.videoWidth/3,f=e.videoHeight/3,s=[[0,0],[u,0],[2*u,0],[0,f],[u,f],[2*u,f],[0,2*f],[u,2*f],[2*u,2*f]].map(([t,n])=>function(t,n,r,i){a.drawImage(e,t,n,r,i,0,0,1,1);const[o,l,u]=a.getImageData(0,0,1,1).data;return`rgb(${o}, ${l}, ${u})`}(t,n,u,f));n.forEach((e,t)=>{e.style.backgroundColor=s[t]}),h=requestAnimationFrame(r)}}(n,d,u);let h=null;function b(){h||y()}function v(){h&&(cancelAnimationFrame(h),h=null)}return t.on("ready",b),t.on("destroy",v),{name:"artplayerPluginAmbilight",start:b,stop:v}}}});
|
||||
!function(t,n){"object"===typeof exports&&"undefined"!==typeof module?module.exports=n():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=n(),define(function(){return t.artplayerPluginAmbilight})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAmbilight=n()}(this,function(){"use strict";function t(t,n,e){let r="stopped",o=null,i=0,l=!1,u=0;function c(){const s=i,f=()=>"running"===r&&s===i;l=!0;try{const r=performance.now();if(r-u<1e3/t||!n()||!f())return;u=r,e(f)}finally{l=!1,"running"===r&&null===o&&function(){const t=i;try{o=requestAnimationFrame(()=>{"running"===r&&t===i&&(o=null,c())})}catch(n){throw r="stopped",i++,n}}()}}return{start:function(){"stopped"===r&&(r="running",i++,l||c())},stop:function(){if("destroyed"===r)return;r="stopped",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)},destroy:function(){if("destroyed"===r)return;r="destroyed",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)}}}return function(n={}){return e=>{let r=e,o=e.isDestroy,i=null,l=null,u=null;const c=[],s={name:"artplayerPluginAmbilight",start(){o||u?.start()},stop(){o||u?.stop()}};if(o)return r=null,s;function f(){const t=[u?.destroy,l?.destroy,i?.destroy];u=null,l=null,i=null;const n=[];for(const r of t)try{r?.()}catch(e){n.push(e)}if(n.length)throw n[0]}function a(){if(o)return;o=!0;const t=r;r=null;const n=[];for(const[r,o]of c.splice(0))try{t?.off(r,o)}catch(e){n.push(e)}try{f()}catch(e){n.push(e)}if(n.length)throw n[0]}function d(t,n){c.push([t,n]),r?.on(t,n)}try{if(d("destroy",a),o)return s;const{$video:c}=e.template,h=e.constructor.utils,{blur:y="50px",opacity:p=.5,frequency:g=10,duration:m=.3}=n;if(o)return s;if(i=function(t){const n=t.createElement("div");n.innerHTML=Array.from({length:9}).fill("<div></div>").join("");const e=Array.from(n.children);return{mount:function(r,o,i){if(i()&&(t.addClass(n,"artplayer-plugin-ambilight"),i()&&(r.parentNode.insertBefore(n,r),i()))){t.setStyles(n,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"});for(const n of e){if(!i())return;t.setStyles(n,{opacity:o.opacity,filter:`blur(${o.blur})`,transition:`background-color ${o.duration}s ease`})}}},render:function(t,n){for(let r=0;r<t.length;r++){if(!n())return;const o=e[r],i=t[r];o&&void 0!==i&&(o.style.backgroundColor=i)}},destroy:function(){try{n.parentNode?.removeChild(n)}finally{e.length=0}}}}(h),o||i.mount(c,{blur:y,opacity:p,duration:m},()=>!o),o)return f(),s;const b=h.createElement("canvas");if(o)return b.width=0,b.height=0,s;if(l=function(t,n,e){let r;try{r=t.getContext("2d"),t.width=3,t.height=3}catch(l){throw t.width=0,t.height=0,l}let o=!1,i=!1;return{read:function(l){if(o||!l())return null;const u=n.videoWidth,c=n.videoHeight;if(!Number.isFinite(u)||!Number.isFinite(c)||u<=0||c<=0)return null;const s=u/3,f=c/3,a=[];try{if(i){const n=e();if(o||!l())return n.width=0,n.height=0,null;if(r=null,r=(t=n).getContext("2d"),o||!l())return t.width=0,t.height=0,null;t.width=3,t.height=3,i=!1}if(!r)return null;for(let t=0;t<3;t++)for(let e=0;e<3;e++){if(o||!l())return null;if(r.drawImage(n,e*s,t*f,s,f,0,0,1,1),o||!l())return null;const[i,u,c]=r.getImageData(0,0,1,1).data;a.push(`rgb(${i}, ${u}, ${c})`)}return a}catch{return o||(i=!0,t.width=0,t.height=0),null}},destroy:function(){o||(o=!0,t.width=0,t.height=0)}}}(b,c,()=>h.createElement("canvas")),o)return f(),s;u=t(g,()=>Boolean(r?.playing),t=>{const n=l?.read(t);n&&t()&&i?.render(n,t)}),d("ready",s.start),o&&f()}catch(h){try{a()}catch{}try{f()}catch{}throw h}return s}}});
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=t(),define(function(){return t.artplayerPluginAmbilight})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAmbilight=t()}(this,function(){"use strict";return function(e={}){return t=>{const{$video:n}=t.template,{createElement:r,addClass:i,setStyles:o}=t.constructor.utils,{blur:a="50px",opacity:l=.5,frequency:u=10,duration:f=.3}=e,s=r("div"),d=((c=s).innerHTML=Array.from({length:9}).fill("<div></div>").join(""),Array.from(c.children));var c,m,p,g;!function(e,t){i(e,"artplayer-plugin-ambilight"),t.parentNode.insertBefore(e,t),o(e,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"})}(s,n),m=a,p=l,g=f,d.forEach(e=>{o(e,{opacity:p,filter:`blur(${m})`,transition:`background-color ${g}s ease`})});const y=function(e,n,i){const o=r("canvas"),a=o.getContext("2d");o.width=3,o.height=3;let l=0;return function r(){const o=performance.now();if(o-l<1e3/i||!t.playing)return void(h=requestAnimationFrame(r));l=o;const u=e.videoWidth/3,f=e.videoHeight/3,s=[[0,0],[u,0],[2*u,0],[0,f],[u,f],[2*u,f],[0,2*f],[u,2*f],[2*u,2*f]].map(([t,n])=>function(t,n,r,i){a.drawImage(e,t,n,r,i,0,0,1,1);const[o,l,u]=a.getImageData(0,0,1,1).data;return`rgb(${o}, ${l}, ${u})`}(t,n,u,f));n.forEach((e,t)=>{e.style.backgroundColor=s[t]}),h=requestAnimationFrame(r)}}(n,d,u);let h=null;function b(){h||y()}function v(){h&&(cancelAnimationFrame(h),h=null)}return t.on("ready",b),t.on("destroy",v),{name:"artplayerPluginAmbilight",start:b,stop:v}}}});
|
||||
!function(t,n){"object"===typeof exports&&"undefined"!==typeof module?module.exports=n():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=n(),define(function(){return t.artplayerPluginAmbilight})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAmbilight=n()}(this,function(){"use strict";function t(t,n,e){let r="stopped",o=null,i=0,l=!1,u=0;function c(){const s=i,f=()=>"running"===r&&s===i;l=!0;try{const r=performance.now();if(r-u<1e3/t||!n()||!f())return;u=r,e(f)}finally{l=!1,"running"===r&&null===o&&function(){const t=i;try{o=requestAnimationFrame(()=>{"running"===r&&t===i&&(o=null,c())})}catch(n){throw r="stopped",i++,n}}()}}return{start:function(){"stopped"===r&&(r="running",i++,l||c())},stop:function(){if("destroyed"===r)return;r="stopped",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)},destroy:function(){if("destroyed"===r)return;r="destroyed",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)}}}return function(n={}){return e=>{let r=e,o=e.isDestroy,i=null,l=null,u=null;const c=[],s={name:"artplayerPluginAmbilight",start(){o||null==u||u.start()},stop(){o||null==u||u.stop()}};if(o)return r=null,s;function f(){const t=[null==u?void 0:u.destroy,null==l?void 0:l.destroy,null==i?void 0:i.destroy];u=null,l=null,i=null;const n=[];for(const r of t)try{null==r||r()}catch(e){n.push(e)}if(n.length)throw n[0]}function a(){if(o)return;o=!0;const t=r;r=null;const n=[];for(const[r,o]of c.splice(0))try{null==t||t.off(r,o)}catch(e){n.push(e)}try{f()}catch(e){n.push(e)}if(n.length)throw n[0]}function d(t,n){c.push([t,n]),null==r||r.on(t,n)}try{if(d("destroy",a),o)return s;const{$video:c}=e.template,h=e.constructor.utils,{blur:y="50px",opacity:p=.5,frequency:g=10,duration:m=.3}=n;if(o)return s;if(i=function(t){const n=t.createElement("div");n.innerHTML=Array.from({length:9}).fill("<div></div>").join("");const e=Array.from(n.children);return{mount:function(r,o,i){if(i()&&(t.addClass(n,"artplayer-plugin-ambilight"),i()&&(r.parentNode.insertBefore(n,r),i()))){t.setStyles(n,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"});for(const n of e){if(!i())return;t.setStyles(n,{opacity:o.opacity,filter:`blur(${o.blur})`,transition:`background-color ${o.duration}s ease`})}}},render:function(t,n){for(let r=0;r<t.length;r++){if(!n())return;const o=e[r],i=t[r];o&&void 0!==i&&(o.style.backgroundColor=i)}},destroy:function(){var t;try{null==(t=n.parentNode)||t.removeChild(n)}finally{e.length=0}}}}(h),o||i.mount(c,{blur:y,opacity:p,duration:m},()=>!o),o)return f(),s;const b=h.createElement("canvas");if(o)return b.width=0,b.height=0,s;if(l=function(t,n,e){let r;try{r=t.getContext("2d"),t.width=3,t.height=3}catch(l){throw t.width=0,t.height=0,l}let o=!1,i=!1;return{read:function(l){if(o||!l())return null;const u=n.videoWidth,c=n.videoHeight;if(!Number.isFinite(u)||!Number.isFinite(c)||u<=0||c<=0)return null;const s=u/3,f=c/3,a=[];try{if(i){const n=e();if(o||!l())return n.width=0,n.height=0,null;if(r=null,r=(t=n).getContext("2d"),o||!l())return t.width=0,t.height=0,null;t.width=3,t.height=3,i=!1}if(!r)return null;for(let t=0;t<3;t++)for(let e=0;e<3;e++){if(o||!l())return null;if(r.drawImage(n,e*s,t*f,s,f,0,0,1,1),o||!l())return null;const[i,u,c]=r.getImageData(0,0,1,1).data;a.push(`rgb(${i}, ${u}, ${c})`)}return a}catch(d){return o||(i=!0,t.width=0,t.height=0),null}},destroy:function(){o||(o=!0,t.width=0,t.height=0)}}}(b,c,()=>h.createElement("canvas")),o)return f(),s;u=t(g,()=>Boolean(null==r?void 0:r.playing),t=>{const n=null==l?void 0:l.read(t);n&&t()&&(null==i||i.render(n,t))}),d("ready",s.start),o&&f()}catch(h){try{a()}catch(y){}try{f()}catch(y){}throw h}return s}}});
|
||||
@@ -4,99 +4,299 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
function createColorSampler(canvas, video, createCanvas) {
|
||||
let context;
|
||||
try {
|
||||
context = canvas.getContext("2d");
|
||||
canvas.width = 3;
|
||||
canvas.height = 3;
|
||||
} catch (error) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
throw error;
|
||||
}
|
||||
let destroyed = false;
|
||||
let replaceCanvas = false;
|
||||
function read(active) {
|
||||
if (destroyed || !active())
|
||||
return null;
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
||||
return null;
|
||||
const w = width / 3;
|
||||
const h = height / 3;
|
||||
const colors = [];
|
||||
try {
|
||||
if (replaceCanvas) {
|
||||
const replacement = createCanvas();
|
||||
if (destroyed || !active()) {
|
||||
replacement.width = 0;
|
||||
replacement.height = 0;
|
||||
return null;
|
||||
}
|
||||
canvas = replacement;
|
||||
context = null;
|
||||
context = canvas.getContext("2d");
|
||||
if (destroyed || !active()) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return null;
|
||||
}
|
||||
canvas.width = 3;
|
||||
canvas.height = 3;
|
||||
replaceCanvas = false;
|
||||
}
|
||||
if (!context)
|
||||
return null;
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let column = 0; column < 3; column++) {
|
||||
if (destroyed || !active())
|
||||
return null;
|
||||
context.drawImage(video, column * w, row * h, w, h, 0, 0, 1, 1);
|
||||
if (destroyed || !active())
|
||||
return null;
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||
colors.push(`rgb(${r}, ${g}, ${b})`);
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
} catch {
|
||||
if (!destroyed) {
|
||||
replaceCanvas = true;
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function destroy() {
|
||||
if (destroyed)
|
||||
return;
|
||||
destroyed = true;
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
return { read, destroy };
|
||||
}
|
||||
function createFrameLoop(frequency, playing, update) {
|
||||
let state = "stopped";
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
let inFrame = false;
|
||||
let lastUpdateTime = 0;
|
||||
function schedule() {
|
||||
const current = generation;
|
||||
try {
|
||||
pending = requestAnimationFrame(() => {
|
||||
if (state !== "running" || current !== generation)
|
||||
return;
|
||||
pending = null;
|
||||
tick();
|
||||
});
|
||||
} catch (error) {
|
||||
state = "stopped";
|
||||
generation++;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function tick() {
|
||||
const current = generation;
|
||||
const active = () => state === "running" && current === generation;
|
||||
inFrame = true;
|
||||
try {
|
||||
const now = performance.now();
|
||||
if (now - lastUpdateTime < 1e3 / frequency || !playing() || !active())
|
||||
return;
|
||||
lastUpdateTime = now;
|
||||
update(active);
|
||||
} finally {
|
||||
inFrame = false;
|
||||
if (state === "running" && pending === null)
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
function start() {
|
||||
if (state !== "stopped")
|
||||
return;
|
||||
state = "running";
|
||||
generation++;
|
||||
if (!inFrame)
|
||||
tick();
|
||||
}
|
||||
function stop() {
|
||||
if (state === "destroyed")
|
||||
return;
|
||||
state = "stopped";
|
||||
generation++;
|
||||
const frame = pending;
|
||||
pending = null;
|
||||
if (frame !== null)
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
function destroy() {
|
||||
if (state === "destroyed")
|
||||
return;
|
||||
state = "destroyed";
|
||||
generation++;
|
||||
const frame = pending;
|
||||
pending = null;
|
||||
if (frame !== null)
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
return { start, stop, destroy };
|
||||
}
|
||||
function createAmbilightView(utils) {
|
||||
const element = utils.createElement("div");
|
||||
element.innerHTML = Array.from({ length: 9 }).fill("<div></div>").join("");
|
||||
const items = Array.from(element.children);
|
||||
function mount(video, option, active) {
|
||||
if (!active())
|
||||
return;
|
||||
utils.addClass(element, "artplayer-plugin-ambilight");
|
||||
if (!active())
|
||||
return;
|
||||
video.parentNode.insertBefore(element, video);
|
||||
if (!active())
|
||||
return;
|
||||
utils.setStyles(element, { position: "absolute", top: 0, left: 0, zIndex: 9, inset: 0, display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gridTemplateRows: "1fr 1fr 1fr" });
|
||||
for (const item of items) {
|
||||
if (!active())
|
||||
return;
|
||||
utils.setStyles(item, { opacity: option.opacity, filter: `blur(${option.blur})`, transition: `background-color ${option.duration}s ease` });
|
||||
}
|
||||
}
|
||||
function render(colors, active) {
|
||||
for (let index = 0; index < colors.length; index++) {
|
||||
if (!active())
|
||||
return;
|
||||
const item = items[index];
|
||||
const color = colors[index];
|
||||
if (item && color !== void 0)
|
||||
item.style.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
function destroy() {
|
||||
try {
|
||||
element.parentNode?.removeChild(element);
|
||||
} finally {
|
||||
items.length = 0;
|
||||
}
|
||||
}
|
||||
return { mount, render, destroy };
|
||||
}
|
||||
function artplayerPluginAmbilight(option = {}) {
|
||||
return (art) => {
|
||||
const { $video } = art.template;
|
||||
const { createElement, addClass, setStyles } = art.constructor.utils;
|
||||
const { blur = "50px", opacity = 0.5, frequency = 10, duration = 0.3 } = option;
|
||||
const $ambilight = createElement("div");
|
||||
const gridItems = createGridItems($ambilight);
|
||||
setupAmbilight($ambilight, $video);
|
||||
setupGridItems(gridItems, blur, opacity, duration);
|
||||
const updateColors = createColorUpdater($video, gridItems, frequency);
|
||||
let animationFrameId = null;
|
||||
function start() {
|
||||
if (!animationFrameId) {
|
||||
updateColors();
|
||||
}
|
||||
}
|
||||
function stop() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
}
|
||||
art.on("ready", start);
|
||||
art.on("destroy", stop);
|
||||
return {
|
||||
let host = art;
|
||||
let closed = art.isDestroy;
|
||||
let view = null;
|
||||
let sampler = null;
|
||||
let loop = null;
|
||||
const subscriptions = [];
|
||||
const result = {
|
||||
name: "artplayerPluginAmbilight",
|
||||
start,
|
||||
stop
|
||||
};
|
||||
function createGridItems($ambilight2) {
|
||||
$ambilight2.innerHTML = Array.from({ length: 9 }).fill("<div></div>").join("");
|
||||
return Array.from($ambilight2.children);
|
||||
}
|
||||
function setupAmbilight($ambilight2, $video2) {
|
||||
addClass($ambilight2, "artplayer-plugin-ambilight");
|
||||
$video2.parentNode.insertBefore($ambilight2, $video2);
|
||||
setStyles($ambilight2, {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 9,
|
||||
inset: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gridTemplateRows: "1fr 1fr 1fr"
|
||||
});
|
||||
}
|
||||
function setupGridItems(gridItems2, blur2, opacity2, duration2) {
|
||||
gridItems2.forEach(($item) => {
|
||||
setStyles($item, {
|
||||
opacity: opacity2,
|
||||
filter: `blur(${blur2})`,
|
||||
transition: `background-color ${duration2}s ease`
|
||||
});
|
||||
});
|
||||
}
|
||||
function createColorUpdater($video2, gridItems2, frequency2) {
|
||||
const canvas = createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.width = 3;
|
||||
canvas.height = 3;
|
||||
function getAverageColor(x, y, w, h) {
|
||||
ctx.drawImage($video2, x, y, w, h, 0, 0, 1, 1);
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
start() {
|
||||
if (!closed)
|
||||
loop?.start();
|
||||
},
|
||||
stop() {
|
||||
if (!closed)
|
||||
loop?.stop();
|
||||
}
|
||||
let lastUpdateTime = 0;
|
||||
return function updateColors2() {
|
||||
const now = performance.now();
|
||||
if (now - lastUpdateTime < 1e3 / frequency2 || !art.playing) {
|
||||
animationFrameId = requestAnimationFrame(updateColors2);
|
||||
return;
|
||||
}
|
||||
lastUpdateTime = now;
|
||||
const w = $video2.videoWidth / 3;
|
||||
const h = $video2.videoHeight / 3;
|
||||
const colors = [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[2 * w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
[2 * w, h],
|
||||
[0, 2 * h],
|
||||
[w, 2 * h],
|
||||
[2 * w, 2 * h]
|
||||
].map(([x, y]) => getAverageColor(x, y, w, h));
|
||||
gridItems2.forEach(($item, index) => {
|
||||
$item.style.backgroundColor = colors[index];
|
||||
});
|
||||
animationFrameId = requestAnimationFrame(updateColors2);
|
||||
};
|
||||
};
|
||||
if (closed) {
|
||||
host = null;
|
||||
return result;
|
||||
}
|
||||
function releaseResources() {
|
||||
const releases = [loop?.destroy, sampler?.destroy, view?.destroy];
|
||||
loop = null;
|
||||
sampler = null;
|
||||
view = null;
|
||||
const failures = [];
|
||||
for (const release of releases) {
|
||||
try {
|
||||
release?.();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0];
|
||||
}
|
||||
function destroy() {
|
||||
if (closed)
|
||||
return;
|
||||
closed = true;
|
||||
const current = host;
|
||||
host = null;
|
||||
const failures = [];
|
||||
for (const [event, callback] of subscriptions.splice(0)) {
|
||||
try {
|
||||
current?.off(event, callback);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
releaseResources();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0];
|
||||
}
|
||||
function listen(event, callback) {
|
||||
subscriptions.push([event, callback]);
|
||||
host?.on(event, callback);
|
||||
}
|
||||
try {
|
||||
listen("destroy", destroy);
|
||||
if (closed)
|
||||
return result;
|
||||
const { $video } = art.template;
|
||||
const utils = art.constructor.utils;
|
||||
const { blur = "50px", opacity = 0.5, frequency = 10, duration = 0.3 } = option;
|
||||
if (closed)
|
||||
return result;
|
||||
view = createAmbilightView(utils);
|
||||
if (!closed)
|
||||
view.mount($video, { blur, opacity, duration }, () => !closed);
|
||||
if (closed) {
|
||||
releaseResources();
|
||||
return result;
|
||||
}
|
||||
const canvas = utils.createElement("canvas");
|
||||
if (closed) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return result;
|
||||
}
|
||||
sampler = createColorSampler(canvas, $video, () => utils.createElement("canvas"));
|
||||
if (closed) {
|
||||
releaseResources();
|
||||
return result;
|
||||
}
|
||||
loop = createFrameLoop(frequency, () => Boolean(host?.playing), (valid) => {
|
||||
const colors = sampler?.read(valid);
|
||||
if (colors && valid())
|
||||
view?.render(colors, valid);
|
||||
});
|
||||
listen("ready", result.start);
|
||||
if (closed)
|
||||
releaseResources();
|
||||
} catch (error) {
|
||||
try {
|
||||
destroy();
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
releaseResources();
|
||||
} catch {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
export {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
export default function artplayerPluginAmbilight(option = {}) {
|
||||
return (art) => {
|
||||
const { $video } = art.template
|
||||
const { createElement, addClass, setStyles } = art.constructor.utils
|
||||
const { blur = '50px', opacity = 0.5, frequency = 10, duration = 0.3 } = option
|
||||
|
||||
const $ambilight = createElement('div')
|
||||
const gridItems = createGridItems($ambilight)
|
||||
|
||||
setupAmbilight($ambilight, $video)
|
||||
setupGridItems(gridItems, blur, opacity, duration)
|
||||
|
||||
const updateColors = createColorUpdater($video, gridItems, frequency)
|
||||
|
||||
let animationFrameId = null
|
||||
|
||||
function start() {
|
||||
if (!animationFrameId) {
|
||||
updateColors()
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
}
|
||||
|
||||
art.on('ready', start)
|
||||
art.on('destroy', stop)
|
||||
|
||||
return {
|
||||
name: 'artplayerPluginAmbilight',
|
||||
start,
|
||||
stop,
|
||||
}
|
||||
|
||||
function createGridItems($ambilight) {
|
||||
$ambilight.innerHTML = Array.from({ length: 9 }).fill('<div></div>').join('')
|
||||
return Array.from($ambilight.children)
|
||||
}
|
||||
|
||||
function setupAmbilight($ambilight, $video) {
|
||||
addClass($ambilight, 'artplayer-plugin-ambilight')
|
||||
$video.parentNode.insertBefore($ambilight, $video)
|
||||
setStyles($ambilight, {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 9,
|
||||
inset: 0,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gridTemplateRows: '1fr 1fr 1fr',
|
||||
})
|
||||
}
|
||||
|
||||
function setupGridItems(gridItems, blur, opacity, duration) {
|
||||
gridItems.forEach(($item) => {
|
||||
setStyles($item, {
|
||||
opacity,
|
||||
filter: `blur(${blur})`,
|
||||
transition: `background-color ${duration}s ease`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createColorUpdater($video, gridItems, frequency) {
|
||||
const canvas = createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
canvas.width = 3
|
||||
canvas.height = 3
|
||||
|
||||
function getAverageColor(x, y, w, h) {
|
||||
ctx.drawImage($video, x, y, w, h, 0, 0, 1, 1)
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
let lastUpdateTime = 0
|
||||
|
||||
return function updateColors() {
|
||||
const now = performance.now()
|
||||
if (now - lastUpdateTime < 1000 / frequency || !art.playing) {
|
||||
animationFrameId = requestAnimationFrame(updateColors)
|
||||
return
|
||||
}
|
||||
|
||||
lastUpdateTime = now
|
||||
|
||||
const w = $video.videoWidth / 3
|
||||
const h = $video.videoHeight / 3
|
||||
|
||||
const colors = [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[2 * w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
[2 * w, h],
|
||||
[0, 2 * h],
|
||||
[w, 2 * h],
|
||||
[2 * w, 2 * h],
|
||||
].map(([x, y]) => getAverageColor(x, y, w, h))
|
||||
|
||||
gridItems.forEach(($item, index) => {
|
||||
$item.style.backgroundColor = colors[index]
|
||||
})
|
||||
|
||||
animationFrameId = requestAnimationFrame(updateColors)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type Artplayer from 'artplayer'
|
||||
import type PublicPlugin from '../types/artplayer-plugin-ambilight'
|
||||
import { createColorSampler } from './sampler'
|
||||
import { createFrameLoop } from './scheduler'
|
||||
import { createAmbilightView } from './view'
|
||||
|
||||
type Option = Parameters<typeof PublicPlugin>[0]
|
||||
type Subscription = ['ready' | 'destroy', () => void]
|
||||
|
||||
export default function artplayerPluginAmbilight(option: Option = {}) {
|
||||
return (art: Artplayer) => {
|
||||
let host: Artplayer | null = art
|
||||
let closed = art.isDestroy
|
||||
let view: ReturnType<typeof createAmbilightView> | null = null
|
||||
let sampler: ReturnType<typeof createColorSampler> | null = null
|
||||
let loop: ReturnType<typeof createFrameLoop> | null = null
|
||||
const subscriptions: Subscription[] = []
|
||||
const result = {
|
||||
name: 'artplayerPluginAmbilight' as const,
|
||||
start() {
|
||||
if (!closed)
|
||||
loop?.start()
|
||||
},
|
||||
stop() {
|
||||
if (!closed)
|
||||
loop?.stop()
|
||||
},
|
||||
}
|
||||
if (closed) {
|
||||
host = null
|
||||
return result
|
||||
}
|
||||
|
||||
function releaseResources() {
|
||||
const releases = [loop?.destroy, sampler?.destroy, view?.destroy]
|
||||
loop = null
|
||||
sampler = null
|
||||
view = null
|
||||
const failures: unknown[] = []
|
||||
for (const release of releases) {
|
||||
try {
|
||||
release?.()
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (closed)
|
||||
return
|
||||
closed = true
|
||||
const current = host
|
||||
host = null
|
||||
const failures: unknown[] = []
|
||||
for (const [event, callback] of subscriptions.splice(0)) {
|
||||
try {
|
||||
current?.off(event, callback)
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
try {
|
||||
releaseResources()
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
|
||||
function listen(event: Subscription[0], callback: Subscription[1]) {
|
||||
subscriptions.push([event, callback])
|
||||
host?.on(event, callback)
|
||||
}
|
||||
|
||||
try {
|
||||
listen('destroy', destroy)
|
||||
if (closed)
|
||||
return result
|
||||
const { $video } = art.template
|
||||
const utils = (art.constructor as typeof Artplayer).utils
|
||||
const { blur = '50px', opacity = 0.5, frequency = 10, duration = 0.3 } = option
|
||||
if (closed)
|
||||
return result
|
||||
view = createAmbilightView(utils)
|
||||
if (!closed)
|
||||
view.mount($video, { blur, opacity, duration }, () => !closed)
|
||||
if (closed) {
|
||||
releaseResources()
|
||||
return result
|
||||
}
|
||||
const canvas = utils.createElement('canvas')
|
||||
if (closed) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
return result
|
||||
}
|
||||
sampler = createColorSampler(canvas, $video, () => utils.createElement('canvas'))
|
||||
if (closed) {
|
||||
releaseResources()
|
||||
return result
|
||||
}
|
||||
loop = createFrameLoop(frequency, () => Boolean(host?.playing), (valid) => {
|
||||
const colors = sampler?.read(valid)
|
||||
if (colors && valid())
|
||||
view?.render(colors, valid)
|
||||
})
|
||||
listen('ready', result.start)
|
||||
if (closed)
|
||||
releaseResources()
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
destroy()
|
||||
}
|
||||
catch { /* Preserve the setup failure after attempting every cleanup. */ }
|
||||
try {
|
||||
releaseResources()
|
||||
}
|
||||
catch { /* A reentrant constructor may return a resource after destruction. */ }
|
||||
throw error
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export function createColorSampler(canvas: HTMLCanvasElement, video: HTMLVideoElement, createCanvas: () => HTMLCanvasElement) {
|
||||
let context: CanvasRenderingContext2D | null
|
||||
try {
|
||||
context = canvas.getContext('2d')
|
||||
canvas.width = 3
|
||||
canvas.height = 3
|
||||
}
|
||||
catch (error) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
throw error
|
||||
}
|
||||
let destroyed = false
|
||||
let replaceCanvas = false
|
||||
|
||||
function read(active: () => boolean): string[] | null {
|
||||
if (destroyed || !active())
|
||||
return null
|
||||
const width = video.videoWidth
|
||||
const height = video.videoHeight
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
||||
return null
|
||||
const w = width / 3
|
||||
const h = height / 3
|
||||
const colors: string[] = []
|
||||
try {
|
||||
if (replaceCanvas) {
|
||||
const replacement = createCanvas()
|
||||
if (destroyed || !active()) {
|
||||
replacement.width = 0
|
||||
replacement.height = 0
|
||||
return null
|
||||
}
|
||||
canvas = replacement
|
||||
context = null
|
||||
context = canvas.getContext('2d')
|
||||
if (destroyed || !active()) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
return null
|
||||
}
|
||||
canvas.width = 3
|
||||
canvas.height = 3
|
||||
replaceCanvas = false
|
||||
}
|
||||
if (!context)
|
||||
return null
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let column = 0; column < 3; column++) {
|
||||
if (destroyed || !active())
|
||||
return null
|
||||
context.drawImage(video, column * w, row * h, w, h, 0, 0, 1, 1)
|
||||
if (destroyed || !active())
|
||||
return null
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data
|
||||
colors.push(`rgb(${r}, ${g}, ${b})`)
|
||||
}
|
||||
}
|
||||
return colors
|
||||
}
|
||||
catch {
|
||||
// Firefox can retain taint after a size reset; replace on the next eligible sample.
|
||||
if (!destroyed) {
|
||||
replaceCanvas = true
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed)
|
||||
return
|
||||
destroyed = true
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
return { read, destroy }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export function createFrameLoop(frequency: number, playing: () => boolean, update: (active: () => boolean) => void) {
|
||||
let state: 'stopped' | 'running' | 'destroyed' = 'stopped'
|
||||
let pending: number | null = null
|
||||
let generation = 0
|
||||
let inFrame = false
|
||||
let lastUpdateTime = 0
|
||||
|
||||
function schedule() {
|
||||
const current = generation
|
||||
try {
|
||||
pending = requestAnimationFrame(() => {
|
||||
if (state !== 'running' || current !== generation)
|
||||
return
|
||||
pending = null
|
||||
tick()
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
state = 'stopped'
|
||||
generation++
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const current = generation
|
||||
const active = () => state === 'running' && current === generation
|
||||
inFrame = true
|
||||
try {
|
||||
const now = performance.now()
|
||||
if (now - lastUpdateTime < 1000 / frequency || !playing() || !active())
|
||||
return
|
||||
lastUpdateTime = now
|
||||
update(active)
|
||||
}
|
||||
finally {
|
||||
inFrame = false
|
||||
if (state === 'running' && pending === null)
|
||||
schedule()
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (state !== 'stopped')
|
||||
return
|
||||
state = 'running'
|
||||
generation++
|
||||
if (!inFrame)
|
||||
tick()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (state === 'destroyed')
|
||||
return
|
||||
state = 'stopped'
|
||||
generation++
|
||||
const frame = pending
|
||||
pending = null
|
||||
if (frame !== null)
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (state === 'destroyed')
|
||||
return
|
||||
state = 'destroyed'
|
||||
generation++
|
||||
const frame = pending
|
||||
pending = null
|
||||
if (frame !== null)
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
return { start, stop, destroy }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type Artplayer from 'artplayer'
|
||||
|
||||
type Utilities = Pick<typeof Artplayer.utils, 'createElement' | 'addClass'> & {
|
||||
// Historical setStyles accepts numeric CSS values; preserve its original arguments.
|
||||
setStyles: (element: HTMLElement, styles: object) => unknown
|
||||
}
|
||||
interface ViewOptions { blur: string, opacity: number, duration: number }
|
||||
|
||||
export function createAmbilightView(utils: Utilities) {
|
||||
const element = utils.createElement('div')
|
||||
element.innerHTML = Array.from({ length: 9 }).fill('<div></div>').join('')
|
||||
const items = Array.from(element.children) as HTMLElement[]
|
||||
|
||||
function mount(video: HTMLVideoElement, option: ViewOptions, active: () => boolean) {
|
||||
if (!active())
|
||||
return
|
||||
utils.addClass(element, 'artplayer-plugin-ambilight')
|
||||
if (!active())
|
||||
return
|
||||
video.parentNode!.insertBefore(element, video)
|
||||
if (!active())
|
||||
return
|
||||
utils.setStyles(element, { position: 'absolute', top: 0, left: 0, zIndex: 9, inset: 0, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gridTemplateRows: '1fr 1fr 1fr' })
|
||||
for (const item of items) {
|
||||
if (!active())
|
||||
return
|
||||
utils.setStyles(item, { opacity: option.opacity, filter: `blur(${option.blur})`, transition: `background-color ${option.duration}s ease` })
|
||||
}
|
||||
}
|
||||
|
||||
function render(colors: string[], active: () => boolean) {
|
||||
for (let index = 0; index < colors.length; index++) {
|
||||
if (!active())
|
||||
return
|
||||
const item = items[index]
|
||||
const color = colors[index]
|
||||
if (item && color !== undefined)
|
||||
item.style.backgroundColor = color
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try {
|
||||
element.parentNode?.removeChild(element)
|
||||
}
|
||||
finally { items.length = 0 }
|
||||
}
|
||||
return { mount, render, destroy }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "allowJs": false },
|
||||
"include": ["src/**/*.ts", "types/**/*.d.ts"]
|
||||
}
|
||||
@@ -14,7 +14,7 @@ stop保留最后画面,核心destroy终止资源。zIndex仍忽略,参数省
|
||||
|
||||
## 02历史回归
|
||||
|
||||
`yarn test:ambilight`运行30项Node回归并接入test:unit;正常参数/频率/暂停与已确认的
|
||||
`node --test test/ambilight.test.js`运行30项Node回归并接入test:unit;正常参数/频率/暂停与已确认的
|
||||
历史问题分别断言。`yarn test:browser test/browser/ambilight.spec.js`运行三实现/三核心/
|
||||
三引擎真实媒体;核心标签published是实际npm5.4.0,published-5.1.7是实际npm5.1.7,
|
||||
candidate是当前源码;以browser-evidence的manifest和实际version为准。
|
||||
@@ -24,3 +24,25 @@ candidate是当前源码;以browser-evidence的manifest和实际version为准
|
||||
02最终30项Node、54项三引擎/三核心浏览器通过,未skip;新建Canvas能读取已恢复的
|
||||
同源视频,而旧插件Canvas仍抛SecurityError。主CI955项通过(另44项重复观察)。
|
||||
完整报告与输入来源见baselines/ambilight-behavior-validation.json。03开始修复这些问题。
|
||||
|
||||
## 03候选生命周期
|
||||
|
||||
`yarn test:ambilight`现在执行30项历史及16项候选测试。候选直接构建当前src入口,
|
||||
不读取旧dist作回退。设置ARTPLAYER_AMBILIGHT_BASELINE=1可把同一16项要求交给冻结
|
||||
工作区:14项失败、2项正常语义对照通过;候选16项通过。环境变量只应用于独立负向
|
||||
命令,正常CI不得设置。另7项发布契约继续通过,专项共53项。
|
||||
|
||||
`yarn test:browser test/browser/ambilight-lifecycle.spec.js`在实际npm核心5.1.7/5.4.0和
|
||||
候选核心、Chromium/Firefox/WebKit中执行18项,无skip。原生视频/Canvas不模拟;
|
||||
词法RAF观察器只跟踪插件自己的帧,调用仍交给原生RAF,核心和测试等待帧不计入。
|
||||
验证ready自动取色、stop保色、destroy(false)移除DOM/清空帧、逃逸方法不复活,及真实
|
||||
跨origin失败后切回同源在同一插件/视图内恢复,不调用stop/start来协助恢复。
|
||||
|
||||
首次宽度重置方案15/18通过,Firefox全部3项恢复失败。独立诊断证明新Canvas能读取
|
||||
已恢复视频,原Canvas重设宽度仍抛SecurityError,随后改为下次有效取色创建新Canvas。
|
||||
保留失败与通过报告;最终18/18通过。持续跨域素材仍不可取色,失败期间的分配成本
|
||||
需在性能复盘评估。正式构建更新三种分发及docs/compiled,未手改生成内容。
|
||||
|
||||
03证据见 [生命周期验证](baselines/ambilight-lifecycle-validation.json) 和
|
||||
[实施记录](changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md)。04公开声明及CJS.default、
|
||||
05代理/实际设备、06安装包和8082 demo仍未完成;三浏览器通过不能代替这些验收。
|
||||
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"task": "PKG-AMBILIGHT-03",
|
||||
"status": "done",
|
||||
"parentCommit": "43f0e9a8c66402e628f358602220aaa854b100e2",
|
||||
"checks": [
|
||||
{
|
||||
"command": "node --test test/ambilight-lifecycle.test.js test/ambilight.test.js refactor/scripts/ambilight-contract.test.mjs",
|
||||
"result": "pass",
|
||||
"tests": 53,
|
||||
"candidate": 16,
|
||||
"historical": 30,
|
||||
"publishedContract": 7
|
||||
},
|
||||
{
|
||||
"command": "ARTPLAYER_AMBILIGHT_BASELINE=1 node --test test/ambilight-lifecycle.test.js",
|
||||
"result": "expected-negative-control",
|
||||
"tests": 16,
|
||||
"failed": 14,
|
||||
"passedNormalSemantics": 2,
|
||||
"source": "immutable frozen-workspace from ambilight-release.json"
|
||||
},
|
||||
{
|
||||
"command": "playwright test test/browser/ambilight-lifecycle.spec.js",
|
||||
"result": "pass",
|
||||
"tests": 18,
|
||||
"skipped": 0,
|
||||
"stats": {
|
||||
"startTime": "2026-09-12T03:28:59.484Z",
|
||||
"duration": 28092.341,
|
||||
"expected": 18,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"versions": [
|
||||
"chromium: 153.0.8010.12",
|
||||
"firefox: 155.0",
|
||||
"webkit: 26.6"
|
||||
],
|
||||
"coreSources": [
|
||||
"npm 5.1.7",
|
||||
"npm 5.4.0",
|
||||
"candidate source"
|
||||
],
|
||||
"report": "refactor/.cache/ambilight03-final-results/report.json",
|
||||
"archivedResults": "refactor/.cache/ambilight03-final-results/results",
|
||||
"originalResultsPrefix": "refactor/.cache/browser/results"
|
||||
},
|
||||
{
|
||||
"command": "yarn build artplayer-plugin-ambilight",
|
||||
"result": "pass",
|
||||
"outputs": [
|
||||
"main es2020",
|
||||
"legacy es2015",
|
||||
"esm es2020",
|
||||
"docs/compiled copies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"command": "focused eslint --no-fix and tsc -p packages/artplayer-plugin-ambilight/tsconfig.json",
|
||||
"result": "pass"
|
||||
},
|
||||
{
|
||||
"command": "yarn ci:check",
|
||||
"result": "pass",
|
||||
"tests": {
|
||||
"unit": 878,
|
||||
"engineering": 14,
|
||||
"baseline": 79,
|
||||
"mainTotal": 971,
|
||||
"repeatedContractExecutions": 44
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedIterations": [
|
||||
{
|
||||
"report": "refactor/.cache/ambilight03-first-results/report.json",
|
||||
"archivedResults": "refactor/.cache/ambilight03-first-results/results",
|
||||
"pass": 15,
|
||||
"fail": 3,
|
||||
"reason": "Firefox retained canvas taint after width reset in all three core combinations"
|
||||
},
|
||||
{
|
||||
"report": "refactor/.cache/ambilight03-firefox-probe-results/report.json",
|
||||
"archivedResults": "refactor/.cache/ambilight03-firefox-probe-results/results",
|
||||
"pass": 0,
|
||||
"fail": 1,
|
||||
"reason": "Fresh native canvas read recovered video but reset old canvas still threw SecurityError; native-canvas-reset-control attachment records it"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/src/index.ts",
|
||||
"sha256LF": "e5df94a5a541530533ccbf91f4365fcaa9965d6727fee0c67cef50dc967e22cb"
|
||||
},
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/src/sampler.ts",
|
||||
"sha256LF": "b6a2ac951ddd162099d39aadaaca3b9fafe7929c5c12a857934d4b9c884edf5e"
|
||||
},
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/src/scheduler.ts",
|
||||
"sha256LF": "2c87328cfa8d916c64e8eed26802116867f9d731991a6cddf871d53808261268"
|
||||
},
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/src/view.ts",
|
||||
"sha256LF": "e30ec83e0a8f1b471c4f04cc436d550b3fed3f75d8042ddfd73be98705103e8b"
|
||||
},
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/tsconfig.json",
|
||||
"sha256LF": "712a3fa32ed57692d16bf854a0e77ffaecd04236efa0f557905aab7291e0aef6"
|
||||
},
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/types/artplayer-plugin-ambilight.d.ts",
|
||||
"sha256LF": "a36998b4d96d9a3200022029bf92918cc516d5ae4f38dc78a1a76b53a736d086"
|
||||
},
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.js",
|
||||
"sha256LF": "56735a767696fe2cbe11b04db9a8af32fc814be58a5664395a8888bb31c7e046"
|
||||
},
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.legacy.js",
|
||||
"sha256LF": "51ed35cffc43ac1748556210af1cee1a5e0b867e48e269e4c477bc45962203b6"
|
||||
},
|
||||
{
|
||||
"file": "packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.mjs",
|
||||
"sha256LF": "7c6b35fa873d4498fabf8ef7097e56664ab7ecd6a2d0efb884b4de0a3055f99d"
|
||||
},
|
||||
{
|
||||
"file": "test/helpers/ambilight.js",
|
||||
"sha256LF": "8cb80dba5270860afc121e3a02336a6cdb20b0a6a41e058cde11efe9174fba4f"
|
||||
},
|
||||
{
|
||||
"file": "test/ambilight-lifecycle.test.js",
|
||||
"sha256LF": "cc4a7a4068709f1c9f5be49ea4d51784f3ccfd22ca6f99bb15314b5032ff8395"
|
||||
},
|
||||
{
|
||||
"file": "test/browser/ambilight-lifecycle.spec.js",
|
||||
"sha256LF": "321693a24d33f0c7aee02fd4137b55175386562a645ba138e15c3a51d6348d2c"
|
||||
},
|
||||
{
|
||||
"file": "test/browser/server.mjs",
|
||||
"sha256LF": "af513e1e64f875668b6a7cbd060276d85d930e0cc1715577e97907cc26c3b58d"
|
||||
},
|
||||
{
|
||||
"file": "refactor/baselines/ambilight-release.json",
|
||||
"sha256LF": "6acbea40a26397ffa36549fbecb1a12c057cfb3fc932f7cb83181ad5fa37bd2f"
|
||||
},
|
||||
{
|
||||
"file": "package.json",
|
||||
"sha256LF": "415ee02f3c38691475c619b1f874c68ee0484630f805f9d835981d421fbbda84"
|
||||
}
|
||||
],
|
||||
"logs": [
|
||||
{
|
||||
"file": "refactor/.cache/ambilight03-targeted-final.log",
|
||||
"sha256": "5524ffd95993fd31600b6f51ae4ae8297fd8a92b5b01670386b9c7a61b0700bb"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/ambilight03-baseline-negative.log",
|
||||
"sha256": "20d82258de1bababe503ada70b95b8295126502f9d9fc26deaa8d8a25ccd3ec8"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/ambilight03-browser.log",
|
||||
"sha256": "9f230d7cc02d6b038f26e7bd64968580b876b4146697c5ecf9d1511958dfb79f"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/ambilight03-firefox-probe.log",
|
||||
"sha256": "8f90b54c0be91571f0070608f7b0f0b1ff02ff4c8ac3c973aaf1a1451ddee6ed"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/ambilight03-browser-fixed.log",
|
||||
"sha256": "9cdb32d658b245fb8efea98b638f1afb0966bfde8b018cdba016b67e49665d2b"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/ambilight03-build.log",
|
||||
"sha256": "a642ae2431cb33e10aa98f6520853f4c51077e5d2fe05659e8297100a81a0c8f"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/ambilight03-lint-final.log",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"file": "refactor/.cache/ambilight03-ci.log",
|
||||
"sha256": "e5dbb40a578179624b5b394887d23f0164db4fd47c938e07de93070d16c044d7"
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"Public declarations and historical CJS.default compatibility remain task 04; no API approval inferred from Ads.",
|
||||
"Proxy and real device combinations remain task 05; package installation and local 8082 demo remain task 06.",
|
||||
"Browser tests consume a direct candidate source bundle; production artifacts are built but not yet packed-consumer validated.",
|
||||
"A permanently unreadable cross-origin source retries with at most one new canvas per eligible sample; performance review remains required.",
|
||||
"VAST alone has user-authorized persistent VPN external-script exception. No skip or exception was used here.",
|
||||
"Full CI retains the known generated docs lint warning and Yarn url.parse deprecation; remote workflows have not run."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# PKG-AMBILIGHT-03 TS职责拆分与生命周期修复
|
||||
|
||||
## 结构变化
|
||||
|
||||
原单文件JS拆为严格TS入口、视图、取色和RAF调度四个模块;新增包tsconfig并纳入现有
|
||||
自动发现的严格类型检查。入口负责订阅和终止,视图只维护9格样式,取色一次收集9色,
|
||||
调度使用运行/停止/销毁状态和代次,避免重入和过期帧重复挂载。包ARCHITECTURE.md说明
|
||||
所有权、失败处理、兼容约束和后续AI应运行的检查;公开声明留给04单独验收。
|
||||
|
||||
## 缺陷修复
|
||||
|
||||
RAF id为0也能停止;destroy移除视图/订阅、释放Canvas,逃逸start不能复活;初始化
|
||||
失败回滚,部分订阅/上下文错误保留原异常;销毁中某一步抛错仍继续其他清理。
|
||||
取色中stop/destroy立刻放弃旧批次,stop/start留到新帧;空context与无效尺寸跳过。
|
||||
绘制/读像素失败不再卡死调度,下一次有效取色重新创建Canvas,同源切源后恢复。
|
||||
|
||||
首轮真实浏览器18项中15通过,Firefox三核心恢复断言失败。补充独立新Canvas对照后,
|
||||
确认同源视频可读,但旧原生Canvas改宽度仍抛SecurityError;据此将仅重置宽度改为
|
||||
丢弃出错Canvas并在下次满足频率/播放/尺寸条件时替换。初次失败和诊断报告均归档,
|
||||
没有扩大超时或skip来绕过问题。永久跨域时最多每个有效采样分配一次替代Canvas;
|
||||
默认10Hz,高频选项仍按历史算式执行,该成本留给后续性能复盘。
|
||||
|
||||
## 兼容与范围
|
||||
|
||||
保留factory/options读取时机、start/stop同步返回、ready启动、暂停不采样、stop保留
|
||||
颜色、3x3位置/默认样式、ignored zIndex和特殊frequency语义。destroy终止与错误恢复
|
||||
是已复现缺陷修正;没有增加公共方法、修改声明或宣称npm分发和设备全部验收。
|
||||
真实npm1.0.0/1.1.0与冻结工作区历史测试不改写;核心版本以实际归档5.1.7/5.4.0为准。
|
||||
|
||||
## 验证与提交
|
||||
|
||||
16项候选回归在冻结工作区上14项失败、2项正常语义通过;修复后与30项历史及7项发布
|
||||
契约合计53项通过。最终三引擎/三核心18项浏览器全部通过,无skip。严格类型、专项
|
||||
lint及三格式正式构建通过;完整CI971项通过(878单元+14工程+79基线),另44项契约
|
||||
报告重复执行不作新增计数;全仓278个生产TS文件纳入严格检查。已知生成文档lint
|
||||
warning和Yarn弃用提示保留。结果与输入哈希见
|
||||
[验证证据](../baselines/ambilight-lifecycle-validation.json)。
|
||||
|
||||
03完成并独立本地commit,随后审计自身完成提交;无推送或发布。04公开类型/旧
|
||||
CJS.default、05代理和设备、06分发/demo仍待实施。AMBILIGHT-LIFE-01保留open以追踪
|
||||
05代理组合的终止/能力边界,不能把本步原生video验收扩展到所有proxy。
|
||||
|
||||
用户更正已核对:VPN外部脚本加载例外只适用于VAST,不适用于Ads。本次Ambilight验证
|
||||
使用本地媒体,不涉及该例外。VAST初始化兼容选择仍是独立待决项。
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 220 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 128 / doing 6 / blocked 0 / done 86 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 127 / doing 6 / blocked 0 / done 87 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| PKG-AMBILIGHT-01 | artplayer-plugin-ambilight<br>核对包契约与历史用法 | BASE-05 | blur/opacity/frequency/duration、start/stop 与挂载样式 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | M | done |
|
||||
| PKG-AMBILIGHT-02 | artplayer-plugin-ambilight<br>建立特有行为与错误测试 | PKG-AMBILIGHT-01, ENG-03, ENG-05 | 跨域 canvas 失败、零尺寸、暂停、重复 start/stop 和 destroy | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | done |
|
||||
| PKG-AMBILIGHT-03 | artplayer-plugin-ambilight<br>整理内部职责与资源 | PKG-AMBILIGHT-02, CORE-03, CORE-12 | 取色计算与绘帧分离,统一 RAF 和 DOM 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | todo |
|
||||
| PKG-AMBILIGHT-03 | artplayer-plugin-ambilight<br>整理内部职责与资源 | PKG-AMBILIGHT-02, CORE-03, CORE-12 | 取色计算与绘帧分离,统一 RAF 和 DOM 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | done |
|
||||
| PKG-AMBILIGHT-04 | artplayer-plugin-ambilight<br>迁移自有源码和公开类型 | PKG-AMBILIGHT-03, ENG-04, ENG-06, CORE-07 | canvas 上下文、参数及 start/stop 的明确类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | M | todo |
|
||||
| PKG-AMBILIGHT-05 | artplayer-plugin-ambilight<br>验证新旧核心和组合 | PKG-AMBILIGHT-04, CORE-22 | 原生 video、canvas proxy 的能力边界及销毁无帧循环 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo |
|
||||
| PKG-AMBILIGHT-06 | artplayer-plugin-ambilight<br>验证分发并同步文档 | PKG-AMBILIGHT-05, ENG-07 | ambilight.js 示例及样式/分发验证 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | M | todo |
|
||||
@@ -478,6 +478,7 @@
|
||||
- PKG-CHAPTER-05: [记录](changes/2026-09-12-PKG-CHAPTER-05-combinations.md) [记录](baselines/chapter-combinations-checkpoint.json)
|
||||
- PKG-AMBILIGHT-01: [记录](changes/2026-09-12-PKG-AMBILIGHT-01-contract.md) [记录](baselines/ambilight-contract.md) [记录](baselines/ambilight-release.json) [记录](baselines/ambilight-contract-validation.json)
|
||||
- PKG-AMBILIGHT-02: [记录](changes/2026-09-12-PKG-AMBILIGHT-02-tests.md) [记录](baselines/ambilight-behavior-validation.json) [记录](ambilight-validation.md)
|
||||
- PKG-AMBILIGHT-03: [记录](changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md) [记录](ambilight-validation.md) [记录](baselines/ambilight-lifecycle-validation.json)
|
||||
- PKG-AUDIO-01: [记录](changes/2026-09-12-PKG-AUDIO-01-contract.md) [记录](baselines/audio-track-contract.md) [记录](baselines/audio-track-release.json)
|
||||
- PKG-AUDIO-02: [记录](changes/2026-09-12-PKG-AUDIO-02-tests.md) [记录](audio-validation.md) [记录](baselines/audio-validation.json)
|
||||
- PKG-AUDIO-03: [记录](changes/2026-09-12-PKG-AUDIO-03-lifecycle.md) [记录](baselines/audio-lifecycle-validation.json)
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 进度与证据
|
||||
|
||||
## 最新完成:Ambilight-03 TS模块与生命周期修复
|
||||
|
||||
生产源码已拆成入口、视图、取色、帧调度4个严格TS模块,修复销毁残留、重入旧帧、
|
||||
初始化回滚和Canvas异常卡死。16项候选要求在旧源码14失败/2正常通过,候选与历史
|
||||
及发布契约共53项通过。首次真实浏览器发现Firefox宽度重置仍污染,加入独立对照后
|
||||
修复为下次有效取色替换Canvas,最终三核心/三引擎18项通过、无skip。正式三格式构建
|
||||
及完整CI971项通过,另44项重复观察;全仓278个生产TS文件严格检查。当前220项:
|
||||
87 done、6 doing、127 todo。04公开类型/历史CJS.default、05代理/设备、06包消费/demo
|
||||
未完成。见 [实施记录](changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md) 和
|
||||
[验证证据](baselines/ambilight-lifecycle-validation.json)。本任务独立本地提交,无推送/发布。
|
||||
|
||||
## 最新完成:Ambilight-02 行为与失败回归
|
||||
|
||||
30项Node复现RAF id零、异常后卡住、销毁/重入/晚注册和初始化回滚问题。54项三引擎/
|
||||
|
||||
+4
-2
@@ -3270,9 +3270,11 @@
|
||||
"refactor/changes/2026-09-12-PKG-AMBILIGHT-01-contract.md",
|
||||
"refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md",
|
||||
"refactor/baselines/ambilight-behavior-validation.json",
|
||||
"refactor/ambilight-validation.md"
|
||||
"refactor/ambilight-validation.md",
|
||||
"refactor/changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md",
|
||||
"refactor/baselines/ambilight-lifecycle-validation.json"
|
||||
],
|
||||
"workspaceState": "30 Node historical tests reproduce RAF id zero, terminal/escaped method, sampling/context, reentrant stop/destroy and setup rollback failures. Native browser sampling of a cross-origin video throws SecurityError and stays tainted after same-origin source recovery. No production fixes in this task."
|
||||
"workspaceState": "Candidate source split into four strict TS modules. RAF zero, terminal cleanup, rollback and reentrant frames repaired; 16 candidate tests pass (14 fail against frozen source). Eighteen native video/canvas tests pass across three cores and three engines after reproducing Firefox width-reset taint and replacing failed canvas. Risk remains open for proxy lifecycle/capability checks in PKG-AMBILIGHT-05; do not extend native video evidence to proxies."
|
||||
},
|
||||
{
|
||||
"id": "AMBILIGHT-TYPE-01",
|
||||
|
||||
+6
-2
@@ -1503,11 +1503,15 @@
|
||||
"CORE-03",
|
||||
"CORE-12"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "done",
|
||||
"risk": "M",
|
||||
"deliverable": "取色计算与绘帧分离,统一 RAF 和 DOM 清理",
|
||||
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md",
|
||||
"ambilight-validation.md",
|
||||
"baselines/ambilight-lifecycle-validation.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PKG-AMBILIGHT-04",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Candidate lifecycle assertions are also run against the frozen source to prove the fixes.
|
||||
import test from 'node:test'
|
||||
import { ambilightCandidate, ambilightEnvironment, ambilightHistorical } from './helpers/ambilight.js'
|
||||
|
||||
const implementation = await ambilightCandidate()
|
||||
|
||||
test('Ambilight candidate matches all historical normal grid, sample order, timing and public return observations', async () => {
|
||||
const observations = []
|
||||
for (const source of [...await ambilightHistorical(), implementation]) {
|
||||
const env = await ambilightEnvironment(source)
|
||||
const result = env.factory()(env.art)
|
||||
const grid = env.parent.children[0]
|
||||
env.art.emit('ready')
|
||||
env.art.playing = true
|
||||
env.frame(99)
|
||||
assert.equal(env.draws.length, 0)
|
||||
env.frame(100)
|
||||
assert.equal(env.draws.length, 9)
|
||||
env.frame(199)
|
||||
assert.equal(env.draws.length, 9)
|
||||
observations.push({ name: result.name, grid: { className: grid.className, style: grid.style }, items: grid.children.map(child => child.style), samples: env.draws.map(args => [...args.slice(1)]), start: result.start(), stop: result.stop(), pending: env.frames.size })
|
||||
}
|
||||
for (const observed of observations.slice(1))
|
||||
assert.deepEqual(observed, observations[0])
|
||||
})
|
||||
|
||||
test('Ambilight candidate tracks RAF zero without duplicate work and cancels it', async () => {
|
||||
const env = await ambilightEnvironment(implementation, { firstFrame: 0 })
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
result.start()
|
||||
assert.equal(env.frames.size, 1)
|
||||
result.stop()
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate core destruction releases its view, canvas and listeners and makes escaped methods inert', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
env.art.emit('ready')
|
||||
env.art.destroy()
|
||||
result.start()
|
||||
result.stop()
|
||||
env.art.emit('ready')
|
||||
env.art.destroy()
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0)
|
||||
assert.equal(env.listeners.get('ready')?.size || 0, 0)
|
||||
assert.equal(env.listeners.get('destroy')?.size || 0, 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate recovers from canvas read/draw failure without wedging its loop', async () => {
|
||||
for (const kind of ['read', 'draw']) {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
env.art.playing = true
|
||||
const failure = Object.assign(new Error(kind), { name: kind === 'read' ? 'SecurityError' : 'InvalidStateError' })
|
||||
if (kind === 'read')
|
||||
env.setReadError(failure)
|
||||
else env.setDrawError(failure)
|
||||
assert.doesNotThrow(() => env.frame(100))
|
||||
assert.equal(env.frames.size, 1)
|
||||
env.setReadError(null)
|
||||
env.setDrawError(null)
|
||||
env.frame(200)
|
||||
assert.equal(env.draws.length, 10)
|
||||
assert.equal(env.parent.children[0].children[8].style.backgroundColor, 'rgb(10, 20, 30)')
|
||||
env.art.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Ambilight candidate skips unavailable contexts and invalid dimensions while retaining a recoverable loop', async () => {
|
||||
const missing = await ambilightEnvironment(implementation, { noContext: true })
|
||||
missing.art.playing = true
|
||||
missing.time(100)
|
||||
assert.doesNotThrow(() => missing.factory()(missing.art).start())
|
||||
assert.equal(missing.frames.size, 1)
|
||||
missing.art.destroy()
|
||||
for (const width of [0, -1, Number.NaN, Infinity]) {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
env.video.videoWidth = width
|
||||
env.art.playing = true
|
||||
env.time(100)
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
assert.equal(env.draws.length, 0)
|
||||
env.video.videoWidth = 300
|
||||
env.frame(200)
|
||||
assert.equal(env.draws.length, 9)
|
||||
env.art.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Ambilight candidate stops in-progress sampling after reentrant stop or core destruction', async () => {
|
||||
for (const operation of ['stop', 'destroy']) {
|
||||
let result
|
||||
const env = await ambilightEnvironment(implementation, { onDraw() {
|
||||
if (operation === 'stop')
|
||||
result.stop()
|
||||
else env.art.destroy()
|
||||
} })
|
||||
result = env.factory()(env.art)
|
||||
const grid = env.parent.children[0]
|
||||
result.start()
|
||||
env.art.playing = true
|
||||
env.frame(100)
|
||||
assert.equal(env.draws.length, 1)
|
||||
assert.equal(grid.children[0].style.backgroundColor, undefined)
|
||||
assert.equal(env.frames.size, 0)
|
||||
env.art.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Ambilight candidate stop/start reentrancy defers the replacement frame and discards obsolete colors', async () => {
|
||||
let result
|
||||
let once = true
|
||||
const env = await ambilightEnvironment(implementation, { onDraw() {
|
||||
if (!once)
|
||||
return
|
||||
once = false
|
||||
result.stop()
|
||||
result.start()
|
||||
} })
|
||||
result = env.factory()(env.art)
|
||||
result.start()
|
||||
env.art.playing = true
|
||||
env.frame(100)
|
||||
assert.equal(env.draws.length, 1)
|
||||
assert.equal(env.frames.size, 1)
|
||||
assert.equal(env.parent.children[0].children[0].style.backgroundColor, undefined)
|
||||
env.frame(200)
|
||||
assert.equal(env.draws.length, 10)
|
||||
env.art.destroy()
|
||||
})
|
||||
|
||||
test('Ambilight candidate rolls back inserted views when setup throws', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const failure = new Error('style setup failed')
|
||||
env.art.constructor.utils.setStyles = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.factory()(env.art), error => error === failure)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate late attachment to a destroyed host allocates no view or canvas', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
env.art.destroy()
|
||||
const count = env.nodes.length
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
assert.equal(env.nodes.length, count)
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(env.listeners.size, 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate releases canvas and view when context acquisition throws', async () => {
|
||||
const failure = new Error('context acquisition failed')
|
||||
const env = await ambilightEnvironment(implementation, { onContext() {
|
||||
throw failure
|
||||
} })
|
||||
assert.throws(() => env.factory()(env.art), error => error === failure)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
const canvas = env.nodes.find(node => node.tag === 'canvas')
|
||||
assert.equal(canvas.width, 0)
|
||||
assert.equal(canvas.height, 0)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate rolls back partial subscription while preserving the setup error', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const failure = new Error('ready subscription failed')
|
||||
const on = env.art.on
|
||||
env.art.on = (name, callback) => {
|
||||
on(name, callback)
|
||||
if (name === 'ready')
|
||||
throw failure
|
||||
return env.art
|
||||
}
|
||||
assert.throws(() => env.factory()(env.art), error => error === failure)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate attempts every release even when host listener cleanup throws', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
const failure = new Error('host off failed')
|
||||
const off = env.art.off
|
||||
env.art.off = (...args) => {
|
||||
off(...args)
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.art.destroy(), error => error === failure)
|
||||
result.start()
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate ignores a cancelled stale callback without losing its replacement frame', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
const stale = [...env.frames.values()][0]
|
||||
result.stop()
|
||||
result.start()
|
||||
const replacement = [...env.frames.keys()][0]
|
||||
stale(100)
|
||||
assert.deepEqual([...env.frames.keys()], [replacement])
|
||||
result.stop()
|
||||
assert.equal(env.frames.size, 0)
|
||||
env.art.destroy()
|
||||
})
|
||||
|
||||
test('Ambilight candidate construction-time destruction releases partially initialized resources', async () => {
|
||||
for (const boundary of ['style', 'context', 'element', 'subscription']) {
|
||||
const env = await ambilightEnvironment(implementation, { onContext() {
|
||||
if (boundary === 'context')
|
||||
env.art.destroy()
|
||||
} })
|
||||
if (boundary === 'style') {
|
||||
const original = env.art.constructor.utils.setStyles
|
||||
env.art.constructor.utils.setStyles = (...args) => {
|
||||
env.art.destroy()
|
||||
return original(...args)
|
||||
}
|
||||
}
|
||||
if (boundary === 'element') {
|
||||
const original = env.art.constructor.utils.createElement
|
||||
env.art.constructor.utils.createElement = (...args) => {
|
||||
env.art.destroy()
|
||||
return original(...args)
|
||||
}
|
||||
}
|
||||
if (boundary === 'subscription') {
|
||||
const original = env.art.on
|
||||
env.art.on = (...args) => {
|
||||
const result = original(...args)
|
||||
env.art.destroy()
|
||||
return result
|
||||
}
|
||||
}
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
assert.deepEqual(env.parent.children, [env.video], boundary)
|
||||
assert.equal(env.frames.size, 0, boundary)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0, boundary)
|
||||
for (const canvas of env.nodes.filter(node => node.tag === 'canvas')) assert.equal(canvas.width, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('Ambilight candidate observes destruction from option getters before allocating resources', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const count = env.nodes.length
|
||||
const result = env.factory({ get blur() {
|
||||
env.art.destroy()
|
||||
return '50px'
|
||||
} })(env.art)
|
||||
result.start()
|
||||
assert.equal(env.nodes.length, count)
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate keeps normal styles, special frequency values, stop colors and synchronous return shape', async () => {
|
||||
for (const [frequency, draws] of [[0, 0], [-1, 9], [Infinity, 9], [Number.NaN, 9], ['2', 0]]) {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const option = { frequency, zIndex: -10 }
|
||||
const attach = env.factory(option)
|
||||
option.opacity = 0.8
|
||||
const result = attach(env.art)
|
||||
env.art.playing = true
|
||||
env.time(100)
|
||||
assert.equal(result.start(), undefined)
|
||||
assert.equal(env.draws.length, draws)
|
||||
const grid = env.parent.children[0]
|
||||
assert.equal(grid.style.zIndex, 9)
|
||||
assert.equal(grid.children[0].style.opacity, 0.8)
|
||||
assert.equal(grid.children[0].style.filter, 'blur(50px)')
|
||||
const colors = grid.children.map(child => child.style.backgroundColor)
|
||||
assert.equal(result.stop(), undefined)
|
||||
assert.deepEqual(grid.children.map(child => child.style.backgroundColor), colors)
|
||||
assert.deepEqual(Object.keys(result), ['name', 'start', 'stop'])
|
||||
env.art.destroy()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { ambilightCandidate } from '../helpers/ambilight.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await ambilightCandidate()
|
||||
|
||||
async function setup(page, core, testInfo, crossOrigin = false) {
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
// Lexical observers delegate to native RAF; core and test frames are not counted.
|
||||
await page.addScriptTag({ content: `(() => {
|
||||
const pending = new Set();
|
||||
const requestAnimationFrame = callback => {
|
||||
const id = window.requestAnimationFrame(time => { pending.delete(id); callback(time); });
|
||||
pending.add(id); return id;
|
||||
};
|
||||
const cancelAnimationFrame = id => { pending.delete(id); window.cancelAnimationFrame(id); };
|
||||
const module = { exports: {} }; const exports = module.exports;
|
||||
${implementation.source};
|
||||
window.ambilightFactory = module.exports.default || module.exports;
|
||||
window.ambilightPending = pending;
|
||||
})();` })
|
||||
await page.evaluate((crossOrigin) => {
|
||||
const url = new URL('/test/pattern.mp4', location.href)
|
||||
if (crossOrigin)
|
||||
url.hostname = 'localhost'
|
||||
window.createPlayer(url.href)
|
||||
window.art.plugins.add(window.ambilightFactory({ frequency: Infinity }))
|
||||
window.ambilight = window.art.plugins.artplayerPluginAmbilight
|
||||
}, crossOrigin)
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.videoWidth)).toBeGreaterThan(0)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0)).toBe(true)
|
||||
await testInfo.attach('ambilight-candidate-inputs', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, sha256: hash(implementation.source), crossOrigin, canvas: 'native canvas and media, lexical native RAF observer' }) })
|
||||
}
|
||||
|
||||
const colors = page => page.evaluate(() => [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].map(node => node.style.backgroundColor))
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await testInfo.attach('ambilight-candidate-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, pending: window.ambilightPending?.size, grids: document.querySelectorAll('.artplayer-plugin-ambilight').length, source: window.art?.url }))) })
|
||||
await page.evaluate(() => {
|
||||
window.ambilight?.stop()
|
||||
if (window.art && !window.art.isDestroy)
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
for (const core of ['published-5.1.7', 'published', 'candidate']) {
|
||||
test(`${core}: Ambilight ready starts native sampling and destruction releases only its frames and view`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, testInfo)
|
||||
await expect.poll(async () => (await colors(page)).filter(color => /^rgb\(/.test(color)).length).toBe(9)
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(1)
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
|
||||
const before = await colors(page)
|
||||
await page.evaluate(() => window.ambilight.stop())
|
||||
expect(await colors(page)).toEqual(before)
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(0)
|
||||
await page.evaluate(() => window.ambilight.start())
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(1)
|
||||
await page.evaluate(async () => {
|
||||
window.art.destroy(false)
|
||||
window.ambilight.start()
|
||||
window.ambilight.stop()
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
})
|
||||
expect(await page.locator('.artplayer-plugin-ambilight').count()).toBe(0)
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(0)
|
||||
})
|
||||
|
||||
test(`${core}: Ambilight recovers from native cross-origin taint after a same-origin source switch`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, testInfo, true)
|
||||
const error = await page.evaluate(() => {
|
||||
const context = document.createElement('canvas').getContext('2d')
|
||||
window.ambilightProbe = context
|
||||
context.drawImage(window.art.template.$video, 0, 0, 1, 1)
|
||||
try {
|
||||
context.getImageData(0, 0, 1, 1)
|
||||
return null
|
||||
}
|
||||
catch (error) { return error.name }
|
||||
})
|
||||
expect(error).toBe('SecurityError')
|
||||
expect(await colors(page)).toEqual(Array.from({ length: 9 }, () => ''))
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(1)
|
||||
await page.evaluate(() => {
|
||||
window.ambilightOriginalGrid = document.querySelector('.artplayer-plugin-ambilight')
|
||||
return window.art.switchUrl('/test/pattern.mp4')
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.template.$video.currentSrc.startsWith(location.origin))).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const context = document.createElement('canvas').getContext('2d')
|
||||
try {
|
||||
context.drawImage(window.art.template.$video, 0, 0, 1, 1)
|
||||
context.getImageData(0, 0, 1, 1)
|
||||
return true
|
||||
}
|
||||
catch { return false }
|
||||
})).toBe(true)
|
||||
await testInfo.attach('native-canvas-reset-control', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => {
|
||||
const context = window.ambilightProbe
|
||||
context.canvas.width = 3
|
||||
try {
|
||||
context.drawImage(window.art.template.$video, 0, 0, 1, 1)
|
||||
context.getImageData(0, 0, 1, 1)
|
||||
return { resetReadable: true }
|
||||
}
|
||||
catch (error) { return { resetReadable: false, error: error.name } }
|
||||
})) })
|
||||
await expect.poll(async () => (await colors(page)).filter(color => /^rgb\(/.test(color)).length).toBe(9)
|
||||
expect(await page.evaluate(() => window.ambilightOriginalGrid === document.querySelector('.artplayer-plugin-ambilight'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(1)
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vm from 'node:vm'
|
||||
import { transform } from 'esbuild'
|
||||
import { build, transform } from 'esbuild'
|
||||
import { verifyAmbilightContract } from '../../refactor/scripts/ambilight-contract.mjs'
|
||||
import { readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { getEntryFile } from '../../scripts/projects.js'
|
||||
|
||||
export async function ambilightCandidate() {
|
||||
if (process.env.ARTPLAYER_AMBILIGHT_BASELINE === '1')
|
||||
return (await ambilightHistorical()).find(item => item.name === 'frozen-workspace')
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const result = await build({ entryPoints: [getEntryFile(path.join(root, 'packages/artplayer-plugin-ambilight'))], bundle: true, write: false, platform: 'browser', format: 'cjs', target: 'es2020' })
|
||||
return { name: 'candidate-source', source: result.outputFiles[0].text, format: 'artifact' }
|
||||
}
|
||||
|
||||
export async function ambilightHistorical() {
|
||||
const contract = await verifyAmbilightContract()
|
||||
@@ -64,7 +76,10 @@ export async function ambilightEnvironment(implementation, settings = {}) {
|
||||
return child
|
||||
},
|
||||
remove() { this.parentNode?.removeChild(this) },
|
||||
getContext: () => context,
|
||||
getContext() {
|
||||
settings.onContext?.()
|
||||
return context
|
||||
},
|
||||
}
|
||||
nodes.push(value)
|
||||
return value
|
||||
|
||||
Reference in new issue
Block a user