SuemorのBlog https://suemor.com 在探索的过程中遇见更好的自己 zh-CN © Suemor Tue, 17 Mar 2026 16:03:59 GMT Mix Space CMS (https://github.com/mx-space) https://mx-space.js.org https://y.suemor.com/suemor-avatar.jpeg SuemorのBlog https://suemor.com ArrayBuffer、TypedArray 和 DataView 在 MP4 Box 解析中的运用 https://suemor.com/posts/programming/arraybuffer-typedarray-dataview-mp4-box Sat, 21 Feb 2026 12:04:18 GMT JavaScript 处理二进制数据的 API 主要有三种:ArrayBuffer、TypedArr 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/posts/programming/arraybuffer-typedarray-dataview-mp4-box

JavaScript 处理二进制数据的 API 主要有三种:ArrayBufferTypedArrayDataView。在 MP4 Box 的解析和处理过程中,这些工具非常有用。本文结合实际的 MP4 box 结构,聊聊它们各自的定位和取舍。

三者的关系

ArrayBuffer

ArrayBuffer 是一块原始的、固定长度的二进制内存,你不能直接读写它,必须通过"视图"来操作。当你 fetch 一个 fMP4 文件后,对其解析会先转为 ArrayBuffer。

const response = await fetch('video.m4s');
const buffer = await response.arrayBuffer();
console.log(buffer); 

TypedArray

TypedArray 是一组类型化数组视图(Uint8Array、Uint16Array、Uint32Array、Float32Array 等),它把 ArrayBuffer 当作同构的数组来访问——所有元素类型相同、等宽排列。它只是在已有的 ArrayBuffer 上建立一个视图,本身不复制也不额外分配数据内存。

const response = await fetch('video.m4s');
const buffer = await response.arrayBuffer();
const uint8 = new Uint8Array(buffer); // 创建视图,不会占用额外内存
console.log(uint8); 

DataView

DataView 也是建立在 ArrayBuffer 上的视图,同样不复制数据。与 TypedArray 不同的是,它不把 buffer 当数组看,而是提供了一组方法让你在任意偏移位置、以任意类型和字节序来读写数据——没有对齐限制,也不要求字段类型统一。

const response = await fetch('video.m4s');
const buffer = await response.arrayBuffer();
const view = new DataView(buffer); 
const size = view.getUint32(0);     
const type = String.fromCharCode(
  view.getUint8(4), view.getUint8(5),
  view.getUint8(6), view.getUint8(7)
);  
console.log(size, type); 

三者的关系可以这样理解:ArrayBuffer 是仓库,TypedArray 和 DataView 是两种不同的取货方式——前者像传送带,只能运同一规格的货物;后者像叉车,想取什么取什么。

MP4 Box 的结构特点

MP4 文件遵循 ISO 14496-12(ISOBMFF)规范,整个文件由嵌套的 box 组成。每个 box 的基本结构是:

[4 bytes] size        (uint32)
[4 bytes] type        (4个ASCII字符)
[可选]    largesize   (uint64,当 size==1 时)
[可选]    version     (uint8) + flags (uint24)
[...]     payload     (各种混合类型字段)

这里有几个关键特征:

  1. 字段类型混杂:一个 box 里 uint8、uint16、uint24、uint32、uint64 混着来,没法用单一的 TypedArray 类型映射整个 box
  2. 字段偏移不对齐:比如 1 字节的 version 后面紧跟 3 字节的 flags,再接 uint32 字段——中间穿插了奇数长度的字段后,后续偏移很容易不是 2、4、8 的倍数,TypedArray 的对齐要求就满足不了
  3. 大端字节序:MP4 规范要求所有多字节整数使用大端字节序,而 TypedArray 使用平台原生字节序(绝大多数设备是小端),直接读出来的值是反的

这三条,直接决定了各 API 的适用程度。

Uint8Array 数据搬运和字节级操作

在 MP4 解析中,Uint8Array 是用得最多的 TypedArray。它没有对齐和字节序的问题——每个元素就是一个字节,强项是数据搬运(切片、拷贝、拼接)和逐字节扫描。

// 切出某个 box 的 payload
const boxPayload = new Uint8Array(buffer, boxOffset + 8, boxSize - 8);

// 拼接两段 segment 数据
const merged = new Uint8Array(a.length + b.length);
merged.set(a, 0);
merged.set(b, a.length);

// 逐字节匹配 box type,找到 mdat box 的位置
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.length - 7; i++) {
  if (bytes[i+4] === 0x6D && bytes[i+5] === 0x64 &&
      bytes[i+6] === 0x61 && bytes[i+7] === 0x74) {
    console.log('mdat box at offset', i);
    break;
  }
}

Uint32Array 为什么在 MP4 解析中几乎没用

直觉上,MP4 box 里到处是 uint32 字段,Uint32Array 应该很适合?实际上它在 MP4 解析里几乎没有用武之地,最核心的原因是它的字节序不对。

MP4 是大端,而 Uint32Array 使用平台原生字节序。绝大多数设备(x86、ARM)是小端,这意味着直接读出来的值是字节反转的。

举个例子,要把值 23 写入 buffer,按 MP4 大端格式应该是 00 00 00 17

// DataView:直接写 23,指定大端
const view = new DataView(buf);
view.setUint32(0, 23, false);  // buffer: 00 00 00 17 ✓

// Uint32Array:写 23 会变成小端排列
const arr = new Uint32Array(buf);
arr[0] = 23;  // 小端机器上 buffer: 17 00 00 00 ✗

// 要得到正确的字节排列,你得手动翻转
arr[0] = 0x17000000;  // 即 23 × 2²⁴ = 385875968
// buffer: 00 00 00 17 ✓

把一个简单的 23 转成 385875968 才能写入,这样就太复杂了。

DataView 的灵活性

DataView 完美解决了上述问题。它允许你在任意偏移位置,以任意类型和字节序来读写数据。

DataView 提供了 10 对 getter/setter:

方法 字节数 说明
getInt8 / getUint8 1 无字节序参数(单字节不需要)
getInt16 / getUint16 2 第二参数控制字节序
getInt32 / getUint32 4 第二参数控制字节序
getFloat32 / getFloat64 4 / 8 IEEE 754 浮点数
getBigInt64 / getBigUint64 8 返回 BigInt

每个 getter 都有对应的 setter,setter 多一个 value 参数。所有多字节方法的最后一个参数 littleEndian 默认为 false(大端),恰好和 MP4 的字节序一致。

总结

API MP4 解析中的角色 典型场景
ArrayBuffer 底层数据容器 承载所有二进制数据
Uint8Array 高频工具 切片、拷贝、扫描、读 box type
Uint32Array 等 几乎不用 字节序/对齐/混合类型三重限制
DataView 核心解析工具 读写任意类型、任意偏移、可控字节序

写 MP4 parser 时,一个简单的原则:搬运数据用 Uint8Array,解析字段用 DataView

看完了?说点什么呢

]]>
69999f428f0c7b2c3ea176b0 posts 编程
2025 - 从校园到沪漂 https://suemor.com/notes/9 Tue, 30 Dec 2025 16:44:47 GMT 找实习 对于转本上岸的我来说,入学已经是大三,这意味着一个学期之后就要开始投递暑期实习,但当时的我 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/notes/9

找实习

对于转本上岸的我来说,入学已经是大三,这意味着一个学期之后就要开始投递暑期实习,但当时的我对此毫无概念,依旧享受着校园生活,以为时间还很充裕。

年初的时候一直在维护我的 Marchen Player和一个 AI 博客项目,期间虽也有想找实习的想法,但看了看噩梦般的八股和算法题,瞬间便打消了斗志。转眼间就到了五月份,当时看到 innei 群里 林陌青川 大佬在讨论实习的事情,才发现自己已经错失投递暑期实习的黄金时间,只剩下些捡漏的 HC。此时的我八股还完全没背,LeetCode 一题没刷,再加上转本的三本学历,可以说 debuff 叠满了。

要找实习,八股和算法是必须要面对的。对于八股我花了几天的时间,对着掘金上这篇八股文勉强背了些,算法则是在 LeetCode 上做了几道两数之和这类的超简单题。在此期间每天不断在 Boss 直聘和企业官网上投递简历,同时也刷着牛客,看着大家纷纷上岸,分享着满是黑话的面经,焦虑感直接拉满。

背了 4 天八股后,即 5 月 16 日,一家在武汉叫 CmsTop 的公司在 Boss 上给了我面试机会,这是我人生中第一次面试,自然十分紧张,说话结结巴巴的,但好在顺利通过了。

5 月 19 日,面试了苏州星海图,是一家搞具身智能的公司,开局要求手撕两道算法题,一题也写不出来,直接挂了。

5 月 26 日,面试了来未来熙牛医疗,这次问的比较简单,都是些 React 的八股,15 分钟速通了。

5 月 22 日,哔哩哔哩给我了面试机会,从面试官得知是前端的日常实习。一面算法题被要求写「链表内指定区间反转」,我算法就两数之和水平,这自然是写不出来的。此时感觉多半凉了,但隔天竟收到二面的通知。

5 月 26 日,进行了哔哩哔哩二面,二面全程八股拷问,回答的中规中矩。面试结束后也是厚着脸皮给 HR 发微信,询问面试结果,以及后续是否有三面。没想到当天下午 HR 便通知我面试通过了,要求下周入职,晚上直接发了意向书。

https://www.suemor.com/notes/7

沪漂

前几天还在校园里上课,下周就要去上海了。

说实话,心情挺复杂的。之前看过 innei 大佬博文里写的沪漂经历,那些关于租房、通勤、独自生活的描述,让我既期待又有点担心。

和 HR 约定好是 6 月 3 日入职,考虑到上海的住宿费用比较高,我提前一天过来看了几家公寓房。上海的房租确实贵得离谱,公司附近的单间基本都得三四千,对于一个实习生来说压力不小。跑了一整天,最终定在了外高桥的一间公寓房——月租 2k,押一付一,通勤大概 40 分钟,勉强能接受。

入职当天先是新人培训,发了入职指南和一个小电视抱枕,讲了公司的基本情况和规章制度,最后领了台 Mac mini 2018 作为工作机。

就这样,沪漂生活正式开始了。

实习后记

实习期间发现自己对于前端音视频挺感兴趣的,开始逐步了解 HLS、fMP4、MSE 这些概念。以前觉得视频播放就是一个 video 标签的事,真正接触后才发现里面水很深的。

最近自己在写一个 HLS 播放器的库,期间发现部分 HLS 流的 fMP4 分片时间戳不是从 0 开始的,导致播放器出现卡死情况。于是硬着头皮去研究 MP4 的 Box 结构,最终成功写了一个处理 MP4 封装的模块来修正时间戳。

https://www.suemor.com/posts/programming/tfdt-decode-time

转眼间实习已经过去几个月了。虽然过程有些曲折,但还是收到了转正答辩的通知。作为一个社恐的 I 人,表达和汇报一直是我的弱项。答辩的时候基本就是对着文档念了一遍,问答环节也是结结巴巴的,回答得很一般。好在最后还是顺利通过了,也算是给这段实习画上了一个还不错的句号。

电子产品

又到了每年的败家时刻。

Surface Pro 9

去年把 Surface Pro 7 挂闲鱼卖掉后,心里一直痒痒的,总觉得缺了点什么。今年终于没忍住,花 4k 多收了台二手 Surface Pro 9,i5-1245U + 16GB 配置,算是填上了这个坑。

M4 Mac mini

公司配的 Intel Mac mini 2018 实在太卡了,VS Code 动不动就无响应,代码补全更是完全出不来。看到 pdd 上全新 M4 Mac mini 只要 2.9k,还是没忍住直接冲了。

KTC 5K 显示器

在公寓里没有外接显示器确实不太方便。本想入手台 5K 显示器,但看着 Studio Display 那可望不可即的价格,还是算了。最后趁某东国补,1.8k 拿下了台 KTC 27 英寸 5K 显示器。实际用下来只能说一般,拖影比较明显。不过考虑到价格,以及支持 5K 和 2K 120Hz 双模切换,性价比还是有的。

关于明年

回顾往年的年终总结,那些信誓旦旦立下的 flag,最后基本都没能完成。今年也不例外。

工作之后愈发感觉时间不够用了,每天下班回来已经没什么精力折腾自己的项目,Github 提交也基本一片空白。再加上这一年 AI 发展得太快了,光是 Claude 生态就冒出一堆新概念:plugin、command、agent、MCP、hook、skills……看着这些层出不穷的名词,很难不焦虑,总担心自己会被时代甩在后面。

不过焦虑归焦虑,日子还是要过的。新的一年,希望自己能在音视频方向继续深入,把播放器的坑填完,同时也多关注下 AI 这边的动态,找机会做些结合 AI 的项目,至少别让自己掉队太远吧。

看完了?说点什么呢

]]>
6954017f868dcd1e9d6bc3de notes false
记一次 HLS 视频流 fMP4 时间戳对齐问题的排查与修复 https://suemor.com/posts/programming/tfdt-decode-time Sun, 28 Dec 2025 16:46:13 GMT 最近尝试接触前端音视频领域。在开发 HLS(fMP4)播放器的过程中,遇到一个比较棘手的问题: 部分 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/posts/programming/tfdt-decode-time

最近尝试接触前端音视频领域。在开发 HLS(fMP4)播放器的过程中,遇到一个比较棘手的问题: 部分 fmp4 分片的 tfdt.baseMediaDecodeTime 并不是从 0 开始连续递增。在直接通过 mediaSource 播放时,容易出现时间轴错位,甚至播放器卡死的情况。这篇文章记录一下修复过程。

踩坑过程

今天在写 fmp4 player 的时候发现部分 M3U8 播放不出来,这边用 ffprobe 看了下发现它的 start 比较诡异:

ffprobe /Users/suemor/Downloads/xxxx.m4s 2>&1 | grep Duration
Duration: 00:00:08.35, start: 4.189002, bitrate: 2071 kb/s

可以看到 start 直接从第 4 秒开始了,这会导致浏览器 mediaSource 播放出现问题。

开始想了一个比较 hack 方式,但这有些问题,当用户 seek 到 0-4 秒之间会被强制 seek 到第 4 秒,体验上欠妥。

  const { buffered } = state.sourceBuffer
  const { currentTime } = state.media
  const GAP_TOLERANCE = 0.5 // 容忍 0.5s 的误差
  const JUMP_OFFSET = 0.1 // 跳入 Buffer 内部 0.1s 以确保安全
  if (buffered.length > 0) {
    const bufferStart = buffered.start(0)
    if (
      currentTime < bufferStart &&
      bufferStart - currentTime > GAP_TOLERANCE
    ) {
      state.media.currentTime = bufferStart + JUMP_OFFSET
      return
    }
  }

为了解决这个问题,我打算在播放链路中引入了一个简单的 transmuxer,在解封装阶段对 baseMediaDecodeTime 进行修正。

Box 结构

要开发一个 transmuxer 需要先了解下目前手里这个 fmp4 box 结构:

[File] (3s Segment)
 ├── [ftyp]
 ├── [moov]
 ├── [moof] (Fragment 1)
 │    ├── [mfhd]
 │    ├── [traf] (Track Fragment - 视频轨?)
 │    │    ├── [tfhd] (Track ID: 1)
 │    │    └── [tfdt] (Decode Time) -> 需要修正!
 │    └── [traf] (Track Fragment - 音频轨?)
 │         ├── [tfhd] (Track ID: 2)
 │         └── [tfdt] (Decode Time) -> 需要修正!
 ├── [mdat] (Media Data 1)
 ├── [moof] (Fragment 2)
 │    ├── [mfhd]
 │    ├── [traf] ...
 │    └── [traf] ...
 ├── [mdat] (Media Data 2)
 └── ... (重复多次)

我查阅资料后发现:TFDT Box 里的 baseMediaDecodeTime 决定了这个片段的绝对解码时间

后续需要读取整个流中第一个 moofbaseMediaDecodeTime 作为基准偏移量,对于分片内的每一个 moof(不仅仅是第一个),都执行 当前时间 - Offset,强行把时间轴整体“平移”回 0 起点。

Box 解析器

要修改二进制数据,首先得能读懂它。JavaScript 的 DataView 是处理二进制数据的神器,它允许我们直接操作内存并控制大端序。

下面实现了一个极简的 MP4Parser 类。它的核心逻辑是:读取 Box 的 Header(大小和类型),并提供访问内容的视图。

// mp4-parser.ts
export type BufferLike = ArrayBuffer | SharedArrayBuffer

export class MP4Parser {
  private readonly buffer: BufferLike
  private readonly fileEnd: number

  public readonly offset: number
  public readonly size: number
  public readonly type: number
  public readonly headerSize: number

  constructor(buffer: BufferLike, offset: number, fileEnd?: number) {
    this.buffer = buffer
    this.offset = offset
    this.fileEnd = fileEnd ?? buffer.byteLength
    
    // 读取前 16 字节来解析 Header
    const view = new DataView(
      buffer,
      offset,
      Math.min(16, this.fileEnd - offset),
    )
    
    const size32 = view.getUint32(0)
    this.type = view.getUint32(4) // Box 类型

    // 处理 size 的不同情况 (标准 MP4 协议)
    if (size32 === 1) {
      // size 为 1 表示这是个 Large Box,真实大小在后面 8 字节
      this.size = Number(view.getBigUint64(8))
      this.headerSize = 16
    } else if (size32 === 0) {
      // size 为 0 表示一直到文件末尾
      this.size = this.fileEnd - offset
      this.headerSize = 8
    } else {
      this.size = size32
      this.headerSize = 8
    }
  }

/**
   * 获取 Box 的内容部分(不包含 Header)
   * 返回 DataView,适合读取内部的具体数值(如 TrackID, TimeStamp)
   */
  public getContentDataView(): DataView {
    return new DataView(
      this.buffer,
      this.offset + this.headerSize,
      this.size - this.headerSize,
    )
  }

	/**
   * 获取 Box 的内容部分(不包含 Header)
   * 返回 Uint8Array,适合作为下一次 findBoxes 的输入,或者进行字节级操作
   */
  public getContentView(): Uint8Array {
    return new Uint8Array(
      this.buffer,
      this.offset + this.headerSize,
      this.size - this.headerSize,
    )
  }
}

查找 Box

有了解析器,我们还需要开发一个快速查找指定 Box 的工具。

在下方 BoxUtils 中,实现了一个 findBoxes 函数。为了性能最大化,它采用了跳跃式遍历:读取一个 Box 的 header 拿到 size,如果不匹配,直接跳过 size 长度的字节,而不是逐字节扫描。

// box-utils.ts
import { MP4Parser } from './mp4-parser'

// 辅助函数:统一转为 Uint8Array
function asU8(data: BytesLike): Uint8Array {
  return data instanceof Uint8Array ? data : new Uint8Array(data)
}

export const BoxUtils = {
  /**
   * 在给定的数据范围内查找指定类型的 Box
   */
  findBoxes(
    data: BytesLike,
    type: number,
    start = 0,
    end?: number,
  ): MP4Parser[] {
    const u8 = asU8(data)
    const buf = u8.buffer
    const base = u8.byteOffset
    const limit = end ?? u8.byteLength

    const boxes: MP4Parser[] = []
    let offset = start
    
    // 循环遍历,直到范围结束
    while (offset < limit) {
      // 剩余数据不足 header 长度,退出
      if (offset + 8 > limit) {
        break
      }
      
      const box = new MP4Parser(buf, base + offset, base + limit)
      const boxSize = box.size === 0 ? limit - offset : box.size

      // 异常检查
      if (boxSize < box.headerSize || offset + boxSize > limit) {
        break
      }

      // 找到目标 Box,加入结果列表
      if (box.type === type) {
        boxes.push(box)
      }
      
      // 关键:直接跳过整个 Box 的大小,进入下一个 Box
      offset += boxSize
    }
    return boxes
  },
}

完成修正

我们先梳理接下来查找 tfdt box 的链路。

根据下方链路图片可以发现:我们需要对 moof -> traf -> tfhd -> tfdt 进行解析。

[File] (3s Segment)
 ├── [moof] (Fragment 1)
 │    ├── [mfhd]
 │    ├── [traf] (Track Fragment - 视频轨?)
 │    │    ├── [tfhd] (Track ID: 1)
 │    │    └── [tfdt] (Decode Time) -> 需要修正!
 │    └── [traf] (Track Fragment - 音频轨?)
 │         ├── [tfhd] (Track ID: 2)
 │         └── [tfdt] (Decode Time) -> 需要修正!
 ├── [moof] (Fragment 2)
 │    ├── [mfhd]
 │    ├── [traf] ...
 │    └── [traf] ...
 └── ... (重复多次)
 
// 对应的 16 进制表示
MOOF: 0x6d6f6f66
TRAF: 0x74726166
TFHD: 0x74666864
TFDT: 0x74666474

解析 tfhd trackId

对于 traf 的 trackId 它位于 tfhd box body 的第 4 个字节处,它占据 4 个字节。

[TFHD Body Layout]
+---------+---------+-----------+
| Version |  Flags  | Track ID  | ...
+---------+---------+-----------+
|  1 byte | 3 bytes |  4 bytes  |
+---------+---------+-----------+
^                   ^
offset 0            offset 4 (读取位置)
parseTfhdTrackId(tfhdBox: MP4Parser): number {
  const view = tfhdBox.getContentDataView()
  // 偏移量 4 = Version(1) + Flags(3)
  // 紧接着就是 TrackID (4 bytes)
  return view.getUint32(4)
},

解析 tfdt baseMediaDecodeTime

tfdt (Track Fragment Decode Time) 存储了该分片的绝对解码时间。这里有一个必须注意的版本兼容性问题:

  • Version 0:使用 32 位整数(UInt32)。
  • Version 1:使用 64 位整数(UInt64)。

视频时间一长,时间戳很容易超过 32 位整数的范围(约 42 亿),因此现代 HLS 流大多使用 Version 1。为了防止精度丢失,我在代码中统一将其转换为 JavaScript 的 BigInt

parseTfdtBaseMediaDecodeTime(tfdtBox: MP4Parser): bigint {
  const view = tfdtBox.getContentDataView()
  const version = view.getUint8(0) // 读取第1个字节:version 用于判断是否 32 位溢出
  
  if (version === 0) {
    // Version 0: 32位,转为 BigInt 统一处理
    return BigInt(view.getUint32(4))
  }
  // Version 1: 64位,必须用 getBigUint64
  return view.getBigUint64(4)
},

修正 tfdt baseMediaDecodeTime

同理,这里也需要注意 32 位溢出的问题,需要用 version 进行额外判断

updateTfdtBaseMediaDecodeTime(
  tfdtBox: MP4Parser,
  newBaseMediaDecodeTime: bigint,
) {
  const view = tfdtBox.getContentDataView()
  const version = view.getUint8(0)
  
  if (version === 0) {
    // 32位写入:需要将 BigInt 转回 Number
    view.setUint32(4, Number(newBaseMediaDecodeTime))
  } else {
    // 64位写入:直接写入 BigInt
    view.setBigUint64(4, newBaseMediaDecodeTime)
  }
},

调用

最后,我们需要在主类中调用上述方法。由于我的流结构包含多个 moof 且每个 moof 包含多个 traf,代码采用了双层循环结构。

这里需要注意,我的 fmp4 包含两个 traf 分别对于视频和音频,他们的 decodeTime 并不是一致的,所以需要解析 tfhd box 获取当前的 track id,来区分当前 traf 属于哪个轨道,从而能够使用到对于轨道的 decodeTime。

// transmuxer.ts
import { BoxUtils } from './box-utils'

export class Transmuxer {
  private baseTimestampOffset: Record<number, bigint> = {} // TrackID -> Offset
  private fmp4: Fmp4 

  constructor(fmp4: Fmp4) { this.fmp4 = fmp4 }

  processTimeOffset(data: ArrayBuffer, isFirstSegment: boolean) {
    // 第一层循环:遍历分片内所有的 MOOF
    BoxUtils.findBoxes(data, BoxUtils.types.MOOF).forEach((moofBoxes) => {
      
      const trafBoxes = BoxUtils.findBoxes(moofBoxes.getContentView(), BoxUtils.types.TRAF)
      
      // 第二层循环:遍历 MOOF 内所有的 TRAF (通常是 Video 和 Audio)
      for (const trafBox of trafBoxes) {
        const trafContent = trafBox.getContentView()
        
        // 1. 获取 Track ID (音频/视频分开处理)
        const tfhdBoxes = BoxUtils.findBoxes(trafContent, BoxUtils.types.TFHD)
        if (tfhdBoxes.length === 0) continue
        const trackId = BoxUtils.parseTfhdTrackId(tfhdBoxes[0])

        // 2. 找到 TFDT 读取时间
        const tfdtBoxes = BoxUtils.findBoxes(trafContent, BoxUtils.types.TFDT)
        if (tfdtBoxes.length === 0) continue
        const tfdtBox = tfdtBoxes[0]
        
        // 使用 BigInt 读取,防止大整数溢出
        const baseMediaDecodeTime = BoxUtils.parseTfdtBaseMediaDecodeTime(tfdtBox)
          
        // 3. 仅在全流的第一个分片记录基准 Offset
        if (isFirstSegment && this.baseTimestampOffset[trackId] === undefined) {
          this.fmp4.logger.log(`[Transmuxer] Track ${trackId} set offset: ${baseMediaDecodeTime}`)
          this.baseTimestampOffset[trackId] = baseMediaDecodeTime
        }

        // 4. 修正时间:当前时间 - Offset
        const offset = this.baseTimestampOffset[trackId] ?? 0n
        const newBaseMediaDecodeTime = baseMediaDecodeTime - offset
        
        if (newBaseMediaDecodeTime < 0n) continue 
        
        // 5. 原地修改 Buffer
        BoxUtils.updateTfdtBaseMediaDecodeTime(tfdtBox, newBaseMediaDecodeTime)
      }
    })
    return data
  }
}

看完了?说点什么呢

]]>
69515ed5eea45dff9c8c4fec posts 编程
B 站实习的第一周 https://suemor.com/notes/8 Sun, 08 Jun 2025 18:37:54 GMT 入职当天先是进行新人培训,工作地点在国正中心 3 号楼,领了台 Mac mini 2018(用起来发 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/notes/8

入职当天先是进行新人培训,工作地点在国正中心 3 号楼,领了台 Mac mini 2018(用起来发热量巨大)。

组里主要负责直播中心那块的维护,基本都是用 Vue 2 写的屎山,一个 App.vue 2000 行的那种。

这周主要阅读文档为主,之后 leader 叫我修个 Bug 并把项目给上线,当时慌得一批,自己对开发流程还不太清楚,涉及到多种环境,对应的文档也是年久失修,最后好在没出什么问题,也算是体验一次大厂项目的上线流程。

至于住房,选择了浦东那边一个 2k 的公寓,租了一个月,押一付一,通勤 40 分钟左右,勉强还能接受,先住一个月看看情况,之后再做打算。

看完了?说点什么呢

]]>
6845d88238a18020bbd1b296 notes false
最近找实习的经历 https://suemor.com/notes/7 Sat, 07 Jun 2025 13:55:05 GMT 转本上岸之后基本天天写些自己的小玩具,一下子就到了 5 月中旬,才发现自己暑期实习还没有找,简单调研 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/notes/7

转本上岸之后基本天天写些自己的小玩具,一下子就到了 5 月中旬,才发现自己暑期实习还没有找,简单调研后发现很多公司部门已经没有 HC 了,此时自己 LeetCode 基本为 0,八股也完全没背过,加上三本学历,boss 上投递自然没人鸟。

武汉公司

背了 4 天八股后,即 5 月 16 日,终于有一家武汉的公司给了我面试机会,同时也是我第一次参加面试。

面试期间全程社恐紧张,说话也是各种结巴,面经如下:

问八股

  1. 先做一个自我介绍
  2. 你写样式的时候,比如你写css的样式的时候,用过哪些像素单位,他们有什么区别?
  3. 为什么要有 Less 和 Sass,他们是干什么用的,为什么 Less 不够还要有 Sass?
  4. 把一个 div 从 a 点拖到 b 点,比如 a(50,50) 拖动到 b(100,100) ,如何用原生 JavaScript 实现?
  5. 那你原生鼠标事件知道哪些?
  6. CSS 你平常用过哪些布局?他们有什么区别?
  7. 用过 float 布局吗,干什么用的?为什么之前很多人用 float ,现在却没有人用了?
  8. 写 JavaScript 的时候,肯定经常去拷贝一些数据是吧?平常用过哪些拷贝?
  9. 你讲一下什么叫深拷贝,什么叫浅拷贝?
  10. 用什么方式可以实现这个浅拷贝,达到这个浅拷贝的目的?深拷贝呢,用什么方法手段达到深拷贝的目的?
  11. 浏览器里面有个概念叫做重排重绘,什么叫重排重绘
  12. 平常在去写功能的时候,肯定可能会用到一些请求是吧?但是有可能我们会遇到跨域,什么叫跨域?为什么会遇到跨域错误?如何解决?

问项目

  1. 可以简单介绍一下你 Electron 开发的播放器吗?
  2. 你刚才有提到说支持自动更新,我看了一下代码好像没有找到相关的,你是用的 Electron builder 原生自带实现的吗?
  3. Mac 里面的签名和公证的区别是什么
  4. 公证的应用和没公证的应用,在 Mac 上打开的时候会发生什么
  5. 这个 Electron 项目是自学还是有参考的?大概做了多长时间?
  6. Web 和 Electron 开发你更喜欢哪一个?
  7. 我看你还有个博客项目,然后里面说集成的AI辅助写作主要是指什么呢?
  8. 跟大模型对接用的什么库?
  9. vercel ai 里面 useChat 和 useCompletion 的区别
  10. 我如果希望你对 vercel ai 做一个二次开发,把聊天记录的功能直接原生集成在库里面,你会怎么去做?

最后也是顺利通过一面了,二面是老板面,跟他讲了下平时如何使用 AI 工具和大模型的,大概讲了 Grok, Gemini, Claude, ChatGPT 平时是如何使用,个人感觉的优劣那些。

大概隔了两天,也是顺利拿下了 offer

苏州公司

大概背了一周八股后,即 5 月 19 日,一家苏州的 AI 公司给了我一次面试机会。

这次面试开局就是两道算法题,反转链表括号匹配。由于自己没有刷过 LeetCode 自然是写不出来的,可以说面试场面非常尴尬。

之后又问了些八股,这次回答的还不错,基本都回答上来了。但由于算法题没写出来的缘故,也只能止于一面了。

哔哩哔哩

之前也通过官网投递了 20 多家公司,在 5 月 21 日哔哩哔哩给我安排了隔天的一面,当时看到面试安排挺激动的,直接翘掉了当天全部的课,用来复习八股和刷了 10 道 easy 算法题。

第一次体验大厂面试,也看了些牛客上的各种面经,害怕会被要求撕各种算法题,晚上焦虑的更是睡不着觉。

一面

面试官交代了面试的是直播部门,面经如下:

1. 自我介绍

2. 做项目的背景是什么,有没有用户量?

我自己比较喜欢一边看动漫,一边有着弹幕,但在 macos 没有类似本地视频弹幕播放器,所以我会去做一个这个

用户目前有几十个,他们有时候也会给我发起一些 Github Issues,我也会及时推进并改善 BUG

3. 你的播放器项目是否支持在线播放?

目前并不支持,我是有这个支持打算的,目前打算做一个媒体库功能

4. 如果做这种本地的话,需要用户去自己去下载操作,会不会增加一些用户的心智?

因为我自己看动漫的习惯还是下载到本地,这样码率会高很多,画面会更加清晰。并且有一套类似自动化的追番流程,有新番更新就会自动下载的那种。

5. 如果做让你把这款项目作为一个商业化的产品,你会怎么去改造它呢?可以简单的跟我说说吗?

  • 可以尝试接入大模型,为字幕文本提供翻译服务,或者可以利用 AI 语言识别来生成字幕(openai whisper),方便用户看生肉动漫
  • 提供 AI 视频总结
  • 支持挂载网盘,或者类似 emby 流媒体服务器
  • 支持数据云同步,云端保存用户设置,视频进度等

6. 还有你觉得有什么功能可以去扩展?

增加媒体库功能,支持对动漫进行元数据刮削

7. 项目里面的一些最主要的一些难点

chromium 在 mac windows linux 上硬件解码依赖用户电脑配置,chromium 自身的 ffmpeg 因为版权问题,软解缺乏对于 h265 的支持,对于不支持 h265 硬解的用户,会出现黑屏问题。所以要考虑降级到 wasm 软解,似乎哔哩哔哩也有这方面的实践(DashPlayer + WasmPlayer)

再提一些 linux 上硬件解码的坑,要打开实验 flag

firefox 不支持 mkv 视频容器,今年 1 月对 h265 硬件解码支持

8. 有没有看过 FFmpeg 源码,知道哪些核心 API ?

有简单了解过,它编解码的核心是 libavcodec,用于对音视频的编码和解码,就是 H.265 H.264 AAC

9. 弹幕是如何进行缓存的?

使用的是 IndexedDB(Dexie.js)进行缓存的,加载的时候把数据注入到 tanstack query 里面。因为项目目前是提供 web linux windows macos 四个版本,使用 IndexedDB 可以确保代码的一致性,减少不同平台之间的 bug。

同时 tanstack query 本身也具备的缓存机制,可以利用其 staleTime 和 gcTime 实现一些临时的缓存效果

10. 简繁体转换是如何实现的?

利用 opencc-js 实现

11. ass/ssa 字幕功能是用什么实现的

libass-wasm 实现的,它是一个用 c 语言编写解析 ASS/SSA 的库, 它是利用 canvas 把字幕画上去的,从而实现 ASS/SSA 字幕复杂样式和动画效果

12. 弹幕功能是如何实现的?

利用的是 danmu.js 实现的,它是利用 DOM 方式实现的,有碰撞算法,利用 requestAnimationFrame 来实现弹幕平滑滚动,它是根据用户设备刷新率来执行的,其中弹幕轨道可以根据播放器的有效高度/设备标准字号实现

13. 如果满屏弹幕的情况下,比如说同时有数千条弹幕的情况下,你怎么去保证它的实时渲染不会卡顿?

  • 利用 轨道数量 = 播放器有效高度 / 设备基准字号,单一屏幕是拥有最大弹幕限制的,这种算法不会出现满屏数千条弹幕
  • DOM节点复用
  • 如果需要渲染数千条弹幕,可以利用 requestAnimationFrame 来驱动弹幕的移动,确保弹幕滚动与浏览器刷新率同步
  • 利用 GPU 加速 transform ,减少重排和重绘
  • 利用 Web Worker 进行复杂任务

14. 弹幕时间轴自动对齐怎么做,弹幕拖进来如果和本地视频时间对不上,该如何处理?

用户可以单独设置每个弹幕的时间轴,来解决

后续或许可以考虑借用大模型辅助对齐

15. 你是如何使用 ffmpeg 或者 ffprobe 实现视频关键帧提取的?

目前历史记录的封面就是利用 ffmpeg 截取用户最后观看位置的图片,利用 -ss 定位时间点,-vframes 确保只提取一帧图像

16. Electron builder 打包的时候,你是如何得知当前是什么平台的?

打包的时候会执行 electron-builder --win 那些 flag,等于告诉 electron 当前是什么平台了,如果后续钩子需要,可以通过 context 获取出来

或者 nodejs os 模块,可以通过 os.platform() 获取平台

17. Electron 项目初次启动时间是多久,后续的启动时间是多久?有没有算过?

后续启动会更快一点,可能操作系统会有些缓存

18. 播放器渲染过程有没有性能问题,比如说内存泄露,你是怎么去防止和监控的?

在组件卸载的时候,我会及时清理 useEffect 里面的副作用,把它放到 useEffect 清理函数里面

react 的话可以利用 react devTools,观察组件的重新渲染变化,也可以使用浏览器控制台里面 performance tab 进行性能录制,从而分析出原因

19. 弹幕如果要支持直播流的话,项目要进行哪些改造?

直接弹幕通常是使用 WebSocket 进行与弹幕服务器双向通信的,在项目中引入 Websocket 客户端逻辑,即可

20. 你对 Websocket 的理解?

  • TCP 全双工通信的协议
  • 可以持久化连接
  • 支持双向通信
  • 低延迟
  • 用的 ws:// 或者 wss://
  • 适合直播弹幕,即时通讯软件

21. 项目有没有遇到过崩溃的例子,你是如何进行监控的?

利用 sentry 和 electron-log 日志处理

22. 除了 sentry 你还知道哪一个性能监控跟错误监控的系统?

不知道

23. 你是二次元所以才想做这个项目的吗?

是的

24. 你实习主要做的都是什么吗?以及你觉得实习让你收获了什么?

说实习经历

25. 我看你技术栈是 React 多,但我们这边都是 Vue 的,如果要上手 Vue,你会怎么做?

他们都是现代前端的框架,很多思想都是相同的,比如组件化架构、虚拟 DOM、响应式数据绑定,并且都是基于 JavaScript。而且我之前也用过 Vue 开发一些后台管理系统和浏览器起始页,我相信我可以在几天之内,通过阅读官方文档快速上手 Vue 的

26. 你是如何使用 monorepo 管理项目的?

monorepo 一般会分为 app 和 packages 两个文件夹,我的项目....

27. 有没有 UI 类组件的封装经验?

比如我博客的 Markdown 双栏编辑器,左侧是封装的 codemirror 实现代码高亮,右侧是用 markdown-to-jsx 写的 markdown 渲染组件。左侧修改 markdown ,几乎可以无延迟的在右侧渲染出来。这里我使用到了 useDeferredValue,它是用到了 react 18 的并发特性,可以实现根据当前渲染压力,来动态实现一个防抖的效果,让用户基本感受不到渲染延迟。

28. 性能优化的常见方式?

减少重排和重绘,图片、组件懒加载,使用防抖和节流函数

29. cdn 上你会托管哪些资源?

CSS JS 图片 .m4s 切片

30. html 为什么一般不用 cdn 托管?

html 也可以放在 cdn 上托管,但很多网站 HTML 是动态生成的,会有延迟问题,所以一般不用 cdn 托管

31. webpack 如何进行打包优化?

  • 代码压缩,图片压缩
  • Tree shaking
  • 代码分割
  • 缓存优化,利用 chunkhash

32. 浏览器从输入到显示页面的全过程

八股

33. 重排和重绘发生的过程是什么?会不会对页面性能有一些影响?

八股

34. 重排和重绘的优化?

八股

35. 谈谈你对宏任务跟微任务理解?

八股

36. 事件循环题目,写出运行结果和 promise 状态

大概长下方这样

const promise1 = Promise.resolve().then(() => {
  setTimeout(() => {
    console.log("111", promise2);
  }, 1000);
});

const promise2 = Promise.resolve().then(() => {
  throw new Error();
});

console.log("111", promise1);
console.log("111", promise2);

setTimeout(() => {
  console.log("111", promise1);
  Promise.resolve().then(() => {
    console.log("111", promise2);
  });
}, 3000);
  • 111 Promise { < pending > }
  • 111 Promise { < pending > }
  • 111 Promise { < rejected >: Error }
  • 111 Promise { < fulfilled >: undefined }
  • 111 Promise { < rejected >: Error }

这题挺麻烦的,还要写出 promise 状态。而且在 Node.js 环境下似乎会直接被 throw new Error() 给中断掉,怪怪的。

37. 算法题:链表内指定区间反转

不会写,只写了个普通的反转链表,给面试官看傻眼了

结果

本以为一面要凉凉,没想到面试结束后 5 分钟就收到了 HR 电话,约了下周一的二面。

二面

这次八股问的多些,面经如下:

问项目

  1. 项目支持发送弹幕功能吗?
  2. 项目有多少人在使用?他们是如何向你反馈问题的?
  3. 字幕功能是如何实现的?
  4. 错误日志是如何进行处理的?
  5. 历史记录里面的数据是如何进行存储的?动漫回看是如何实现的?
  6. IndexedDB 最大能存储多少数据?不同域名下 IndexedDB 里面数据能够互相访问吗?

八股

  1. const let var 区别
  2. Promise all allSettled race 用法
  3. array object 有哪些常用方法?
  4. React 常用 hooks 有哪些?
  5. useEffect 用法
  6. 谈谈重排和理解定义,如何减少他们?
  7. 图片懒加载如何实现的?组件懒加载呢?
  8. 谈谈 HTTP 缓存
  9. 什么时候用强缓存,什么时候用协商缓存?
  10. 什么是跨域,跨域的解决方案,简单请求和复杂请求具体的区别
  11. 为什么 GET, POST 是简单请求,而 DELETE, PUT, PATCH 是复杂请求?
  12. css 如何实现动画的?
  13. css 有哪些方式脱离文档流?
  14. bfc 是什么?
  15. Framer Motion 这个动画库是如何实现的?
  16. 解构赋值是深拷贝还是浅拷贝?
  17. 箭头函数和普通函数的区别?
  18. SSR 可以提高首屏加载速度吗?为什么?

结果

这次问的还算简单,当天下午 HR 就联系我二面通过,因为是日常实习,没有 HR 面,当天给了 offer

杭州公司

5 月 26 日,杭州一家 500 人左右公司,给了我一面。

有了被大厂拷打经验之后,这次面试直接速通,基本都是秒答,面了 15 分钟就结束了,隔天 HR 面后,也是给了 offer

看完了?说点什么呢

]]>
684444b938a18020bbd18ee5 notes false
理解 CORS、预检请求 (Preflight) 和跨域 https://suemor.com/posts/programming/understand-cross-domain Sat, 19 Apr 2025 17:46:40 GMT 这篇主要来聊一聊前端常见的跨域问题,以及后端如何处理 CORS 和预检请求 (Preflight)。 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/posts/programming/understand-cross-domain

这篇主要来聊一聊前端常见的跨域问题,以及后端如何处理 CORS 和预检请求 (Preflight)。

浏览器在什么情况下会发生跨域

浏览器通过“同源策略”限制不同源之间的资源交互,以保护用户隐私和安全。其中由三个部分组成:协议域名端口。只有这三者同时满足才是同源。否则,就是跨域,向服务端发送请求时会触发浏览器的跨域限制,报以下错误:

Access to fetch at 'https://server.suemor.com/api/posts' from origin 'https://suemor.com' CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

具体看下方 4 个例子。

跨域

以下是三种跨域情况:

不同协议

https://suemor.com

http://suemor.com/api/posts

不同域名

https://suemor.com

https://server.suemor.com/api/posts

//tips: 下方这个也是跨域
http://127.0.0.1:3000 -> http://localhost:3000/api/posts

不同端口

http://localhost:3000

http://localhost:5050/api/posts

同源

下方这个是同源,没有跨域问题。

https://suemor.com

https://suemor.com/api/posts

解决跨域

跨域问题通常在服务端解决,通过配置反向代理或修改后端代码。

跨域请求分为简单请求复杂请求

简单请求

对于同时满足以下三个条件的即为简单请求,服务器只需返回正确的 CORS 头,即 Access-Control-Allow-Origin

  • 请求方法:GET、POST 或 HEAD。
  • Content-Type:限于 application/x-www-form-urlencoded、multipart/form-data 或 text/plain。
  • 不包含自定义头:如 Authorization。

看以下 Express 示例:

// 假设 web 端位于 http://localhost:3000
// 假设 server 端位于 http://localhost:5050

// web
fetch("http://localhost:5000/api/posts", { method: "GET" });

// server
app.use((req: Request, res: Response, next: NextFunction) => {
  res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); // 或者  res.setHeader("Access-Control-Allow-Origin", "");

  next();
});

复杂请求

符合以下任意一条,即为复杂请求:

  • 使用 PUT、DELETE 、PATCH 方法。
  • Content-Type: application/json。
  • 包含自定义请求头(如 Authorization)。

复杂请求比较特殊,浏览器会先发送一个 OPTIONS 方法的预检请求(Preflight),检查服务器是否允许该跨域请求。如果不允许,则直接抛出 CORS 错误,不再发送实际请求。

因此,我们需要单独处理这个预检请求(Preflight):

// 假设 web 端位于 http://localhost:3000
// 假设 server 端位于 http://localhost:5050

// web
fetch("http://localhost:5050/api/data", {
  method: "PUT",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer token",
  },
  body: JSON.stringify({ data: "example" }),
});

// server
app.use((req: Request, res: Response, next: NextFunction) => {
  res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
    res.header("Access-Control-Allow-Methods", "PUT");
    res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.status(200).end();
    return;
  }

  next();
});

设置 Access-Control-Max-Age

复杂请求在无缓存或缓存失效时会发送两次请求:Preflight(OPTIONS)和实际请求,这会增加网络开销。为此,服务器可以通过设置 Access-Control-Max-Age 响应头来控制浏览器缓存预检结果的时长。这个头字段的值表示缓存的有效期(以秒为单位)。在缓存有效期内,浏览器会复用之前的预检结果,跳过对相同接口的 Preflight 请求,从而提升性能。

res.header('Access-Control-Max-Age', '86400'); // 缓存 1 天

支持跨域 Cookie 的配置

在跨域场景下,如果后端响应头返回 Set-Cookie,默认不会生效,因为设置 Cookie 需要额外配置以绕过浏览器的安全限制。核心是启用 Access-Control-Allow-Credentials 并明确指定 Access-Control-Allow-Origin。以下是一个 Express 示例:

app.use((req: Request, res: Response, next: NextFunction) => {
  res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000"); // 一定要指定具体地址,不能为 *
  res.setHeader("Access-Control-Allow-Credentials", "true"); //添加这个
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");// 一定要指定具体地址,不能为 *
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Max-Age", "86400");
    res.status(200).end();
    return;
  }
  next();
});

app.post("/api/posts", (req: Request, res: Response) => {
  res.cookie("sessionId", "123456789", {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "none",
    maxAge: 24 * 60 * 60 * 1000,
  });
  res.json({ success: true, message: "Cookie set" });
});

注意这里 Access-Control-Allow-Origin 一定要指定具体的地址,不能设置为 Access-Control-Allow-Origin: *,否则 Cookie 无效。

前端如果使用 fetch 调用,则一定要加上 credentials: "include"否则无法设置 Cookie。如果是 axios 则加上 withCredentials: true

//fetch
fetch("http://localhost:5050/api/posts", {
  method: "POST",
  credentials: "include", // 允许携带和接收 Cookie
}).then((res) => res.json());

//axios
axios({
  url: "http://localhost:5050/api/posts",
  method: "POST",
  withCredentials: true, // 允许携带和接收 Cookie
})
  .then((res) => res.data)
  .catch((err) => console.error(err));

看完了?说点什么呢

]]>
6803e180a6f8c9b4f446996b posts 编程
2024 年终总结 https://suemor.com/notes/6 Wed, 08 Jan 2025 10:59:13 GMT 今年上半年经历在 迟来的 2023 年度总结基本讲过了,所以本文主要聚焦于 2024 后半年的事情。 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/notes/6

今年上半年经历在 迟来的 2023 年度总结基本讲过了,所以本文主要聚焦于 2024 后半年的事情。

https://www.suemor.com/notes/5

第二次实习经历

专转本结束后距离 9 月开学还有很长时间,毕竟闲着也是浪费时间,我准备找一个实习来提升一下自己的水平。遗憾的是经历了长时间的备考,之前掌握的很多技术都已记忆模糊,简单的温故一段时间后,MiaoMint 说他那边正好有个公司缺人,并且是远程工作的,于是我便欣然接受了。

我在 6 月初顺利入了职,这是一个 AI 初创公司,还处于起步阶段,我是公司里唯一的前端。老板挺热情的,和他沟通了下项目的具体规划后,我便主导了前端项目的开发。因为是前后端分离的,并且是远程工作,前后端及时沟通就显得尤其重要,起初我也是忽略了这一点,导致项目开发进度缓慢。于是我和后端们拉了一个 Discord 语音频道,沟通顺畅了不少,也是赶上了任务期限。

就这样持续到 10 月中旬,也算是挣到人生的第二桶金了。

AdventureX 黑客松

6 月中旬那会儿,MiaoMint 说他报名参加了一个黑客松, 7月15日-7月19日在杭州湖畔创研中心举办。我一开始没放在心上,毕竟有实习任务在身,但听说有不少推友参加,也想和 MiaoMint 面基一下,于是我也报名参加了。开始担心报名时间比较晚,会不会来不及,但好在运气比较好,没过几天就通过了筛选,收到了参赛邮件。

这应该是我人生中第一次独自出远门,所以有些紧张,直到和 MiaoMint 面基,Mint 人挺好的,和他聊得很来。他挺擅长社交的,托他的福,当天我们便组好了一个四人的队伍,取名为 Diet.AI,做一个分析饮食建议的 APP。我负责的是用 NestJS 后端部分的开发,整体没什么难度,就是调用下 Dify 的接口,MongoDB 存储一下数据。

最终得益于我们不错的 UI 设计,还是获得很多奖项的。

黑客松结束的时候也是得知 Innei 要来上海,于是我和 Mint 决定去上海面基 innei,和 innei 一起来的都是挺厉害的大佬,和他们交流之后,也是知道了自己有多菜 😭。

https://x.com/Suemor233/status/1814620333443579960

步入大学

经历了漫长的假期之后,在 9 月 15 日的时候也是迎来了全新的大学生活,由于是转本班的缘故,我们班上一共有 60 个人,班主任是个男老师,对我们还是挺关心的,这点不错。

考虑到之前的经历,我没有选择住宿,而是在外租房走读,在泰州每月 1600 整租了一个 90 多平的房子。办了联通 20 元/月 1000M 的宽带,装宽带的小哥比较好说话,成功要到了公网 IP,配合 NAS 方便不少,顺便也跑了下 PCDN,基本每天 3.5 块收益。

至于课程安排,不出乎预料,都是一些水课,上课的时候我就坐在后排写一些自己的项目。

在此期间我主要写了 Marchen Player,这是一个可以自动匹配弹幕的动漫播放器。因为我个人看动漫比较喜欢有着弹幕,感觉这样热闹一些,虽然市面上也有弹弹play这种客户端,但 mac 版本一直缺乏官方支持,于是我便学习了下 Electron 开发,尝试写一个类似的播放器。得益于 Electron 浏览器套壳的特性,同时受到最近大火 Follow 的影响,我打算同时兼容 Web macOS Windows Linux 四个版本,其中兼容 Web 版本尤为烦人,受限于浏览器限制,直到至今 Web 版本也是一个残血的阉割版。

https://github.com/marchen-dev/marchen-player

数码产品

假期实习期间攒了一点小钱,一部分我买了些纳指和美股,另一部分也尝试更新了下数码产品。

组装台式电脑

8 月份的时候黑神话悟空热度很高,PS5 的二手价格涨的比全新价格还高,于是我便把 PS5 挂闲鱼给卖掉了,准备装一台台式电脑。

毕竟是我人生中第一次装机,但英特尔那边缩肛爆料不断,装机配置我一直挺纠结的,这个想法一直延续到了双十一,当时正逢Intel Core Ultra 7 265KZ890 主板刚刚发售,虽然这 CPU 网上黑点挺多的,但考虑到不错的能耗控制和办公性能,以及后续 BIOS 更新的改善,我还是选择了这套组合。

具体配置如下,因为网上对于 Z890M 的装机配置基本没有,所以我自己搭配了一套,至于显卡等马上发售的 50 系了。

  • CPU:Intel Core Ultra 7 265K
  • 主板:技嘉冰雕 Z890M AORUS ELITE
  • GPU:核显 4 个 Xe-LPG 核心
  • 内存:宏碁掠夺者冰刃 32G DDR5 8000
  • 扇热:瓦尔基里 DL125 双塔风冷
  • 硬盘:海力士 P41 1T + 希捷酷玩 520 1TB
  • 电源:玄武 850k v2 白色
  • 机箱:乔思伯 Z20
  • 总花费:6638 元

NAS

NAS 作为男生的梦想,今年暑假的时候我入手了台丐版极空间 Z4Pro,虽然可玩性不如群辉,但更符合国内使用习惯,并且也有 Docker 和虚拟机功能,对我来说也足够了。

上了两块希捷的 4T 盘,组了 ZDR 动态双备份,搭配施耐德的 UPS,稳定运行了半年,目前没出什么问题。

汇总

  • 台式电脑
  • 极空间 Z4Pro
  • 京东京造 Z5 Soft
  • 酷态科 15 号超级电能柱 Ultra
  • 小米智能落地扇1X 升级版
  • 米家卧室吸顶灯 450
  • 施耐德 UPS BK650M2-CH
  • 水星 SE106 Pro 2.5G 智能网管交换机
  • 妙控鼠标
  • Apple TV 7 代 128GB

2025 年的目标

  • 继续维护 Marchen Player
  • 写一个博客主题
  • 暑假期间找到线下实习
  • 学习前端设计模式
  • 学习 React Native

看完了?说点什么呢

]]>
677e5a81f7c1066229de396f notes false
Electron 安装和打包不同平台的 FFmpeg https://suemor.com/posts/programming/electron-packaging-of-ffmpeg Sat, 23 Nov 2024 18:52:34 GMT 最近在写一个自动匹配弹幕的动漫播放器,里面需要使用 FFmpeg 对视频进行解析,但发现如何根据不同 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/posts/programming/electron-packaging-of-ffmpeg

最近在写一个自动匹配弹幕的动漫播放器,里面需要使用 FFmpeg 对视频进行解析,但发现如何根据不同的平台打包不同 FFmpeg 到 Electron 里,是个挺麻烦的问题,这篇文章就来讲述下我的解决思路。

安装

用户的电脑很有可能没有安装 FFmpeg,所以我们需要把 FFmpeg 打包进我们的应用里面。

想要在 Electron 开发环境里面导入 FFmpeg 还是比较简单的,只需要安装下面的包,然后就能够在 Electron 中使用了。

pnpm add -D @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe fluent-ffmpeg

创建 ffmpeg.ts,具体使用方式可以阅读 fluent-ffmpeg

import ffmpegPath from '@ffmpeg-installer/ffmpeg' // 安装 ffmpeg 的二进制文件
import ffprobePath from '@ffprobe-installer/ffprobe' // 安装 ffprobe 的二进制文件
import ffmpeg from 'fluent-ffmpeg' // 一个封装了 ffmpeg API 的库,当然可以选择不安装,直接使用字符串拼接的方式调用

ffmpeg.setFfmpegPath(ffmpegPath.path)
ffmpeg.setFfprobePath(ffprobePath.path)

export default class FFmpeg {
  ffmpeg: ffmpeg.FfmpegCommand

  constructor(inputPath: string) {
    this.ffmpeg = ffmpeg(inputPath)
  }
}

之后我们在开发环境里面就能正常使用 FFmpeg 了。

打包

路径问题

我的项目是使用 electron builder 进行打包的(具体的 electron-builder.yml配置可以在 Electron 代码签名和公证 中查看),打包之后你会发现项目是无法正确使用 FFmpeg,但在 dev 环境下到是正常的。

这是因为 ffmpeg 是二进制文件,会被打包进 app.asar.unpacked 而非 app.asar 从而导致 setFfmpegPath 路径出现问题,所以修改对应的 path 即可,这个问题在 @ffmpeg-installer/ffmpeg 中也有提到。

import ffmpegPath from '@ffmpeg-installer/ffmpeg' 
import ffprobePath from '@ffprobe-installer/ffprobe'
import ffmpeg from 'fluent-ffmpeg' 

ffmpeg.setFfmpegPath(ffmpegPath.path.replace('app.asar', 'app.asar.unpacked')) // 修改
ffmpeg.setFfprobePath(ffprobePath.path.replace('app.asar', 'app.asar.unpacked')) // 修改
export default class FFmpeg {
  ffmpeg: ffmpeg.FfmpegCommand

  constructor(inputPath: string) {
    this.ffmpeg = ffmpeg(inputPath)
  }
}

我使用的电脑是 MacBook Pro M1 Pro 即 macOS ARM64

打包完成之后,此时运行 ARM64 版本的 .app 是没有问题的,FFmpeg 也能正确运行。

FFmpeg 的架构版本问题

启动报错

但可不要高兴的太早,我们换一台运行 macOS x64 的电脑,运行刚才用 macOS ARM64 电脑打包出来的 x64 版本的 .app 就会直接报错,然后显示一个完全摸不到头脑的错误。

原因分析

我一开始看到这个错误也是完全懵逼的,使用 debugtron 对主线程进行调试也完全没有输出。之后尝试对包进行分析,发现 x64 版本的 .app 打包的 FFmpeg 竟然是 ARM64 版本的,这不报错才怪呢。

这里我便对 @ffmpeg-installer/ffmpeg的实现感到了好奇,他是如何匹配不同的平台,从而安装对应其平台的 FFmpeg 二进制文件。通过阅读其源码:

{
  "name": "@ffmpeg-installer/ffmpeg",
  "optionalDependencies": {
    "@ffmpeg-installer/darwin-arm64": "4.1.5",
    "@ffmpeg-installer/darwin-x64": "4.1.0",
    "@ffmpeg-installer/linux-arm": "4.1.3",
    "@ffmpeg-installer/linux-arm64": "4.1.4",
    "@ffmpeg-installer/linux-ia32": "4.1.0",
    "@ffmpeg-installer/linux-x64": "4.1.0",
    "@ffmpeg-installer/win32-ia32": "4.1.0",
    "@ffmpeg-installer/win32-x64": "4.1.0"
  }
}
{
  "name": "@ffmpeg-installer/darwin-x64",
  "os": [
    "darwin"
  ],
  "cpu": [
    "x64"
  ],
}

发现@ffmpeg-installer/ffmpeg 封装了多个平台 FFmpeg 依赖,然后放入 optionalDependencies 中。每个平台的 FFmpeg 包再通过设置 cpu + os 字段,从而实现用户安装 @ffmpeg-installer/ffmpeg 即可匹配用户系统,来安装对应的 ffmpeg,这也让我涨知识了。

因此也难怪 x64 版本的 .app 打包的 FFmpeg 是 ARM64 版本的,因为我们在最开始 pnpm install 的时候,就只安装了对应操作系统的 FFmpeg,build 的时候也只能打包当前安装的 FFmpeg。

举个例子,我是 ARM64 macOS, pnpm install 的时候只会安装 ARM 版本 FFmpeg,打包 x64 的时候,当然也只能打包 ARM 版本 FFmpeg 了,从而导致的错误。

整理思路

那么我们的思路就很明确了:

  • ARM64 macOS -> 打包 ARM64 应用 -> 使用 ARM64 FFmpeg
  • ARM64 macOS -> 打包 x64 应用 -> 使用 x64 FFmpeg

同理:

  • x64 macOS -> 打包 ARM64 应用 -> 使用 ARM64 FFmpeg
  • x64 macOS -> 打包 x64 应用 -> 使用 x64 FFmpeg

那么如何实现呢?

这里思路完全是自己想的,或许有更好的方法,也请多多指教。

说一下我的思路,首先我们在 pnpm install 的时候只安装当前操作系统的 FFmpeg 是不变的,这样可以节约我们电脑的空间和安装依赖的速度。

之后只需要在执行 pnpm build:mac的时候,执行一个安装 mac 平台全部架构的 FFmpeg 依赖脚本就可以了。

解决问题

编写 scripts/install-darwin-deps.js

/* eslint-disable no-console */
import { exec } from 'node:child_process'
import os from 'node:os'

const platform = os.platform()

if (platform === 'darwin') {
  console.log('Detected macOS, installing darwin dependencies...')
  // 为了在 macos arm64 架构下进行打包 x64 架构的 APP, 所以需要同时安装 x64 arm64 架构的 ffmpeg 和 ffprobe
  exec(
    'pnpm i @ffmpeg-installer/darwin-x64@^4.1.0 @ffprobe-installer/darwin-x64@^5.1.0 @ffmpeg-installer/darwin-arm64@^4.1.5 @ffprobe-installer/darwin-arm64@^5.0.1 -D',
    (err, stdout, stderr) => {
      if (err) {
        console.error(`Error installing optional dependencies: ${stderr}`)
        throw new Error('Error installing optional dependencies')
      } else {
        console.log(`Optional dependencies installed: ${stdout}`)
      }
    },
  )
} else {
  console.log('Non-macOS platform detected, skipping optional darwin installation.')
}

之后在 package.json 里面加上 "build:mac": "node scripts/install-darwin-deps.js && electron-vite build && electron-builder --mac --publish never" 即可。

执行打包命令之后,macOS x64 也是正确运行 macOS ARM64 打包出来的 x64 版本的 xxx.app ,不再会出现之前那个摸不着头脑报错了。

不同平台只打包对应的 FFmpeg

这里新的问题有又出现了,我们发现当前 xxx.dmg 包体积大了很多,那是因为所有平台的 FFmpeg 都被打包进去了。例如,ARM64 版本 xxx.app 把 x64 和 ARM64 FFmpeg 都打包进去了。

这里我们得写一个脚本,在 electron builder 打包之后,把与目标平台不相符的 FFmpeg 给删除掉,编写 scripts/cleaned-unused-arch-deps.js

/* eslint-disable no-console */
import fs from 'node:fs'
import path from 'node:path'

export default async function cleanDeps(context) {
  const { packager, arch, appOutDir } = context
  const platform = packager.platform.nodeName
  if (platform !== 'darwin') {
    return
  }
  const archMap = {
    1: 'x64',
    3: 'arm64',
  }
  const currentArch = archMap[arch]

  if (!currentArch) {
    return
  }

  const unpackedPath = path.resolve(
    appOutDir,
    'Marchen.app',
    'Contents',
    'Resources',
    'app.asar.unpacked',
    'node_modules',
  )
  if (!fs.existsSync(unpackedPath)) {
    return
  }

  const ffmpegPath = path.resolve(unpackedPath, '@ffmpeg-installer')
  const ffprobePath = path.resolve(unpackedPath, '@ffprobe-installer')

  if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) {
    return
  }

  const removeUnusedArch = (basePath, unusedArch) => {
    const unusedPath = path.resolve(basePath, `darwin-${unusedArch}`)
    if (fs.existsSync(unusedPath)) {
      fs.rmSync(unusedPath, { recursive: true })
    }
  }

  if (currentArch === 'x64') {
    removeUnusedArch(ffmpegPath, 'arm64')
    removeUnusedArch(ffprobePath, 'arm64')
  } else if (currentArch === 'arm64') {
    removeUnusedArch(ffmpegPath, 'x64')
    removeUnusedArch(ffprobePath, 'x64')
  }
  console.log('Cleaned unused arch dependencies.')
}

之后在 electron-builder.yml 里面使用 afterPack: scripts/cleaned-unused-arch-deps.js 导入脚本。

然后执行 pnpm build:mac就实现了不同平台只打包对应的 FFmpeg,并且运行都正常了。查看包内容,发现确实只包含了目标平台的 FFmpeg。

看完了?说点什么呢

]]>
67422472f7c1066229db031b posts 编程
Electron 代码签名和公证 https://suemor.com/posts/programming/electron-code-signing-and-notarization Sat, 23 Nov 2024 15:03:03 GMT 本文的方式仅限用于 macOS 最近尝试入门了下 Electron 开发,写了一个可以自动匹配弹幕 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/posts/programming/electron-code-signing-and-notarization

本文的方式仅限用于 macOS

最近尝试入门了下 Electron 开发,写了一个可以自动匹配弹幕的动漫播放器,期间遇到挺多坑的,写篇文章来记录一下。

代码签名和公证

想要在 macOS 上运行一个桌面端应用,那就必须对它代码进行签名,否则是无法打开,会出现如下错误。

Apple Developer 注册

想要进行代码签名就得花 688 元去注册苹果开发者,这里注册也比较玄学,注册过程中运气不好就容易出现 联系我们以继续流程的弹窗,这我在用 MacBook 注册时候出现过一次,然后换 iPhone 上注册就没有这个问题了。

注册到最后一步,付完费用之后,你会发现还是没办法使用,打开 Developer APP 账户页面里面,会显示一个灰色的现在注册按钮,然后显示将很快收到相关邮件,打开邮箱会发现两封名为你的订阅确认Apple 提供的收据的邮件,但这个其实并不是上文 Apple 所提到的相关邮件。这里不用慌张,这其实就是 Apple 正在审核的意思,我是晚上注册的,等隔天早上 9 点之后,就会收到一份 欢迎加入 Apple Developer Program的邮件,这才表明注册成功了。

代码签名

关于具体如何生成和上传证书,网上相关教程有很多,这里就不展开说明了。之后就是把 Developer ID certificates 的私钥 .p12 文件,配置到终端环境变量中,填写 CSC_LINKCSC_KEY_PASSWORD

这里 Electron 打包我选择使用 electron-builder ,执行 electron-vite build && electron-builder --mac --publish never 的时候,它会自动读取上方配置好的两个环境变量从而就行签名,无需进行额外配置。

下面是我的 electron-builder.yml

appId: com.suemor.Marchen
productName: Marchen
directories:
  buildResources: build
files:
  - '!**/.vscode/*'
  - '!src/*'
  - '!electron.vite.config.{js,ts,mjs,cjs}'
  - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
  - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
  - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
  - resources/**
win:
  executableName: Marchen
nsis:
  artifactName: ${productName}-${version}-setup.${ext}
  shortcutName: ${productName}
  uninstallDisplayName: ${productName}
  createDesktopShortcut: always
  allowToChangeInstallationDirectory: true
  oneClick: false
mac:
  entitlementsInherit: build/entitlements.mac.plist
  extendInfo:
    - NSCameraUsageDescription: Application requests access to the device's camera.
    - NSMicrophoneUsageDescription: Application requests access to the device's microphone.
    - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
    - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
  notarize: false
  target:
    - target: dmg
      arch:
        - arm64
        - x64
    - target: zip
      arch:
        - arm64
        - x64
dmg:
  artifactName: ${productName}-${version}-${arch}.${ext}
linux:
  target:
    - target: AppImage
      arch:
        - arm64
        - x64
  maintainer: github.com/suemor233
  category: Utility
appImage:
  artifactName: ${productName}-${version}-${arch}.${ext}
npmRebuild: false
publish:
  provider: github
  owner: marchen-dev
  repo: MarchenPlay
afterSign: scripts/notarize.js
releaseInfo:
  releaseNotes: |
    本次更新:
      可以切换动漫内嵌字幕和手动导入字幕
      新增视频播放器设置功能
      可以对历史记录动漫进行删除和从新识别弹幕库

公证

代码签完名之后,我们需要把打包后的程序上传给苹果,来确保我们程序的安全性和没有被其他人篡改。

这里我们安装 @electron/notarizedotenv这两个包, 然后创建一个 scripts/notarize.js文件,写法如下,他可以读取我们 .env 里面的相关变量,然后进行公证。

import { notarize } from '@electron/notarize'
import { config } from 'dotenv'

config()

export default async function notarizing(context) {
  if (context.electronPlatformName !== 'darwin') {
    return
  }

  const appBundleId = process.env.APPLE_APP_BUNDLE_ID // 随便填一个,类似 com.suemor.Marchen
  const appleId = process.env.APPLE_ID // 你的 Apple ID 
  const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD // https://account.apple.com/account/manage 然后点击 App 专用密码
  const teamId = process.env.APPLE_TEAM_ID // https://developer.apple.com/account 里面 会员资格详细信息 卡片里面,有个 团队 ID

  if (!appBundleId || !appleId || !appleIdPassword || !teamId) {
    return
  }

  const appName = context.packager.appInfo.productFilename
  const appPath = `${context.appOutDir}/${appName}.app`
  // eslint-disable-next-line no-console
  console.log('Notarizing app:', appPath)

  await notarize({
    appPath,
    appBundleId,
    appleId,
    appleIdPassword,
    teamId,
  })
}

之后在 electron-builder.yml 里面导入这个路径 afterSign: scripts/notarize.js

然后执行 "build:mac": "electron-vite build && electron-builder --mac --publish never",就可以完成公证了。

这里公证速度一般都挺慢的,因为要把你的应用上传到苹果服务器上去,取决于你宽带的上传速度,得耐心等待。另外我看别人说公证成功之后收到 Apple Developer 的相关邮件,不清楚什么原因,我从来没有收到过。

那么如何校验我们这个应用是否公证成功了呢?安装完 App 之后,我们只要去终端执行下方的命令,显示The validate action worked!就代表成功了。

stapler validate /Applications/xxxx.app

配置 GitHub Actions

为了方便发布版本,我们可能会利用 Github Actions 进行自动化打包,为了 Github Actions 能够在打包过程中进行签名和公证,如下图所示,我们需要把上文所配置的环境变量放到 Github Repository secrets 里面。

CSC_LINK // 填写 base64
CSC_KEY_PASSWORD
APPLE_ID
APPLE_APP_SPECIFIC_PASSWORD
APPLE_TEAM_ID
APPLE_APP_BUNDLE_ID

这里有个麻烦的地方,就是 CSC_LINK 这个字段的填写。之前我们在配置终端环境变量的时候,是直接使用绝对路径的方式来链接到 .p12 文件的,但这里的 Repository secrets 是不支持上传文件的,所以得把之前 Developer ID certificates 的私钥 .p12 文件转换为 base64 然后复制到 Repository secrets 的 CSC_LINK 的变量里面去。

base64 -i xxxx.p12 | pbcopy 

下方是我完整的 release.yml

name: Build/release Electron app

on:
  push:
    tags:
      - v*.*.*

jobs:
  release:
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [20.x]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          lfs: true

      - name: Checkout LFS objects
        run: git lfs checkout

      - name: Setup pnpm
        uses: pnpm/[email protected]

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'

      - name: Install Dependencies
        run: pnpm install

      - name: Install snapcraft
        if: matrix.os == 'ubuntu-latest'
        run: sudo snap install snapcraft --classic

      - name: Build for Linux
        if: matrix.os == 'ubuntu-latest'
        run: pnpm run build:linux

      - name: Build for macOS
        if: matrix.os == 'macos-latest'
        run: pnpm run build:mac
        env:
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          APPLE_APP_BUNDLE_ID: ${{ secrets.APPLE_APP_BUNDLE_ID }}
          CSC_LINK: ${{ secrets.CSC_LINK }}
          CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
          GH_TOKEN: ${{ secrets.GH_TOKEN }}

      - name: Build for Windows
        if: matrix.os == 'windows-latest'
        run: pnpm run build:win

      - name: Generate Changelog
        if: github.event_name == 'push'
        run: npx @suemor/changelogithub
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Create Release
        uses: softprops/action-gh-release@v2
        with:
          draft: false
          prerelease: true
          files: |
            dist/*.exe
            dist/*.zip
            dist/*.dmg
            dist/*.AppImage
            dist/*.snap
            dist/*.deb
            dist/*.rpm
            dist/*.tar.gz
            dist/latest*.yml
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

下期预告

下篇文章准备来讲一下如何打包 FFmpeg 到 Electron 里面,这里面的坑还是挺多的,特别是利用 Github Actions 打包的时候,得实现根据不同的平台打包不同 FFmpeg 才行,比如用 arm64 mac 打包 x64 的应用,如何正确打包 x64 版本的 FFmpeg。

看完了?说点什么呢

]]>
6741eea7f7c1066229dafb3b posts 编程
迟来的 2023 年度总结 https://suemor.com/notes/5 Sat, 08 Jun 2024 13:16:25 GMT 2023 的年度总结我一直搁置着没写,因为那时一直在忙于备考转本,并且心情极度焦虑,转眼间就过去半 该渲染由 marked 生成,可能存在排版问题,最佳体验请前往:https://suemor.com/notes/5

2023 的年度总结我一直搁置着没写,因为那时一直在忙于备考转本,并且心情极度焦虑,转眼间就过去半年了,导致很多事情的结果都与 2024 年有些挂钩,所以这篇总结也会包含少许的 2024 年的事情。

正文

​ 2022 年末的时候我无意间了解到「字节青训营」的存在,当时正好有空,就报名参加了基础班,笔试很简单,基本人人都能过。整个流程大概就是去学习掘金里面的课程,最后组队写一个小项目。我们小队采用 Next.js 仿写掘金官网的项目,我也很幸运地被选为队长,因为涉及到多人协作,让我对使用 Git 有了些提升。项目开发整体还是挺顺利的,最后获得了第二名。

​ 二月中旬,一天群里聊天的时候,偶然间听 Akarachen 说有一个远程实习的机会,当时我挺激动的,于是就去试试水。简单跟 Lemon 交流后,便被安排面试。当时其实挺不方便的,被学校领导叫去南京来陪学弟参加比赛,面试基本就一边走路一边完成,挺是尴尬。最终也是顺利入职,里面大佬挺多的,写的项目也很符合我的技术栈。就这样一直干到了五月份,因为要准备专转本,而且还要上学,时间越来越不够用了,所以只能无奈选择离职。这也算是我第一桶金,挣了蛮多钱的,狠狠消费了一波,报了个转本的全程班,抢了 NuPhy Air75 V2 的首发,买了台 N100 小主机等等。

​ 期间还看到 innei 大佬多次参与 xLog 项目,吸引了我的兴趣,看到用的技术栈也挺切合我的,于是便 clone 项目学习了下。当时项目还是初期阶段,有几个明显的 bug,也因此水了好几个 PR。当时挺兴奋的,GitHub 涨了挺多 followers 的,也是第一次给大型项目提了好些 PR,同时还认识了些大佬。

​ 大概从 6 月开始正式进入转本的学习,因为我手上并没有备考的资料,于是便听从往届上岸学长的建议,花了 4k 报了某机构的线上全程班,结果就被坑了。发的专业课资料上面错题很多,答疑速度也非常慢,不如直接去问 GPT,配套的视频播放器也很垃圾,各种离奇的 bug 和卡顿。英语的课程就更离谱了,报课后,就给了一大堆书和视频,完全没有侧重点,也没有相关的模拟卷。答疑的英语老师一看就是外边随便找的,对转本相关的东西完全不了解,很业余,纯纯被当成韭菜收割了。

​ 我的英语很糟糕,基本就小学生水平,并且自己的记性还很差,凡是涉及到需要背诵的内容,就很难记住,这使我背诵单词和词组要花费了比其他人更多的时间,于是我从年初开始每天都坚持背单词,到暑假结束后,看到核心单词基本可以迅速明白意思。其次我对于语法则是一窍不通,暑假的时候便尝试跟着英语的平行世界的视频学习,从初级语法学到高级语法,他讲的还是挺不错的,不敢说有多深入,但确实是通俗易懂,让我对英语语法有了全新的理解。考虑到我挺喜欢玩 Galgame 的,闲暇之余找了款原始语言是英语的 The Letter ,看它恰好有 Mac 版本,配合上 Bob 翻译,让我对英语的阅读有了挺大的提升。

​ 暑假过后学校开双选会,里面基本都是些电子厂、客服之类的,还要求强制实习,不然不给毕业。而且每天都要定位打卡+实习日志/周志+拍照,有时学校领导还会实地走访,很是恶心。为了避免打扰到转本,我想了些方法糊弄了下,虽然全程都在斗智斗勇,但还是成功逃脱了实习。

​ 之后就是在家从早到晚的刷题,随之而来问题也产生了,因为班上就我一个人在准备转本,很孤独,完全没有陪伴,缺乏动力感,导致早晨很容易起不来,或者起来后犯困,忍不住去睡回笼觉。再三考虑下,决定线下找个自习室去学习,再搭配上魔爪饮料,成功摆脱了犯困的问题。大概就这样一直持续到 2024 年 3 月 24 日,期间压力是很大的,经常看到许多比我优秀的人,有时候也会怀疑起自己就不是学习的料,害怕考不上,活生生浪费大半年写代码的时间,因此基本每天都在哭,网上测试心理状况基本都是重度抑郁,好在功夫不负有心人,最终考试分数还是挺不错的,比录取分数线高出很多,全班就我一个人上岸了。

其他

闲鱼验货宝买 AirPods Max 被坑

此耳机在 2024 年 6 月 1 日去世,无法连接到手机,花了 580 元才维修好,下次再也不在闲鱼买贵重物品了。

​ 之前一直想买台耳机,当时正好发完实习工资,便想买台 AirPods Max,看了下官方价格太高,便想贪点便宜闲鱼买台在保的 AirPods Max,我也知道 AirPods Max 假货多,特地要求走闲鱼验货宝,结果验货宝验货一切正常,到手后发现耳机退不了 ID,查找功能用不了,找卖家和验货宝官方基本都是装死或者答非所问,因为走了验货宝也无法退货,只能自认倒霉。好在其他功能都正常,将就着用了。

折腾小主机

​ 双11看零刻 N100 在打折,便入手了一台,第一次折腾 pve,尝试 All in boom,装了 OpenWrt、黑群晖、Home Assistant 之类的,把米家和家里的摄像头都接入到了 HomeKit 里,学习到挺多网络知识的。

游玩 Galgame

​ 从去年开始就逐渐对 Galgame 感兴趣起来,今年玩了挺多作品的,其中「樱之诗」应该是印象最深刻的了,体会到了艺术与哲学交织,让我对哲学有了点兴趣。还有就是玩「装甲恶鬼村正」的时候提到「元日战争」和「正宗」,转本之后也是买了台 PS5 通关了「对马岛之魂」。

下面列举 2023 年玩过的 Galgame

  • 樱之诗
  • 樱之刻
  • 素晴日
  • 星空列车与白的旅行
  • 青空下的加缪
  • 装甲恶鬼村正
  • 你和她和她的恋爱
  • 电波消逝之日
  • Muv-Luv 系列
  • 兰斯3 重制版
  • 兰斯10
  • 拔作岛1
  • 水仙
  • 多娜多娜一起干坏事吧
  • 恋爱绮谭不存在的真相

新增的电子产品

  • 零刻 N100
  • NuPhy Air75 V2
  • AirPods Max
  • PlayStation 5
  • HomePod mini * 2
  • 奔图 P1 激光打印机
  • TP-LINK XDR6088 路由器
  • 小米电动牙刷 T501

学会的新技能

  • VSCode Vim
  • Next.js App Router
  • TanStack Query

未来的期待

提高代码质量,学习设计模式的使用,多阅读优秀的开源项目,并参与其中。

往期

看完了?说点什么呢

]]>
666459a9c276c57e12eacba5 notes false