angular 完成自定义 gif 表情包字幕

432 阅读4分钟
原文链接: zhuanlan.zhihu.com

项目地址
在线demo

实现思路

  • 解码 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[]) => {

}

其中 GifReaderomggif.jsgif 进行解码后得到的信息, 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 对象后, 也就知道了 widthheight, 可以根据这两个属性开始构建画布, 但在这之前, 还需要对填充的字幕设置一些默认样式

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)">