实现思路
- 解码
gif
图片, 遍历每一帧. - 利用
canvas
将定义好的模板文字绘制上去. - 利用
Observable
自由开始/结束绘制.
首先实现一个 gif
工具库
解码使用了omggif.js
编码使用了gif.js
我设计了三个函数, 分别处理gif
解码, 编码, 以及canvas
的创建
// gif.util.ts
export const createCanvas = (width: number, height: number) => {
}
export const gifParser = (file: ArrayBuffer) => {
}
export const gifEncoder = (gifReader: GifReader, context: CanvasReaderingContext2D, textInfo: TemplateContent[]) => {
}
其中 GifReader
是 omggif.js
对 gif
进行解码后得到的信息, TemplateContent
是我对字幕模板自定义的一个类型, 定义如下:
export interface TemplateContent {
text: string;
startTime: number;
endTime: number;
}
export interface Template {
name: string;
content: TemplateContent[];
}
首先实现 gifParser()
, 只需要将 ArrayBuffer
通过 omggif.js
暴露的 API
进行转换即可
export const gifParser = (file: ArrayBuffer) =>
new GifReader(new Uint8Array(file))
其中, GifReader
的类型定义如下
export declare class GifReader {
width: number;
height: number;
numFrames: (() => number);
loopCount: (() => any);
frameInfo: ((frame_num: any) => any);
decodeAndBlitFrameBGRA: ((frame_num: any, pixels: any) => void);
decodeAndBlitFrameRGBA: ((frame_num: any, pixels: any) => void);
constructor(buf: Uint8Array)
}
得到了 GifReader
对象后, 也就知道了 width
和 height
, 可以根据这两个属性开始构建画布, 但在这之前, 还需要对填充的字幕设置一些默认样式
const DEFAULT_FONT_SIZE = 20;
const DEFAULT_FONT_FAMILY = '"Microsoft YaHei", sans-serif';
const DEFAULT_FILL_STYLE = 'white';
const DEFAULT_STROKE_STYLE = 'black';
现在可以实现 createCanvas()
了
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d') as CanvasRenderingContext2D;
context.font = `${DEFAULT_FONT_SIZE}px ${DEFAULT_FONT_FAMILY}`;
context.textAlign = 'center';
context.textBaseline = 'bottom';
context.fillStyle = DEFAULT_FILL_STYLE;
context.strokeStyle = DEFAULT_STROKE_STYLE;
context.lineWidth = 3;
context.lineJoin = 'round';
return context;
gifEncoder()
的逻辑比前两个函数稍稍复杂一些, 分为三个部分来实现
// 利用 gif.js 创建出一个 GIF 对象方便逐帧处理
const [width, height] = [gifReader.width, gifReader.height];
const gif = new GIF({
width: width,
height: height,
workerScript: './assets/js/gif.worker.js',
});
const pixelBuffer = new Uint8ClampedArray(width * height * 4);
// gifReader.numFrames() 可以获取总帧数
// girReader.frameInfo(i) 获取第 i 帧
// 通过 gif.addFrame() 插入文字至 gif 图
for (let i = 0, textIndex = 0, time = 0; i < gifReader.numFrames(); i++) {
const frameInfo = gifReader.frameInfo(i);
gifReader.decodeAndBlitFrameRGBA(i, pixelBuffer);
const imageData = new ImageData(pixelBuffer, width, height);
context.putImageData(imageData, 0, 0);
if (textIndex < textInfo.length) {
const info = textInfo[textIndex];
if (info.startTime <= time && time < info.endTime) {
context.strokeText(info.text, width / 2, height - 8, width);
context.fillText(info.text, width / 2, height - 8, width);
}
time += frameInfo.delay / 100;
if (time >= info.endTime) {
textIndex++;
}
}
gif.addFrame(context, {
copy: true,
delay: frameInfo.delay * 10,
dispose: frameInfo.disposal,
});
}
// 将返回的对象转成 Observable 以便随时订阅/取消
return from(new Promise<string>(resolve => {
gif.on('finished', (blob: Blob) => {
resolve(window.URL.createObjectURL(blob));
});
gif.render();
}));
工具库完成之后, 开始一个 Angular
项目
利用路由来控制渲染的模板, 默认跳转至第一个模板
// app.routing.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { MainComponent } from './main/main.component';
const routes: Routes = [
{ path: '', redirectTo: '窃格瓦拉', pathMatch: 'full' },
{ path: ':name', component: MainComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
app component
中只存在一个 menu
和 路由插座
// app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<app-menu></app-menu>
<router-outlet></router-outlet>
`,
styles: [`
app-menu {
position: fixed;
width: 100%;
z-index: 1;
}
:host ::ng-deep app-main {
display: flex;
flex-direction: column;
padding-top: 64px;
}
`]
})
export class AppComponent {
title = 'app';
constructor(
) { }
}
在 menu component
中, 我需要获取到模板数据来进行组建渲染
我在 assets
文件夹下定义了一个模板文件
[
{
"name": "窃格瓦拉",
"content": [
{
"text": "没有钱啊,肯定要做的啊",
"startTime": 0,
"endTime": 1.8
},
{
"text": "不做的话没有钱用",
"startTime": 1.88,
"endTime": 3.76
},
{
"text": "那你不会去打工啊",
"startTime": 3.81,
"endTime": 4.66
},
{
"text": "有手有脚的",
"startTime": 4.66,
"endTime": 5.90
},
{
"text": "打工是不可能打工的",
"startTime": 6.02,
"endTime": 8.42
},
{
"text": "这辈子都不可能打工的",
"startTime": 8.42,
"endTime": 10.75
}
]
},
{
"name": "为所欲为",
"content": [
{
"text": "好啊",
"startTime": 1.18,
"endTime": 1.56
},
{
"text": "我是一等良民就不说",
"startTime": 3.18,
"endTime": 4.43
},
{
"text": "即使一定要冤枉我",
"startTime": 5.31,
"endTime": 7.43
},
{
"text": "我也有钱聘请大律师帮我",
"startTime": 7.56,
"endTime": 9.93
},
{
"text": "我想我别指望坐牢了",
"startTime": 10.06,
"endTime": 11.56
},
{
"text": "你别以为有钱就能为所欲为",
"startTime": 11.93,
"endTime": 13.06
},
{
"text": "抱歉,有钱是真的能为所欲为的",
"startTime": 13.81,
"endTime": 16.31
},
{
"text": "但我看他领会不到这种意境",
"startTime": 18.06,
"endTime": 19.56
},
{
"text": "领会不到",
"startTime": 19.6,
"endTime": 21.6
}
]
},
{
"name": "王境泽",
"content": [
{
"text": "我王境泽就是饿死",
"startTime": 0,
"endTime": 1.04
},
{
"text": "死外边,从这里跳下去",
"startTime": 1.46,
"endTime": 2.9
},
{
"text": "也不会吃你们一点东西",
"startTime": 3.09,
"endTime": 4.33
},
{
"text": "真香",
"startTime": 4.59,
"endTime": 5.93
}
]
}
]
之后我只要将 json
文件声明为一个 module
, 即可在 Angular
中直接使用
// index.d.ts
declare module '*.json';
// menu.component.ts
import { Component, OnInit } from '@angular/core';
import { Template } from '../models/template';
import templates from '../../assets/templates.json';
@Component({
selector: 'app-menu',
template: `
<mat-toolbar fxLayout="row" fxLayoutAlign="start center" class="mat-elevation-z6">
<button mat-icon-button [matMenuTriggerFor]="menu" color="primary">
<mat-icon>menu</mat-icon>
</button>
</mat-toolbar>
<mat-menu #menu="matMenu">
<a mat-menu-item *ngFor="let name of templateNames" [routerLink]="name">{{ name }}</a>
<a mat-button routerLink="custom">自定义GIF上传</a>
</mat-menu>
`,
styles: [`
mat-toolbar {
height: 48px;
}
`]
})
export class MenuComponent implements OnInit {
templateNames: string[];
constructor() { }
ngOnInit() {
this.templateNames = (templates as Template[]).map(v => v.name);
}
}
main component
是核心, 也是最复杂的一个, 但在这之前, 我还需要一个 service
来获取 gif
图片
// gif.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class GifService {
fetch(name: string) {
return this.http.get(`./assets/gif/${name}.gif`, {
responseType: 'arraybuffer'
});
}
constructor(
public http: HttpClient
) { }
}
在 main component
中, 我需要一些属性来确定程序执行的当前状态
name: string; // 当前渲染的模板名称
arraybuffer: ArrayBuffer; // 当前模板的 gif 图
blob: SafeUrl; // 最终展示的 gif 图
imgWidth: number; // gif width
imgHeight: number; // gif height
isComplete: boolean; // 绘制是否完成
templateContent: TemplateContent[]; // 当前模板内容
prevSubscription: Subscription; // 上一次订阅的绘制操作
可以利用 ActivatedRoute
获取当前路由参数
// route: ActivatedRoute
// gifService: GifService
ngOnInit() {
this.route.params.forEach(param => {
this.name = param.name;
const template = (templates as Template[]).find(v => v.name === this.name);
this.templateContent = template ? template.content : [];
if (this.templateContent.length > 0) {
this.arraybuffer = new ArrayBuffer(0);
this.gifService.fetch(this.name).subscribe(arraybuffer => {
this.arraybuffer = arraybuffer;
// Todo build
});
}
});
}
将参数的初始化抽离出来
init(name: string) {
this.name = name;
this.arraybuffer = null;
this.blob = '';
this.imgWidth = 0;
this.imgHeight = 0;
this.isComplete = false;
const template = (templates as Template[]).find(v => v.name === this.name);
this.templateContent = template ? template.content : [];
// 如果上一次绘制还未完成, 可以通过取消订阅来终止上一次绘制
if (this.prevSubscription) { this.prevSubscription.unsubscribe(); }
}
ngOnInit() {
this.route.params.forEach(param => {
this.init(param.name);
if (this.templateContent.length > 0) {
this.arraybuffer = new ArrayBuffer(0);
this.gifService.fetch(this.name).subscribe(arraybuffer => {
this.arraybuffer = arraybuffer;
// Todo build
});
}
});
}
build()
函数非常容易实现
build() {
this.isComplete = false;
const gifReader = gifParser(this.arraybuffer);
const ctx = createCanvas(gifReader.width, gifReader.height);
[this.imgWidth, this.imgHeight] = [gifReader.width, gifReader.height];
this.prevSubscription = gifEncoder(gifReader, ctx, this.templateContent.subscribe(blob => {
this.blob = this.sanitizer.bypassSecurityTrustUrl(blob);
this.isComplete = true;
});
}
功能大体完成了
import { Component, OnInit } from '@angular/core';
import { Template, TemplateContent } from '../models/template';
import { createCanvas, GifReader, gifParser, gifEncoder } from '../util/gif';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { ActivatedRoute } from '@angular/router';
import { GifService } from '../services/gif.service';
import templates from '../../assets/templates.json';
@Component({
selector: 'app-main',
templateUrl: './main.component.html',
styles: [`
:host ::ng-deep .mat-form-field-infix {
width: auto;
}
`]
})
export class MainComponent implements OnInit {
name: string;
arraybuffer: ArrayBuffer;
blob: SafeUrl;
imgWidth: number;
imgHeight: number;
isComplete: boolean;
templateContent: TemplateContent[];
prevSubscription: Subscription;
init(name: string) {
this.name = name;
this.arraybuffer = null;
this.blob = '';
this.imgWidth = 0;
this.imgHeight = 0;
this.isComplete = false;
const template = (templates as Template[]).find(v => v.name === this.name);
this.templateContent = template ? template.content : [];
if (this.prevSubscription) { this.prevSubscription.unsubscribe(); }
}
build() {
this.isComplete = false;
const gifReader = gifParser(this.arraybuffer);
const ctx = createCanvas(gifReader.width, gifReader.height);
[this.imgWidth, this.imgHeight] = [gifReader.width, gifReader.height];
this.prevSubscription = gifEncoder(gifReader, ctx, this.templateContent).subscribe(blob => {
this.blob = this.sanitizer.bypassSecurityTrustUrl(blob);
this.isComplete = true;
});
}
ngOnInit() {
this.route.params.forEach(param => {
this.init(param.name);
if (this.templateContent.length > 0) {
this.arraybuffer = new ArrayBuffer(0);
this.gifService.fetch(this.name).subscribe(arraybuffer => {
this.arraybuffer = arraybuffer;
this.build();
});
}
});
}
constructor(
public sanitizer: DomSanitizer,
public route: ActivatedRoute,
public gifService: GifService
) { }
}
再添加一个对用户自己上传的 gif
图进行绘制的功能, 只需要再增加两个函数
// onChange() 在上传的文件发生改变时调用
onChange(e: Event) {
const file = (e.target as HTMLInputElement).files[0];
const fileData = new Blob([file]);
const reader = new FileReader();
reader.readAsArrayBuffer(fileData);
reader.onload = () => {
this.arraybuffer = reader.result;
this.build();
};
}
addTemplate() {
this.templateContent = [...this.templateContent, { text: '', startTime: 0, endTime: 0 }];
}
然后是组件的 html
<div fxLayout="column" fxLayoutAlign="center center" style="padding: 0 24px;">
<!-- 增加 arraybuffer 的判断是为了防止在自定义页面图片未上传时就出现加载动画 -->
<img [src]="blob" [width]="imgWidth" [height]="imgHeight" *ngIf="isComplete && arraybuffer">
<mat-spinner *ngIf="!isComplete && arraybuffer"></mat-spinner>
<!-- 这一块内容仅在绘制自定义 gif 时显示 -->
<div *ngIf="name === 'custom'">
<button mat-button (click)="fileIpt.click()">上传GIF</button>
<button mat-button (click)="addTemplate()">增加模板输入框</button>
</div>
<div *ngFor="let i of templateContent" style="padding-bottom: 12px;">
<div fxLayout="column">
<mat-form-field style="width: 100%;">
<input matInput placeholder="text" [(ngModel)]="i.text">
</mat-form-field>
<div fxLayout="row" fxLayoutAlign="center center" fxLayoutGap="12px">
<mat-form-field>
<input matInput placeholder="startTime" type="number" [(ngModel)]="i.startTime">
</mat-form-field>
<mat-form-field>
<input matInput placeholder="endTime" type="number" [(ngModel)]="i.endTime">
</mat-form-field>
</div>
</div>
</div>
<button mat-button (click)="build()">生成</button>
</div>
<input type="file" hidden #fileIpt (change)="onChange($event)">