From 6caee44dbf23073b5b307fe083633172679a4e07 Mon Sep 17 00:00:00 2001 From: Harvey Zhao Date: Sat, 12 Sep 2026 11:35:13 +0800 Subject: [PATCH] refactor(ambilight): [PKG-AMBILIGHT-03] split typed lifecycle and recover native canvas sampling --- docs/compiled/artplayer-plugin-ambilight.js | 2 +- .../artplayer-plugin-ambilight.legacy.js | 2 +- docs/compiled/artplayer-plugin-ambilight.mjs | 376 ++++++++++++++---- package.json | 4 +- .../ARCHITECTURE.md | 46 +++ packages/artplayer-plugin-ambilight/README.md | 7 + .../dist/artplayer-plugin-ambilight.js | 2 +- .../dist/artplayer-plugin-ambilight.legacy.js | 2 +- .../dist/artplayer-plugin-ambilight.mjs | 376 ++++++++++++++---- .../artplayer-plugin-ambilight/src/index.js | 115 ------ .../artplayer-plugin-ambilight/src/index.ts | 125 ++++++ .../artplayer-plugin-ambilight/src/sampler.ts | 80 ++++ .../src/scheduler.ts | 74 ++++ .../artplayer-plugin-ambilight/src/view.ts | 49 +++ .../artplayer-plugin-ambilight/tsconfig.json | 5 + refactor/ambilight-validation.md | 24 +- .../ambilight-lifecycle-validation.json | 196 +++++++++ .../2026-09-12-PKG-AMBILIGHT-03-lifecycle.md | 44 ++ refactor/plan.md | 5 +- refactor/progress.md | 11 + refactor/risks.json | 6 +- refactor/tasks.json | 8 +- test/ambilight-lifecycle.test.js | 294 ++++++++++++++ test/browser/ambilight-lifecycle.spec.js | 114 ++++++ test/helpers/ambilight.js | 19 +- 25 files changed, 1680 insertions(+), 306 deletions(-) create mode 100644 packages/artplayer-plugin-ambilight/ARCHITECTURE.md delete mode 100644 packages/artplayer-plugin-ambilight/src/index.js create mode 100644 packages/artplayer-plugin-ambilight/src/index.ts create mode 100644 packages/artplayer-plugin-ambilight/src/sampler.ts create mode 100644 packages/artplayer-plugin-ambilight/src/scheduler.ts create mode 100644 packages/artplayer-plugin-ambilight/src/view.ts create mode 100644 packages/artplayer-plugin-ambilight/tsconfig.json create mode 100644 refactor/baselines/ambilight-lifecycle-validation.json create mode 100644 refactor/changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md create mode 100644 test/ambilight-lifecycle.test.js create mode 100644 test/browser/ambilight-lifecycle.spec.js diff --git a/docs/compiled/artplayer-plugin-ambilight.js b/docs/compiled/artplayer-plugin-ambilight.js index a55f58bc3..8356fa320 100644 --- a/docs/compiled/artplayer-plugin-ambilight.js +++ b/docs/compiled/artplayer-plugin-ambilight.js @@ -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("
").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("
").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!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}}}); diff --git a/docs/compiled/artplayer-plugin-ambilight.legacy.js b/docs/compiled/artplayer-plugin-ambilight.legacy.js index a55f58bc3..c6c89eca5 100644 --- a/docs/compiled/artplayer-plugin-ambilight.legacy.js +++ b/docs/compiled/artplayer-plugin-ambilight.legacy.js @@ -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("
").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("
").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!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}}}); diff --git a/docs/compiled/artplayer-plugin-ambilight.mjs b/docs/compiled/artplayer-plugin-ambilight.mjs index 0a79f243c..15089cf5d 100644 --- a/docs/compiled/artplayer-plugin-ambilight.mjs +++ b/docs/compiled/artplayer-plugin-ambilight.mjs @@ -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("
").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("
").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 { diff --git a/package.json b/package.json index 4cca802c8..09621b2db 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/artplayer-plugin-ambilight/ARCHITECTURE.md b/packages/artplayer-plugin-ambilight/ARCHITECTURE.md new file mode 100644 index 000000000..88becd27a --- /dev/null +++ b/packages/artplayer-plugin-ambilight/ARCHITECTURE.md @@ -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`. diff --git a/packages/artplayer-plugin-ambilight/README.md b/packages/artplayer-plugin-ambilight/README.md index 57e318bae..d8be996ba 100644 --- a/packages/artplayer-plugin-ambilight/README.md +++ b/packages/artplayer-plugin-ambilight/README.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) diff --git a/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.js b/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.js index a55f58bc3..8356fa320 100644 --- a/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.js +++ b/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.js @@ -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("
").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("
").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!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}}}); diff --git a/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.legacy.js b/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.legacy.js index a55f58bc3..c6c89eca5 100644 --- a/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.legacy.js +++ b/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.legacy.js @@ -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("
").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("
").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!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}}}); diff --git a/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.mjs b/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.mjs index 0a79f243c..15089cf5d 100644 --- a/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.mjs +++ b/packages/artplayer-plugin-ambilight/dist/artplayer-plugin-ambilight.mjs @@ -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("
").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("
").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 { diff --git a/packages/artplayer-plugin-ambilight/src/index.js b/packages/artplayer-plugin-ambilight/src/index.js deleted file mode 100644 index 0e0afb19c..000000000 --- a/packages/artplayer-plugin-ambilight/src/index.js +++ /dev/null @@ -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('
').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) - } - } - } -} diff --git a/packages/artplayer-plugin-ambilight/src/index.ts b/packages/artplayer-plugin-ambilight/src/index.ts new file mode 100644 index 000000000..8c4bad1af --- /dev/null +++ b/packages/artplayer-plugin-ambilight/src/index.ts @@ -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[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 | null = null + let sampler: ReturnType | null = null + let loop: ReturnType | 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 + } +} diff --git a/packages/artplayer-plugin-ambilight/src/sampler.ts b/packages/artplayer-plugin-ambilight/src/sampler.ts new file mode 100644 index 000000000..84cd0159f --- /dev/null +++ b/packages/artplayer-plugin-ambilight/src/sampler.ts @@ -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 } +} diff --git a/packages/artplayer-plugin-ambilight/src/scheduler.ts b/packages/artplayer-plugin-ambilight/src/scheduler.ts new file mode 100644 index 000000000..3f6e4bef9 --- /dev/null +++ b/packages/artplayer-plugin-ambilight/src/scheduler.ts @@ -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 } +} diff --git a/packages/artplayer-plugin-ambilight/src/view.ts b/packages/artplayer-plugin-ambilight/src/view.ts new file mode 100644 index 000000000..baf4c58ad --- /dev/null +++ b/packages/artplayer-plugin-ambilight/src/view.ts @@ -0,0 +1,49 @@ +import type Artplayer from 'artplayer' + +type Utilities = Pick & { + // 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('
').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 } +} diff --git a/packages/artplayer-plugin-ambilight/tsconfig.json b/packages/artplayer-plugin-ambilight/tsconfig.json new file mode 100644 index 000000000..285047f94 --- /dev/null +++ b/packages/artplayer-plugin-ambilight/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "allowJs": false }, + "include": ["src/**/*.ts", "types/**/*.d.ts"] +} diff --git a/refactor/ambilight-validation.md b/refactor/ambilight-validation.md index 6d688eaf3..8c45920d6 100644 --- a/refactor/ambilight-validation.md +++ b/refactor/ambilight-validation.md @@ -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仍未完成;三浏览器通过不能代替这些验收。 diff --git a/refactor/baselines/ambilight-lifecycle-validation.json b/refactor/baselines/ambilight-lifecycle-validation.json new file mode 100644 index 000000000..a52acba31 --- /dev/null +++ b/refactor/baselines/ambilight-lifecycle-validation.json @@ -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." + ] +} diff --git a/refactor/changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md b/refactor/changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md new file mode 100644 index 000000000..83c38d1a4 --- /dev/null +++ b/refactor/changes/2026-09-12-PKG-AMBILIGHT-03-lifecycle.md @@ -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初始化兼容选择仍是独立待决项。 diff --git a/refactor/plan.md b/refactor/plan.md index 21a37bb50..6d82b8872 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -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
核对包契约与历史用法 | BASE-05 | blur/opacity/frequency/duration、start/stop 与挂载样式 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | M | done | | PKG-AMBILIGHT-02 | artplayer-plugin-ambilight
建立特有行为与错误测试 | PKG-AMBILIGHT-01, ENG-03, ENG-05 | 跨域 canvas 失败、零尺寸、暂停、重复 start/stop 和 destroy | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | done | -| PKG-AMBILIGHT-03 | artplayer-plugin-ambilight
整理内部职责与资源 | PKG-AMBILIGHT-02, CORE-03, CORE-12 | 取色计算与绘帧分离,统一 RAF 和 DOM 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | todo | +| PKG-AMBILIGHT-03 | artplayer-plugin-ambilight
整理内部职责与资源 | PKG-AMBILIGHT-02, CORE-03, CORE-12 | 取色计算与绘帧分离,统一 RAF 和 DOM 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | done | | PKG-AMBILIGHT-04 | artplayer-plugin-ambilight
迁移自有源码和公开类型 | PKG-AMBILIGHT-03, ENG-04, ENG-06, CORE-07 | canvas 上下文、参数及 start/stop 的明确类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | M | todo | | PKG-AMBILIGHT-05 | artplayer-plugin-ambilight
验证新旧核心和组合 | PKG-AMBILIGHT-04, CORE-22 | 原生 video、canvas proxy 的能力边界及销毁无帧循环 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo | | PKG-AMBILIGHT-06 | artplayer-plugin-ambilight
验证分发并同步文档 | 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) diff --git a/refactor/progress.md b/refactor/progress.md index f9f68e894..8f00dd9f7 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -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项三引擎/ diff --git a/refactor/risks.json b/refactor/risks.json index 363d0aedf..459b5ad20 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -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", diff --git a/refactor/tasks.json b/refactor/tasks.json index a93502c18..19ffa1765 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -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", diff --git a/test/ambilight-lifecycle.test.js b/test/ambilight-lifecycle.test.js new file mode 100644 index 000000000..03a7e299b --- /dev/null +++ b/test/ambilight-lifecycle.test.js @@ -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() + } +}) diff --git a/test/browser/ambilight-lifecycle.spec.js b/test/browser/ambilight-lifecycle.spec.js new file mode 100644 index 000000000..086e68fc7 --- /dev/null +++ b/test/browser/ambilight-lifecycle.spec.js @@ -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) + }) +} diff --git a/test/helpers/ambilight.js b/test/helpers/ambilight.js index 875b7960a..8f2b0b28e 100644 --- a/test/helpers/ambilight.js +++ b/test/helpers/ambilight.js @@ -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