refactor(ambilight): [PKG-AMBILIGHT-03] split typed lifecycle and recover native canvas sampling

This commit is contained in:
Harvey Zhao committed 2026-09-12 11:35:13 +08:00
1 parent 43f0e9a8c6
commit 6caee44dbf
25 files changed
+1680 -306

No files matched your search

+1 -1
View File
@@ -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}}});
+288 -88
View File
@@ -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
View File
@@ -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}}});
@@ -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"]
}
+23 -1
View File
@@ -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
View File
@@ -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)
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+294
View File
@@ -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()
}
})
+114
View File
@@ -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)
})
}
+17 -2
View File
@@ -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