好玩的字符画

好玩的字符画播放器 CharPlayer

1. 说明

该项目是将图片及视频以字符画的方式呈现
是抖音比较火爆的特效
基于个人爱好开发分享给爱折腾的小伙伴们

1.1 技术说明

引用库 库说明 引用说明 备注
vue 虚拟DOM框架 加快dom渲染及内部变量间联动控制 核心代码
js-url url解析 url参数解析使用
stats.js 性能监视器 监视运行时(帧率/帧耗时/内存)情况
hls.js 直播流解析 对m3u8直播流的解析支持
flv.js flv解码 对flv文件及直播流的解析支持
dot js模板引擎 利用模板加快数据帧数据生成
libgif GIF解析器和播放器 对gif动画效果的解析支持
inferno 虚拟DOM框架 利用该框架的高性能差异化渲染 供渲染方法五使用
inferno-create-element inferno扩展 方便inferno的节点创建

1.2 github开源地址

https://github.com/Febby315/code_wallpaper/tree/master/TXTplayer/v3

1.3 预览地址

https://g.febby315.top/TXTplayer/v3/index.html

1.2.1 默认效果预览

默认特效.jpg

1.2.2 全特效预览

特效全开.jpg

1.4 使用说明

1.4.1 URL参数说明

参数 默认值 说明 备注
src video/v.mp4 图片/视频uri地址 uri编码后的字符串
showStats 显示(帧率、耗时、内存)性能信息
enableColor 启用颜色 该特效会严重影响性能
enableReverse 反转前景与背景色 目前不美观
className 启用内置的特效 目前仅支持(shadow、reverse)
style 额外的样式 经过JSON.stringify&的对象字符串

注意: src参数不支持跨域资源但支持flv、m3u8直播链接
src和style参数都需要编码为uri参数

1.4.2 示例地址

  // 外部图片源(需要经过uri编码)
  var imgSrc = decodeURIComponent('https://i.loli.net/2019/09/02/yOHcCG7XlFVv4M5.png');
  // 自定义样式(先经过JSON字符序列化再经过uri编码)
  var style = decodeURIComponent(JSON.stringify({ transform: 'scale(-0.8)' }));
  // 阴影&性能信息
  var url = `https://g.febby315.top/TXTplayer/v3/?className=shadow&showStats=1`;
  // 彩色&外链图片:
  var url = `https://g.febby315.top/TXTplayer/v3/?enableColor=1&src=${imgSrc}`;
  // 反转色彩&自定义样式
  var url = `https://g.febby315.top/TXTplayer/v3/?enableReverse=1&style=${style}`;

1.4.3 支持格式

  • 图片: (.jpg|.jpeg|.png|.gif)
  • 视频: (.mp4|.ogg|.webp|.flv)
  • 直播链接: (.flv|.m3u8)

2. 完整代码

2.1. index.html




    
    
    
    字符画 v3
    
    
    
    
    
    
    
    
    
    
    
    


    

2.2. css/style.css

:root{
    --pic-view-bg: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBvbHlnb24gcG9pbnRzPSIwLDAgMTAsMCAxMCwxMCAyMCwxMCAyMCwyMCAxMCwyMCAxMCwxMCAwLDEwIiBzdHlsZT0iZmlsbDpyZ2JhKDEzOCwxMzgsMTM4LDAuMyk7c3Ryb2tlLXdpZHRoOjAiLz48L3N2Zz4=");
}
::-webkit-scrollbar{ display: none; }
/*  */
html{ font-family: monospace; background: #000 var(--pic-view-bg) fixed; }
html,body,#app{ margin: 0px; padding: 0px; width: 100%; height: 100%; overflow: hidden; }
#app{ display: flex; align-items: center; justify-content: center; background-color: rgba(0,0,0,0.25); color: #fff; }
.box{ display: none; opacity: 0.5; }
.video{ position: absolute; left: 0px; top: 0px; max-width: 6vw; max-height: 12vh; }
.image{ position: absolute; left: 0px; bottom: 0px; max-width: 6vw; max-height: 12vh; }
.canvas{ position: fixed; right: 0px; top: 0px; }
/* position: fixed; */
#view{
    /* transform: scale(0.8); width: 100%; */
    bottom: 0px; overflow: hidden; z-index: 2;
    font-family: monospace; font-size: 12px; line-height: 1em; letter-spacing:0px; word-spacing:0px; text-align: center;
}
#tool{ position: fixed; bottom: 0px; right: 0px; padding: 10px; text-align: right; z-index: 10; }
.stats{ position: fixed; top: 0px; z-index: 100; }
/* 红色阴影特效 */
.shadow{ text-shadow: 2px -1px 1px #f00a; }
/* 颜色反转 */
.reverse{ background: #fff; color: #000; }

2.3. js/script.html

const getImageBlob = function(url, callback) {
    var xhr = new XMLHttpRequest();
    xhr.open('get', url, true);
    xhr.responseType = 'blob';
    xhr.onload = function() {
        if(this.status === 200 && callback instanceof Function) callback(URL.createObjectURL(this.response));
    };
    xhr.send();
}

// 页面body加载完成
function onload(){
    const UA = navigator.userAgent;
    const isAndroid = UA.indexOf('Android') > -1 || UA.indexOf('Adr') > -1; //android终端
    const space = isAndroid? ' ' : ' ';
    // 利用vue虚拟DOM技术加速DOM节点数据渲染
    var v$app = window.v$app = new Vue({
        el: "#app",
        data: {
            src: "",
            flvsrc: "//aliyun-flv.yy.com/live/15013_xv_22490906_22490906_0_0_0-15013_xa_22490906_22490906_0_0_0-96597708953498332-96597708953498333-2-2748477-33.flv?codec=orig&secret=bec0e1c80fad166895855545ff4efc89&t=1562310185&appid=15013",
            m3u8src: "//ivi.bupt.edu.cn/hls/cctv10.m3u8",
            content: null, // 视图html内容
            timer: null, // 定时器索引
            range: document.createRange(), // 用于通过TagString创建虚拟dom(DocumentFragment)节点
            stats: new Stats(), // 性能监视器:含fps、耗时ms、内存分配
            showStats: !!url("?showStats"), // 显示统计信息
            enableColor: !!url("?enableColor"), // 启用输出色彩
            enableReverse: !!url("?enableReverse"), // 启用色彩反转
            // 拉伸/自适应
            fps: 30, // fps(流畅度)
            fontSize: 12, // 视图容器字体大小
            chars: [space, '.', ':', ';', '!', 'i', 'c', 'e', 'm', '@'], // 映射字符集;
            styleTemplate: doT.template('color: rgb({{=it.R}},{{=it.G}},{{=it.B}});'), // 彩色字符style模板
            spanTemplate: doT.template('{{=it.T}}'), // 彩色字符模板
            sw: document.body.offsetWidth, sh: document.body.offsetHeight, // 存储屏幕宽高(含初始化)
            sourceScale: 1, // 默认素材宽高比
            currRowTempFn: null, // 行模板
            currFrameTempFn: null, // 帧模板
        },
        // 动态计算
        computed:{
            // 配置灰度字符映射表
            charMap: function() {
                var chars = !this.enableReverse ? this.chars : this.chars.reverse();
                var len = 256, step = ~~(len/(chars.length-1)); // 映射步长=最大字符长度/映射字符长度
                return Array.apply(!0, Array(len)).map(function(v,i,c){
                    return chars[~~(i / step)];
                });
            },
            // 屏幕宽高比
            screenScale: function() {
                return this.sw / this.sh;
            },
            // 屏幕允许最大行数
            maxRow: function() {
                return ~~(this.sh / this.fontSize);
            },
            // 屏幕允许最大列数
            maxCol: function() {
                var fontWidth = this.fontSize / 2;
                return ~~(this.sw / fontWidth);
            },
            // 画面帧间隔时间ms
            fpsStep: function() {
                return 1000 / this.fps;
            },
            viewClass: function() {
                var className = url("?className");
                if(!Array.isArray(className)) className = [className];
                className.push({
                    reverse: url("?enableReverse") // 反转色彩
                });
                return className;
            },
            viewStyle: function() {
                var style = url("?style");
                return style ? JSON.parse(style) : undefined;
            },
        },
        mounted: function() {
            this.$nextTick(function() {
                this.src = url("?src") || "video/v.mp4";
                this.initStats(); // 初始化统计工具
                window.onresize = this.resetToCharsConfig; // 窗口大小改变
            });
        },
        // 数据监听
        watch: {
            src: function(nv, ov) {
                var video = this.$refs.video, canvas = this.$refs.canvas;
                this.timer ? clearInterval(this.timer) : null; // 移除定时器
                var ctx = canvas.getContext('2d');
                ctx.clearRect(0, 0, canvas.width, canvas.height); // 清除画布
                var ext = url("fileext", nv);
                switch(String(ext).toLowerCase()) {
                    case "flv": this.loadFlv(nv, ext); break;
                    case "m3u8": this.loadHls(nv, ext); break;
                    case "jpg": this.loadImage(nv, ext); break;
                    case "png": this.loadImage(nv, ext); break;
                    case "gif": this.loadImage(nv, ext); break;
                    default: video.src = nv; break;
                }
                this.$nextTick(function() {
                    video.load();
                });
            },
            enableColor: function(nv, ov) {
                this.resetToCharsConfig();
            }
        },
        methods: {
            // 加载Flv链接地址
            loadFlv: function(src, callback) {
                var video = this.$refs.video;
                if(flvjs.isSupported()) {
                    var flvPlayer = flvjs.createPlayer({ type: 'flv', url: src });
                    flvPlayer.attachMediaElement(video);
                    flvPlayer.load();
                    video.load();
                    // flvPlayer.play();
                    if(callback instanceof Function) callback(flvPlayer);
                }
            },
            // 加载Hls链接地址(m3u8)
            loadHls: function(src, callback) {
                var video = this.$refs.video;
                if(Hls.isSupported()) {
                    var hls = new Hls();
                    hls.loadSource(src);
                    hls.attachMedia(video);
                    video.load();
                    if(callback instanceof Function) callback(hls);
                }
            },
            // 加载静态图片链接地址
            loadImage: function(src, callback) {
                var image = this.$refs.image;
                getImageBlob(src, function(url) {
                    image.src = url;
                });
            },
            // 渲染帧数据
            renderFrame: function(frameData) {
                var enableColor = this.enableColor, spanTemplate = this.spanTemplate;
                return frameData.map(function(rowData) {
                    return rowData.map(function(v) {
                        return enableColor ? spanTemplate(v) : v.T;
                    }).join('');
                }).join('
\n'); }, // 实时生成行模板 rowTempFn: function(rowData) { var canvas = this.$refs.canvas, templates = []; if(this.enableColor) { for(var i = 0; i < canvas.width; i += 1) { templates.push('{{=it['+i+'].T}}'); } } else { for(var i = 0; i < canvas.width; i += 1) { templates.push('{{=it['+i+'].T}}'); } } return doT.template(templates.join('')); }, // 实时生成帧模板 frameTempFn: function() { var canvas = this.$refs.canvas, templates = []; if(this.enableColor) { for(var i = 0; i < canvas.height; i += 1) { for(var j = 0; j < canvas.width; j += 1) { templates.push('{{=it['+i+']['+j+'].T}}'); } templates.push('
\n'); } } else { for(var i = 0; i < canvas.height; i += 1) { for(var j = 0; j < canvas.width; j += 1) { templates.push('{{=it['+i+']['+j+'].T}}'); } templates.push('
\n'); } } return doT.template(templates.join('')); }, // 重置采集参数 resetToCharsConfig: function() { var canvas = this.$refs.canvas, app = this.$refs.app; // 采集屏幕宽高 this.sw = app.offsetWidth; this.sh = app.offsetHeight; // console.log("最大允许 宽:%s 高:%s ", this.maxCol, this.maxRow); // console.log("素材比屏幕宽?(%s) 素材宽高比:%s 屏幕宽高比:%s", this.sourceScale>this.screenScale, this.sourceScale, this.screenScale); // 拉伸模式 // canvas.width = this.maxCol; // canvas.height = this.maxRow; // 自适应模式 if(this.sourceScale > this.screenScale) { canvas.width = this.maxCol;// 宽度自适应 // 在宽度自适应情况下高度/2与宽度保持比例(因字体高度是宽度的2倍, 为保证画面与素材保持正确比例) canvas.height = this.maxCol / this.sourceScale / 2; }else{ canvas.height = this.maxRow;// 高度自适应 // 在高度自适应情况下宽度*2与高度保持比例(因字体高度是宽度的2倍, 为保证画面与素材保持正确比例) canvas.width = this.maxRow * this.sourceScale * 2; } // console.log("最终canvas宽高", canvas.width, canvas.height); this.currRowTempFn = this.rowTempFn(); // 生成行模版 this.currFrameTempFn = this.frameTempFn(); // 生成帧模版 }, // 绘制canvas drawCanvas: function(ctx, ele) { const canvas = this.$refs.canvas; ctx.drawImage(ele, 0, 0, canvas.width, canvas.height); // 绘制图像 this.toFrameData(ctx, canvas.width, canvas.height, this.update); // 将画布图像数据转换为字符画 }, // 图像转字符画数据 toFrameData: function(ctx, cw, ch, callback) { const canvas = this.$refs.canvas; const styleTemplate = this.styleTemplate; var image = ctx.getImageData(0, 0, canvas.width, canvas.height); var imgDate = image.data ; // 当前画布图像数据 // 遍历每个字符画像素获取灰度值映射字符追加至字符画帧数据 var rowArray = [], rowVNodes = []; for(var i = 0, idx = 0; i < image.height; i += 1) { var colArray = [], colVNodes = []; for(var j = 0; j < image.width; j += 1, idx += 4) { var p = { R: 0, G: 0, B: 0 }; p.R = ~~imgDate[idx], p.G = ~~imgDate[idx+1], p.B = ~~imgDate[idx+2]; // 获取区域平均灰度及平均RGB色彩值 为提高效率将单像素灰度计算中的除以100提出 // https://www.cnblogs.com/zhangjiansheng/p/6925722.html var Gray = (p.R*38 + p.G*75 + p.B*15) >> 7; p.T = this.charMap[Gray]; // 映射灰度字符 colArray.push(p); // 行数据 colVNodes.push(Inferno.createElement('span', { style: styleTemplate(p) }, Inferno.createTextVNode(p.T))); } rowArray.push(colArray); // 帧数据 rowVNodes.push(Inferno.createElement('div', null, colVNodes)); }; var VNode = Inferno.createElement('div', null, rowVNodes); if(callback instanceof Function) callback(rowArray, VNode); }, // 更新画面 update: function(frameData, frameVNode) { var _this = this, view = this.$refs.view; // 方法一 行模板渲染(相较方法二兼容更多浏览器,不易发生栈溢出) var frame = frameData.map(function(v) { return _this.currRowTempFn(v); }).join("
\n"); // 方法二 帧模板渲染(效率高但兼容差易超出堆栈上限: Maximum call stack size exceeded) // var frame = this.currFrameTempFn(frameData); // 方法三 字符模板渲染(效率仅次于方法一,兼容性好); // var frame = this.renderFrame(frameData); // 方法四 fragment预加载渲染(无法清除旧的innerHtml) // view.innerHtml = null; // view.appendChild(this.range.createContextualFragment(frame)); // 方法五 Inferno差异化渲染(当前场景效率低) // Inferno.render(frameVNode, view); this.content = frame; // 渲染画面 this.$nextTick(function() { this.stats.update(); // 触发性能统计 }); }, // 初始化统计工具 initStats: function() { var tool = this.$refs.tool, statsEle = this.stats.domElement; if(this.showStats && tool && statsEle) { statsEle.className = "stats"; tool.appendChild(statsEle); } }, // vue事件 // fileChange 文件更改时修改视频源 fileChange: function(e) { var file = this.$refs.file, image = this.$refs.image; if(file.files[0]) { this.src = URL.createObjectURL(file.files[0]); // 兼容图片 var type = file.files[0].type; if(type.split("/")[0] === "image") { image.src = this.src; image.setAttribute("data-type", type); } } }, // imgLoaded 图片加载成功 imgLoaded: function(e) { var image = this.$refs.image; this.sourceScale = image.width/image.height; this.resetToCharsConfig(); // 开始渲染 var _this = this, canvas = this.$refs.canvas; var ctx = canvas.getContext('2d'); this.drawCanvas(ctx, image); // gif支持 if(["image/gif"].indexOf(image.getAttribute("data-type")) !== -1) { var rub = new SuperGif({ gif: image, progressbar_height: 0 }); rub.load(function() { var gifCanvas = rub.get_canvas(); _this.timer = setInterval(function() { _this.drawCanvas(ctx, gifCanvas); }, _this.fpsStep); }); } }, // canplay 媒体可播放 // loadedmetadata 媒体元数据加载 loadedmetadata: function(e) { var video = this.$refs.video; this.sourceScale = video.videoWidth/video.videoHeight || this.screenScale; this.resetToCharsConfig(); }, // play 视频播放事件 play: function(e) { var _this = this, video = this.$refs.video, canvas = this.$refs.canvas; var ctx = canvas.getContext('2d'); this.timer = setInterval(function() { if(!video.paused) { _this.drawCanvas(ctx, video); } }, _this.fpsStep); }, // pause 视频暂停/停止事件 pause: function(e) { clearInterval(this.timer); // 视频暂停或结束停止定时器 e.type === "ended" ? this.content=null : null; // 结束播放清除视图 }, // videoPlay 播放按钮点击事件 videoPlay: function(e) { var video = this.$refs.video; video.paused ? video.play() : video.pause(); }, } }); }

你可能感兴趣的:(好玩的字符画)