<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>维他入我心</title><id>https://vitaovo.com/tags/ios/</id><link href="https://vitaovo.com/tags/ios/"/><link href="https://vitaovo.com/tags/ios/atom.xml" rel="self" type="application/atom+xml"/><updated>2026-09-20T00:00:00+08:00</updated><author><name>Wtrwx</name></author><entry><title>DYYY 的抖音动态表情转 GIF：解析 HEVC 图像序列的 mvhd 时长</title><id>https://vitaovo.com/posts/dyyy-heif-to-gif/</id><link href="https://vitaovo.com/posts/dyyy-heif-to-gif/"/><published>2026-09-20T00:00:00+08:00</published><updated>2026-09-20T00:00:00+08:00</updated><summary>从 box 长度、大端序和 version 0/1 字段布局出发，详细解析 DYYY 如何读取 mvhd，并将容器时长用于 GIF 帧延迟回退。</summary><content type="html">&lt;p&gt;DYYY 的表情包保存，需要把应用内部使用的图片资源转换成方便保存、分享的文件。对于本文讨论的 HEVC 编码 HEIF 图像序列，这件事至少有三个环节：&lt;strong&gt;取出帧、确定每帧停留多久、把结果写进 GIF&lt;/strong&gt;。取到了图片，并不代表还原了动画；写出了 GIF，也不代表播放节奏正确。&lt;/p&gt;
&lt;p&gt;这篇文章重点拆解抖音 HEVC 图像序列转 GIF 中的 &lt;code&gt;mvhd&lt;/code&gt; 时长解析：从原始字节找到容器字段，再把时间单位换成秒，最后接入 GIF 帧延迟回退。代码以本文整理时 DYYY &lt;code&gt;main&lt;/code&gt; 分支的最新提交 &lt;a href="https://github.com/Wtrwx/DYYY/commit/6bdc7c35c60620f4e914d07f812b9e9478b86542"&gt;&lt;code&gt;6bdc7c3&lt;/code&gt;&lt;/a&gt; 为准，重点看 &lt;code&gt;DYYYUtils.m&lt;/code&gt; 的转换方法和 &lt;code&gt;DYYYManager.m&lt;/code&gt; 的保存流程。&lt;/p&gt;
&lt;h2 id="真实样本与转换成品"&gt;真实样本与转换成品&lt;/h2&gt;
&lt;p&gt;本文样本来自抖音评论区动态表情资源。更准确地说，它是 &lt;strong&gt;HEVC 编码的 HEIF 图像序列&lt;/strong&gt;：HEIF 描述封装，HEVC 描述图像编码；文件扩展名是 &lt;code&gt;.heif&lt;/code&gt;，但不是一张普通静态 HEIF 图片。&lt;/p&gt;
&lt;p&gt;实测 &lt;code&gt;ftyp&lt;/code&gt; 的 major brand 为 &lt;code&gt;msf1&lt;/code&gt;，compatible brands 为 &lt;code&gt;msf1&lt;/code&gt;、&lt;code&gt;hevc&lt;/code&gt;；图像序列轨道的 sample entry 为 &lt;code&gt;hvc1&lt;/code&gt;，编码器字符串为 &lt;code&gt;BYTEVC1 Coding&lt;/code&gt;。FFmpeg 将其识别并解码为 HEVC。这里的 BYTEVC1 是样本中的编码器标记，不能仅凭这个字符串认定它采用另一种独立的编码格式。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://vitaovo.com/samples/dyyy-heif-to-gif/douyin-sticker.heif"&gt;下载原始 HEIF 图像序列（199,875 字节）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vitaovo.com/samples/dyyy-heif-to-gif/douyin-sticker.gif"&gt;下载转换后的 GIF&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="https://vitaovo.com/samples/dyyy-heif-to-gif/douyin-sticker.gif" alt="抖音动态表情转换后的 GIF"&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;检查项&lt;/th&gt;
&lt;th&gt;原始样本&lt;/th&gt;
&lt;th&gt;GIF 成品&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;尺寸&lt;/td&gt;
&lt;td&gt;300 × 300&lt;/td&gt;
&lt;td&gt;300 × 300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;帧数&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;时长&lt;/td&gt;
&lt;td&gt;2.937 秒&lt;/td&gt;
&lt;td&gt;2.930 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;GIF 时长以百分之一秒表示，输出时间轴发生量化，因此与源文件有 0.007 秒的差异。&lt;/p&gt;
&lt;p&gt;这个样本的顶层结构和 &lt;code&gt;mvhd&lt;/code&gt; 字段可以直接对应后文解析过程：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;文件偏移（十进制） box 字节数
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;0 ftyp 24
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;24 moov 1077
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;32 mvhd 108
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;140 trak 961
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1101 mdat 198774
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mvhd payload 从文件偏移 40 开始，version = 0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;文件偏移 52：00 00 03 E8 → timescale = 1000
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;文件偏移 56：00 00 0B 79 → duration = 2937
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;总时长：2937 / 1000 = 2.937 秒
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;平均帧时长：2.937 / 89 = 0.033 秒
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;平均值验证了这个样本的容器时长与帧数之间的关系；DYYY 是否实际使用该回退值，仍取决于宿主解码器返回的逐帧时长。&lt;/p&gt;
&lt;h2 id="从保存入口找到原始数据"&gt;从保存入口找到原始数据&lt;/h2&gt;
&lt;p&gt;DYYY 有不止一条表情保存路径。&lt;/p&gt;
&lt;p&gt;评论区的保存入口会从表情模型取得资源 URL，交给 &lt;code&gt;DYYYManager&lt;/code&gt; 下载。下载完成后，代码通过 &lt;code&gt;detectFileFormat:&lt;/code&gt; 检查文件头，再决定如何处理：WebP 走 WebP 转换，HEIC/HEIF 走 &lt;code&gt;convertHeicToGif:completion:&lt;/code&gt;，GIF 则直接进入保存流程。&lt;/p&gt;
&lt;p&gt;这一步很有必要：调用时的 &lt;code&gt;MediaTypeHeic&lt;/code&gt; 是业务传入的类型，不能替代对实际文件的检查。不过，当前文件头识别仍然比较粗——遇到 &lt;code&gt;ftyp&lt;/code&gt; 后，无法明确区分的品牌也会归到 HEIF。它是面向这条下载链路的格式分流，还不是完整的媒体容器识别器。&lt;/p&gt;
&lt;p&gt;另一条路径从正在显示的贴纸视图开始。&lt;code&gt;isBDImageWithHeifURL:&lt;/code&gt; 检查图片类名是否包含 &lt;code&gt;BDImage&lt;/code&gt;，确认它响应 &lt;code&gt;bd_webURL&lt;/code&gt;，并判断 URL 中是否含有 &lt;code&gt;.heif&lt;/code&gt; 或 &lt;code&gt;.heic&lt;/code&gt;。随后 &lt;code&gt;saveHeifSticker:&lt;/code&gt; 取出这个 URL，调用同一个转换方法。&lt;/p&gt;
&lt;p&gt;两条入口的区别在于原始数据从哪里来。后面的核心流程一致：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;原始资源 URL / 已下载的本地文件
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↓
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 读取 NSData
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↓
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; YYImageDecoder 解出动画帧
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↓
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 确定每帧时长，ImageIO 写 GIF
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↓
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; PhotoKit 保存，清理临时文件
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;URL 里的后缀只是贴纸入口的启发式判断。如果宿主更换图片类、隐藏资源后缀，或者不再提供 &lt;code&gt;bd_webURL&lt;/code&gt;，这条入口就需要重新适配。&lt;/p&gt;
&lt;h2 id="复用宿主解码器而不是只导出当前画面"&gt;复用宿主解码器，而不是只导出当前画面&lt;/h2&gt;
&lt;p&gt;转换方法没有把 &lt;code&gt;stickerView.image&lt;/code&gt; 当成一张普通静态图写出去，而是读取原始数据，交给宿主进程里的 &lt;code&gt;YYImageDecoder&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;关键在于运行时查找。下面是相关逻辑的节选：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;Class&lt;/span&gt; &lt;span class="n"&gt;decoderClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NSClassFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;@&amp;#34;YYImageDecoder&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;decoderClass&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decoderClass&lt;/span&gt; &lt;span class="nl"&gt;respondsToSelector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;@selector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;decoderWithData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nl"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:)])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kt"&gt;id&lt;/span&gt; &lt;span class="n"&gt;decoderInstance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="kt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;decoderClass&lt;/span&gt; &lt;span class="nl"&gt;decoderWithData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="nl"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;1.0f&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decoderInstance&lt;/span&gt; &lt;span class="nl"&gt;isKindOfClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;decoderClass&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这样做不需要在转换方法里另带一套 HEIF 解码库，但会依赖宿主现有的解码能力。&lt;strong&gt;类名叫 &lt;code&gt;YYImageDecoder&lt;/code&gt;，不等于任意环境里的同名类都支持这些 HEVC 图像序列资源。&lt;/strong&gt; 这里借用的是目标应用进程中实际存在的实现。&lt;/p&gt;
&lt;p&gt;取得解码器后，代码用 &lt;code&gt;frameCount&lt;/code&gt; 遍历帧，再调用：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;YYImageFrame&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt; &lt;span class="nl"&gt;frameAtIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="nl"&gt;decodeForDisplay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;YES&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;CGImageRef&lt;/span&gt; &lt;span class="n"&gt;imageRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CGImage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;CGImageRef&lt;/code&gt; 才是交给 GIF 写入器的像素图像。当前实现逐帧取得图像并写入，没有先在业务代码里建立一个包含所有帧的 &lt;code&gt;UIImage&lt;/code&gt; 数组；但原始 &lt;code&gt;NSData&lt;/code&gt; 和解码器仍在内存中，解码器是否缓存帧也不受这段循环控制，因此不能据此说它是恒定内存的流式转码。&lt;/p&gt;
&lt;h2 id="从容器字节里找到-mvhd"&gt;从容器字节里找到 mvhd&lt;/h2&gt;
&lt;p&gt;当解码器能取出帧，却没有提供有效的帧时长时，DYYY 会尝试从原始数据里读取总时长。相关代码集中在三个函数：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;DYYYUtilsHEIFDurationFromData(data)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ DYYYUtilsParseHEIFDuration(bytes, length)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ 找到 moov，进入它的 payload
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ DYYYUtilsParseMVHDDuration(bytes, length)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ 找到 mvhd，读取 timescale 和 duration
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里的目标是读取资源中可用的 movie header 时间信息。它不负责解码 HEVC 图像，也不会仅凭 &lt;code&gt;mvhd&lt;/code&gt; 重建每一帧的播放时刻。对于没有这条 box 路径的资源，代码返回 &lt;code&gt;0&lt;/code&gt;，表示没有取得可用的总时长。&lt;/p&gt;
&lt;h3 id="先理解-box-的边界"&gt;先理解 box 的边界&lt;/h3&gt;
&lt;p&gt;这段解析面对的是按 box 组织的字节数据。普通 box 的开头有 8 个字节：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;相对 box 起点的偏移
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;0 4 8
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+---------------+---------------+----------------------+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| size：4 字节 | type：4 字节 | payload ... |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+---------------+---------------+----------------------+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;size 包含头部本身，不只是 payload 的长度。
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;type 是四字节标识，例如 moov、mvhd、mdat。
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;position&lt;/code&gt; 表示当前 box 在本层数据中的起点，&lt;code&gt;length&lt;/code&gt; 表示本层解析范围的长度。读完一个 box 后，游标移动的是完整的 &lt;code&gt;rawSize&lt;/code&gt;：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSUInteger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;rawSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;不能每次只加 8，也不能从当前位置一直搜索字符串 &lt;code&gt;mvhd&lt;/code&gt;。压缩数据内部也可能碰巧出现相同的四个字节；只有从正确的 box 边界读取 type，才能把结构字段和普通数据区分开。&lt;/p&gt;
&lt;p&gt;DYYY 在顶层遍历中寻找 &lt;code&gt;moov&lt;/code&gt;，找到后把它的 payload 交给下一层。内层再遍历 &lt;code&gt;moov&lt;/code&gt; 的直接子 box，寻找 &lt;code&gt;mvhd&lt;/code&gt;。两层使用相同的长度处理方式，区别只在于寻找的 type 和找到后的动作。&lt;/p&gt;
&lt;h3 id="size-的三种情况"&gt;size 的三种情况&lt;/h3&gt;
&lt;p&gt;读取最前面的 32 位 &lt;code&gt;size&lt;/code&gt; 后，不能立即把它当成最终长度：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;初始 size&lt;/th&gt;
&lt;th&gt;解释&lt;/th&gt;
&lt;th style="text-align: right"&gt;头部长度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;普通长度值&lt;/td&gt;
&lt;td&gt;整个 box 的字节数&lt;/td&gt;
&lt;td style="text-align: right"&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;真正长度在后面的 64 位 &lt;code&gt;largesize&lt;/code&gt; 中&lt;/td&gt;
&lt;td style="text-align: right"&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;本实现按延伸到当前解析范围末尾处理&lt;/td&gt;
&lt;td style="text-align: right"&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;扩展长度的布局如下：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;0 4 8 16
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+---------------+---------------+-----------------------+----------+
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;| size = 1 | type | largesize：8 字节 | payload |
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;+---------------+---------------+-----------------------+----------+
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;注意：使用扩展长度时，type 仍在 &lt;code&gt;+4&lt;/code&gt;，移动的是 payload 起点。源码因此用变量 &lt;code&gt;header&lt;/code&gt; 保存 8 或 16，而不是写死 &lt;code&gt;payload = bytes + position + 8&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;代码中的处理是：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;uint64_t&lt;/span&gt; &lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DYYYUtilsReadUInt32BigEndian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NSUInteger&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DYYYUtilsReadUInt64BigEndian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;进入 &lt;code&gt;moov&lt;/code&gt; 的下一层时，传入的是：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="c1"&gt;// 子范围起点
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSUInteger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="c1"&gt;// 子范围长度
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这样，内层的 &lt;code&gt;position = 0&lt;/code&gt; 指向 &lt;code&gt;moov&lt;/code&gt; 中的第一个子 box，而不是再次读取 &lt;code&gt;moov&lt;/code&gt; 自己。内层也不能越过父 box 的末尾去读取后续顶层数据。&lt;/p&gt;
&lt;h3 id="多字节整数要按大端序读取"&gt;多字节整数要按大端序读取&lt;/h3&gt;
&lt;p&gt;比如四个字节 &lt;code&gt;00 00 03 E8&lt;/code&gt;，表示整数 &lt;code&gt;1000&lt;/code&gt;。不能直接把这个地址强转成 &lt;code&gt;uint32_t *&lt;/code&gt; 再解引用：那会受宿主字节序和内存对齐影响。&lt;/p&gt;
&lt;p&gt;DYYY 的 32 位读取函数逐字节移位：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;uint32_t&lt;/span&gt; &lt;span class="nf"&gt;DYYYUtilsReadUInt32BigEndian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;uint8_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;uint32_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;uint32_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;uint32_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uint32_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;先转为 &lt;code&gt;uint32_t&lt;/code&gt;，再移位和按位或，避免让字节值经过不合适的有符号整数运算。64 位版本则从左到右循环：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;uint64_t&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSUInteger&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uint64_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这两个函数本身没有长度参数，不负责检查能不能读满 4 或 8 字节。边界检查必须由调用方在调用前完成。&lt;/p&gt;
&lt;h3 id="为什么-version-0-是-12-和-16"&gt;为什么 version 0 是 +12 和 +16&lt;/h3&gt;
&lt;p&gt;找到 &lt;code&gt;mvhd&lt;/code&gt; 后，代码定义：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;uint8_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NSUInteger&lt;/span&gt; &lt;span class="n"&gt;payloadLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSUInteger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;uint8_t&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;mvhd&lt;/code&gt; 的 payload 开头还包含 &lt;strong&gt;1 字节 version + 3 字节 flags&lt;/strong&gt;。这 4 字节没有计入前面普通 box 的 8 字节头部。讨论字段偏移时，必须先说清从哪里算起。&lt;/p&gt;
&lt;p&gt;version 0 的时间相关字段布局为：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: right"&gt;相对 payload 的偏移&lt;/th&gt;
&lt;th style="text-align: right"&gt;长度&lt;/th&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td&gt;version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td style="text-align: right"&gt;3&lt;/td&gt;
&lt;td&gt;flags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td&gt;creation_time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;8&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td&gt;modification_time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;12&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td&gt;timescale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;16&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td&gt;duration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;因此，读取时长需要至少 20 字节 payload：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;timescale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DYYYUtilsReadUInt32BigEndian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DYYYUtilsReadUInt32BigEndian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里的“至少 20 字节”只是&lt;strong&gt;读到 duration 字段所需的最短前缀&lt;/strong&gt;，不是一个完整 &lt;code&gt;mvhd&lt;/code&gt; 的全部长度。后面还有其他字段，当前提取时长的函数不读取它们，也不验证整个 header 的语义。&lt;/p&gt;
&lt;p&gt;如果是普通 8 字节 box 头，timescale 位于 box 起点的 &lt;code&gt;8 + 12 = 20&lt;/code&gt;；如果是 16 字节扩展头，就位于 &lt;code&gt;16 + 12 = 28&lt;/code&gt;。始终相对 payload 取偏移，可以把这两种头部布局统一起来。&lt;/p&gt;
&lt;h3 id="version-1-为什么要换偏移"&gt;version 1 为什么要换偏移&lt;/h3&gt;
&lt;p&gt;version 1 把 creation_time、modification_time 和 duration 扩展成 64 位，timescale 仍然是 32 位。字段布局变成：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: right"&gt;相对 payload 的偏移&lt;/th&gt;
&lt;th style="text-align: right"&gt;长度&lt;/th&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td&gt;version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td style="text-align: right"&gt;3&lt;/td&gt;
&lt;td&gt;flags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td style="text-align: right"&gt;8&lt;/td&gt;
&lt;td&gt;creation_time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;12&lt;/td&gt;
&lt;td style="text-align: right"&gt;8&lt;/td&gt;
&lt;td&gt;modification_time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;20&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td&gt;timescale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;24&lt;/td&gt;
&lt;td style="text-align: right"&gt;8&lt;/td&gt;
&lt;td&gt;duration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;读取 duration 的末尾需要到 payload 第 32 字节，所以源码在这个分支单独检查 &lt;code&gt;payloadLength &amp;lt; 32&lt;/code&gt;，随后读取：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;timescale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DYYYUtilsReadUInt32BigEndian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;uint64_t&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DYYYUtilsReadUInt64BigEndian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;如果仍按 version 0 的偏移读，取到的可能是时间戳字段的一部分，而不是 timescale。这种错误未必立刻崩溃，更可能得到一个看似合法却完全错误的播放时长。&lt;/p&gt;
&lt;h3 id="用一组字节算一遍"&gt;用一组字节算一遍&lt;/h3&gt;
&lt;p&gt;下面是构造的 version 0 &lt;strong&gt;payload 前 20 字节&lt;/strong&gt;，只用来说明字段读取，不是完整 HEIF 文件，也不是完整 &lt;code&gt;mvhd&lt;/code&gt;：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;偏移 字节 含义
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;00 00 version = 0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;01 00 00 00 flags
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;04 00 00 00 00 creation_time（示例占位）
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;08 00 00 00 00 modification_time（示例占位）
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;12 00 00 03 E8 timescale = 1000
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;16 00 00 09 60 duration = 2400
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;timescale&lt;/code&gt; 表示一秒对应多少个时间单位，不是帧率。&lt;code&gt;duration&lt;/code&gt; 使用同一时间单位，所以：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;totalSeconds = 2400 / 1000 = 2.4 秒
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;源码先把两个整数转换成 &lt;code&gt;NSTimeInterval&lt;/code&gt;，再做除法：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timescale&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSTimeInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSTimeInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;timescale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;如果先做整数除法再转换，&lt;code&gt;2400 / 1000&lt;/code&gt; 会先变成 &lt;code&gt;2&lt;/code&gt;，小数部分就丢了。检查 &lt;code&gt;timescale &amp;gt; 0&lt;/code&gt; 则是为了避免除零。&lt;/p&gt;
&lt;p&gt;假设解码器报告 24 帧，那么写入函数可据此得到 &lt;code&gt;2.4 / 24 = 0.1&lt;/code&gt; 秒的平均回退值。这个值只有在某帧的解码器时长无效时才会被使用；正常的逐帧时长依然优先。&lt;/p&gt;
&lt;h3 id="边界检查做到了哪里"&gt;边界检查做到了哪里&lt;/h3&gt;
&lt;p&gt;当前实现先确认剩余数据足够读取头部，再检查：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rawSize&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;前半句防止出现“box 总长度比头部还短”的情况；后半句试图确保 box 不越过当前范围。遇到截断或错误长度时，代码停止这一层扫描，不尝试跳到某个猜测位置继续解析。找不到有效结果则返回 &lt;code&gt;0&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当前检查使用 &lt;code&gt;position + rawSize&lt;/code&gt; 判断末尾；这段加法没有单独处理整数溢出，因此不能将它视为对任意恶意输入都完备的边界验证。&lt;/p&gt;
&lt;p&gt;这仍不是一个完整的容器校验器。当前代码只关心能够读取时长的那段前缀，没有验证所有后续字段，也没有从轨道采样表恢复逐帧时间。因此应把返回值理解为&lt;strong&gt;这条资源路径下可用的总时长候选值&lt;/strong&gt;，并结合实际帧数和输出播放效果验证。&lt;/p&gt;
&lt;h2 id="总时长怎样接回-gif-帧时长"&gt;总时长怎样接回 GIF 帧时长&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;DYYYUtilsHEIFDurationFromData&lt;/code&gt; 返回的值作为 &lt;code&gt;fallbackTotalDuration&lt;/code&gt; 传给 GIF 写入函数。写入器用解码器的帧数得到平均值，然后逐帧决定采用哪个时长。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;解码器给出的时长&lt;/th&gt;
&lt;th&gt;当前处理&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;有限，且不小于 0.01 秒&lt;/td&gt;
&lt;td&gt;保留原值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;非有限值或不大于 0，且存在有效总时长&lt;/td&gt;
&lt;td&gt;使用总时长 ÷ 帧数，再做归一化&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;正数，但小于 0.01 秒&lt;/td&gt;
&lt;td&gt;直接归一化为 0.1 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;没有可用回退值&lt;/td&gt;
&lt;td&gt;归一化为 0.1 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对应源码是：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;CGFloat&lt;/span&gt; &lt;span class="n"&gt;frameDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isfinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frameDuration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;frameDuration&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fallbackFrameDuration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;frameDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fallbackFrameDuration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;CGFloat&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DYYYUtilsNormalizedDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frameDuration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;DYYYUtilsNormalizedDelay&lt;/code&gt; 会把非有限值和小于 &lt;code&gt;0.01&lt;/code&gt; 秒的值改为 &lt;code&gt;0.1&lt;/code&gt; 秒。这意味着一个重要细节：&lt;strong&gt;正数但过短的解码器时长，不会先尝试容器平均值，而是直接走默认值。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;另外，平均回退没有做“剩余时长重新分配”。如果只有部分帧缺少时长，代码仍给这些帧使用 &lt;code&gt;总时长 / 全部帧数&lt;/code&gt;，不会先减掉其他有效帧的时长。由此生成的 GIF 总时长不一定等于 &lt;code&gt;mvhd&lt;/code&gt; 中读出的总时长。&lt;/p&gt;
&lt;p&gt;例如，总时长为 1 秒、两帧中第一帧时长为 0.2 秒、第二帧时长缺失，当前逻辑会给第二帧补 0.5 秒，合计 0.7 秒，而不是 1 秒。这个例子说明了回退的定位：在信息不完整时给出可用估计，不能保证恢复原始时间轴。&lt;/p&gt;
&lt;h2 id="补充unclampeddelaytime-能否提供逐帧时长ios-未验证"&gt;补充：UnclampedDelayTime 能否提供逐帧时长（iOS 未验证）&lt;/h2&gt;
&lt;p&gt;ImageIO 为 HEIC 图像序列提供了 &lt;code&gt;kCGImagePropertyHEICSUnclampedDelayTime&lt;/code&gt;。它从 &lt;strong&gt;iOS 13 / macOS 10.15&lt;/strong&gt; 起可用，位于每帧属性的 &lt;code&gt;kCGImagePropertyHEICSDictionary&lt;/code&gt; 中，值是以秒为单位的浮点数。&lt;a href="https://developer.apple.com/documentation/imageio/kcgimagepropertyheicsunclampeddelaytime"&gt;Apple 官方文档&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;这个 API 面向的是 &lt;strong&gt;HEIC 图像序列的帧间播放时序&lt;/strong&gt;，表示显示下一张图像前应等待多久。&lt;code&gt;Unclamped&lt;/code&gt; 表示未经最小延迟限制调整的时长：读取它，可以让动画播放或转码代码自行决定如何处理很短的帧间隔。它不是抖音或 BYTEVC1 专用接口，也不是 HEVC 解码器，更不是用于控制 GIF 写入的属性；GIF 对应另一个独立的键 &lt;code&gt;kCGImagePropertyGIFUnclampedDelayTime&lt;/code&gt;。&lt;/p&gt;
&lt;h3 id="样本中观察到了什么"&gt;样本中观察到了什么&lt;/h3&gt;
&lt;p&gt;对上面的真实样本，在 macOS 上调用 &lt;code&gt;CGImageSourceCopyPropertiesAtIndex&lt;/code&gt;，首帧的 &lt;code&gt;{HEICS}&lt;/code&gt; 字典返回：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;DelayTime = 0.1 秒
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;UnclampedDelayTime = 0.033 秒
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;后者与本样本的 &lt;code&gt;mvhd&lt;/code&gt; 总时长除以帧数所得的 &lt;code&gt;2.937 / 89 = 0.033&lt;/code&gt; 秒一致。这个样本里，普通 &lt;code&gt;DelayTime&lt;/code&gt; 已经被调整，而 &lt;code&gt;UnclampedDelayTime&lt;/code&gt; 保留了更短的帧间隔。遇到非等时长动画，仍需逐帧读取。&lt;/p&gt;
&lt;h3 id="ios-读取示例"&gt;iOS 读取示例&lt;/h3&gt;
&lt;p&gt;下面只演示读取某一帧的元数据。&lt;code&gt;source&lt;/code&gt; 是通过原始资源数据创建的有效 &lt;code&gt;CGImageSourceRef&lt;/code&gt;，&lt;code&gt;index&lt;/code&gt; 必须小于 &lt;code&gt;CGImageSourceGetCount(source)&lt;/code&gt;：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// iOS 13+；演示代码，未经过 iOS 真机验证。
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NSTimeInterval&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iOS&lt;/span&gt; &lt;span class="mf"&gt;13.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;NSDictionary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CFBridgingRelease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;CGImageSourceCopyPropertiesAtIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;NSDictionary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;heics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__bridge&lt;/span&gt; &lt;span class="n"&gt;NSString&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;kCGImagePropertyHEICSDictionary&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;NSNumber&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;heics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__bridge&lt;/span&gt; &lt;span class="n"&gt;NSString&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;kCGImagePropertyHEICSUnclampedDelayTime&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="nl"&gt;isKindOfClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;NSNumber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doubleValue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isfinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// delay == 0 表示没有读到有效值，交给调用方决定回退策略。
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;目前只在 macOS 上读到了这些元数据，&lt;strong&gt;iOS 真机尚未验证，也没有接入 DYYY&lt;/strong&gt;。另外，ImageIO 导出后续帧仍会失败，读取时长和解码图像要分开看。&lt;/p&gt;
&lt;p&gt;可以尝试继续用宿主 &lt;code&gt;YYImageDecoder&lt;/code&gt; 取帧，用 ImageIO 补充逐帧时长。前提是两边的帧数和顺序对得上，再根据真机结果确定时长的优先级与回退方式。&lt;/p&gt;
&lt;h2 id="让-imageio-负责-gif-写入"&gt;让 ImageIO 负责 GIF 写入&lt;/h2&gt;
&lt;p&gt;帧图像和时长准备好后，代码用 &lt;code&gt;CGImageDestinationCreateWithURL&lt;/code&gt; 创建 GIF 目标，预期图像数量设为解码器的帧数。&lt;/p&gt;
&lt;p&gt;全局属性将 &lt;code&gt;kCGImagePropertyGIFLoopCount&lt;/code&gt; 设为 &lt;code&gt;0&lt;/code&gt;，表示循环播放。每一帧则把刚才算出的 &lt;code&gt;delay&lt;/code&gt; 放进 &lt;code&gt;kCGImagePropertyGIFDelayTime&lt;/code&gt;：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NSDictionary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;frameProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="l"&gt;@{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__bridge&lt;/span&gt; &lt;span class="n"&gt;NSString&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nl"&gt;kCGImagePropertyGIFDictionary&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="l"&gt;@{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__bridge&lt;/span&gt; &lt;span class="n"&gt;NSString&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nl"&gt;kCGImagePropertyGIFDelayTime&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="l"&gt;@(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="l"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="l"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;CGImageDestinationAddImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;imageRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__bridge&lt;/span&gt; &lt;span class="n"&gt;CFDictionaryRef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;frameProps&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里写入的是普通 GIF delay 属性，没有同时写入 unclamped delay。最终播放速度还要看 GIF 编码和播放器对时长的处理，不能把传入的浮点数等同于最终显示设备上的精确停留时间。&lt;/p&gt;
&lt;p&gt;另一个细节是成功条件。代码用 &lt;code&gt;hasFrame&lt;/code&gt; 记录是否至少写入过一帧，再调用 &lt;code&gt;CGImageDestinationFinalize&lt;/code&gt;，以它的返回值判断写入结果，最后 &lt;code&gt;CFRelease&lt;/code&gt; 释放目标对象。&lt;/p&gt;
&lt;p&gt;如果某帧没有可用的 &lt;code&gt;CGImageRef&lt;/code&gt;，循环会跳过它。这样不会把空图像传给写入器，但也意味着成功标志本身不足以证明“原始帧完整保留”：预期帧数仍然是解码器报告的数量，坏帧的时长也没有补偿。要确认输出质量，还应重新读取生成文件，检查帧数和播放时长。&lt;/p&gt;
&lt;p&gt;转换成功后，&lt;code&gt;DYYYManager&lt;/code&gt; 通过 PhotoKit 将 GIF 保存到相册，等保存回调完成后再清理临时文件。&lt;/p&gt;
&lt;h2 id="对应源码"&gt;对应源码&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Wtrwx/DYYY/blob/6bdc7c35c60620f4e914d07f812b9e9478b86542/DYYYUtils.m"&gt;转换、解码器调用、时长解析与 GIF 写入：DYYYUtils.m&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Wtrwx/DYYY/blob/6bdc7c35c60620f4e914d07f812b9e9478b86542/DYYYManager.m"&gt;媒体分流与相册保存：DYYYManager.m&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Wtrwx/DYYY/blob/6bdc7c35c60620f4e914d07f812b9e9478b86542/DYYY.xm"&gt;评论区保存入口：DYYY.xm&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content></entry></feed>