好玩的字符畫播放器 CharPlayer
1. 說明
該項目是將圖片及視頻以字符畫的方式呈現(xiàn)
是抖音比較火爆的特效
基于個人愛好開發(fā)分享給愛折騰的小伙伴們
1.1 技術(shù)說明
引用庫 | 庫說明 | 引用說明 | 備注 |
---|---|---|---|
vue | 虛擬DOM框架 | 加快dom渲染及內(nèi)部變量間聯(lián)動控制 | 核心代碼 |
js-url | url解析 | url參數(shù)解析使用 | |
stats.js | 性能監(jiān)視器 | 監(jiān)視運行時(幀率/幀耗時/內(nèi)存)情況 | |
hls.js | 直播流解析 | 對m3u8直播流的解析支持 | |
flv.js | flv解碼 | 對flv文件及直播流的解析支持 | |
dot | js模板引擎 | 利用模板加快數(shù)據(jù)幀數(shù)據(jù)生成 | |
libgif | GIF解析器和播放器 | 對gif動畫效果的解析支持 | |
inferno | 虛擬DOM框架 | 利用該框架的高性能差異化渲染 | 供渲染方法五使用 |
inferno-create-element | inferno擴展 | 方便inferno的節(jié)點創(chuàng)建 |
1.2 github開源地址
https://github.com/Febby315/code_wallpaper/tree/master/TXTplayer/v3
1.3 預(yù)覽地址
1.2.1 默認效果預(yù)覽
1.2.2 全特效預(yù)覽
1.4 使用說明
1.4.1 URL參數(shù)說明
參數(shù) | 默認值 | 說明 | 備注 |
---|---|---|---|
src | video/v.mp4 | 圖片/視頻uri地址 | uri編碼后的字符串 |
showStats | 空 | 顯示(幀率秒紧、耗時琴昆、內(nèi)存)性能信息 | |
enableColor | 空 | 啟用顏色 | 該特效會嚴重影響性能 |
enableReverse | 空 | 反轉(zhuǎn)前景與背景色 | 目前不美觀 |
className | 空 | 啟用內(nèi)置的特效 | 目前僅支持(shadow快毛、reverse) |
style | 空 | 額外的樣式 | 經(jīng)過JSON.stringify&的對象字符串 |
注意: src參數(shù)不支持跨域資源但支持flv刽虹、m3u8直播鏈接
src和style參數(shù)都需要編碼為uri參數(shù)
1.4.2 示例地址
// 外部圖片源(需要經(jīng)過uri編碼)
var imgSrc = decodeURIComponent('https://i.loli.net/2019/09/02/yOHcCG7XlFVv4M5.png');
// 自定義樣式(先經(jīng)過JSON字符序列化再經(jīng)過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}`;
// 反轉(zhuǎn)色彩&自定義樣式
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
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>字符畫 v3</title>
<link rel="stylesheet" href="css/style.css">
<!-- CDN http://www.jsdelivr.com/ -->
<script src="http://cdn.jsdelivr.net/npm/js-url@2.3.0/url.min.js"></script>
<script src="http://cdn.jsdelivr.net/npm/hls.js@0.13.0/dist/hls.light.min.js"></script>
<script src="http://cdn.jsdelivr.net/npm/stats.js@0.17.0/build/stats.min.js"></script>
<script src="http://cdn.jsdelivr.net/npm/flv.js@1.5.0/dist/flv.min.js"></script>
<script src="http://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
<script src="http://cdn.jsdelivr.net/npm/dot@1.1.3/doT.min.js"></script>
<script src="http://cdn.jsdelivr.net/npm/libgif@0.0.3/libgif.min.js"></script>
<!-- inferno -->
<script src="http://cdn.jsdelivr.net/npm/inferno@7.3.3/dist/inferno.min.js"></script>
<script src="http://cdn.jsdelivr.net/npm/inferno-create-element@7.3.3/dist/inferno-create-element.min.js"></script>
</head>
<body onload="javascript: onload();">
<div id="app" ref="app">
<div class="box">
<!-- @canplay="loadedmetadata($event)" -->
<!-- preload autoplay controls -->
<!-- playsinline webkit-playsinline x5-playsinline -->
<!-- x5-video-player-type="h5" x5-video-player-fullscreen="ture" -->
<video ref="video" class="video" @loadedmetadata="loadedmetadata($event)" @play="play($event)" @pause="pause($event)" @ended="pause($event)" autoplay></video>
<img ref="image" class="image" @load="imgLoaded($event)" />
<canvas ref="canvas" class="canvas"></canvas>
</div>
<div id="view" ref="view" v-html="content" :class="viewClass" :style="viewStyle"></div>
<div id="tool" ref="tool">
<input id="file" ref="file" type="file" @change="fileChange($event)" hidden>
<button type="button" @click="$refs.file.click();">圖片/視頻</button>
<button type="button" @click="videoPlay($event)">播放/暫停</button>
</div>
</div>
<script src="js/script.js"></script>
</body>
</html>
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; }
/* 顏色反轉(zhuǎn) */
.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技術(shù)加速DOM節(jié)點數(shù)據(jù)渲染
var v$app = window.v$app = new Vue({
el: "#app",
data: {
src: "",
flvsrc: "http://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: "http://ivi.bupt.edu.cn/hls/cctv10.m3u8",
content: null, // 視圖html內(nèi)容
timer: null, // 定時器索引
range: document.createRange(), // 用于通過TagString創(chuàng)建虛擬dom(DocumentFragment)節(jié)點
stats: new Stats(), // 性能監(jiān)視器:含fps讨越、耗時ms盈厘、內(nèi)存分配
showStats: !!url("?showStats"), // 顯示統(tǒng)計信息
enableColor: !!url("?enableColor"), // 啟用輸出色彩
enableReverse: !!url("?enableReverse"), // 啟用色彩反轉(zhuǎn)
// 拉伸/自適應(yīng)
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('<span style="color:rgb({{=it.R}},{{=it.G}},{{=it.B}});">{{=it.T}}</span>'), // 彩色字符模板
sw: document.body.offsetWidth, sh: document.body.offsetHeight, // 存儲屏幕寬高(含初始化)
sourceScale: 1, // 默認素材寬高比
currRowTempFn: null, // 行模板
currFrameTempFn: null, // 幀模板
},
// 動態(tài)計算
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;
},
// 屏幕允許最大行數(shù)
maxRow: function() {
return ~~(this.sh / this.fontSize);
},
// 屏幕允許最大列數(shù)
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") // 反轉(zhuǎn)色彩
});
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(); // 初始化統(tǒng)計工具
window.onresize = this.resetToCharsConfig; // 窗口大小改變
});
},
// 數(shù)據(jù)監(jiān)聽
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);
}
},
// 加載靜態(tài)圖片鏈接地址
loadImage: function(src, callback) {
var image = this.$refs.image;
getImageBlob(src, function(url) {
image.src = url;
});
},
// 渲染幀數(shù)據(jù)
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('<br/>\n');
},
// 實時生成行模板
rowTempFn: function(rowData) {
var canvas = this.$refs.canvas, templates = [];
if(this.enableColor) {
for(var i = 0; i < canvas.width; i += 1) {
templates.push('<span style="color:rgb({{=it['+i+'].R}},{{=it['+i+'].G}},{{=it['+i+'].B}});">{{=it['+i+'].T}}</span>');
}
} 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('<span style="color:rgb({{=it['+i+']['+j+'].R}},{{=it['+i+']['+j+'].G}},{{=it['+i+']['+j+'].B}});">{{=it['+i+']['+j+'].T}}</span>');
}
templates.push('<br/>\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('<br/>\n');
}
}
return doT.template(templates.join(''));
},
// 重置采集參數(shù)
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;
// 自適應(yīng)模式
if(this.sourceScale > this.screenScale) {
canvas.width = this.maxCol;// 寬度自適應(yīng)
// 在寬度自適應(yīng)情況下高度/2與寬度保持比例(因字體高度是寬度的2倍, 為保證畫面與素材保持正確比例)
canvas.height = this.maxCol / this.sourceScale / 2;
}else{
canvas.height = this.maxRow;// 高度自適應(yīng)
// 在高度自適應(yīng)情況下寬度*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); // 將畫布圖像數(shù)據(jù)轉(zhuǎn)換為字符畫
},
// 圖像轉(zhuǎn)字符畫數(shù)據(jù)
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 ; // 當(dāng)前畫布圖像數(shù)據(jù)
// 遍歷每個字符畫像素獲取灰度值映射字符追加至字符畫幀數(shù)據(jù)
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];
// 獲取區(qū)域平均灰度及平均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); // 行數(shù)據(jù)
colVNodes.push(Inferno.createElement('span', { style: styleTemplate(p) }, Inferno.createTextVNode(p.T)));
}
rowArray.push(colArray); // 幀數(shù)據(jù)
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;
// 方法一 行模板渲染(相較方法二兼容更多瀏覽器,不易發(fā)生棧溢出)
var frame = frameData.map(function(v) {
return _this.currRowTempFn(v);
}).join("<br/>\n");
// 方法二 幀模板渲染(效率高但兼容差易超出堆棧上限: Maximum call stack size exceeded)
// var frame = this.currFrameTempFn(frameData);
// 方法三 字符模板渲染(效率僅次于方法一,兼容性好);
// var frame = this.renderFrame(frameData);
// 方法四 fragment預(yù)加載渲染(無法清除舊的innerHtml)
// view.innerHtml = null;
// view.appendChild(this.range.createContextualFragment(frame));
// 方法五 Inferno差異化渲染(當(dāng)前場景效率低)
// Inferno.render(frameVNode, view);
this.content = frame; // 渲染畫面
this.$nextTick(function() {
this.stats.update(); // 觸發(fā)性能統(tǒng)計
});
},
// 初始化統(tǒng)計工具
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 媒體元數(shù)據(jù)加載
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); // 視頻暫退驯洌或結(jié)束停止定時器
e.type === "ended" ? this.content=null : null; // 結(jié)束播放清除視圖
},
// videoPlay 播放按鈕點擊事件
videoPlay: function(e) {
var video = this.$refs.video;
video.paused ? video.play() : video.pause();
},
}
});
}