JavaScript 处理二进制数据的 API 主要有三种:ArrayBuffer、TypedArray 和 DataView。在 MP4 Box 的解析和处理过程中,这些工具非常有用。本文结合实际的 MP4 box 结构,聊聊它们各自的定位和取舍。
ArrayBuffer 是一块原始的、固定长度的二进制内存,你不能直接读写它,必须通过"视图"来操作。当你 fetch 一个 fMP4 文件后,对其解析会先转为 ArrayBuffer。
const response = await fetch('video.m4s');
const buffer = await response.arrayBuffer();
console.log(buffer);
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 也是建立在 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 文件遵循 ISO 14496-12(ISOBMFF)规范,整个文件由嵌套的 box 组成。每个 box 的基本结构是:
[4 bytes] size (uint32)
[4 bytes] type (4个ASCII字符)
[可选] largesize (uint64,当 size==1 时)
[可选] version (uint8) + flags (uint24)
[...] payload (各种混合类型字段)这里有几个关键特征:
这三条,直接决定了各 API 的适用程度。
在 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;
}
}直觉上,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 提供了 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。
]]>对于转本上岸的我来说,入学已经是大三,这意味着一个学期之后就要开始投递暑期实习,但当时的我对此毫无概念,依旧享受着校园生活,以为时间还很充裕。
年初的时候一直在维护我的 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 7 挂闲鱼卖掉后,心里一直痒痒的,总觉得缺了点什么。今年终于没忍住,花 4k 多收了台二手 Surface Pro 9,i5-1245U + 16GB 配置,算是填上了这个坑。
公司配的 Intel Mac mini 2018 实在太卡了,VS Code 动不动就无响应,代码补全更是完全出不来。看到 pdd 上全新 M4 Mac mini 只要 2.9k,还是没忍住直接冲了。
在公寓里没有外接显示器确实不太方便。本想入手台 5K 显示器,但看着 Studio Display 那可望不可即的价格,还是算了。最后趁某东国补,1.8k 拿下了台 KTC 27 英寸 5K 显示器。实际用下来只能说一般,拖影比较明显。不过考虑到价格,以及支持 5K 和 2K 120Hz 双模切换,性价比还是有的。
回顾往年的年终总结,那些信誓旦旦立下的 flag,最后基本都没能完成。今年也不例外。
工作之后愈发感觉时间不够用了,每天下班回来已经没什么精力折腾自己的项目,Github 提交也基本一片空白。再加上这一年 AI 发展得太快了,光是 Claude 生态就冒出一堆新概念:plugin、command、agent、MCP、hook、skills……看着这些层出不穷的名词,很难不焦虑,总担心自己会被时代甩在后面。
不过焦虑归焦虑,日子还是要过的。新的一年,希望自己能在音视频方向继续深入,把播放器的坑填完,同时也多关注下 AI 这边的动态,找机会做些结合 AI 的项目,至少别让自己掉队太远吧。
]]>最近尝试接触前端音视频领域。在开发 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 进行修正。
要开发一个 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 决定了这个片段的绝对解码时间。
后续需要读取整个流中第一个 moof 的 baseMediaDecodeTime 作为基准偏移量,对于分片内的每一个 moof(不仅仅是第一个),都执行 当前时间 - Offset,强行把时间轴整体“平移”回 0 起点。
要修改二进制数据,首先得能读懂它。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 的工具。
在下方 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对于 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 (Track Fragment Decode Time) 存储了该分片的绝对解码时间。这里有一个必须注意的版本兼容性问题:
UInt32)。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)
},同理,这里也需要注意 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
}
}
]]>
入职当天先是进行新人培训,工作地点在国正中心 3 号楼,领了台 Mac mini 2018(用起来发热量巨大)。
组里主要负责直播中心那块的维护,基本都是用 Vue 2 写的屎山,一个 App.vue 2000 行的那种。
这周主要阅读文档为主,之后 leader 叫我修个 Bug 并把项目给上线,当时慌得一批,自己对开发流程还不太清楚,涉及到多种环境,对应的文档也是年久失修,最后好在没出什么问题,也算是体验一次大厂项目的上线流程。
至于住房,选择了浦东那边一个 2k 的公寓,租了一个月,押一付一,通勤 40 分钟左右,勉强还能接受,先住一个月看看情况,之后再做打算。
]]>转本上岸之后基本天天写些自己的小玩具,一下子就到了 5 月中旬,才发现自己暑期实习还没有找,简单调研后发现很多公司部门已经没有 HC 了,此时自己 LeetCode 基本为 0,八股也完全没背过,加上三本学历,boss 上投递自然没人鸟。
背了 4 天八股后,即 5 月 16 日,终于有一家武汉的公司给了我面试机会,同时也是我第一次参加面试。
面试期间全程社恐紧张,说话也是各种结巴,面经如下:
最后也是顺利通过一面了,二面是老板面,跟他讲了下平时如何使用 AI 工具和大模型的,大概讲了 Grok, Gemini, Claude, ChatGPT 平时是如何使用,个人感觉的优劣那些。
大概隔了两天,也是顺利拿下了 offer
大概背了一周八股后,即 5 月 19 日,一家苏州的 AI 公司给了我一次面试机会。
这次面试开局就是两道算法题,反转链表和括号匹配。由于自己没有刷过 LeetCode 自然是写不出来的,可以说面试场面非常尴尬。
之后又问了些八股,这次回答的还不错,基本都回答上来了。但由于算法题没写出来的缘故,也只能止于一面了。
之前也通过官网投递了 20 多家公司,在 5 月 21 日哔哩哔哩给我安排了隔天的一面,当时看到面试安排挺激动的,直接翘掉了当天全部的课,用来复习八股和刷了 10 道 easy 算法题。
第一次体验大厂面试,也看了些牛客上的各种面经,害怕会被要求撕各种算法题,晚上焦虑的更是睡不着觉。
面试官交代了面试的是直播部门,面经如下:
我自己比较喜欢一边看动漫,一边有着弹幕,但在 macos 没有类似本地视频弹幕播放器,所以我会去做一个这个
用户目前有几十个,他们有时候也会给我发起一些 Github Issues,我也会及时推进并改善 BUG
目前并不支持,我是有这个支持打算的,目前打算做一个媒体库功能
因为我自己看动漫的习惯还是下载到本地,这样码率会高很多,画面会更加清晰。并且有一套类似自动化的追番流程,有新番更新就会自动下载的那种。
增加媒体库功能,支持对动漫进行元数据刮削
chromium 在 mac windows linux 上硬件解码依赖用户电脑配置,chromium 自身的 ffmpeg 因为版权问题,软解缺乏对于 h265 的支持,对于不支持 h265 硬解的用户,会出现黑屏问题。所以要考虑降级到 wasm 软解,似乎哔哩哔哩也有这方面的实践(DashPlayer + WasmPlayer)
再提一些 linux 上硬件解码的坑,要打开实验 flag
firefox 不支持 mkv 视频容器,今年 1 月对 h265 硬件解码支持
有简单了解过,它编解码的核心是 libavcodec,用于对音视频的编码和解码,就是 H.265 H.264 AAC
使用的是 IndexedDB(Dexie.js)进行缓存的,加载的时候把数据注入到 tanstack query 里面。因为项目目前是提供 web linux windows macos 四个版本,使用 IndexedDB 可以确保代码的一致性,减少不同平台之间的 bug。
同时 tanstack query 本身也具备的缓存机制,可以利用其 staleTime 和 gcTime 实现一些临时的缓存效果
利用 opencc-js 实现
libass-wasm 实现的,它是一个用 c 语言编写解析 ASS/SSA 的库, 它是利用 canvas 把字幕画上去的,从而实现 ASS/SSA 字幕复杂样式和动画效果
利用的是 danmu.js 实现的,它是利用 DOM 方式实现的,有碰撞算法,利用 requestAnimationFrame 来实现弹幕平滑滚动,它是根据用户设备刷新率来执行的,其中弹幕轨道可以根据播放器的有效高度/设备标准字号实现
用户可以单独设置每个弹幕的时间轴,来解决
后续或许可以考虑借用大模型辅助对齐
目前历史记录的封面就是利用 ffmpeg 截取用户最后观看位置的图片,利用 -ss 定位时间点,-vframes 确保只提取一帧图像
打包的时候会执行 electron-builder --win 那些 flag,等于告诉 electron 当前是什么平台了,如果后续钩子需要,可以通过 context 获取出来
或者 nodejs os 模块,可以通过 os.platform() 获取平台
后续启动会更快一点,可能操作系统会有些缓存
在组件卸载的时候,我会及时清理 useEffect 里面的副作用,把它放到 useEffect 清理函数里面
react 的话可以利用 react devTools,观察组件的重新渲染变化,也可以使用浏览器控制台里面 performance tab 进行性能录制,从而分析出原因
直接弹幕通常是使用 WebSocket 进行与弹幕服务器双向通信的,在项目中引入 Websocket 客户端逻辑,即可
利用 sentry 和 electron-log 日志处理
不知道
是的
说实习经历
他们都是现代前端的框架,很多思想都是相同的,比如组件化架构、虚拟 DOM、响应式数据绑定,并且都是基于 JavaScript。而且我之前也用过 Vue 开发一些后台管理系统和浏览器起始页,我相信我可以在几天之内,通过阅读官方文档快速上手 Vue 的
monorepo 一般会分为 app 和 packages 两个文件夹,我的项目....
比如我博客的 Markdown 双栏编辑器,左侧是封装的 codemirror 实现代码高亮,右侧是用 markdown-to-jsx 写的 markdown 渲染组件。左侧修改 markdown ,几乎可以无延迟的在右侧渲染出来。这里我使用到了 useDeferredValue,它是用到了 react 18 的并发特性,可以实现根据当前渲染压力,来动态实现一个防抖的效果,让用户基本感受不到渲染延迟。
减少重排和重绘,图片、组件懒加载,使用防抖和节流函数
CSS JS 图片 .m4s 切片
html 也可以放在 cdn 上托管,但很多网站 HTML 是动态生成的,会有延迟问题,所以一般不用 cdn 托管
八股
八股
八股
八股
大概长下方这样
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);这题挺麻烦的,还要写出 promise 状态。而且在 Node.js 环境下似乎会直接被 throw new Error() 给中断掉,怪怪的。
不会写,只写了个普通的反转链表,给面试官看傻眼了
本以为一面要凉凉,没想到面试结束后 5 分钟就收到了 HR 电话,约了下周一的二面。
这次八股问的多些,面经如下:
这次问的还算简单,当天下午 HR 就联系我二面通过,因为是日常实习,没有 HR 面,当天给了 offer
5 月 26 日,杭州一家 500 人左右公司,给了我一面。
有了被大厂拷打经验之后,这次面试直接速通,基本都是秒答,面了 15 分钟就结束了,隔天 HR 面后,也是给了 offer
]]>这篇主要来聊一聊前端常见的跨域问题,以及后端如何处理 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/postshttps://suemor.com
https://server.suemor.com/api/posts
//tips: 下方这个也是跨域
http://127.0.0.1:3000 -> http://localhost:3000/api/postshttp://localhost:3000
http://localhost:5050/api/posts下方这个是同源,没有跨域问题。
https://suemor.com
https://suemor.com/api/posts跨域问题通常在服务端解决,通过配置反向代理或修改后端代码。
跨域请求分为简单请求和复杂请求:
对于同时满足以下三个条件的即为简单请求,服务器只需返回正确的 CORS 头,即 Access-Control-Allow-Origin:
看以下 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();
});符合以下任意一条,即为复杂请求:
复杂请求比较特殊,浏览器会先发送一个 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();
});
复杂请求在无缓存或缓存失效时会发送两次请求:Preflight(OPTIONS)和实际请求,这会增加网络开销。为此,服务器可以通过设置 Access-Control-Max-Age 响应头来控制浏览器缓存预检结果的时长。这个头字段的值表示缓存的有效期(以秒为单位)。在缓存有效期内,浏览器会复用之前的预检结果,跳过对相同接口的 Preflight 请求,从而提升性能。
res.header('Access-Control-Max-Age', '86400'); // 缓存 1 天在跨域场景下,如果后端响应头返回 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));
]]>
今年上半年经历在 迟来的 2023 年度总结基本讲过了,所以本文主要聚焦于 2024 后半年的事情。
https://www.suemor.com/notes/5
专转本结束后距离 9 月开学还有很长时间,毕竟闲着也是浪费时间,我准备找一个实习来提升一下自己的水平。遗憾的是经历了长时间的备考,之前掌握的很多技术都已记忆模糊,简单的温故一段时间后,MiaoMint 说他那边正好有个公司缺人,并且是远程工作的,于是我便欣然接受了。
我在 6 月初顺利入了职,这是一个 AI 初创公司,还处于起步阶段,我是公司里唯一的前端。老板挺热情的,和他沟通了下项目的具体规划后,我便主导了前端项目的开发。因为是前后端分离的,并且是远程工作,前后端及时沟通就显得尤其重要,起初我也是忽略了这一点,导致项目开发进度缓慢。于是我和后端们拉了一个 Discord 语音频道,沟通顺畅了不少,也是赶上了任务期限。
就这样持续到 10 月中旬,也算是挣到人生的第二桶金了。
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 265K和Z890 主板刚刚发售,虽然这 CPU 网上黑点挺多的,但考虑到不错的能耗控制和办公性能,以及后续 BIOS 更新的改善,我还是选择了这套组合。
具体配置如下,因为网上对于 Z890M 的装机配置基本没有,所以我自己搭配了一套,至于显卡等马上发售的 50 系了。
NAS 作为男生的梦想,今年暑假的时候我入手了台丐版极空间 Z4Pro,虽然可玩性不如群辉,但更符合国内使用习惯,并且也有 Docker 和虚拟机功能,对我来说也足够了。
上了两块希捷的 4T 盘,组了 ZDR 动态双备份,搭配施耐德的 UPS,稳定运行了半年,目前没出什么问题。
最近在写一个自动匹配弹幕的动漫播放器,里面需要使用 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 也能正确运行。
但可不要高兴的太早,我们换一台运行 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 了,从而导致的错误。
那么我们的思路就很明确了:
同理:
那么如何实现呢?
这里思路完全是自己想的,或许有更好的方法,也请多多指教。
说一下我的思路,首先我们在 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 ,不再会出现之前那个摸不着头脑报错了。
这里新的问题有又出现了,我们发现当前 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。
本文的方式仅限用于 macOS
最近尝试入门了下 Electron 开发,写了一个可以自动匹配弹幕的动漫播放器,期间遇到挺多坑的,写篇文章来记录一下。
想要在 macOS 上运行一个桌面端应用,那就必须对它代码进行签名,否则是无法打开,会出现如下错误。
想要进行代码签名就得花 688 元去注册苹果开发者,这里注册也比较玄学,注册过程中运气不好就容易出现 联系我们以继续流程的弹窗,这我在用 MacBook 注册时候出现过一次,然后换 iPhone 上注册就没有这个问题了。
注册到最后一步,付完费用之后,你会发现还是没办法使用,打开 Developer APP 账户页面里面,会显示一个灰色的现在注册按钮,然后显示将很快收到相关邮件,打开邮箱会发现两封名为你的订阅确认和 Apple 提供的收据的邮件,但这个其实并不是上文 Apple 所提到的相关邮件。这里不用慌张,这其实就是 Apple 正在审核的意思,我是晚上注册的,等隔天早上 9 点之后,就会收到一份 欢迎加入 Apple Developer Program的邮件,这才表明注册成功了。
关于具体如何生成和上传证书,网上相关教程有很多,这里就不展开说明了。之后就是把 Developer ID certificates 的私钥 .p12 文件,配置到终端环境变量中,填写 CSC_LINK和 CSC_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/notarize 和 dotenv这两个包, 然后创建一个 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 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。
]]>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 日,期间压力是很大的,经常看到许多比我优秀的人,有时候也会怀疑起自己就不是学习的料,害怕考不上,活生生浪费大半年写代码的时间,因此基本每天都在哭,网上测试心理状况基本都是重度抑郁,好在功夫不负有心人,最终考试分数还是挺不错的,比录取分数线高出很多,全班就我一个人上岸了。
此耳机在 2024 年 6 月 1 日去世,无法连接到手机,花了 580 元才维修好,下次再也不在闲鱼买贵重物品了。
之前一直想买台耳机,当时正好发完实习工资,便想买台 AirPods Max,看了下官方价格太高,便想贪点便宜闲鱼买台在保的 AirPods Max,我也知道 AirPods Max 假货多,特地要求走闲鱼验货宝,结果验货宝验货一切正常,到手后发现耳机退不了 ID,查找功能用不了,找卖家和验货宝官方基本都是装死或者答非所问,因为走了验货宝也无法退货,只能自认倒霉。好在其他功能都正常,将就着用了。
双11看零刻 N100 在打折,便入手了一台,第一次折腾 pve,尝试 All in boom,装了 OpenWrt、黑群晖、Home Assistant 之类的,把米家和家里的摄像头都接入到了 HomeKit 里,学习到挺多网络知识的。
从去年开始就逐渐对 Galgame 感兴趣起来,今年玩了挺多作品的,其中「樱之诗」应该是印象最深刻的了,体会到了艺术与哲学交织,让我对哲学有了点兴趣。还有就是玩「装甲恶鬼村正」的时候提到「元日战争」和「正宗」,转本之后也是买了台 PS5 通关了「对马岛之魂」。
下面列举 2023 年玩过的 Galgame
提高代码质量,学习设计模式的使用,多阅读优秀的开源项目,并参与其中。