diff --git a/docs/compiled/artplayer-proxy-canvas.js b/docs/compiled/artplayer-proxy-canvas.js index a033c5dc5..0db3ddc76 100644 --- a/docs/compiled/artplayer-proxy-canvas.js +++ b/docs/compiled/artplayer-proxy-canvas.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.artplayerProxyCanvas=t(),define(function(){return t.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";return function(e){return t=>{const{constructor:n}=t,{createElement:i}=n.utils,o=i("canvas"),a=i("video"),r=o.getContext("2d");let c=null;const d={};for(const e in o)"function"===typeof o[e]&&(d[e]=o[e].bind(o));for(const e in a)e in o||Object.defineProperty(o,e,{get(){const t=a[e];return"function"===typeof t?t.bind(a):t},set(t){a[e]=t},configurable:!0,enumerable:!0});for(const e in d)o[e]=function(...t){return e in d?d[e](...t):a[e](...t)};return function(){const{events:e}=n.config;setTimeout(()=>{e.forEach(e=>{t.proxy(a,e,e=>{t.emit(`video:${e.type}`,e)})})})}(),t.on("video:loadedmetadata",()=>{o.width=a.videoWidth,o.height=a.videoHeight}),t.on("video:play",()=>{cancelAnimationFrame(c),f()}),t.on("video:pause",()=>{cancelAnimationFrame(c)}),t.on("resize",()=>{!function(){const e=t.template?.$player;if(!e||t.option.autoSize)return;const n=a.videoWidth/a.videoHeight,i=e.clientWidth,r=e.clientHeight;let c,d;i/r>n?(d=r,c=d*n):(c=i,d=c/n),o.width=c,o.height=d;const s=(i-c)/2,f=(r-d)/2;Object.assign(o.style,{padding:`${f}px ${s}px`})}(),s()}),t.on("destroy",()=>{cancelAnimationFrame(c)}),o;async function s(){try{if("undefined"!==typeof createImageBitmap){const e=await createImageBitmap(a);r.drawImage(e,0,0,o.width,o.height),e.close()}else r.drawImage(a,0,0,o.width,o.height);e&&e(r,a),t.emit("artplayerProxyCanvas:draw",r,a)}catch(n){t.emit("artplayerProxyCanvas:error",n)}}async function f(){await s(),c=requestAnimationFrame(f)}}}}); +!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyCanvas=t(),define(function(){return e.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";function e(e){return Number.isFinite(e.videoWidth)&&Number.isFinite(e.videoHeight)&&e.videoWidth>0&&e.videoHeight>0}function t(e,t){let n=!1,i=!1,o=!1,r=!1,a=0,s=null;function l(){null!==s&&cancelAnimationFrame(s),s=null}function c(){if(!o&&!i&&t())if(r){r=!1,o=!0;const n=a,s=()=>{o=!1,c()};e(()=>!i&&t()&&a===n).then(s,s)}else n&&null===s&&(s=requestAnimationFrame(()=>{s=null,r=!0,c()}))}function u(){!i&&t()&&(a++,r=!0,l(),c())}function f(){n=!1,r=!1,a++,l()}return{request:u,start(){n||i||!t()||(n=!0,u())},stop:f,destroy(){i=!0,f()}}}return function(n){return i=>{const o=i.constructor,r=o.utils.createElement("canvas"),a=o.utils.createElement("video");let s=i,l=i.isDestroy,c=null;const u=[],f=()=>!l&&!s?.isDestroy,d=function(e,t,n){let i=!1;return{mount(){if(i||!n())return;const o=t();o&&(e.setAttribute("aria-hidden","true"),e.tabIndex=-1,e.playsInline=!0,Object.assign(e.style,{position:"absolute",width:"auto",height:"auto",opacity:"0",pointerEvents:"none",left:"0",top:"0"}),o.appendChild(e),i=!0,n()||e.remove())},destroy(){const t=[],n=()=>{e.srcObject=null};for(const i of[()=>e.pause(),()=>e.removeAttribute("src"),n,()=>e.load(),()=>e.remove()])try{i()}catch(o){t.push(o)}if(i=!1,t.length)throw t[0]}}}(a,()=>s?.template?.$player,f),p=function(t,n,i,o,r){const a=t.getContext("2d");let s=!1;return{available:()=>!!a||(s||(s=!0,r(new Error("Canvas 2D context is unavailable"))),!1),async draw(s){const l=()=>s()&&n.readyState>=2&&!n.seeking;if(!a||!l()||!e(n)||!t.width||!t.height)return;let c=!1;try{if("undefined"!==typeof createImageBitmap){c=!0;const e=await createImageBitmap(n);c=!1;try{l()&&a.drawImage(e,0,0,t.width,t.height)}finally{e.close()}}else a.drawImage(n,0,0,t.width,t.height);if(!l())return;i&&i(a,n),l()&&o(a,n)}catch(u){const e=c&&"object"===typeof u&&null!==u&&"name"in u&&"InvalidStateError"===u.name&&0===n.currentTime&&"function"===typeof n.getVideoPlaybackQuality&&0===n.getVideoPlaybackQuality().totalVideoFrames;l()&&!e&&r(u)}}}}(r,a,n,(e,t)=>s?.emit("artplayerProxyCanvas:draw",e,t),e=>s?.emit("artplayerProxyCanvas:error",e)),h=t(p.draw,f);if(function(e,t,n,i,o){const r=new Map;for(const a in e){const t=Reflect.get(e,a);"function"===typeof t&&r.set(a,(...n)=>Reflect.apply(t,e,n))}for(const a in t)a in e||Object.defineProperty(e,a,{get(){const e=Reflect.get(t,a);return"function"!==typeof e?e:(...r)=>n()?("load"===a&&i(),"play"===a&&o(),Reflect.apply(e,t,r)):"play"===a?Promise.resolve():void 0},set(e){n()&&("src"!==a&&"srcObject"!==a||i(),Reflect.set(t,a,e))},configurable:!0,enumerable:!0});for(const[a,s]of r)Reflect.set(e,a,s)}(r,a,f,h.stop,d.mount),l)return s=null,r;function y(){if(l)return;l=!0,h.destroy(),null!==c&&clearTimeout(c),c=null;const e=[];for(const n of[...u.splice(0).reverse(),d.destroy])try{n()}catch(t){e.push(t)}if(s=null,r.width=0,r.height=0,e.length)throw e[0]}function m(e,t){if(!f())return;const n=()=>{("destroy"===e||f())&&t()},o=()=>i.off(e,n);u.push(o),i.on(e,n),l&&o()}function v(){p.available()&&h.request()}try{m("destroy",y),m("video:loadedmetadata",()=>{e(a)&&(r.width=a.videoWidth,r.height=a.videoHeight)}),m("video:play",()=>{d.mount(),p.available()&&h.start()}),m("video:pause",h.stop),m("video:emptied",h.stop),m("video:seeked",v),m("resize",()=>{!function(t,n,i,o){if(!i||o||!e(n))return;const r=i.clientWidth,a=i.clientHeight;if(!Number.isFinite(r)||!Number.isFinite(a)||r<=0||a<=0)return;const s=n.videoWidth/n.videoHeight,l=r/a>s?a*s:r,c=r/a>s?a:r/s;t.width=l,t.height=c,t.style.padding=`${(a-c)/2}px ${(r-l)/2}px`}(r,a,s?.template?.$player,s?.option.autoSize),v()}),f()&&(c=setTimeout(()=>{if(c=null,f())try{d.mount();for(const e of o.config.events){if(!f())break;const t=e=>{f()&&s?.emit(`video:${e.type}`,e)},n=()=>a.removeEventListener(e,t);u.push(n);const o=i.proxy(a,e,t);f()?u.push(o):o()}}catch(e){try{y()}catch{}i.emit("artplayerProxyCanvas:error",e)}}))}catch(g){try{y()}catch{}throw g}return r}}}); diff --git a/docs/compiled/artplayer-proxy-canvas.legacy.js b/docs/compiled/artplayer-proxy-canvas.legacy.js index cb5cbd9e7..4097740d7 100644 --- a/docs/compiled/artplayer-proxy-canvas.legacy.js +++ b/docs/compiled/artplayer-proxy-canvas.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.artplayerProxyCanvas=t(),define(function(){return t.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";var e=(e,t,n)=>new Promise((i,o)=>{var r=e=>{try{c(n.next(e))}catch(t){o(t)}},a=e=>{try{c(n.throw(e))}catch(t){o(t)}},c=e=>e.done?i(e.value):Promise.resolve(e.value).then(r,a);c((n=n.apply(e,t)).next())});return function(t){return n=>{const{constructor:i}=n,{createElement:o}=i.utils,r=o("canvas"),a=o("video"),c=r.getContext("2d");let d=null;const l={};for(const e in r)"function"===typeof r[e]&&(l[e]=r[e].bind(r));for(const e in a)e in r||Object.defineProperty(r,e,{get(){const t=a[e];return"function"===typeof t?t.bind(a):t},set(t){a[e]=t},configurable:!0,enumerable:!0});for(const e in l)r[e]=function(...t){return e in l?l[e](...t):a[e](...t)};return function(){const{events:e}=i.config;setTimeout(()=>{e.forEach(e=>{n.proxy(a,e,e=>{n.emit(`video:${e.type}`,e)})})})}(),n.on("video:loadedmetadata",()=>{r.width=a.videoWidth,r.height=a.videoHeight}),n.on("video:play",()=>{cancelAnimationFrame(d),u()}),n.on("video:pause",()=>{cancelAnimationFrame(d)}),n.on("resize",()=>{!function(){var e;const t=null==(e=n.template)?void 0:e.$player;if(!t||n.option.autoSize)return;const i=a.videoWidth/a.videoHeight,o=t.clientWidth,c=t.clientHeight;let d,l;o/c>i?(l=c,d=l*i):(d=o,l=d/i),r.width=d,r.height=l;const s=(o-d)/2,u=(c-l)/2;Object.assign(r.style,{padding:`${u}px ${s}px`})}(),s()}),n.on("destroy",()=>{cancelAnimationFrame(d)}),r;function s(){return e(this,null,function*(){try{if("undefined"!==typeof createImageBitmap){const e=yield createImageBitmap(a);c.drawImage(e,0,0,r.width,r.height),e.close()}else c.drawImage(a,0,0,r.width,r.height);t&&t(c,a),n.emit("artplayerProxyCanvas:draw",c,a)}catch(e){n.emit("artplayerProxyCanvas:error",e)}})}function u(){return e(this,null,function*(){yield s(),d=requestAnimationFrame(u)})}}}}); +!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyCanvas=t(),define(function(){return e.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";function e(e){return Number.isFinite(e.videoWidth)&&Number.isFinite(e.videoHeight)&&e.videoWidth>0&&e.videoHeight>0}function t(t,n,i,o,r){const l=t.getContext("2d");let a=!1;return{available:()=>!!l||(a||(a=!0,r(new Error("Canvas 2D context is unavailable"))),!1),draw(a){return s=this,u=null,c=function*(){const s=()=>a()&&n.readyState>=2&&!n.seeking;if(!l||!s()||!e(n)||!t.width||!t.height)return;let u=!1;try{if("undefined"!==typeof createImageBitmap){u=!0;const e=yield createImageBitmap(n);u=!1;try{s()&&l.drawImage(e,0,0,t.width,t.height)}finally{e.close()}}else l.drawImage(n,0,0,t.width,t.height);if(!s())return;i&&i(l,n),s()&&o(l,n)}catch(c){const e=u&&"object"===typeof c&&null!==c&&"name"in c&&"InvalidStateError"===c.name&&0===n.currentTime&&"function"===typeof n.getVideoPlaybackQuality&&0===n.getVideoPlaybackQuality().totalVideoFrames;s()&&!e&&r(c)}},new Promise((e,t)=>{var n=e=>{try{o(c.next(e))}catch(n){t(n)}},i=e=>{try{o(c.throw(e))}catch(n){t(n)}},o=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,i);o((c=c.apply(s,u)).next())});var s,u,c}}}function n(e,t){let n=!1,i=!1,o=!1,r=!1,l=0,a=null;function s(){null!==a&&cancelAnimationFrame(a),a=null}function u(){if(!o&&!i&&t())if(r){r=!1,o=!0;const n=l,a=()=>{o=!1,u()};e(()=>!i&&t()&&l===n).then(a,a)}else n&&null===a&&(a=requestAnimationFrame(()=>{a=null,r=!0,u()}))}function c(){!i&&t()&&(l++,r=!0,s(),u())}function d(){n=!1,r=!1,l++,s()}return{request:c,start(){n||i||!t()||(n=!0,c())},stop:d,destroy(){i=!0,d()}}}return function(i){return o=>{const r=o.constructor,l=r.utils.createElement("canvas"),a=r.utils.createElement("video");let s=o,u=o.isDestroy,c=null;const d=[],f=()=>!u&&!(null==s?void 0:s.isDestroy),h=function(e,t,n){let i=!1;return{mount(){if(i||!n())return;const o=t();o&&(e.setAttribute("aria-hidden","true"),e.tabIndex=-1,e.playsInline=!0,Object.assign(e.style,{position:"absolute",width:"auto",height:"auto",opacity:"0",pointerEvents:"none",left:"0",top:"0"}),o.appendChild(e),i=!0,n()||e.remove())},destroy(){const t=[],n=()=>{e.srcObject=null};for(const i of[()=>e.pause(),()=>e.removeAttribute("src"),n,()=>e.load(),()=>e.remove()])try{i()}catch(o){t.push(o)}if(i=!1,t.length)throw t[0]}}}(a,()=>{var e;return null==(e=null==s?void 0:s.template)?void 0:e.$player},f),p=t(l,a,i,(e,t)=>null==s?void 0:s.emit("artplayerProxyCanvas:draw",e,t),e=>null==s?void 0:s.emit("artplayerProxyCanvas:error",e)),y=n(p.draw,f);if(function(e,t,n,i,o){const r=new Map;for(const l in e){const t=Reflect.get(e,l);"function"===typeof t&&r.set(l,(...n)=>Reflect.apply(t,e,n))}for(const l in t)l in e||Object.defineProperty(e,l,{get(){const e=Reflect.get(t,l);return"function"!==typeof e?e:(...r)=>n()?("load"===l&&i(),"play"===l&&o(),Reflect.apply(e,t,r)):"play"===l?Promise.resolve():void 0},set(e){n()&&("src"!==l&&"srcObject"!==l||i(),Reflect.set(t,l,e))},configurable:!0,enumerable:!0});for(const[l,a]of r)Reflect.set(e,l,a)}(l,a,f,y.stop,h.mount),u)return s=null,l;function v(){if(u)return;u=!0,y.destroy(),null!==c&&clearTimeout(c),c=null;const e=[];for(const n of[...d.splice(0).reverse(),h.destroy])try{n()}catch(t){e.push(t)}if(s=null,l.width=0,l.height=0,e.length)throw e[0]}function m(e,t){if(!f())return;const n=()=>{("destroy"===e||f())&&t()},i=()=>o.off(e,n);d.push(i),o.on(e,n),u&&i()}function g(){p.available()&&y.request()}try{m("destroy",v),m("video:loadedmetadata",()=>{e(a)&&(l.width=a.videoWidth,l.height=a.videoHeight)}),m("video:play",()=>{h.mount(),p.available()&&y.start()}),m("video:pause",y.stop),m("video:emptied",y.stop),m("video:seeked",g),m("resize",()=>{var t;!function(t,n,i,o){if(!i||o||!e(n))return;const r=i.clientWidth,l=i.clientHeight;if(!Number.isFinite(r)||!Number.isFinite(l)||r<=0||l<=0)return;const a=n.videoWidth/n.videoHeight,s=r/l>a?l*a:r,u=r/l>a?l:r/a;t.width=s,t.height=u,t.style.padding=`${(l-u)/2}px ${(r-s)/2}px`}(l,a,null==(t=null==s?void 0:s.template)?void 0:t.$player,null==s?void 0:s.option.autoSize),g()}),f()&&(c=setTimeout(()=>{if(c=null,f())try{h.mount();for(const e of r.config.events){if(!f())break;const t=e=>{f()&&(null==s||s.emit(`video:${e.type}`,e))},n=()=>a.removeEventListener(e,t);d.push(n);const i=o.proxy(a,e,t);f()?d.push(i):i()}}catch(e){try{v()}catch(t){}o.emit("artplayerProxyCanvas:error",e)}}))}catch(b){try{v()}catch(w){}throw b}return l}}}); diff --git a/docs/compiled/artplayer-proxy-canvas.mjs b/docs/compiled/artplayer-proxy-canvas.mjs index 705b3ca71..edce867ba 100644 --- a/docs/compiled/artplayer-proxy-canvas.mjs +++ b/docs/compiled/artplayer-proxy-canvas.mjs @@ -4,119 +4,322 @@ * (c) 2017-2026 Harvey Zhao * Released under the MIT License. */ -function artplayerProxyCanvas(callback) { - return (art) => { - const { constructor } = art; - const { createElement } = constructor.utils; - const canvas = createElement("canvas"); - const video = createElement("video"); - const ctx = canvas.getContext("2d"); - let animationId = null; - const originalCanvasMethods = {}; - for (const prop in canvas) { - if (typeof canvas[prop] === "function") { - originalCanvasMethods[prop] = canvas[prop].bind(canvas); - } - } - for (const prop in video) { - if (!(prop in canvas)) { - Object.defineProperty(canvas, prop, { - get() { - const value = video[prop]; - return typeof value === "function" ? value.bind(video) : value; - }, - set(value) { - video[prop] = value; - }, - configurable: true, - enumerable: true - }); - } - } - for (const prop in originalCanvasMethods) { - canvas[prop] = function(...args) { - if (prop in originalCanvasMethods) { - return originalCanvasMethods[prop](...args); - } - return video[prop](...args); +function forwardMedia(canvas, video, active, invalidate, mount) { + const methods = /* @__PURE__ */ new Map(); + for (const name in canvas) { + const value = Reflect.get(canvas, name); + if (typeof value === "function") + methods.set(name, (...args) => Reflect.apply(value, canvas, args)); + } + for (const name in video) { + if (name in canvas) + continue; + Object.defineProperty(canvas, name, { + get() { + const value = Reflect.get(video, name); + if (typeof value !== "function") + return value; + return (...args) => { + if (!active()) + return name === "play" ? Promise.resolve() : void 0; + if (name === "load") + invalidate(); + if (name === "play") + mount(); + return Reflect.apply(value, video, args); + }; + }, + set(value) { + if (!active()) + return; + if (name === "src" || name === "srcObject") + invalidate(); + Reflect.set(video, name, value); + }, + configurable: true, + enumerable: true + }); + } + for (const [name, method] of methods) + Reflect.set(canvas, name, method); +} +function hasDimensions(video) { + return Number.isFinite(video.videoWidth) && Number.isFinite(video.videoHeight) && video.videoWidth > 0 && video.videoHeight > 0; +} +function resizeCanvas(canvas, video, player, autoSize) { + if (!player || autoSize || !hasDimensions(video)) + return; + const width = player.clientWidth; + const height = player.clientHeight; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) + return; + const aspect = video.videoWidth / video.videoHeight; + const canvasWidth = width / height > aspect ? height * aspect : width; + const canvasHeight = width / height > aspect ? height : width / aspect; + canvas.width = canvasWidth; + canvas.height = canvasHeight; + canvas.style.padding = `${(height - canvasHeight) / 2}px ${(width - canvasWidth) / 2}px`; +} +function ownMedia(video, parent, active) { + let mounted = false; + return { + mount() { + if (mounted || !active()) + return; + const target = parent(); + if (!target) + return; + video.setAttribute("aria-hidden", "true"); + video.tabIndex = -1; + video.playsInline = true; + Object.assign(video.style, { position: "absolute", width: "auto", height: "auto", opacity: "0", pointerEvents: "none", left: "0", top: "0" }); + target.appendChild(video); + mounted = true; + if (!active()) + video.remove(); + }, + destroy() { + const failures = []; + const clearObject = () => { + video.srcObject = null; }; + for (const release of [() => video.pause(), () => video.removeAttribute("src"), clearObject, () => video.load(), () => video.remove()]) { + try { + release(); + } catch (error) { + failures.push(error); + } + } + mounted = false; + if (failures.length) + throw failures[0]; } - setupEventListeners(); - setupArtPlayerEvents(); - return canvas; - function setupEventListeners() { - const { events } = constructor.config; - setTimeout(() => { - events.forEach((event) => { - art.proxy(video, event, (event2) => { - art.emit(`video:${event2.type}`, event2); - }); - }); - }); - } - async function draw() { + }; +} +function createRenderer(canvas, video, callback, notify, error) { + const context = canvas.getContext("2d"); + let reported = false; + return { + available() { + if (context) + return true; + if (!reported) { + reported = true; + error(new Error("Canvas 2D context is unavailable")); + } + return false; + }, + async draw(valid) { + const usable = () => valid() && video.readyState >= 2 && !video.seeking; + if (!context || !usable() || !hasDimensions(video) || !canvas.width || !canvas.height) + return; + let acquiring = false; try { if (typeof createImageBitmap !== "undefined") { + acquiring = true; const bitmap = await createImageBitmap(video); - ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); - bitmap.close(); + acquiring = false; + try { + if (usable()) + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + } finally { + bitmap.close(); + } } else { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + context.drawImage(video, 0, 0, canvas.width, canvas.height); } - callback && callback(ctx, video); - art.emit("artplayerProxyCanvas:draw", ctx, video); - } catch (error) { - art.emit("artplayerProxyCanvas:error", error); + if (!usable()) + return; + if (callback) + callback(context, video); + if (usable()) + notify(context, video); + } catch (failure) { + const awaitingFirstFrame = acquiring && typeof failure === "object" && failure !== null && "name" in failure && failure.name === "InvalidStateError" && video.currentTime === 0 && typeof video.getVideoPlaybackQuality === "function" && video.getVideoPlaybackQuality().totalVideoFrames === 0; + if (usable() && !awaitingFirstFrame) + error(failure); } } - async function animation() { - await draw(); - animationId = requestAnimationFrame(animation); + }; +} +function createFrameScheduler(draw, active) { + let running = false; + let closed = false; + let busy = false; + let pending = false; + let generation = 0; + let frame = null; + function cancel() { + if (frame !== null) + cancelAnimationFrame(frame); + frame = null; + } + function drain() { + if (busy || closed || !active()) + return; + if (pending) { + pending = false; + busy = true; + const version = generation; + const valid = () => !closed && active() && generation === version; + const complete = () => { + busy = false; + drain(); + }; + void draw(valid).then(complete, complete); + } else if (running && frame === null) { + frame = requestAnimationFrame(() => { + frame = null; + pending = true; + drain(); + }); } - function resize() { - const player = art.template?.$player; - if (!player || art.option.autoSize) + } + function request() { + if (closed || !active()) + return; + generation++; + pending = true; + cancel(); + drain(); + } + function stop() { + running = false; + pending = false; + generation++; + cancel(); + } + return { + request, + start() { + if (running || closed || !active()) return; - const aspectRatio = video.videoWidth / video.videoHeight; - const containerWidth = player.clientWidth; - const containerHeight = player.clientHeight; - const containerRatio = containerWidth / containerHeight; - let canvasWidth, canvasHeight; - if (containerRatio > aspectRatio) { - canvasHeight = containerHeight; - canvasWidth = canvasHeight * aspectRatio; - } else { - canvasWidth = containerWidth; - canvasHeight = canvasWidth / aspectRatio; + running = true; + request(); + }, + stop, + destroy() { + closed = true; + stop(); + } + }; +} +function artplayerProxyCanvas(callback) { + return (art) => { + const constructor = art.constructor; + const canvas = constructor.utils.createElement("canvas"); + const video = constructor.utils.createElement("video"); + let host = art; + let closed = art.isDestroy; + let timer = null; + const releases = []; + const active = () => !closed && !host?.isDestroy; + const media = ownMedia(video, () => host?.template?.$player, active); + const renderer = createRenderer(canvas, video, callback, (context, source) => host?.emit("artplayerProxyCanvas:draw", context, source), (error) => host?.emit("artplayerProxyCanvas:error", error)); + const scheduler = createFrameScheduler(renderer.draw, active); + forwardMedia(canvas, video, active, scheduler.stop, media.mount); + if (closed) { + host = null; + return canvas; + } + function destroy() { + if (closed) + return; + closed = true; + scheduler.destroy(); + if (timer !== null) + clearTimeout(timer); + timer = null; + const failures = []; + for (const release of [...releases.splice(0).reverse(), media.destroy]) { + try { + release(); + } catch (error) { + failures.push(error); + } } - canvas.width = canvasWidth; - canvas.height = canvasHeight; - const paddingLeft = (containerWidth - canvasWidth) / 2; - const paddingTop = (containerHeight - canvasHeight) / 2; - Object.assign(canvas.style, { - padding: `${paddingTop}px ${paddingLeft}px` - }); + host = null; + canvas.width = 0; + canvas.height = 0; + if (failures.length) + throw failures[0]; } - function setupArtPlayerEvents() { - art.on("video:loadedmetadata", () => { - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - }); - art.on("video:play", () => { - cancelAnimationFrame(animationId); - animation(); - }); - art.on("video:pause", () => { - cancelAnimationFrame(animationId); - }); - art.on("resize", () => { - resize(); - draw(); - }); - art.on("destroy", () => { - cancelAnimationFrame(animationId); - }); + function listen(name, handler) { + if (!active()) + return; + const listener = () => { + if (name === "destroy" || active()) + handler(); + }; + const release = () => art.off(name, listener); + releases.push(release); + art.on(name, listener); + if (closed) + release(); } + function request() { + if (renderer.available()) + scheduler.request(); + } + try { + listen("destroy", destroy); + listen("video:loadedmetadata", () => { + if (hasDimensions(video)) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + } + }); + listen("video:play", () => { + media.mount(); + if (renderer.available()) + scheduler.start(); + }); + listen("video:pause", scheduler.stop); + listen("video:emptied", scheduler.stop); + listen("video:seeked", request); + listen("resize", () => { + resizeCanvas(canvas, video, host?.template?.$player, host?.option.autoSize); + request(); + }); + if (active()) { + timer = setTimeout(() => { + timer = null; + if (!active()) + return; + try { + media.mount(); + for (const name of constructor.config.events) { + if (!active()) + break; + const forward = (event) => { + if (active()) + host?.emit(`video:${event.type}`, event); + }; + const remove = () => video.removeEventListener(name, forward); + releases.push(remove); + const release = art.proxy(video, name, forward); + if (active()) + releases.push(release); + else + release(); + } + } catch (error) { + try { + destroy(); + } catch { + } + art.emit("artplayerProxyCanvas:error", error); + } + }); + } + } catch (error) { + try { + destroy(); + } catch { + } + throw error; + } + return canvas; }; } export { diff --git a/package.json b/package.json index f6d663662..8256bb9dd 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/canvas.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.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/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.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", @@ -72,7 +72,7 @@ "test:contracts": "node refactor/scripts/run-contracts.mjs", "test:ambilight": "node --test test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js", "test:ambilight-types-package": "node refactor/scripts/ambilight-package-types.mjs", - "test:canvas": "node --test test/canvas.test.js" + "test:canvas": "node --test test/canvas.test.js test/canvas-lifecycle.test.js" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/packages/artplayer-proxy-canvas/.npmignore b/packages/artplayer-proxy-canvas/.npmignore index 4439c6874..2b272d565 100644 --- a/packages/artplayer-proxy-canvas/.npmignore +++ b/packages/artplayer-proxy-canvas/.npmignore @@ -2,4 +2,5 @@ node_modules .DS_Store yarn.lock package-lock.json -src \ No newline at end of file +src +tsconfig.json diff --git a/packages/artplayer-proxy-canvas/ARCHITECTURE.md b/packages/artplayer-proxy-canvas/ARCHITECTURE.md new file mode 100644 index 000000000..df0233b99 --- /dev/null +++ b/packages/artplayer-proxy-canvas/ARCHITECTURE.md @@ -0,0 +1,73 @@ +# Canvas proxy maintenance + +Public entry: `src/index.ts`; public declarations remain in +`types/artplayer-proxy-canvas.d.ts`. The factory accepts the optional drawing callback, +then synchronously returns an actual HTMLCanvasElement when registered with ArtPlayer. +Public declaration/export consumer work continues in PKG-CANVAS-04. +Historical JavaScript falsy callback arguments remain ignored at runtime; the typed public +callback remains optional and function-valued. + +| Module | Ownership and responsibility | +| --- | --- | +| index.ts | Host event subscriptions, deferred media forwarding, initialization rollback and terminal disposal | +| adapter.ts | Preserve native canvas methods; forward only video properties absent from canvas; guard escaped media actions after disposal | +| media.ts | Connect the backing video before playback and release its source, stream reference and DOM node | +| geometry.ts | Finite intrinsic dimensions and aspect-fit canvas/padding calculations | +| renderer.ts | Decoded-frame eligibility, bitmap acquisition/close, callback and draw/error notification order | +| scheduler.ts | One outstanding draw, coalesced requests, generations and nullable RAF ownership | + +Only the entry receives ArtPlayer. The renderer has no host/DOM ownership, the scheduler +has no media knowledge, and geometry is independent of scheduling. No package imports +private core implementation modules. + +## State and event order + +Canvas methods keep their receiver and win over same-named video methods. Media properties +remain live enumerable/configurable descriptors. Assigning src/srcObject or invoking load +invalidates pending frames; the returned canvas and callback arguments keep their identities. +The video is connected before play with absolute positioning, transparent opacity, no focus +or pointer interaction, and intrinsic CSS sizing. Do not change it to a 1px square: tested +WebKit then reports 1x1 media dimensions and loses the source geometry. + +Media forwarding remains deferred until core initialization has installed its event registry. +Native events are forwarded as `video:` with the original Event. Native removal is +owned before calling the extensible art.proxy registrar, so partial registration can roll back. +Host subscriptions are also owned before art.on, including reentrant destruction during setup. + +play starts one draw chain; duplicate play does not start another. resize coalesces a new +draw and invalidates an in-flight result. pause/source replacement invalidates pending work +and cancels the current RAF, including ID zero. Paused seek requests one fresh frame. +After bitmap acquisition, stale results are closed without drawing. Acquired bitmaps are +closed in finally even if drawImage throws, and before invoking the public callback. An +active callback failure emits the original error; a callback destroying the host cannot emit +a subsequent draw event or restart work. Observer failures cannot strand the scheduler busy. + +readyState alone does not prove a drawable frame. Seeking or insufficient data suspends +drawing. The recorded Chromium first-frame case rejects bitmap acquisition with +InvalidStateError at currentTime 0 and zero video frames even at readyState 4; that exact +acquisition condition is deferred until another request. A zero frame counter is not a +general capability check: tested WebKit draws successfully with that counter at zero. +Errors from user callbacks or drawImage are not classified as bitmap acquisition failures. + +destroy invalidates generations, clears the deferred timer, removes all host/native +subscriptions, pauses and unloads backing video, releases srcObject without stopping +consumer-owned tracks, removes the internal video and clears canvas buffers. Cleanup +attempts every resource even when one disposer throws. Captured play/load methods and +forwarded setters become inert. Native Canvas methods still operate on the same element. + +## Verification and remaining work + +`yarn test:canvas` runs frozen historical and candidate lifecycle tests. Candidate tests can +be run against the frozen workspace with ARTPLAYER_CANVAS_BASELINE=1. Historical inputs +come from verified archives/Git via `refactor/scripts/canvas-contract.mjs`, not current dist. + +`test/browser/canvas-lifecycle.spec.js` tests actual candidate source, native pixels and +dimensions, paused seek, source replacement, callback destruction and escaped methods on +actual npm core 5.4.0 and the candidate core in three engines. `ambilight-proxy.spec.js` +uses the candidate source loader after module splitting; its palette test proves sampling +geometry and is separate from native media pixel acceptance. npm core 5.1.7 has no proxy +option and is only a native-player capability control. + +Public type/import compatibility, full installed artifacts and 8082 demos remain 04/06. +Real Safari/mobile/device scope remains 05; Windows Playwright WebKit is not a device claim. +See `refactor/canvas-validation.md` and the task evidence for actual outcomes and limitations. diff --git a/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.js b/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.js index a033c5dc5..0db3ddc76 100644 --- a/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.js +++ b/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.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.artplayerProxyCanvas=t(),define(function(){return t.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";return function(e){return t=>{const{constructor:n}=t,{createElement:i}=n.utils,o=i("canvas"),a=i("video"),r=o.getContext("2d");let c=null;const d={};for(const e in o)"function"===typeof o[e]&&(d[e]=o[e].bind(o));for(const e in a)e in o||Object.defineProperty(o,e,{get(){const t=a[e];return"function"===typeof t?t.bind(a):t},set(t){a[e]=t},configurable:!0,enumerable:!0});for(const e in d)o[e]=function(...t){return e in d?d[e](...t):a[e](...t)};return function(){const{events:e}=n.config;setTimeout(()=>{e.forEach(e=>{t.proxy(a,e,e=>{t.emit(`video:${e.type}`,e)})})})}(),t.on("video:loadedmetadata",()=>{o.width=a.videoWidth,o.height=a.videoHeight}),t.on("video:play",()=>{cancelAnimationFrame(c),f()}),t.on("video:pause",()=>{cancelAnimationFrame(c)}),t.on("resize",()=>{!function(){const e=t.template?.$player;if(!e||t.option.autoSize)return;const n=a.videoWidth/a.videoHeight,i=e.clientWidth,r=e.clientHeight;let c,d;i/r>n?(d=r,c=d*n):(c=i,d=c/n),o.width=c,o.height=d;const s=(i-c)/2,f=(r-d)/2;Object.assign(o.style,{padding:`${f}px ${s}px`})}(),s()}),t.on("destroy",()=>{cancelAnimationFrame(c)}),o;async function s(){try{if("undefined"!==typeof createImageBitmap){const e=await createImageBitmap(a);r.drawImage(e,0,0,o.width,o.height),e.close()}else r.drawImage(a,0,0,o.width,o.height);e&&e(r,a),t.emit("artplayerProxyCanvas:draw",r,a)}catch(n){t.emit("artplayerProxyCanvas:error",n)}}async function f(){await s(),c=requestAnimationFrame(f)}}}}); +!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyCanvas=t(),define(function(){return e.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";function e(e){return Number.isFinite(e.videoWidth)&&Number.isFinite(e.videoHeight)&&e.videoWidth>0&&e.videoHeight>0}function t(e,t){let n=!1,i=!1,o=!1,r=!1,a=0,s=null;function l(){null!==s&&cancelAnimationFrame(s),s=null}function c(){if(!o&&!i&&t())if(r){r=!1,o=!0;const n=a,s=()=>{o=!1,c()};e(()=>!i&&t()&&a===n).then(s,s)}else n&&null===s&&(s=requestAnimationFrame(()=>{s=null,r=!0,c()}))}function u(){!i&&t()&&(a++,r=!0,l(),c())}function f(){n=!1,r=!1,a++,l()}return{request:u,start(){n||i||!t()||(n=!0,u())},stop:f,destroy(){i=!0,f()}}}return function(n){return i=>{const o=i.constructor,r=o.utils.createElement("canvas"),a=o.utils.createElement("video");let s=i,l=i.isDestroy,c=null;const u=[],f=()=>!l&&!s?.isDestroy,d=function(e,t,n){let i=!1;return{mount(){if(i||!n())return;const o=t();o&&(e.setAttribute("aria-hidden","true"),e.tabIndex=-1,e.playsInline=!0,Object.assign(e.style,{position:"absolute",width:"auto",height:"auto",opacity:"0",pointerEvents:"none",left:"0",top:"0"}),o.appendChild(e),i=!0,n()||e.remove())},destroy(){const t=[],n=()=>{e.srcObject=null};for(const i of[()=>e.pause(),()=>e.removeAttribute("src"),n,()=>e.load(),()=>e.remove()])try{i()}catch(o){t.push(o)}if(i=!1,t.length)throw t[0]}}}(a,()=>s?.template?.$player,f),p=function(t,n,i,o,r){const a=t.getContext("2d");let s=!1;return{available:()=>!!a||(s||(s=!0,r(new Error("Canvas 2D context is unavailable"))),!1),async draw(s){const l=()=>s()&&n.readyState>=2&&!n.seeking;if(!a||!l()||!e(n)||!t.width||!t.height)return;let c=!1;try{if("undefined"!==typeof createImageBitmap){c=!0;const e=await createImageBitmap(n);c=!1;try{l()&&a.drawImage(e,0,0,t.width,t.height)}finally{e.close()}}else a.drawImage(n,0,0,t.width,t.height);if(!l())return;i&&i(a,n),l()&&o(a,n)}catch(u){const e=c&&"object"===typeof u&&null!==u&&"name"in u&&"InvalidStateError"===u.name&&0===n.currentTime&&"function"===typeof n.getVideoPlaybackQuality&&0===n.getVideoPlaybackQuality().totalVideoFrames;l()&&!e&&r(u)}}}}(r,a,n,(e,t)=>s?.emit("artplayerProxyCanvas:draw",e,t),e=>s?.emit("artplayerProxyCanvas:error",e)),h=t(p.draw,f);if(function(e,t,n,i,o){const r=new Map;for(const a in e){const t=Reflect.get(e,a);"function"===typeof t&&r.set(a,(...n)=>Reflect.apply(t,e,n))}for(const a in t)a in e||Object.defineProperty(e,a,{get(){const e=Reflect.get(t,a);return"function"!==typeof e?e:(...r)=>n()?("load"===a&&i(),"play"===a&&o(),Reflect.apply(e,t,r)):"play"===a?Promise.resolve():void 0},set(e){n()&&("src"!==a&&"srcObject"!==a||i(),Reflect.set(t,a,e))},configurable:!0,enumerable:!0});for(const[a,s]of r)Reflect.set(e,a,s)}(r,a,f,h.stop,d.mount),l)return s=null,r;function y(){if(l)return;l=!0,h.destroy(),null!==c&&clearTimeout(c),c=null;const e=[];for(const n of[...u.splice(0).reverse(),d.destroy])try{n()}catch(t){e.push(t)}if(s=null,r.width=0,r.height=0,e.length)throw e[0]}function m(e,t){if(!f())return;const n=()=>{("destroy"===e||f())&&t()},o=()=>i.off(e,n);u.push(o),i.on(e,n),l&&o()}function v(){p.available()&&h.request()}try{m("destroy",y),m("video:loadedmetadata",()=>{e(a)&&(r.width=a.videoWidth,r.height=a.videoHeight)}),m("video:play",()=>{d.mount(),p.available()&&h.start()}),m("video:pause",h.stop),m("video:emptied",h.stop),m("video:seeked",v),m("resize",()=>{!function(t,n,i,o){if(!i||o||!e(n))return;const r=i.clientWidth,a=i.clientHeight;if(!Number.isFinite(r)||!Number.isFinite(a)||r<=0||a<=0)return;const s=n.videoWidth/n.videoHeight,l=r/a>s?a*s:r,c=r/a>s?a:r/s;t.width=l,t.height=c,t.style.padding=`${(a-c)/2}px ${(r-l)/2}px`}(r,a,s?.template?.$player,s?.option.autoSize),v()}),f()&&(c=setTimeout(()=>{if(c=null,f())try{d.mount();for(const e of o.config.events){if(!f())break;const t=e=>{f()&&s?.emit(`video:${e.type}`,e)},n=()=>a.removeEventListener(e,t);u.push(n);const o=i.proxy(a,e,t);f()?u.push(o):o()}}catch(e){try{y()}catch{}i.emit("artplayerProxyCanvas:error",e)}}))}catch(g){try{y()}catch{}throw g}return r}}}); diff --git a/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.legacy.js b/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.legacy.js index cb5cbd9e7..4097740d7 100644 --- a/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.legacy.js +++ b/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.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.artplayerProxyCanvas=t(),define(function(){return t.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";var e=(e,t,n)=>new Promise((i,o)=>{var r=e=>{try{c(n.next(e))}catch(t){o(t)}},a=e=>{try{c(n.throw(e))}catch(t){o(t)}},c=e=>e.done?i(e.value):Promise.resolve(e.value).then(r,a);c((n=n.apply(e,t)).next())});return function(t){return n=>{const{constructor:i}=n,{createElement:o}=i.utils,r=o("canvas"),a=o("video"),c=r.getContext("2d");let d=null;const l={};for(const e in r)"function"===typeof r[e]&&(l[e]=r[e].bind(r));for(const e in a)e in r||Object.defineProperty(r,e,{get(){const t=a[e];return"function"===typeof t?t.bind(a):t},set(t){a[e]=t},configurable:!0,enumerable:!0});for(const e in l)r[e]=function(...t){return e in l?l[e](...t):a[e](...t)};return function(){const{events:e}=i.config;setTimeout(()=>{e.forEach(e=>{n.proxy(a,e,e=>{n.emit(`video:${e.type}`,e)})})})}(),n.on("video:loadedmetadata",()=>{r.width=a.videoWidth,r.height=a.videoHeight}),n.on("video:play",()=>{cancelAnimationFrame(d),u()}),n.on("video:pause",()=>{cancelAnimationFrame(d)}),n.on("resize",()=>{!function(){var e;const t=null==(e=n.template)?void 0:e.$player;if(!t||n.option.autoSize)return;const i=a.videoWidth/a.videoHeight,o=t.clientWidth,c=t.clientHeight;let d,l;o/c>i?(l=c,d=l*i):(d=o,l=d/i),r.width=d,r.height=l;const s=(o-d)/2,u=(c-l)/2;Object.assign(r.style,{padding:`${u}px ${s}px`})}(),s()}),n.on("destroy",()=>{cancelAnimationFrame(d)}),r;function s(){return e(this,null,function*(){try{if("undefined"!==typeof createImageBitmap){const e=yield createImageBitmap(a);c.drawImage(e,0,0,r.width,r.height),e.close()}else c.drawImage(a,0,0,r.width,r.height);t&&t(c,a),n.emit("artplayerProxyCanvas:draw",c,a)}catch(e){n.emit("artplayerProxyCanvas:error",e)}})}function u(){return e(this,null,function*(){yield s(),d=requestAnimationFrame(u)})}}}}); +!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyCanvas=t(),define(function(){return e.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";function e(e){return Number.isFinite(e.videoWidth)&&Number.isFinite(e.videoHeight)&&e.videoWidth>0&&e.videoHeight>0}function t(t,n,i,o,r){const l=t.getContext("2d");let a=!1;return{available:()=>!!l||(a||(a=!0,r(new Error("Canvas 2D context is unavailable"))),!1),draw(a){return s=this,u=null,c=function*(){const s=()=>a()&&n.readyState>=2&&!n.seeking;if(!l||!s()||!e(n)||!t.width||!t.height)return;let u=!1;try{if("undefined"!==typeof createImageBitmap){u=!0;const e=yield createImageBitmap(n);u=!1;try{s()&&l.drawImage(e,0,0,t.width,t.height)}finally{e.close()}}else l.drawImage(n,0,0,t.width,t.height);if(!s())return;i&&i(l,n),s()&&o(l,n)}catch(c){const e=u&&"object"===typeof c&&null!==c&&"name"in c&&"InvalidStateError"===c.name&&0===n.currentTime&&"function"===typeof n.getVideoPlaybackQuality&&0===n.getVideoPlaybackQuality().totalVideoFrames;s()&&!e&&r(c)}},new Promise((e,t)=>{var n=e=>{try{o(c.next(e))}catch(n){t(n)}},i=e=>{try{o(c.throw(e))}catch(n){t(n)}},o=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,i);o((c=c.apply(s,u)).next())});var s,u,c}}}function n(e,t){let n=!1,i=!1,o=!1,r=!1,l=0,a=null;function s(){null!==a&&cancelAnimationFrame(a),a=null}function u(){if(!o&&!i&&t())if(r){r=!1,o=!0;const n=l,a=()=>{o=!1,u()};e(()=>!i&&t()&&l===n).then(a,a)}else n&&null===a&&(a=requestAnimationFrame(()=>{a=null,r=!0,u()}))}function c(){!i&&t()&&(l++,r=!0,s(),u())}function d(){n=!1,r=!1,l++,s()}return{request:c,start(){n||i||!t()||(n=!0,c())},stop:d,destroy(){i=!0,d()}}}return function(i){return o=>{const r=o.constructor,l=r.utils.createElement("canvas"),a=r.utils.createElement("video");let s=o,u=o.isDestroy,c=null;const d=[],f=()=>!u&&!(null==s?void 0:s.isDestroy),h=function(e,t,n){let i=!1;return{mount(){if(i||!n())return;const o=t();o&&(e.setAttribute("aria-hidden","true"),e.tabIndex=-1,e.playsInline=!0,Object.assign(e.style,{position:"absolute",width:"auto",height:"auto",opacity:"0",pointerEvents:"none",left:"0",top:"0"}),o.appendChild(e),i=!0,n()||e.remove())},destroy(){const t=[],n=()=>{e.srcObject=null};for(const i of[()=>e.pause(),()=>e.removeAttribute("src"),n,()=>e.load(),()=>e.remove()])try{i()}catch(o){t.push(o)}if(i=!1,t.length)throw t[0]}}}(a,()=>{var e;return null==(e=null==s?void 0:s.template)?void 0:e.$player},f),p=t(l,a,i,(e,t)=>null==s?void 0:s.emit("artplayerProxyCanvas:draw",e,t),e=>null==s?void 0:s.emit("artplayerProxyCanvas:error",e)),y=n(p.draw,f);if(function(e,t,n,i,o){const r=new Map;for(const l in e){const t=Reflect.get(e,l);"function"===typeof t&&r.set(l,(...n)=>Reflect.apply(t,e,n))}for(const l in t)l in e||Object.defineProperty(e,l,{get(){const e=Reflect.get(t,l);return"function"!==typeof e?e:(...r)=>n()?("load"===l&&i(),"play"===l&&o(),Reflect.apply(e,t,r)):"play"===l?Promise.resolve():void 0},set(e){n()&&("src"!==l&&"srcObject"!==l||i(),Reflect.set(t,l,e))},configurable:!0,enumerable:!0});for(const[l,a]of r)Reflect.set(e,l,a)}(l,a,f,y.stop,h.mount),u)return s=null,l;function v(){if(u)return;u=!0,y.destroy(),null!==c&&clearTimeout(c),c=null;const e=[];for(const n of[...d.splice(0).reverse(),h.destroy])try{n()}catch(t){e.push(t)}if(s=null,l.width=0,l.height=0,e.length)throw e[0]}function m(e,t){if(!f())return;const n=()=>{("destroy"===e||f())&&t()},i=()=>o.off(e,n);d.push(i),o.on(e,n),u&&i()}function g(){p.available()&&y.request()}try{m("destroy",v),m("video:loadedmetadata",()=>{e(a)&&(l.width=a.videoWidth,l.height=a.videoHeight)}),m("video:play",()=>{h.mount(),p.available()&&y.start()}),m("video:pause",y.stop),m("video:emptied",y.stop),m("video:seeked",g),m("resize",()=>{var t;!function(t,n,i,o){if(!i||o||!e(n))return;const r=i.clientWidth,l=i.clientHeight;if(!Number.isFinite(r)||!Number.isFinite(l)||r<=0||l<=0)return;const a=n.videoWidth/n.videoHeight,s=r/l>a?l*a:r,u=r/l>a?l:r/a;t.width=s,t.height=u,t.style.padding=`${(l-u)/2}px ${(r-s)/2}px`}(l,a,null==(t=null==s?void 0:s.template)?void 0:t.$player,null==s?void 0:s.option.autoSize),g()}),f()&&(c=setTimeout(()=>{if(c=null,f())try{h.mount();for(const e of r.config.events){if(!f())break;const t=e=>{f()&&(null==s||s.emit(`video:${e.type}`,e))},n=()=>a.removeEventListener(e,t);d.push(n);const i=o.proxy(a,e,t);f()?d.push(i):i()}}catch(e){try{v()}catch(t){}o.emit("artplayerProxyCanvas:error",e)}}))}catch(b){try{v()}catch(w){}throw b}return l}}}); diff --git a/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.mjs b/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.mjs index 705b3ca71..edce867ba 100644 --- a/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.mjs +++ b/packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.mjs @@ -4,119 +4,322 @@ * (c) 2017-2026 Harvey Zhao * Released under the MIT License. */ -function artplayerProxyCanvas(callback) { - return (art) => { - const { constructor } = art; - const { createElement } = constructor.utils; - const canvas = createElement("canvas"); - const video = createElement("video"); - const ctx = canvas.getContext("2d"); - let animationId = null; - const originalCanvasMethods = {}; - for (const prop in canvas) { - if (typeof canvas[prop] === "function") { - originalCanvasMethods[prop] = canvas[prop].bind(canvas); - } - } - for (const prop in video) { - if (!(prop in canvas)) { - Object.defineProperty(canvas, prop, { - get() { - const value = video[prop]; - return typeof value === "function" ? value.bind(video) : value; - }, - set(value) { - video[prop] = value; - }, - configurable: true, - enumerable: true - }); - } - } - for (const prop in originalCanvasMethods) { - canvas[prop] = function(...args) { - if (prop in originalCanvasMethods) { - return originalCanvasMethods[prop](...args); - } - return video[prop](...args); +function forwardMedia(canvas, video, active, invalidate, mount) { + const methods = /* @__PURE__ */ new Map(); + for (const name in canvas) { + const value = Reflect.get(canvas, name); + if (typeof value === "function") + methods.set(name, (...args) => Reflect.apply(value, canvas, args)); + } + for (const name in video) { + if (name in canvas) + continue; + Object.defineProperty(canvas, name, { + get() { + const value = Reflect.get(video, name); + if (typeof value !== "function") + return value; + return (...args) => { + if (!active()) + return name === "play" ? Promise.resolve() : void 0; + if (name === "load") + invalidate(); + if (name === "play") + mount(); + return Reflect.apply(value, video, args); + }; + }, + set(value) { + if (!active()) + return; + if (name === "src" || name === "srcObject") + invalidate(); + Reflect.set(video, name, value); + }, + configurable: true, + enumerable: true + }); + } + for (const [name, method] of methods) + Reflect.set(canvas, name, method); +} +function hasDimensions(video) { + return Number.isFinite(video.videoWidth) && Number.isFinite(video.videoHeight) && video.videoWidth > 0 && video.videoHeight > 0; +} +function resizeCanvas(canvas, video, player, autoSize) { + if (!player || autoSize || !hasDimensions(video)) + return; + const width = player.clientWidth; + const height = player.clientHeight; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) + return; + const aspect = video.videoWidth / video.videoHeight; + const canvasWidth = width / height > aspect ? height * aspect : width; + const canvasHeight = width / height > aspect ? height : width / aspect; + canvas.width = canvasWidth; + canvas.height = canvasHeight; + canvas.style.padding = `${(height - canvasHeight) / 2}px ${(width - canvasWidth) / 2}px`; +} +function ownMedia(video, parent, active) { + let mounted = false; + return { + mount() { + if (mounted || !active()) + return; + const target = parent(); + if (!target) + return; + video.setAttribute("aria-hidden", "true"); + video.tabIndex = -1; + video.playsInline = true; + Object.assign(video.style, { position: "absolute", width: "auto", height: "auto", opacity: "0", pointerEvents: "none", left: "0", top: "0" }); + target.appendChild(video); + mounted = true; + if (!active()) + video.remove(); + }, + destroy() { + const failures = []; + const clearObject = () => { + video.srcObject = null; }; + for (const release of [() => video.pause(), () => video.removeAttribute("src"), clearObject, () => video.load(), () => video.remove()]) { + try { + release(); + } catch (error) { + failures.push(error); + } + } + mounted = false; + if (failures.length) + throw failures[0]; } - setupEventListeners(); - setupArtPlayerEvents(); - return canvas; - function setupEventListeners() { - const { events } = constructor.config; - setTimeout(() => { - events.forEach((event) => { - art.proxy(video, event, (event2) => { - art.emit(`video:${event2.type}`, event2); - }); - }); - }); - } - async function draw() { + }; +} +function createRenderer(canvas, video, callback, notify, error) { + const context = canvas.getContext("2d"); + let reported = false; + return { + available() { + if (context) + return true; + if (!reported) { + reported = true; + error(new Error("Canvas 2D context is unavailable")); + } + return false; + }, + async draw(valid) { + const usable = () => valid() && video.readyState >= 2 && !video.seeking; + if (!context || !usable() || !hasDimensions(video) || !canvas.width || !canvas.height) + return; + let acquiring = false; try { if (typeof createImageBitmap !== "undefined") { + acquiring = true; const bitmap = await createImageBitmap(video); - ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); - bitmap.close(); + acquiring = false; + try { + if (usable()) + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + } finally { + bitmap.close(); + } } else { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + context.drawImage(video, 0, 0, canvas.width, canvas.height); } - callback && callback(ctx, video); - art.emit("artplayerProxyCanvas:draw", ctx, video); - } catch (error) { - art.emit("artplayerProxyCanvas:error", error); + if (!usable()) + return; + if (callback) + callback(context, video); + if (usable()) + notify(context, video); + } catch (failure) { + const awaitingFirstFrame = acquiring && typeof failure === "object" && failure !== null && "name" in failure && failure.name === "InvalidStateError" && video.currentTime === 0 && typeof video.getVideoPlaybackQuality === "function" && video.getVideoPlaybackQuality().totalVideoFrames === 0; + if (usable() && !awaitingFirstFrame) + error(failure); } } - async function animation() { - await draw(); - animationId = requestAnimationFrame(animation); + }; +} +function createFrameScheduler(draw, active) { + let running = false; + let closed = false; + let busy = false; + let pending = false; + let generation = 0; + let frame = null; + function cancel() { + if (frame !== null) + cancelAnimationFrame(frame); + frame = null; + } + function drain() { + if (busy || closed || !active()) + return; + if (pending) { + pending = false; + busy = true; + const version = generation; + const valid = () => !closed && active() && generation === version; + const complete = () => { + busy = false; + drain(); + }; + void draw(valid).then(complete, complete); + } else if (running && frame === null) { + frame = requestAnimationFrame(() => { + frame = null; + pending = true; + drain(); + }); } - function resize() { - const player = art.template?.$player; - if (!player || art.option.autoSize) + } + function request() { + if (closed || !active()) + return; + generation++; + pending = true; + cancel(); + drain(); + } + function stop() { + running = false; + pending = false; + generation++; + cancel(); + } + return { + request, + start() { + if (running || closed || !active()) return; - const aspectRatio = video.videoWidth / video.videoHeight; - const containerWidth = player.clientWidth; - const containerHeight = player.clientHeight; - const containerRatio = containerWidth / containerHeight; - let canvasWidth, canvasHeight; - if (containerRatio > aspectRatio) { - canvasHeight = containerHeight; - canvasWidth = canvasHeight * aspectRatio; - } else { - canvasWidth = containerWidth; - canvasHeight = canvasWidth / aspectRatio; + running = true; + request(); + }, + stop, + destroy() { + closed = true; + stop(); + } + }; +} +function artplayerProxyCanvas(callback) { + return (art) => { + const constructor = art.constructor; + const canvas = constructor.utils.createElement("canvas"); + const video = constructor.utils.createElement("video"); + let host = art; + let closed = art.isDestroy; + let timer = null; + const releases = []; + const active = () => !closed && !host?.isDestroy; + const media = ownMedia(video, () => host?.template?.$player, active); + const renderer = createRenderer(canvas, video, callback, (context, source) => host?.emit("artplayerProxyCanvas:draw", context, source), (error) => host?.emit("artplayerProxyCanvas:error", error)); + const scheduler = createFrameScheduler(renderer.draw, active); + forwardMedia(canvas, video, active, scheduler.stop, media.mount); + if (closed) { + host = null; + return canvas; + } + function destroy() { + if (closed) + return; + closed = true; + scheduler.destroy(); + if (timer !== null) + clearTimeout(timer); + timer = null; + const failures = []; + for (const release of [...releases.splice(0).reverse(), media.destroy]) { + try { + release(); + } catch (error) { + failures.push(error); + } } - canvas.width = canvasWidth; - canvas.height = canvasHeight; - const paddingLeft = (containerWidth - canvasWidth) / 2; - const paddingTop = (containerHeight - canvasHeight) / 2; - Object.assign(canvas.style, { - padding: `${paddingTop}px ${paddingLeft}px` - }); + host = null; + canvas.width = 0; + canvas.height = 0; + if (failures.length) + throw failures[0]; } - function setupArtPlayerEvents() { - art.on("video:loadedmetadata", () => { - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - }); - art.on("video:play", () => { - cancelAnimationFrame(animationId); - animation(); - }); - art.on("video:pause", () => { - cancelAnimationFrame(animationId); - }); - art.on("resize", () => { - resize(); - draw(); - }); - art.on("destroy", () => { - cancelAnimationFrame(animationId); - }); + function listen(name, handler) { + if (!active()) + return; + const listener = () => { + if (name === "destroy" || active()) + handler(); + }; + const release = () => art.off(name, listener); + releases.push(release); + art.on(name, listener); + if (closed) + release(); } + function request() { + if (renderer.available()) + scheduler.request(); + } + try { + listen("destroy", destroy); + listen("video:loadedmetadata", () => { + if (hasDimensions(video)) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + } + }); + listen("video:play", () => { + media.mount(); + if (renderer.available()) + scheduler.start(); + }); + listen("video:pause", scheduler.stop); + listen("video:emptied", scheduler.stop); + listen("video:seeked", request); + listen("resize", () => { + resizeCanvas(canvas, video, host?.template?.$player, host?.option.autoSize); + request(); + }); + if (active()) { + timer = setTimeout(() => { + timer = null; + if (!active()) + return; + try { + media.mount(); + for (const name of constructor.config.events) { + if (!active()) + break; + const forward = (event) => { + if (active()) + host?.emit(`video:${event.type}`, event); + }; + const remove = () => video.removeEventListener(name, forward); + releases.push(remove); + const release = art.proxy(video, name, forward); + if (active()) + releases.push(release); + else + release(); + } + } catch (error) { + try { + destroy(); + } catch { + } + art.emit("artplayerProxyCanvas:error", error); + } + }); + } + } catch (error) { + try { + destroy(); + } catch { + } + throw error; + } + return canvas; }; } export { diff --git a/packages/artplayer-proxy-canvas/src/adapter.ts b/packages/artplayer-proxy-canvas/src/adapter.ts new file mode 100644 index 000000000..112053827 --- /dev/null +++ b/packages/artplayer-proxy-canvas/src/adapter.ts @@ -0,0 +1,39 @@ +export function forwardMedia(canvas: HTMLCanvasElement, video: HTMLVideoElement, active: () => boolean, invalidate: () => void, mount: () => void): void { + const methods = new Map unknown>() + for (const name in canvas) { + const value: unknown = Reflect.get(canvas, name) + if (typeof value === 'function') + methods.set(name, (...args) => Reflect.apply(value, canvas, args)) + } + for (const name in video) { + if (name in canvas) + continue + Object.defineProperty(canvas, name, { + get() { + const value: unknown = Reflect.get(video, name) + if (typeof value !== 'function') + return value + return (...args: unknown[]) => { + if (!active()) + return name === 'play' ? Promise.resolve() : undefined + if (name === 'load') + invalidate() + if (name === 'play') + mount() + return Reflect.apply(value, video, args) + } + }, + set(value: unknown) { + if (!active()) + return + if (name === 'src' || name === 'srcObject') + invalidate() + Reflect.set(video, name, value) + }, + configurable: true, + enumerable: true, + }) + } + for (const [name, method] of methods) + Reflect.set(canvas, name, method) +} diff --git a/packages/artplayer-proxy-canvas/src/geometry.ts b/packages/artplayer-proxy-canvas/src/geometry.ts new file mode 100644 index 000000000..a275f9e26 --- /dev/null +++ b/packages/artplayer-proxy-canvas/src/geometry.ts @@ -0,0 +1,18 @@ +export function hasDimensions(video: HTMLVideoElement): boolean { + return Number.isFinite(video.videoWidth) && Number.isFinite(video.videoHeight) && video.videoWidth > 0 && video.videoHeight > 0 +} + +export function resizeCanvas(canvas: HTMLCanvasElement, video: HTMLVideoElement, player: HTMLElement | null | undefined, autoSize: boolean | undefined): void { + if (!player || autoSize || !hasDimensions(video)) + return + const width = player.clientWidth + const height = player.clientHeight + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) + return + const aspect = video.videoWidth / video.videoHeight + const canvasWidth = width / height > aspect ? height * aspect : width + const canvasHeight = width / height > aspect ? height : width / aspect + canvas.width = canvasWidth + canvas.height = canvasHeight + canvas.style.padding = `${(height - canvasHeight) / 2}px ${(width - canvasWidth) / 2}px` +} diff --git a/packages/artplayer-proxy-canvas/src/index.js b/packages/artplayer-proxy-canvas/src/index.js deleted file mode 100644 index 125eb6107..000000000 --- a/packages/artplayer-proxy-canvas/src/index.js +++ /dev/null @@ -1,139 +0,0 @@ -export default function artplayerProxyCanvas(callback) { - return (art) => { - const { constructor } = art - const { createElement } = constructor.utils - - const canvas = createElement('canvas') - const video = createElement('video') - const ctx = canvas.getContext('2d') - - let animationId = null - - const originalCanvasMethods = {} - for (const prop in canvas) { - if (typeof canvas[prop] === 'function') { - originalCanvasMethods[prop] = canvas[prop].bind(canvas) - } - } - - for (const prop in video) { - if (!(prop in canvas)) { - Object.defineProperty(canvas, prop, { - get() { - const value = video[prop] - return typeof value === 'function' ? value.bind(video) : value - }, - set(value) { - video[prop] = value - }, - configurable: true, - enumerable: true, - }) - } - } - - for (const prop in originalCanvasMethods) { - canvas[prop] = function (...args) { - if (prop in originalCanvasMethods) { - return originalCanvasMethods[prop](...args) - } - return video[prop](...args) - } - } - - setupEventListeners() - setupArtPlayerEvents() - - return canvas - - function setupEventListeners() { - const { events } = constructor.config - setTimeout(() => { - events.forEach((event) => { - art.proxy(video, event, (event) => { - art.emit(`video:${event.type}`, event) - }) - }) - }) - } - - async function draw() { - try { - if (typeof createImageBitmap !== 'undefined') { - const bitmap = await createImageBitmap(video) - ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height) - bitmap.close() - } - else { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height) - } - callback && callback(ctx, video) - art.emit('artplayerProxyCanvas:draw', ctx, video) - } - catch (error) { - art.emit('artplayerProxyCanvas:error', error) - } - } - - async function animation() { - await draw() - animationId = requestAnimationFrame(animation) - } - - function resize() { - const player = art.template?.$player - if (!player || art.option.autoSize) - return - - const aspectRatio = video.videoWidth / video.videoHeight - const containerWidth = player.clientWidth - const containerHeight = player.clientHeight - const containerRatio = containerWidth / containerHeight - - let canvasWidth, canvasHeight - if (containerRatio > aspectRatio) { - canvasHeight = containerHeight - canvasWidth = canvasHeight * aspectRatio - } - else { - canvasWidth = containerWidth - canvasHeight = canvasWidth / aspectRatio - } - - canvas.width = canvasWidth - canvas.height = canvasHeight - - const paddingLeft = (containerWidth - canvasWidth) / 2 - const paddingTop = (containerHeight - canvasHeight) / 2 - - Object.assign(canvas.style, { - padding: `${paddingTop}px ${paddingLeft}px`, - }) - } - - function setupArtPlayerEvents() { - art.on('video:loadedmetadata', () => { - canvas.width = video.videoWidth - canvas.height = video.videoHeight - }) - - art.on('video:play', () => { - cancelAnimationFrame(animationId) - animation() - }) - - art.on('video:pause', () => { - cancelAnimationFrame(animationId) - }) - - art.on('resize', () => { - resize() - draw() - }) - - art.on('destroy', () => { - cancelAnimationFrame(animationId) - }) - } - } -} diff --git a/packages/artplayer-proxy-canvas/src/index.ts b/packages/artplayer-proxy-canvas/src/index.ts new file mode 100644 index 000000000..d011d677f --- /dev/null +++ b/packages/artplayer-proxy-canvas/src/index.ts @@ -0,0 +1,132 @@ +import type Artplayer from 'artplayer' +import type { DrawCallback } from './renderer' +import { forwardMedia } from './adapter' +import { hasDimensions, resizeCanvas } from './geometry' +import { ownMedia } from './media' +import { createRenderer } from './renderer' +import { createFrameScheduler } from './scheduler' + +export default function artplayerProxyCanvas(callback?: DrawCallback) { + return (art: Artplayer): HTMLCanvasElement => { + const constructor = art.constructor as typeof Artplayer + const canvas = constructor.utils.createElement('canvas') + const video = constructor.utils.createElement('video') + let host: Artplayer | null = art + let closed = art.isDestroy + let timer: ReturnType | null = null + const releases: Array<() => void> = [] + const active = () => !closed && !host?.isDestroy + const media = ownMedia(video, () => host?.template?.$player, active) + const renderer = createRenderer(canvas, video, callback, (context, source) => host?.emit('artplayerProxyCanvas:draw', context, source), error => host?.emit('artplayerProxyCanvas:error', error)) + const scheduler = createFrameScheduler(renderer.draw, active) + forwardMedia(canvas, video, active, scheduler.stop, media.mount) + if (closed) { + host = null + return canvas + } + + function destroy(): void { + if (closed) + return + closed = true + scheduler.destroy() + if (timer !== null) + clearTimeout(timer) + timer = null + const failures: unknown[] = [] + for (const release of [...releases.splice(0).reverse(), media.destroy]) { + try { + release() + } + catch (error) { failures.push(error) } + } + host = null + canvas.width = 0 + canvas.height = 0 + if (failures.length) + throw failures[0] + } + + function listen(name: string, handler: () => void): void { + if (!active()) + return + const listener = () => { + if (name === 'destroy' || active()) + handler() + } + const release = () => art.off(name, listener) + releases.push(release) + art.on(name, listener) + if (closed) + release() + } + + function request(): void { + if (renderer.available()) + scheduler.request() + } + + try { + listen('destroy', destroy) + listen('video:loadedmetadata', () => { + if (hasDimensions(video)) { + canvas.width = video.videoWidth + canvas.height = video.videoHeight + } + }) + listen('video:play', () => { + media.mount() + if (renderer.available()) + scheduler.start() + }) + listen('video:pause', scheduler.stop) + listen('video:emptied', scheduler.stop) + listen('video:seeked', request) + listen('resize', () => { + resizeCanvas(canvas, video, host?.template?.$player, host?.option.autoSize) + request() + }) + if (active()) { + timer = setTimeout(() => { + timer = null + if (!active()) + return + try { + media.mount() + for (const name of constructor.config.events) { + if (!active()) + break + const forward = (event: Event) => { + if (active()) + host?.emit(`video:${event.type}`, event) + } + // Own native removal before invoking an extensible proxy registrar. + const remove = () => video.removeEventListener(name, forward) + releases.push(remove) + const release = art.proxy(video, name, forward) + if (active()) + releases.push(release) + else + release() + } + } + catch (error) { + try { + destroy() + } + catch { /* Finish rollback without replacing the initialization error. */ } + art.emit('artplayerProxyCanvas:error', error) + } + }) + } + } + catch (error) { + try { + destroy() + } + catch { /* Preserve the original setup error. */ } + throw error + } + return canvas + } +} diff --git a/packages/artplayer-proxy-canvas/src/media.ts b/packages/artplayer-proxy-canvas/src/media.ts new file mode 100644 index 000000000..3cde138a2 --- /dev/null +++ b/packages/artplayer-proxy-canvas/src/media.ts @@ -0,0 +1,36 @@ +export function ownMedia(video: HTMLVideoElement, parent: () => HTMLElement | null | undefined, active: () => boolean) { + let mounted = false + return { + mount() { + if (mounted || !active()) + return + const target = parent() + if (!target) + return + video.setAttribute('aria-hidden', 'true') + video.tabIndex = -1 + video.playsInline = true + // Keep media connected before playback without adding another visible player. + Object.assign(video.style, { position: 'absolute', width: 'auto', height: 'auto', opacity: '0', pointerEvents: 'none', left: '0', top: '0' }) + target.appendChild(video) + mounted = true + if (!active()) + video.remove() + }, + destroy() { + const failures: unknown[] = [] + const clearObject = () => { + video.srcObject = null + } + for (const release of [() => video.pause(), () => video.removeAttribute('src'), clearObject, () => video.load(), () => video.remove()]) { + try { + release() + } + catch (error) { failures.push(error) } + } + mounted = false + if (failures.length) + throw failures[0] + }, + } +} diff --git a/packages/artplayer-proxy-canvas/src/renderer.ts b/packages/artplayer-proxy-canvas/src/renderer.ts new file mode 100644 index 000000000..f64e42239 --- /dev/null +++ b/packages/artplayer-proxy-canvas/src/renderer.ts @@ -0,0 +1,55 @@ +import { hasDimensions } from './geometry' + +export type DrawCallback = (context: CanvasRenderingContext2D, video: HTMLVideoElement) => void + +export function createRenderer(canvas: HTMLCanvasElement, video: HTMLVideoElement, callback: DrawCallback | undefined, notify: DrawCallback, error: (failure: unknown) => void) { + const context = canvas.getContext('2d') + let reported = false + return { + available() { + if (context) + return true + if (!reported) { + reported = true + error(new Error('Canvas 2D context is unavailable')) + } + return false + }, + async draw(valid: () => boolean): Promise { + const usable = () => valid() && video.readyState >= 2 && !video.seeking + if (!context || !usable() || !hasDimensions(video) || !canvas.width || !canvas.height) + return + let acquiring = false + try { + if (typeof createImageBitmap !== 'undefined') { + acquiring = true + const bitmap = await createImageBitmap(video) + acquiring = false + try { + if (usable()) + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height) + } + finally { bitmap.close() } + } + else { + context.drawImage(video, 0, 0, canvas.width, canvas.height) + } + if (!usable()) + return + if (callback) + callback(context, video) + if (usable()) + notify(context, video) + } + catch (failure) { + // Chromium can expose readyState 4 before its first frame is usable. + // Other engines report zero frame counts even while drawing successfully. + const awaitingFirstFrame = acquiring && typeof failure === 'object' && failure !== null && 'name' in failure + && failure.name === 'InvalidStateError' && video.currentTime === 0 + && typeof video.getVideoPlaybackQuality === 'function' && video.getVideoPlaybackQuality().totalVideoFrames === 0 + if (usable() && !awaitingFirstFrame) + error(failure) + } + }, + } +} diff --git a/packages/artplayer-proxy-canvas/src/scheduler.ts b/packages/artplayer-proxy-canvas/src/scheduler.ts new file mode 100644 index 000000000..0f5f18159 --- /dev/null +++ b/packages/artplayer-proxy-canvas/src/scheduler.ts @@ -0,0 +1,69 @@ +export function createFrameScheduler(draw: (valid: () => boolean) => Promise, active: () => boolean) { + let running = false + let closed = false + let busy = false + let pending = false + let generation = 0 + let frame: number | null = null + + function cancel(): void { + if (frame !== null) + cancelAnimationFrame(frame) + frame = null + } + + function drain(): void { + if (busy || closed || !active()) + return + if (pending) { + pending = false + busy = true + const version = generation + const valid = () => !closed && active() && generation === version + const complete = () => { + busy = false + drain() + } + // An observer throwing must not leave the scheduler busy or reject unhandled. + void draw(valid).then(complete, complete) + } + else if (running && frame === null) { + frame = requestAnimationFrame(() => { + frame = null + pending = true + drain() + }) + } + } + + function request(): void { + if (closed || !active()) + return + generation++ + pending = true + cancel() + drain() + } + + function stop(): void { + running = false + pending = false + generation++ + cancel() + } + + return { + request, + start() { + if (running || closed || !active()) + return + running = true + request() + }, + stop, + destroy() { + closed = true + stop() + }, + } +} diff --git a/packages/artplayer-proxy-canvas/tsconfig.json b/packages/artplayer-proxy-canvas/tsconfig.json new file mode 100644 index 000000000..285047f94 --- /dev/null +++ b/packages/artplayer-proxy-canvas/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "allowJs": false }, + "include": ["src/**/*.ts", "types/**/*.d.ts"] +} diff --git a/refactor/baselines/canvas-lifecycle-validation.json b/refactor/baselines/canvas-lifecycle-validation.json new file mode 100644 index 000000000..3a7fddcbd --- /dev/null +++ b/refactor/baselines/canvas-lifecycle-validation.json @@ -0,0 +1,350 @@ +{ + "schemaVersion": 1, + "task": "PKG-CANVAS-03", + "status": "done", + "checks": [ + { + "command": "ARTPLAYER_CANVAS_BASELINE=1 node --test test/canvas-lifecycle.test.js", + "result": "expected historical failures", + "tests": 24, + "failed": 20, + "normalControlsPassed": 4 + }, + { + "command": "node --test test/canvas-lifecycle.test.js test/canvas.test.js refactor/scripts/canvas-contract.test.mjs", + "result": "pass", + "tests": 70, + "candidate": 24, + "historical": 33, + "contract": 13 + }, + { + "command": "focused eslint --no-fix", + "result": "pass" + }, + { + "command": "yarn build artplayer-proxy-canvas", + "result": "pass", + "formats": [ + "main/es2020", + "legacy/es2015", + "esm/es2020" + ] + }, + { + "command": "playwright test canvas.spec.js canvas-lifecycle.spec.js ambilight-proxy.spec.js ambilight-lifecycle.spec.js", + "result": "pass", + "tests": 87, + "skipped": 0, + "scope": "42 frozen historical observations +18 candidate proxy +9 Ambilight proxy/core capability +18 native Ambilight lifecycle; before final falsy-callback parity correction" + }, + { + "command": "playwright test canvas-lifecycle.spec.js ambilight-proxy.spec.js", + "result": "pass", + "tests": 27, + "skipped": 0, + "browsers": [ + { + "project": "chromium", + "version": "153.0.8010.12", + "platform": "win32" + }, + { + "project": "firefox", + "version": "155.0", + "platform": "win32" + }, + { + "project": "webkit", + "version": "26.6", + "platform": "win32" + } + ], + "scope": "Final candidate source after falsy callback correction: rerun all 18 candidate proxy and 9 source-dependent composition/capability cases. Earlier unchanged 42 historical and 18 native Ambilight cases are not counted as fresh executions." + }, + { + "command": "yarn ci:check", + "result": "pass", + "tests": { + "unit": 938, + "engineering": 14, + "baseline": 96, + "mainTotal": 1048, + "repeatedContractExecutions": 44 + }, + "strictProductionTypeScriptFiles": 284 + } + ], + "limitations": [ + "Public declaration/import and full installed artifact validation remain tasks 04/06.", + "Windows Playwright WebKit does not establish Safari or mobile device acceptance." + ], + "sourceCommit": "e931520e6611a1520c70d87ed5460f11f6c13110", + "modules": [ + "index", + "adapter", + "media", + "geometry", + "renderer", + "scheduler" + ], + "inputs": [ + { + "file": "test/canvas-lifecycle.test.js", + "sha256LF": "8a10d43b8af38e7eb6c447282293566e700a03ee020e5561248f775d5bc7b434" + }, + { + "file": "test/helpers/canvas.js", + "sha256LF": "72a2f70eef76eb4e223f2f9092ef321b017f94ce194043e898cc24c827ac5104" + }, + { + "file": "test/browser/canvas-lifecycle.spec.js", + "sha256LF": "5c50ad4f11d1df91c4247dc24c07edd2c9540e128e00fd07ee096406174512a7" + }, + { + "file": "test/browser/ambilight-proxy.spec.js", + "sha256LF": "e02b1fd1f5deafeb993f4584a3bde0c42b1b7ca798665382b3861c92cd8a0f70" + }, + { + "file": "packages/artplayer-proxy-canvas/tsconfig.json", + "sha256LF": "712a3fa32ed57692d16bf854a0e77ffaecd04236efa0f557905aab7291e0aef6" + }, + { + "file": "packages/artplayer-proxy-canvas/src/index.ts", + "sha256LF": "ee0709df702b0773ca9bfe47dcf7ff1c010cb63f4a475ca7548c31205691b2a4" + }, + { + "file": "packages/artplayer-proxy-canvas/src/adapter.ts", + "sha256LF": "68acf8f0a594f31df981d776099ddd97e013ba2ced6eb61c18a1a967870d5597" + }, + { + "file": "packages/artplayer-proxy-canvas/src/media.ts", + "sha256LF": "f828c55f5fb5aad02dfd3a7bd35392ae03bb10725fa0c845932264783ddbc838" + }, + { + "file": "packages/artplayer-proxy-canvas/src/geometry.ts", + "sha256LF": "067feae2cd568f153e44346d40c1e01fdcd9b4ed021ee29e3918c218e7bf19ac" + }, + { + "file": "packages/artplayer-proxy-canvas/src/renderer.ts", + "sha256LF": "ba5f2f546b296cc6f688e7ac1129e89d0f64035ad1aa203cf479ff023d520a01" + }, + { + "file": "packages/artplayer-proxy-canvas/src/scheduler.ts", + "sha256LF": "2e32a634c655bd408250f0500d7895aab3311835b89d578aad07121e727d5fc9" + } + ], + "artifacts": [ + { + "file": "packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.js", + "sha256LF": "55e1c3ca512a02ea454414734d383a1b87bb8a4e8d80e98b8ea3e80419644f02" + }, + { + "file": "packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.legacy.js", + "sha256LF": "0796737096eb59d0af1eb7d69859502a4e23b817471366b3922224f9bad5d97b" + }, + { + "file": "packages/artplayer-proxy-canvas/dist/artplayer-proxy-canvas.mjs", + "sha256LF": "98ee5a30ac170913205459e2d9b85a019159f8122d225967ece0894b9fe9f010" + }, + { + "file": "docs/compiled/artplayer-proxy-canvas.js", + "sha256LF": "55e1c3ca512a02ea454414734d383a1b87bb8a4e8d80e98b8ea3e80419644f02" + }, + { + "file": "docs/compiled/artplayer-proxy-canvas.legacy.js", + "sha256LF": "0796737096eb59d0af1eb7d69859502a4e23b817471366b3922224f9bad5d97b" + }, + { + "file": "docs/compiled/artplayer-proxy-canvas.mjs", + "sha256LF": "98ee5a30ac170913205459e2d9b85a019159f8122d225967ece0894b9fe9f010" + } + ], + "preservedBrowserAttempts": [ + { + "file": "refactor/.cache/canvas03-first-results/report.json", + "sha256": "4a85c2084c0724d094d53c254f59bdf506c719f00d71bd808222bd368f2557f4", + "stats": { + "startTime": "2026-09-12T04:49:55.760Z", + "duration": 22178.867, + "expected": 21, + "skipped": 0, + "unexpected": 6, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-first-results/results" + }, + { + "file": "refactor/.cache/canvas03-layout-probe-results/report.json", + "sha256": "2e92892971b83972cf63de53e3ff49de064587ea2bec228b64d4f8f394913b00", + "stats": { + "startTime": "2026-09-12T04:51:33.300Z", + "duration": 4576.275, + "expected": 0, + "skipped": 0, + "unexpected": 1, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-layout-probe-results/results" + }, + { + "file": "refactor/.cache/canvas03-second-results/report.json", + "sha256": "b1df18b795109551918a0fe4b2cbdc833c4eeebdca7aff312d2ea95a81d92750", + "stats": { + "startTime": "2026-09-12T04:52:41.768Z", + "duration": 20413.01, + "expected": 25, + "skipped": 0, + "unexpected": 2, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-second-results/results" + }, + { + "file": "refactor/.cache/canvas03-bitmap-probe-results/report.json", + "sha256": "210ff2eea0eab7b173a2cb8ff63ff514342fcc88f66dd3184e7faaf24c647ec3", + "stats": { + "startTime": "2026-09-12T04:54:33.938Z", + "duration": 3814.563, + "expected": 0, + "skipped": 0, + "unexpected": 1, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-bitmap-probe-results/results" + }, + { + "file": "refactor/.cache/canvas03-third-results/report.json", + "sha256": "db4c9100a4f88710374e67554e72c0c411947dd50438948fe872b841efb4ac3c", + "stats": { + "startTime": "2026-09-12T04:55:24.268Z", + "duration": 26707.128, + "expected": 25, + "skipped": 0, + "unexpected": 2, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-third-results/results" + }, + { + "file": "refactor/.cache/canvas03-native-fallback-probe-results/report.json", + "sha256": "19b8819931cd0b526de758ffcdda4ed57d1f0053f0d5a1a971ae4a0fa3ca2702", + "stats": { + "startTime": "2026-09-12T04:57:05.184Z", + "duration": 3502.855, + "expected": 0, + "skipped": 0, + "unexpected": 1, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-native-fallback-probe-results/results" + }, + { + "file": "refactor/.cache/canvas03-frame-count-probe-results/report.json", + "sha256": "ea9a49f26f6df1aa681d0d9e706da03856ff98c8ca95de992556d54c6d42267f", + "stats": { + "startTime": "2026-09-12T04:58:06.373Z", + "duration": 3458.946, + "expected": 0, + "skipped": 0, + "unexpected": 1, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-frame-count-probe-results/results" + }, + { + "file": "refactor/.cache/canvas03-fourth-results/report.json", + "sha256": "c31b99eb40330829318e172d97d67ad93f486672e1584e6bbf3061671223a06a", + "stats": { + "startTime": "2026-09-12T04:58:38.380Z", + "duration": 38111.093, + "expected": 19, + "skipped": 0, + "unexpected": 8, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-fourth-results/results" + }, + { + "file": "refactor/.cache/canvas03-fifth-results/report.json", + "sha256": "d6dd221438e708c3f99ce17cdbd8a7d66c87bce3955efd05a3f509359d06e42c", + "stats": { + "startTime": "2026-09-12T05:00:10.543Z", + "duration": 20479.555, + "expected": 27, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-fifth-results/results" + }, + { + "file": "refactor/.cache/canvas03-wide-results/report.json", + "sha256": "410e333e89f2343ed6fe89e9b383cf67260e0a332551c537673e9b9492b2849a", + "stats": { + "startTime": "2026-09-12T05:02:59.019Z", + "duration": 61692.277, + "expected": 87, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-wide-results/results" + }, + { + "file": "refactor/.cache/canvas03-final-results/report.json", + "sha256": "5a448f03d32130b492b24211db94c360dcebf8b77d4dfab1db516b3304d03921", + "stats": { + "startTime": "2026-09-12T05:05:56.204Z", + "duration": 23901.64, + "expected": 27, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "resultsDirectory": "refactor/.cache/canvas03-final-results/results" + } + ], + "logs": [ + { + "file": "refactor/.cache/canvas03-before.log", + "sha256": "e9bb781a28df272462ca86d508274dd28ba575c0829877eecc3bb249d1041cb3" + }, + { + "file": "refactor/.cache/canvas03-baseline-final24.log", + "sha256": "b3877dc5cabc1b07faec0f322a96172d4d850e11b7b59021b203f48e8491d034" + }, + { + "file": "refactor/.cache/canvas03-falsy-before.log", + "sha256": "37edde1f7b9944aebbdb33401d013d4d0ec3fd1ed84ea5275a76502816777167" + }, + { + "file": "refactor/.cache/canvas03-targeted-final.log", + "sha256": "f0816a3fa739383919c20869aa913e4624ab8deda6bfac00ea8aaa7c43118095" + }, + { + "file": "refactor/.cache/canvas03-lint-final.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "file": "refactor/.cache/canvas03-build-final.log", + "sha256": "21a40f641ca7873cff075ac4b03b75a358f2436c3f08e38dc244a02e3abf82f3" + }, + { + "file": "refactor/.cache/canvas03-browser-final.log", + "sha256": "99350e3995457a42c143a7b05e3d42a21e6221031e67b5720e0ec3c9720a236e" + }, + { + "file": "refactor/.cache/canvas03-browser-final27.log", + "sha256": "fcfb0ad5e126ac601d433c160bbe1145fdaacd281ce5116d402f120cd113453e" + }, + { + "file": "refactor/.cache/canvas03-ci.log", + "sha256": "afaf0d37208af4657653c7879e5f19d4013c8c232d32aa940f5006076102edc3" + }, + { + "file": "refactor/.cache/canvas03-ci-final.log", + "sha256": "305427e2812e64942d3bada2db7c750f8ee26879aa515c825c96e0b7efe237a8" + } + ] +} diff --git a/refactor/canvas-validation.md b/refactor/canvas-validation.md index a7db14c81..0ccab12bf 100644 --- a/refactor/canvas-validation.md +++ b/refactor/canvas-validation.md @@ -13,8 +13,7 @@ play/pause、destroy前后订阅、缺context和尺寸异常;保留历史失 03拆媒体adapter/绘制调度/订阅和所有权,04明确公共类型(尤其可选参数及canvas自有 方法优先),05/06核验新旧核心、实际媒体/浏览器/设备/安装包和8082 demo。 -当前已有的Ambilight代理颜色测试会直接加载proxy的src/index.js;代理迁移文件入口 -或模块拆分时必须同步改为实际候选入口加载,不能用旧dist掩盖改名或遗漏模块。 +Ambilight代理颜色测试在03同步改用实际候选入口bundle,不能用旧dist掩盖改名或遗漏模块。 Canvas正式历史浏览器矩阵在02建立;01没有使用新浏览器结果作包整体验收。 01最终13项Canvas专项、7项契约索引检查与991项完整CI通过;证据见 @@ -39,3 +38,21 @@ WebKit历史代理输出透明是明确的失败基线;测试成功表示重 失败尝试与最终报告均保留report.json和完整results,哈希与范围见 [02验证证据](baselines/canvas-behavior-validation.json);问题处理细节见 [02实施记录](changes/2026-09-12-PKG-CANVAS-02-tests.md)。本任务不修改生产源码。 + +## 03 严格TS和候选生命周期 + +源码拆为六个TS模块;职责、数据流和错误边界见 +[包内架构](../packages/artplayer-proxy-canvas/ARCHITECTURE.md)。`yarn test:canvas`现在 +同时运行历史和候选测试,候选可通过ARTPLAYER_CANVAS_BASELINE=1切换冻结源码以 +证明失败来源,不能把这个变量留在正常测试环境中。 + +候选浏览器文件canvas-lifecycle.spec.js覆盖实际5.4.0/候选核心×三个引擎×三场景 +共18项:实际原始尺寸和多点像素、resize/切源、暂停seek补绘、回调销毁以及逃逸方法 +不重启资源。组合运行Ambilight的9项代理/旧核心能力对照及18项原生生命周期检查。 +所有加载均来自候选源码bundle;历史42项继续从归档/Git加载。 + +WebKit须验证intrinsic尺寸,不能仅alpha255;1px挂载曾令videoWidth/videoHeight +也变为1。Chromium临时无首帧不宜当稳定错误,WebKit零帧计数却不能作全局绘制门槛。 +失败诊断与针对性回归见[03记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md)和 +[03证据](baselines/canvas-lifecycle-validation.json)。04公开类型/导出和05设备、06 +完整分发/demo仍需完成;本步的strict内部类型不替代安装消费者验收。 diff --git a/refactor/changes/2026-09-12-PKG-CANVAS-03-lifecycle.md b/refactor/changes/2026-09-12-PKG-CANVAS-03-lifecycle.md new file mode 100644 index 000000000..7456a000d --- /dev/null +++ b/refactor/changes/2026-09-12-PKG-CANVAS-03-lifecycle.md @@ -0,0 +1,32 @@ +# PKG-CANVAS-03 TS职责与资源修复 + +将旧单文件拆成index/adapter/media/geometry/renderer/scheduler六个严格TS模块。 +入口独占宿主订阅和回滚;其余模块只接收所需能力。公开callback、实际Canvas返回、 +Canvas自有方法优先、媒体属性转发和原始Event继续保留,公开声明/导出消费者留04。 +包内ARCHITECTURE.md记录模块地图、事件顺序、状态和维护边界。 + +绘制只允许一个挂起任务;resize合并新请求并淘汰旧结果,pause/src/load/destroy +使旧帧失效。bitmap始终关闭,包括绘制异常与过期结果;回调销毁不再发draw/排RAF。 +延迟订阅可取消,宿主和媒体注册先记清理后执行,初始化错误及重入销毁会回滚。 +销毁后暂停/卸载底层video、解除srcObject引用但不停止用户拥有的轨道,释放Canvas +buffer;已捕获媒体方法与setter不再重启工作。暂停seek补绘一帧,不重启连续播放。 + +真实浏览器修复过程保留失败证据:旧未挂载video在WebKit透明;接入宿主并在播放前 +挂载后可绘制。但1×1隐藏尺寸令WebKit媒体尺寸也变为1×1,因此改为原始CSS尺寸, +并增加320×180尺寸和多点颜色断言,避免仅alpha255误判。底层video绝对定位、 +opacity0、不可聚焦/点击,不增加可见播放器。 + +切源或seeking期间未形成可用帧会暂缓绘制。Chromium首次bitmap失败时readyState4、 +currentTime0、totalVideoFrames0,独立直接绘制仍透明;WebKit却可能一直上报零帧。 +因此不能全局以帧计数阻止绘制,只在bitmap获取阶段的精确首帧InvalidStateError +条件等待后续请求,稳定错误、drawImage错误和用户回调错误仍保持事件语义。 + +初始14项候选测试在旧源码13失败/1正常通过;随着浏览器发现继续补充回归。最终 +测试/构建/浏览器结果和输入哈希见[验证证据](../baselines/canvas-lifecycle-validation.json)。 +生命周期和像素风险在最终验证前继续open;设备、公开类型、npm消费者并未以本步代替。 + +源码复查发现可选调用callback?.会把旧JS传入false/0/空串的无回调用法改成TypeError。 +新增真实三历史实现对照后在初稿复现,恢复旧truthy guard;声明依然只承诺可选函数, +不以严格TS为由破坏已有运行时宽容行为。最终对照与修复日志单独保留。 + +最终24项候选要求在旧源码20失败/4正常通过,候选与历史/契约共70项通过;专项lint、正式三格式构建通过。广浏览器矩阵87项通过,最后falsy修正后受影响27项重跑通过,另60项未受影响的历史/原生检查不重复计数。最终完整CI1048项通过(938单元+14工程+96基线),另44项契约重复观察,284个生产TS文件严格检查。CANVAS-LIFE-01与记录的桌面CANVAS-PIXEL-01关闭,类型/分发/设备仍待04~06。本任务done并独立本地提交,不推送/发布。 diff --git a/refactor/plan.md b/refactor/plan.md index 5f0132fc5..677ee6654 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 221 项,范围 22 个包及工作区/示例。 -状态:todo 124 / doing 6 / blocked 0 / done 91 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 123 / doing 6 / blocked 0 / done 92 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -320,7 +320,7 @@ | --- | --- | --- | --- | --- | --- | --- | | PKG-CANVAS-01 | artplayer-proxy-canvas
核对包契约与历史用法 | BASE-05 | callback、canvas 原方法、转发 media 属性/方法/事件 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | | PKG-CANVAS-02 | artplayer-proxy-canvas
建立特有行为与错误测试 | PKG-CANVAS-01, ENG-03, ENG-05 | ready/loadeddata/canplay、play/seek、回调、resize 与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done | -| PKG-CANVAS-03 | artplayer-proxy-canvas
整理内部职责与资源 | PKG-CANVAS-02, CORE-06, CORE-11, CORE-16 | video adapter/原 canvas 方法/RAF 绘制/事件订阅分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | +| PKG-CANVAS-03 | artplayer-proxy-canvas
整理内部职责与资源 | PKG-CANVAS-02, CORE-06, CORE-11, CORE-16 | video adapter/原 canvas 方法/RAF 绘制/事件订阅分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done | | PKG-CANVAS-04 | artplayer-proxy-canvas
迁移自有源码和公开类型 | PKG-CANVAS-03, ENG-04, ENG-06, CORE-07 | canvas 与媒体能力的精确组合类型,保持 Result | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | | PKG-CANVAS-05 | artplayer-proxy-canvas
验证新旧核心和组合 | PKG-CANVAS-04, CORE-22 | 真实 video/canvas 绘制、字幕和 document PiP 恢复 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo | | PKG-CANVAS-06 | artplayer-proxy-canvas
验证分发并同步文档 | PKG-CANVAS-05, ENG-07 | canvas.js、三种产物、调用兼容与资源证据 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo | @@ -508,3 +508,4 @@ - PKG-VAST-03: [记录](changes/2026-09-12-PKG-VAST-03-lifecycle.md) [记录](baselines/vast-lifecycle-validation.json) [记录](vast-compatibility-decision.md) - PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json) - PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md) +- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md) diff --git a/refactor/progress.md b/refactor/progress.md index 70f816489..ca8ec5a5b 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,18 @@ # 进度与证据 +## 最新完成:Canvas-03 严格TS职责与生命周期 + +生产源码拆为入口/媒体转发/底层video所有权/尺寸/绘制/调度六个严格TS模块,修复 +过期bitmap、并发RAF、回调销毁后继续执行和订阅残留。底层video在播放前挂载, +WebKit保留原始尺寸和多点颜色;Chromium首帧/寻址条件有精确处理。额外保留旧JS +falsy callback行为,避免可选调用语法引入异常。24项候选要求在旧源码20失败/4正常 +通过,最终70项专项、三格式构建和1048项完整CI通过;另44项重复契约观察。 +广矩阵87项通过,最终falsy兼容修正后重跑受影响27项通过,未重复计为新增用例。 +全仓284个生产TS文件严格检查。生命周期/记录的桌面像素风险关闭,类型/两代分发与 +真实设备仍留04~06。当前221项:92 done、6 doing、123 todo。下一步Canvas-04。 +见 [本步记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) 和 +[验证证据](baselines/canvas-lifecycle-validation.json)。独立本地提交,无推送/发布。 + ## 最新完成:Canvas-02 历史错误与原生浏览器对照 33项Node回归与42项真实浏览器检查通过,就绪事件顺序、播放/暂停/seek/resize/切源 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 42b9dccbe..20d220524 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -155,7 +155,7 @@ | AMBILIGHT-TYPE-01 | resolved / 已复现 | Ambilight declarations disagree with optional factory argument and differ across published generations | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 | | AMBILIGHT-DIST-01 | resolved / 已复现 | Ambilight 1.0.0 CommonJS default namespace differs from callable 1.1.0 | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 | | AMBILIGHT-PROXY-01 | resolved / 已复现 | Canvas proxy output pixels differ from forwarded video dimensions | PKG-AMBILIGHT-PROXY-01, PKG-AMBILIGHT-05 | -| CANVAS-LIFE-01 | open / 已复现 | Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership | PKG-CANVAS-02, PKG-CANVAS-03, PKG-CANVAS-05 | +| CANVAS-LIFE-01 | resolved / 已复现 | Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership | PKG-CANVAS-02, PKG-CANVAS-03, PKG-CANVAS-05 | | CANVAS-TYPE-01 | open / 源码/产物事实 | Canvas proxy declarations change required callback across generations and omit forwarded media capabilities | PKG-CANVAS-04, PKG-CANVAS-06 | | CANVAS-DIST-01 | open / 已复现 | Canvas proxy published CommonJS namespace differs from current callable export | PKG-CANVAS-04, PKG-CANVAS-06 | -| CANVAS-PIXEL-01 | open / 已复现 | Historical Canvas proxy produces transparent frames with detached native video in tested WebKit | PKG-CANVAS-03, PKG-CANVAS-05 | +| CANVAS-PIXEL-01 | resolved / 已复现 | Historical Canvas proxy produces transparent frames with detached native video in tested WebKit | PKG-CANVAS-03, PKG-CANVAS-05 | diff --git a/refactor/risks.json b/refactor/risks.json index 2b2115536..1ec432969 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -3351,7 +3351,7 @@ ], "compatibleResolution": "Reproduce pending bitmap completion and rollback/late subscription before adding explicit draw scheduling and resource ownership.", "closureCriteria": "Historical failures reproduced and candidate terminal/error/bitmap/real media lifecycle verified.", - "status": "open", + "status": "resolved", "evidence": [ "refactor/baselines/canvas-contract.md", "refactor/baselines/canvas-release.json", @@ -3359,7 +3359,12 @@ "refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md", "refactor/baselines/canvas-behavior-validation.json" ], - "workspaceState": "33 historical Node checks reproduce pending bitmap, concurrent RAF, close omission, reentrant destroy and delayed/partial registration leaks; 18 real browser core/proxy combinations observe RAF continuing after destroy inside callback. Candidate fixes pending task 03." + "workspaceState": "24 candidate assertions show historical 20 failures/4 normal controls and all pass after ownership changes. Final native 18 proxy cases across actual npm core 5.4.0/candidate and three engines verify media/source/pause/seek/callback destruction and escaped-method termination. Full device and public declaration/distribution acceptance remains tasks 04-06.", + "resolutionEvidence": [ + "refactor/changes/2026-09-12-PKG-CANVAS-03-lifecycle.md", + "refactor/baselines/canvas-lifecycle-validation.json" + ], + "resolutionRationale": "24 candidate assertions show historical 20 failures/4 normal controls and all pass after ownership changes. Final native 18 proxy cases across actual npm core 5.4.0/candidate and three engines verify media/source/pause/seek/callback destruction and escaped-method termination. Full device and public declaration/distribution acceptance remains tasks 04-06." }, { "id": "CANVAS-TYPE-01", @@ -3398,7 +3403,7 @@ { "id": "CANVAS-PIXEL-01", "title": "Historical Canvas proxy produces transparent frames with detached native video in tested WebKit", - "status": "open", + "status": "resolved", "confirmation": "reproduced", "owners": [ "PKG-CANVAS-03", @@ -3410,7 +3415,12 @@ "refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md", "refactor/baselines/canvas-behavior-validation.json" ], - "workspaceState": "All six historical WebKit proxy combinations produce alpha0 while time advances. Separate native video mounted before play yields alpha255; detached native control alpha0. Chromium/Firefox both paths yield255. These historical observations do not prove candidate fix or all Safari device behavior." + "workspaceState": "Final native pixel cases verify original 320x180 media dimensions, multiple colors, alpha255, resize and source replacement across two cores and three engines. Backing video is mounted before play at intrinsic CSS size. Diagnostic zero-count and 1px regressions are preserved; this resolves the recorded Windows Playwright defect, not all Safari/mobile device validation.", + "resolutionEvidence": [ + "refactor/changes/2026-09-12-PKG-CANVAS-03-lifecycle.md", + "refactor/baselines/canvas-lifecycle-validation.json" + ], + "resolutionRationale": "Final native pixel cases verify original 320x180 media dimensions, multiple colors, alpha255, resize and source replacement across two cores and three engines. Backing video is mounted before play at intrinsic CSS size. Diagnostic zero-count and 1px regressions are preserved; this resolves the recorded Windows Playwright defect, not all Safari/mobile device validation." } ] } diff --git a/refactor/tasks.json b/refactor/tasks.json index e082119b7..e3986d3d3 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -3291,11 +3291,15 @@ "CORE-11", "CORE-16" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "video adapter/原 canvas 方法/RAF 绘制/事件订阅分离", "acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过", - "evidence": [] + "evidence": [ + "changes/2026-09-12-PKG-CANVAS-03-lifecycle.md", + "baselines/canvas-lifecycle-validation.json", + "canvas-validation.md" + ] }, { "id": "PKG-CANVAS-04", diff --git a/test/browser/ambilight-proxy.spec.js b/test/browser/ambilight-proxy.spec.js index c1d0d57c5..72cee6d6c 100644 --- a/test/browser/ambilight-proxy.spec.js +++ b/test/browser/ambilight-proxy.spec.js @@ -1,12 +1,11 @@ -import fs from 'node:fs' -import { transform } from 'esbuild' import { hash } from '../../refactor/scripts/releases.mjs' import { ambilightCandidate } from '../helpers/ambilight.js' +import { canvasCandidate } from '../helpers/canvas.js' import { expect, test } from './fixtures.js' const implementation = await ambilightCandidate() -const proxySource = fs.readFileSync('packages/artplayer-proxy-canvas/src/index.js', 'utf8') -const proxy = (await transform(proxySource, { format: 'cjs', target: 'es2020' })).code +const proxySource = (await canvasCandidate()).source +const proxy = proxySource for (const core of ['published-5.1.7', 'published', 'candidate']) { const scenario = core === 'published-5.1.7' ? 'historical core has no proxy option and retains native Ambilight playback' : 'Ambilight samples all nine canvas proxy regions after output resizing' diff --git a/test/browser/canvas-lifecycle.spec.js b/test/browser/canvas-lifecycle.spec.js new file mode 100644 index 000000000..b8a9fb758 --- /dev/null +++ b/test/browser/canvas-lifecycle.spec.js @@ -0,0 +1,134 @@ +import { hash } from '../../refactor/scripts/releases.mjs' +import { canvasCandidate } from '../helpers/canvas.js' +import { expect, test } from './fixtures.js' + +const candidate = await canvasCandidate() + +async function setup(page, core, testInfo) { + await page.goto(`/test/player.html?core=${core}`) + await page.addScriptTag({ content: `(() => { + const pending = new Set(); + const state = { requested: 0, fired: 0, cancelled: 0 }; + const requestAnimationFrame = callback => { + const id = window.requestAnimationFrame(time => { pending.delete(id); state.fired++; callback(time); }); + pending.add(id); state.requested++; return id; + }; + const cancelAnimationFrame = id => { pending.delete(id); state.cancelled++; window.cancelAnimationFrame(id); }; + const module = { exports: {} }; const exports = module.exports; + ${candidate.source}; + window.canvasFactory = module.exports.default || module.exports; + window.canvasFrames = { pending, state }; + })();` }) + await page.evaluate(() => { + window.proxyDraws = 0 + window.proxyErrors = [] + window.proxyErrorStates = [] + window.proxyEvents = [] + window.art = new window.Artplayer({ + container: '.player', + url: '/test/pattern.mp4', + muted: true, + loop: true, + proxy: window.canvasFactory((context, video) => { + window.proxyContext = context + window.proxyVideo = video + window.proxyDraws++ + if (window.destroyNextDraw) { + window.destroyNextDraw = false + window.art.destroy(false) + window.destroyedInCallback = true + } + }), + }) + for (const name of ['video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:seeked', 'artplayerProxyCanvas:draw', 'destroy']) + window.art.on(name, () => window.proxyEvents.push(name)) + window.art.on('artplayerProxyCanvas:error', (error) => { + window.proxyErrors.push({ name: error.name, message: error.message }) + const video = window.proxyVideo || window.art?.template.$player.querySelector('video') + let direct + if (video) { + const canvas = document.createElement('canvas') + try { + const context = canvas.getContext('2d') + context.drawImage(video, 0, 0, 300, 150) + direct = [...context.getImageData(0, 0, 1, 1).data] + } + catch (failure) { direct = failure.name } + } + window.proxyErrorStates.push({ readyState: video?.readyState, seeking: video?.seeking, paused: video?.paused, currentTime: video?.currentTime, source: video?.currentSrc, dimensions: [video?.videoWidth, video?.videoHeight], frames: video?.getVideoPlaybackQuality?.().totalVideoFrames, direct }) + }) + document.querySelector('#play').onclick = () => window.art.play() + document.querySelector('#pause').onclick = () => window.art.pause() + }) + await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) + await page.click('#play') + await expect.poll(() => page.evaluate(() => window.proxyDraws)).toBeGreaterThan(1) + await expect.poll(() => page.evaluate(() => window.proxyVideo.currentTime)).toBeGreaterThan(0) + await testInfo.attach('canvas-candidate-input', { contentType: 'application/json', body: JSON.stringify({ core, sha256: hash(candidate.source), scope: 'actual candidate source bundle, native media/Canvas/bitmap; lexical RAF observer delegates native scheduling' }) }) +} + +test.afterEach(async ({ page }, testInfo) => { + await testInfo.attach('canvas-candidate-error-states', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.proxyErrorStates)) }) + await testInfo.attach('canvas-candidate-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, destroyed: window.art?.isDestroy, connected: window.proxyVideo?.isConnected, currentSrc: window.proxyVideo?.currentSrc, currentTime: window.proxyVideo?.currentTime, frames: window.canvasFrames?.state, pending: window.canvasFrames?.pending.size, draws: window.proxyDraws, errors: window.proxyErrors, events: window.proxyEvents }))) }) + await page.evaluate(() => { + if (window.art && !window.art.isDestroy) + window.art.destroy() + }) +}) + +for (const core of ['published', 'candidate']) { + test(`${core}: candidate canvas paints native pixels, resizes and switches media`, async ({ page }, testInfo) => { + await setup(page, core, testInfo) + expect(await page.evaluate(() => window.art.template.$video.nodeName)).toBe('CANVAS') + expect(await page.evaluate(() => window.proxyVideo.isConnected)).toBe(true) + expect(await page.evaluate(() => [window.proxyVideo.videoWidth, window.proxyVideo.videoHeight])).toEqual([320, 180]) + await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255) + expect(await page.evaluate(() => { + const { canvas } = window.proxyContext + const values = [0.2, 0.5, 0.8].map(ratio => [...window.proxyContext.getImageData(Math.floor(canvas.width * ratio), Math.floor(canvas.height / 2), 1, 1).data].join(',')) + return new Set(values).size + })).toBeGreaterThan(1) + await page.evaluate(() => { + window.art.template.$player.style.width = '320px' + window.art.template.$player.style.height = '480px' + window.art.emit('resize') + }) + await testInfo.attach('canvas-resize-dimensions', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ media: [window.proxyVideo.videoWidth, window.proxyVideo.videoHeight], css: [window.proxyVideo.clientWidth, window.proxyVideo.clientHeight], player: [window.art.template.$player.clientWidth, window.art.template.$player.clientHeight], canvas: [window.proxyContext.canvas.width, window.proxyContext.canvas.height], padding: window.proxyContext.canvas.style.padding }))) }) + expect(await page.evaluate(() => window.art.template.$video.style.padding)).toBe('150px 0px') + await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255) + await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4')) + await page.click('#play') + await expect.poll(() => page.evaluate(() => window.proxyVideo.currentSrc.endsWith('/assets/sample/video.mp4') && window.proxyVideo.currentTime > 0)).toBe(true) + await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255) + expect(await page.evaluate(() => window.proxyErrors)).toEqual([]) + }) + + test(`${core}: candidate paused seek draws the new video frame without restarting RAF`, async ({ page }, testInfo) => { + await setup(page, core, testInfo) + await page.click('#pause') + await expect.poll(() => page.evaluate(() => window.canvasFrames.pending.size)).toBe(0) + const count = await page.evaluate(() => window.proxyDraws) + await page.evaluate(() => window.art.currentTime = 0.5) + await expect.poll(() => page.evaluate(() => window.proxyDraws)).toBeGreaterThan(count) + await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255) + expect(await page.evaluate(() => ({ paused: window.proxyVideo.paused, pending: window.canvasFrames.pending.size }))).toEqual({ paused: true, pending: 0 }) + }) + + test(`${core}: candidate destroy inside callback leaves no media or native RAF and escaped play is inert`, async ({ page }, testInfo) => { + await setup(page, core, testInfo) + await page.evaluate(() => { + window.escapedCanvas = window.art.template.$video + window.escapedPlay = window.escapedCanvas.play + window.destroyNextDraw = true + }) + await expect.poll(() => page.evaluate(() => window.destroyedInCallback && window.art.isDestroy)).toBe(true) + expect(await page.evaluate(() => window.canvasFrames.pending.size)).toBe(0) + const before = await page.evaluate(() => window.canvasFrames.state.fired) + await page.evaluate(async () => { + window.escapedCanvas.src = '/assets/sample/video.mp4' + await window.escapedPlay() + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + }) + expect(await page.evaluate(() => ({ connected: window.proxyVideo.isConnected, paused: window.proxyVideo.paused, source: window.proxyVideo.getAttribute('src'), pending: window.canvasFrames.pending.size, fired: window.canvasFrames.state.fired, last: window.proxyEvents.at(-1) }))).toEqual({ connected: false, paused: true, source: null, pending: 0, fired: before, last: 'destroy' }) + }) +} diff --git a/test/canvas-lifecycle.test.js b/test/canvas-lifecycle.test.js new file mode 100644 index 000000000..fdfa8fcab --- /dev/null +++ b/test/canvas-lifecycle.test.js @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Run the same candidate assertions against frozen source to establish failures. +import test from 'node:test' +import { canvasCandidate, canvasEnvironment, canvasHistorical } from './helpers/canvas.js' + +const implementation = await canvasCandidate() +const listenerCount = env => [...env.listeners.values()].reduce((count, set) => count + set.size, 0) +function deferred() { + let resolve, reject + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +test('Canvas candidate preserves native canvas methods, live video forwarding and normal resize/callback/event order', async () => { + const observations = [] + for (const source of [...await canvasHistorical(), implementation]) { + const env = await canvasEnvironment(source) + const calls = [] + const canvas = env.factory((ctx, video) => calls.push(ctx === env.context && video === env.video))(env.art) + canvas.src = 'movie.mp4' + await canvas.play() + env.art.emit('video:loadedmetadata') + env.art.emit('resize') + await env.flush() + observations.push({ same: canvas === env.canvas, url: env.video.src, paused: env.video.paused, native: canvas.toDataURL(), callbacks: calls, size: [canvas.width, canvas.height], padding: canvas.style.padding, events: env.emitted.map(event => event.name) }) + } + for (const value of observations.slice(1)) assert.deepEqual(value, observations[0]) +}) + +for (const operation of ['pause', 'destroy', 'source']) { + test(`Canvas candidate discards and closes bitmap that resolves after ${operation}`, async () => { + const pending = deferred() + let closed = 0 + const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise }) + const canvas = env.factory()(env.art) + env.art.emit('video:play') + if (operation === 'pause') + env.art.emit('video:pause') + else if (operation === 'destroy') + env.art.destroy() + else canvas.src = 'replacement.mp4' + pending.resolve({ close() { + closed++ + } }) + await env.flush() + assert.equal(closed, 1) + assert.equal(env.draws.length, 0) + assert.equal(env.frames.size, 0) + assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:draw').length, 0) + }) +} + +test('Canvas candidate serializes repeated play and resize while discarding outdated frame results', async () => { + const pending = [deferred(), deferred()] + let count = 0 + let closed = 0 + const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending[count++].promise }) + env.factory()(env.art) + env.art.emit('video:play') + env.art.emit('video:play') + env.art.emit('resize') + assert.equal(count, 1) + pending[0].resolve({ close() { + closed++ + } }) + await env.flush() + assert.equal(env.draws.length, 0) + assert.equal(count, 2) + const latest = { close() { + closed++ + } } + pending[1].resolve(latest) + await env.flush() + assert.deepEqual(env.draws.map(args => args[0]), [latest]) + assert.equal(closed, 2) + assert.equal(env.frames.size, 1) + env.art.destroy() + assert.equal(env.frames.size, 0) +}) + +test('Canvas candidate closes bitmap on draw error and preserves the original error before retry', async () => { + const failure = new Error('draw failed') + let closed = 0 + const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() { + closed++ + } }), onDraw() { + throw failure + } }) + env.factory()(env.art) + env.art.emit('video:play') + await env.flush() + assert.equal(closed, 1) + assert.equal(env.emitted.at(-1).args[0], failure) + assert.equal(env.frames.size, 1) + env.art.destroy() +}) + +test('Canvas candidate contains callback exceptions and stops reentrant destroy before draw event or RAF', async () => { + for (const throws of [false, true]) { + const env = await canvasEnvironment(implementation) + env.factory(() => { + env.art.destroy() + if (throws) + throw new Error('callback destroyed then failed') + })(env.art) + env.art.emit('video:play') + await env.flush() + assert.equal(env.frames.size, 0) + assert.equal(env.emitted.filter(event => event.name.startsWith('artplayerProxyCanvas:')).length, 0) + } +}) + +test('Canvas candidate destroy before deferred setup removes every host handler and timer', async () => { + const env = await canvasEnvironment(implementation) + const canvas = env.factory()(env.art) + const play = canvas.play + env.art.destroy() + canvas.src = 'late.mp4' + await play() + env.flushTimers() + assert.equal(env.proxies.length, 0) + assert.equal(env.timers.size, 0) + assert.equal(listenerCount(env), 0) + assert.notEqual(env.video.src, 'late.mp4') + assert.equal(env.video.paused, true) +}) + +test('Canvas candidate mounts and owns backing media, releases subscriptions and media on destroy', async () => { + const env = await canvasEnvironment(implementation) + env.factory()(env.art) + env.flushTimers() + assert.deepEqual(env.art.template.$player.children, [env.video]) + assert.equal(env.proxies.length, 5) + env.art.destroy() + assert.deepEqual(env.art.template.$player.children, []) + assert.equal(env.proxies.length, 0) + assert.equal(listenerCount(env), 0) + assert.equal(env.video.paused, true) + assert.equal(env.calls.filter(call => call.name === 'load').length, 1) +}) + +test('Canvas candidate late registration returns inert canvas without resources', async () => { + const env = await canvasEnvironment(implementation) + env.art.destroy() + assert.equal(env.factory()(env.art), env.canvas) + env.flushTimers() + assert.equal(env.timers.size, 0) + assert.equal(listenerCount(env), 0) + assert.equal(env.proxies.length, 0) +}) + +test('Canvas candidate setup rollback preserves failure and removes partially registered handler', async () => { + const env = await canvasEnvironment(implementation) + const on = env.art.on + const failure = new Error('registration failed') + env.art.on = (...args) => { + on(...args) + throw failure + } + assert.throws(() => env.factory()(env.art), error => error === failure) + assert.equal(listenerCount(env), 0) + assert.equal(env.timers.size, 0) +}) + +test('Canvas candidate zero dimensions defer drawing and keep finite geometry until metadata', async () => { + const env = await canvasEnvironment(implementation) + env.video.videoWidth = 0 + env.video.videoHeight = 0 + env.factory()(env.art) + env.art.emit('resize') + await env.flush() + assert.equal(env.draws.length, 0) + assert(Number.isFinite(env.canvas.width) && Number.isFinite(env.canvas.height)) + env.video.videoWidth = 320 + env.video.videoHeight = 180 + env.art.emit('video:loadedmetadata') + env.art.emit('resize') + await env.flush() + assert.equal(env.draws.length, 1) +}) + +test('Canvas candidate missing 2D context reports a capability error and does not spin a frame loop', async () => { + const env = await canvasEnvironment(implementation, { noContext: true }) + env.factory()(env.art) + env.art.emit('video:play') + await env.flush() + assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 1) + assert.equal(env.frames.size, 0) +}) + +test('Canvas candidate RAF zero is cancelled and paused seek draws once without restarting playback', async () => { + const env = await canvasEnvironment(implementation, { firstFrame: 0 }) + env.factory()(env.art) + env.art.emit('video:play') + await env.flush() + assert(env.frames.has(0)) + env.art.emit('video:pause') + assert.equal(env.frames.size, 0) + const before = env.draws.length + env.art.emit('video:seeked') + await env.flush() + assert.equal(env.draws.length, before + 1) + assert.equal(env.frames.size, 0) +}) + +test('Canvas candidate waits for a decoded frame before requesting bitmap and then resumes drawing', async () => { + let allocated = 0 + const env = await canvasEnvironment(implementation, { createImageBitmap: () => { + allocated++ + return Promise.resolve({ close() {} }) + } }) + env.video.readyState = 1 + env.factory()(env.art) + env.art.emit('video:play') + await env.flush() + assert.equal(allocated, 0) + env.video.readyState = 4 + env.art.emit('resize') + await env.flush() + assert.equal(allocated, 1) + env.art.destroy() +}) + +test('Canvas candidate ignores late bitmap rejection after terminal disposal', async () => { + const pending = deferred() + const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise }) + env.factory()(env.art) + env.art.emit('video:play') + env.art.destroy() + pending.reject(new Error('late failure')) + await env.flush() + assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0) + assert.equal(env.frames.size, 0) +}) + +test('Canvas candidate preserves active callback error identity and closes bitmap before invoking it', async () => { + let closed = false + const failure = new Error('callback failure') + const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() { + closed = true + } }) }) + env.factory(() => { + assert.equal(closed, true) + throw failure + })(env.art) + env.art.emit('resize') + await env.flush() + assert.equal(env.emitted.at(-1).args[0], failure) + assert.equal(env.frames.size, 0) +}) + +test('Canvas candidate deferred partial media registration failure rolls back native and host subscriptions', async () => { + const env = await canvasEnvironment(implementation) + const proxy = env.art.proxy + const failure = new Error('partial media registration') + env.art.proxy = (...args) => { + proxy(...args) + throw failure + } + env.factory()(env.art) + env.flushTimers() + assert.equal(env.proxies.length, 0) + assert.equal(listenerCount(env), 0) + assert.equal(env.video.parentNode, null) + assert.equal(env.emitted.at(-1).args[0], failure) +}) + +test('Canvas candidate handles destruction reentered before host registration returns', async () => { + const env = await canvasEnvironment(implementation) + const on = env.art.on + env.art.on = (name, handler) => { + if (name === 'destroy') { + env.art.isDestroy = true + handler() + } + return on(name, handler) + } + env.factory()(env.art) + assert.equal(listenerCount(env), 0) + assert.equal(env.timers.size, 0) +}) + +test('Canvas candidate attempts remaining cleanup when a host unsubscribe throws', async () => { + const env = await canvasEnvironment(implementation) + env.factory()(env.art) + env.flushTimers() + const off = env.art.off + const failure = new Error('unsubscribe failure') + env.art.off = (...args) => { + off(...args) + throw failure + } + assert.throws(() => env.art.destroy(), error => error === failure) + assert.equal(listenerCount(env), 0) + assert.equal(env.proxies.length, 0) + assert.equal(env.video.parentNode, null) + assert.equal(env.canvas.width, 0) +}) + +test('Canvas candidate drops bitmap errors from frames invalidated by a native seeking transition', async () => { + const pending = deferred() + const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise }) + env.factory()(env.art) + env.art.emit('video:play') + env.video.readyState = 1 + env.video.seeking = true + pending.reject(new Error('old native frame unavailable')) + await env.flush() + assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0) + env.art.destroy() +}) + +test('Canvas candidate defers only initial unavailable bitmap errors, without trusting zero frame counters as a global capability gate', async () => { + let frames = 0 + const failure = Object.assign(new Error('first frame unavailable'), { name: 'InvalidStateError' }) + const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.reject(failure) }) + env.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: frames }) + env.factory()(env.art) + env.art.emit('resize') + await env.flush() + assert.equal(env.draws.length, 0) + assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0) + frames = 1 + env.art.emit('resize') + await env.flush() + assert.equal(env.emitted.at(-1).args[0], failure) + const direct = await canvasEnvironment(implementation) + direct.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: 0 }) + direct.factory()(direct.art) + direct.art.emit('resize') + await direct.flush() + assert.equal(direct.draws.length, 1) +}) + +test('Canvas candidate never classifies a user callback InvalidStateError as a missing native first frame', async () => { + const failure = Object.assign(new Error('callback invalid state'), { name: 'InvalidStateError' }) + const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {} }) }) + env.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: 0 }) + env.factory(() => { + throw failure + })(env.art) + env.art.emit('resize') + await env.flush() + assert.equal(env.emitted.at(-1).args[0], failure) +}) + +test('Canvas candidate preserves historical JavaScript calls with a falsy callback', async () => { + for (const source of [...await canvasHistorical(), implementation]) { + for (const option of [undefined, null, false, 0, '']) { + const env = await canvasEnvironment(source) + env.factory(option)(env.art) + env.art.emit('resize') + await env.flush() + assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0, `${source.name}: ${String(option)}`) + assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:draw') + } + } +}) diff --git a/test/helpers/canvas.js b/test/helpers/canvas.js index e779ba4c0..0c19ba842 100644 --- a/test/helpers/canvas.js +++ b/test/helpers/canvas.js @@ -1,7 +1,19 @@ +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 { verifyCanvasContract } from '../../refactor/scripts/canvas-contract.mjs' import { readMember } from '../../refactor/scripts/releases.mjs' +import { getEntryFile } from '../../scripts/projects.js' + +export async function canvasCandidate() { + if (process.env.ARTPLAYER_CANVAS_BASELINE === '1') + return (await canvasHistorical()).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-proxy-canvas'))], bundle: true, write: false, platform: 'browser', format: 'cjs', target: 'es2020' }) + return { name: 'candidate-source', source: result.outputFiles[0].text, format: 'artifact' } +} export async function canvasHistorical() { const contract = await verifyCanvasContract() @@ -50,6 +62,7 @@ export async function canvasEnvironment(implementation, settings = {}) { title: 'video-title', src: '', currentTime: 0, + readyState: 4, paused: true, muted: false, style: {}, @@ -63,11 +76,35 @@ export async function canvasEnvironment(implementation, settings = {}) { this.paused = true }, addEventListener(...args) { calls.push({ name: 'video-listener', receiver: this, args }) }, + removeEventListener(name, callback) { + for (let index = proxies.length - 1; index >= 0; index--) { + if (proxies[index].target === this && proxies[index].name === name && proxies[index].callback === callback) + proxies.splice(index, 1) + } + }, + setAttribute(name, value) { this[name] = value }, + removeAttribute(name) { delete this[name] }, + remove() { this.parentNode?.removeChild(this) }, + load() { calls.push({ name: 'load', receiver: this }) }, + } + const player = { + clientWidth: 640, + clientHeight: 480, + children: [], + appendChild(node) { + this.children.push(node) + node.parentNode = this + return node + }, + removeChild(node) { + this.children.splice(this.children.indexOf(node), 1) + node.parentNode = null + }, } const art = { constructor: { config: { events: ['loadedmetadata', 'play', 'pause', 'seeked', 'error'] }, utils: { createElement: tag => tag === 'canvas' ? canvas : video } }, option: { autoSize: false }, - template: { $player: { clientWidth: 640, clientHeight: 480 } }, + template: { $player: player }, isDestroy: false, on(name, callback) { if (!listeners.has(name))