本文為作者原創,轉載請注明出處:https://www.cnblogs.com/leisure_chn/p/10662941.html
FLV (Flash Video) 是由 Adobe 公司推出的一種封裝格式,主要用於流媒體系統。FLV 封裝的媒體文件具有體積輕巧、封裝播放簡單等特點,很適合網絡應用。目前各瀏覽器普遍使用 Flash Player 作為網頁播放器,使得安裝有瀏覽器的計算機終端不需要另外安裝播放器,這也是 FLV 格式廣為流行的原因之一。
FLV 封裝格式的文件擴展名為 .flv。FLV 文件主要由一個 Header 加上由多個 Tag 組成的 Body 構成。如下所述:
1. FLV Header
所有 FLV 格式文件都以 FLV Header 開頭。FLV Header 類型是 FLVHEADER,FLVHEADER 定義如下:
字段 | 類型 | 說明 |
---|---|---|
Signature | UI8 | 'F' (0x46) |
Signature | UI8 | 'L' (0x4C) |
Signature | UI8 | 'V' (0x56) |
Version | UI8 | FLV 版本。例如,0x01 表示 FLV 版本 1 |
TypeFlags | UI8 | b[0] 是否存在視頻流 b[2] 是否存在音頻流 其他字段保留,值為0 |
DataOffset | UI32 | FLV Header 長度(字節) |
在 FLV 版本 1 中,“數據偏移”字段值為 9。在 FLV 未來版本中,此字段可兼容更大尺寸的 FLV Header。
typedef struct {
UI8 Signature;
UI8 Signature;
UI8 Signature;
UI8 Version;
UI8 TypeFlags;
UI32 DataOffset;
} FLVHEADER;
2. FLV Body
一個 FLV 文件,除開頭的 FLV Header 外,剩余部分就是 FLV Body。FLV Body 由一系列 back-pointer 和 tag 交織構成。back-pointer 表示前一 tag 大小。FLV Body 類型是 FLVBODY,FLVBODY 定義如下:
字段 | 類型 | 說明 |
---|---|---|
PreviousTagSize0 | UI32 | 值總為 0 |
Tag1 | FLVTAG | 第一個 Tag |
PreviousTagSize1 | UI32 | 前一 Tag 大小,單位字節。FLV 版本 1 中, 此值等於前一 Tag 的 DataSize + 11 |
Tag2 | FLVTAG | 第二個 Tag |
... | ... | ... |
PreviousTagSizeN-1 | UI32 | 倒數第二個 Tag 大小,單位字節 |
TagN | FLVTAG | 最后一個 Tag |
PreviousTagSizeN | UI32 | 最后一個 Tag 的大小,單位字節 |
typedef struct {
UI32 PreviousTagSize0;
FLVTAG Tag1;
UI32 PreviousTagSize1;
FLVTAG Tag2;
...
UI32 PreviousTagSizeN-1;
FLVTAG TagN;
UI32 PreviousTagSizeN;
} FLVBODY;
3. FLV Tag
FLV Tag 包含音頻、視頻或腳本元數據、可選的加密元數據和 payload。FLV Tag 類型是 FLVTAG,FLVTAG 定義如下:
字段 | 類型 | 說明 |
---|---|---|
Reserved | UB [2] | 用於 FMS 的保留字段, 值為 0 |
Filter | UB [1] | 指示 packet 是否需要預處理。 0 = 不需要預處理。 1 = packet 在渲染前需要預處理(例如解密)。 未加密文件中此值為0,加密文件中此值為1。 |
TagType | UB [5] | 8 = 音頻 9 = 視頻 18 = 腳本數據 |
DataSize | UI24 | Tag 中除通用頭外的長度,即 Header + Data 字段的長度 (等於 Tag 總長度 – 11) |
Timestamp | UI24 | 當前 Tag 的解碼時間戳 (DTS),單位是毫秒。FLV 文件中第一個 Tag 的 DTS 總為 0 |
TimestampExtended | UI8 | 和 Timestamp 字段一起構成一個 32 位值, 此字段為高 8 位。單位毫秒 |
StreamID | UI24 | 總為 0 |
Header | IF TagType == 8 AudioTagHeader IF TagType == 9 VideoTagHeader |
音頻或視頻 TagHeader,注意腳本沒有 TagHeader |
Data | IF TagType == 8 AUDIODATA IF TagType == 9 VIDEODATA IF TagType == 18 SCRIPTDATA |
音頻、視頻或腳本 TagBody |
typedef struct {
UB[2] Reserved;
UB[1] Filter;
UB[5] TagType;
UI24 DataSize;
UI24 Timestamp;
UI8 TimestampExtended;
UI24 StreamID;
IF TagType == 8
AudioTagHeader Header;
IF TagType == 9
VideoTagHeader Header;
IF TagType == 8
AUDIODATA Data;
IF TagType == 9
VIDEODATA Data;
IF TagType == 18
SCRIPTDATA Data;
} FLVTAG;
一個 FLVTAG 中,前 11 個字節是通用 TagHeader,后面緊跟跟着音頻 Tag、視頻 Tag 或腳本 Tag,其中音頻 Tag 和視頻 Tag 都包含 TagHeader 和 TagBody 兩部分,腳本 Tag 只有 TagBody 部分。
上面 Timestamp 和 TimestampExtended 兩個字段拼成一個 32 位的時間戳,是當前 Tag 的解碼時間戳 (DTS)。對於音頻幀來說,PTS 和 DTS 相同。對於視頻幀來說,若含 B 幀,則 PTS 和 DTS 不同,H264 視頻幀 PTS = DTS + CTS,CTS 就是 CompositionTime 字段,參考 3.2.1 節 CompositionTime 字段的定義。
3.1 Audio Tag
Audio Tag 包括 AudioTagHeader 和 AudioTagBody 兩部分。
3.1.1 AudioTagHeader
AudioTagHeader 定義如下:
字段 | 類型 | 說明 |
---|---|---|
SoundFormat | UB [4] | 聲音格式: 0 = Linear PCM, platform endian 1 = ADPCM 2 = MP3 3 = Linear PCM, little endian 4 = Nellymoser 16-kHz mono 5 = Nellymoser 8-kHz mono 6 = Nellymoser 7 = G.711 A-law logarithmic PCM 8 = G.711 mu-law logarithmic PCM 9 = reserved 10 = AAC 11 = Speex 14 = MP3 8-Khz 15 = Device-specific sound |
SoundRate | UB [2] | 采樣率: AAC: 總為3 0 = 5.5 kHz 1 = 11 kHz 2 = 22 kHz 3 = 44 kHz |
SoundSize | UB [1] | 采樣位深。此參數僅適用未壓縮格式,壓縮格式總在內部被解碼為16位。 0 = 8位 1 = 16位 |
SoundType | UB [1] | 0 = 單聲道 1 = 立體聲 |
IF SoundFormat == 10 AACPacketType |
UI8 | AAC幀類型。僅當聲音格式為 10 時,存在此字段 0 = AAC sequence header 1 = AAC raw |
格式 3,linear PCM,存儲原始 PCM 采樣點。如果采樣位深為 8,采樣點數據為無符號型。如果采樣位深為 16,采樣點數據為小端存儲的帶符號型。如果是立體聲,左右聲道采樣點交織存放:左-右-左-右-...
格式 0 與格式 3 的不同之處只有一點:格式 0 存儲 16 位采樣數據,采用的大小端順序是創建 FLV 文件的平台所使用的大小端順序。因此,不應使用格式 0,而應使用格式 3。
格式 4 (Nellymoser 16-kHz mono) 和格式 5 (Nellymoser 8 kHz mono),是兩種特殊情況, 因為采樣率字段無法表示 8 kHz 和 16 kHz。當采樣格式是格式 4 或格式 5 時,Flash 播放器會忽略采樣率和聲音類型兩個字段。對於其他采樣率的 Nellymoser 格式, 即格式 6,則正常使用采樣率和聲音類型兩個字段。
格式 10,AAC,聲音類型應為 1 (立體聲) 且采樣率應為 3 (44 kHz)。這並不表示 FLV 中的 AAC 音頻總是立體聲、44 kHz的數據。實際上,Flash 播放器會忽略這兩個值,而從已編碼的 AAC 位流中提取出聲道數和采樣率信息。
格式 11,Speex,音頻以 16 kHz采樣率壓縮為單聲道,采樣率字段值應為 0,采樣位深字段值應為 1,聲音類型字段值應為 0。
格式 7,8,14 和 15 保留。
typedef struct {
UB [4] SoundFormat;
UB [2] SoundRate;
UB [1] SoundSize;
UB [1] SoundType;
IF SoundFormat == 10
UI8 AACPacketType;
}
3.1.2 AudioTagBody/AUDIODATA
AUDIODATA 定義如下:
字段 | 類型 | 說明 |
---|---|---|
Body | IF Encrypted EncryptedBody ELSE AudioTagBody |
類型分加密與非加密兩種 |
typedef struct {
IF Encrypted
EncryptedBody Body
else
AudioTagBody Body;
} AUDIODATA;
AUDIODATA 包含 Body 字段。如果采用了加密,Body 的類型是 EncryptedBody,可參考規范文檔“附件 F. FLV 加密”章節獲得詳細信息,此處略。如果未采用加密,則 Body 的類型是 AudioTagBody,下面詳述。
AudioTagBody 定義如下:
字段 | 類型 | 說明 |
---|---|---|
SoundData | IF SoundFormat == 10 AACAUDIODATA ELSE Varies by format |
字段類型根據聲音格式確定 |
typedef struct {
IF SoundFormat == 10
AACAUDIODATA SoundData;
ELSE
Varies by format
} AudioTagBody;
3.1.3 AACAUDIODATA
Flash 播放器 9.0.115.0 及以上版本支持 AAC 格式。AACAUDIODATA 定義如下:
字段 | 類型 | 說明 |
---|---|---|
Data | IF AACPacketType == 0 AudioSpecificConfig ELSE IF AACPacketType == 1 Raw AAC frame data in UI8 [] |
AudioSpecificConfig 在 ISO 14496-3 中定義 |
3.2 Video Tag
Video Tag 包含 VideoTagHeader 和 VideoTagBody 兩部分。
3.2.1 VideoTagHeader
字段 | 類型 | 說明 |
---|---|---|
FrameType | UB [4] | 幀類型: 1: keyframe (for AVC, a seekable frame) 2: inter frame (for AVC, a non-seekable frame) 3: disposable inter frame (H.263 only) 4: generated keyframe (reserved for server use only) 5: video info/command frame |
CodecID | UB [4] | 編解碼器標識: 1: JPEG (currently unused) 2: Sorenson H.263 3: Screen video 4: On2 VP6 5: On2 VP6 with alpha channel 6: Screen video version 2 7: AVC |
IF CodecID == 7 AVCPacketType |
UI8 | AVC幀類型: 0 = AVC sequence header 1 = AVC NALU 2 = AVC end of sequence (lower level NALU sequence ender is not required or supported) |
IF CodecID == 7 CompositionTime |
UI24 | PTS 與 DTS 的時間偏移值,單位 ms,記作 CTS。參考 "ISO 14496-12, 8.15.3" |
H.264 的命名遵循了 ITU-T 的命名約定,它是 VCEG 視頻編碼標准 H.26x 線中的一員;MPEG-4 AVC 的命名來自 ISO/IEC MPEG 的命名約定,它是 ISO/IEC 14496 的第 10 部分,該協議族被稱為 MPEG-4。
3.2.2 VideoTagBody/VIDEODATA
VIDEODATA 定義如下:
字段 | 類型 | 說明 |
---|---|---|
Body | IF Encrypted EncryptedBody ELSE VideoTagBody |
類型分加密與非加密兩種 |
typedef struct {
IF Encrypted
EncryptedBody Body
else
VideoTagBody Body;
} VIDEODATA;
VIDEODATA 包含 Body 字段。如果采用了加密,Body 的類型是 EncryptedBody,可參考規范文檔“附件 F. FLV 加密”章節獲得詳細信息,此處略。如果未采用加密,則 Body 的類型是 VideoTagBody,下面詳述。
VideoTagBody 包含視頻幀凈荷數據。VideoTagBody 定義如下:
字段 | 長度 | 說明 |
---|---|---|
VideoData | IF FrameType == 5 UI8 ELSE ( IF CodecID == 2 H263VIDEOPACKET IF CodecID == 3 SCREENVIDEOPACKET IF CodecID == 4 VP6FLVVIDEOPACKET IF CodecID == 5 VP6FLVALPHAVIDEOPACKET IF CodecID == 6 SCREENV2VIDEOPACKET IF CodecID == 7 AVCVIDEOPACKET ) |
視頻幀凈荷數據或視頻幀信息 除 AVCVIDEOPACKET 外的所有格式都可以參考 SWF 文件格式規范 |
typedef struct {
IF FrameType == 5
UI8 VideoData;
ELSE (
IF CodecID == 2
H263VIDEOPACKET VideoData;
IF CodecID == 3
SCREENVIDEOPACKET VideoData;
IF CodecID == 4
VP6FLVVIDEOPACKET VideoData;
IF CodecID == 5
VP6FLVALPHAVIDEOPACKET VideoData;
IF CodecID == 6
SCREENV2VIDEOPACKET VideoData;
IF CodecID == 7
AVCVIDEOPACKET VideoData;
)
} VideoTagBody;
3.2.3 AVCVIDEOPACKET
AVCVIDEOPACKET 包含 AVC(H264) 視頻凈荷數據。AVCVIDEOPACKET 定義如下:
字段 | 長度 | 說明 |
---|---|---|
Data | IF AVCPacketType == 0 AVCDecoderConfigurationRecord IF AVCPacketType == 1 One or more NALUs (Full frames are required) |
參考 ISO 14496-15, 5.2.4.1 中對 AVCDecoderConfigurationRecord 的描述 |
typedef struct {
IF AVCPacketType == 0
AVCDecoderConfigurationRecord Data;
IF AVCPacketType == 1
One or more NALUs
} AVCVIDEOPACKET;
3.3 Data Tag
數據 Tag 封裝了單一方法,此方法通常在 Flash 播放器中的網絡流對象上被調用。數據 Tag 包含方法名和一組參數。
3.3.1 ScriptTagBody/SCRIPTDATA
SCRIPTDATA 定義如下:
字段 | 類型 | 說明 |
---|---|---|
Body | IF Encrypted EncryptedBody ELSE ScriptTagBody |
類型分加密與非加密兩種 |
typedef struct {
IF Encrypted
EncryptedBody Body
else
ScriptTagBody Body;
} SCRIPTDATA;
SCRIPTDATA 包含 Body 字段。如果采用了加密,Body 的類型是 EncryptedBody,可參考規范文檔“附件 F. FLV 加密”章節獲得詳細信息,此處略。如果未采用加密,則 Body 的類型是 ScriptTagBody,下面詳述。
ScriptTagBody 包含以 AMF(Action Message Format) 編碼的 SCRIPTDATA。AMF 是一種緊湊二進制格式,用於序列化 ActionScript 對象圖。ScriptTagBody 定義如下:
字段 | 類型 | 說明 |
---|---|---|
Name | SCRIPTDATAVALUE | 方法名或對象名 |
Value | SCRIPTDATAVALUE | AMF 參數或對象屬性 |
這里的 Name 就是上面提到的數據 Tag 中的方法名,Value 是此方法的一組參數。
typedef struct {
SCRIPTDATAVALUE Name;
SCRIPTDATAVALUE Value;
} ScriptTagBody;
3.3.2 SCRIPTDATAVALUE
一個 SCRIPTDATAVALUE 記錄包含一個特定類型的 ActionScript 值。
SCRIPTDATAVALUE 定義如下:
字段 | 類型 | 說明 |
---|---|---|
Type | UI8 | ScriptDataValue 的類型: 0 = Number 1 = Boolean 2 = String 3 = Object 4 = MovieClip (保留,不支持) 5 = Null 6 = Undefined 7 = Reference 8 = ECMA array 9 = Object end marker 10 = Strict array 11 = Date 12 = Long string |
ScriptDataValue | Type 字段值 -> 本字段類型: 0 -> DOUBLE 1 -> UI8 2 -> SCRIPTDATASTRING 3 -> SCRIPTDATAOBJECT 7 -> UI16 8 -> SCRIPTDATAECMAARRAY 10 -> SCRIPTDATASTRICTARRAY 11 -> SCRIPTDATADATE 12 -> SCRIPTDATALONGSTRING |
腳本數據值 |
SCRIPTDATAVALUE 的兩個字段,Type 是類型,ScriptDataValue 是值。Type 的值確定 ScriptDataValue 的類型。因為 ScriptDataValue 的類型是動態的,由運行時解析得到的 Type 的值確定,所以這里類型和值用了兩個字段。如果是靜態類型,顯然只用一個字段就可以了。
typedef struct {
UI8 Type;
IF Type == 0
DOUBLE ScriptDataValue;
IF Type == 1
UI8 ScriptDataValue;
IF Type == 2
SCRIPTDATASTRING ScriptDataValue;
IF Type == 3
SCRIPTDATAOBJECT ScriptDataValue;
IF Type == 7
UI16 ScriptDataValue;
IF Type == 8
SCRIPTDATAECMAARRAY ScriptDataValue;
IF Type == 10
SCRIPTDATASTRICTARRAY ScriptDataValue;
IF Type == 11
SCRIPTDATADATE ScriptDataValue;
IF Type == 12
SCRIPTDATALONGSTRING ScriptDataValue;
} SCRIPTDATAVALUE;
3.3.1 節中 Name 字段和 Value 字段的類型都是SCRIPTDATAVALUE。Name 表示方法名,實際類型通常是SCRIPTDATASTRING。Value 字段表示方法的一組參數,實際類型通常是SCRIPTDATAECMAARRAY。后文將介紹 SCRIPTDATASTRING 和 SCRIPTDATAECMAARRAY 兩種類型。其他類型略,詳情可參考 FLV 規范文檔。
3.3.3 SCRIPTDATASTRING
SCRIPTDATASTRING 和 SCRIPTDATALONGSTRING 兩種類型用於存儲字符串,二者可存儲字符串長度不同,SCRIPTDATASTRING 用於存儲不超過 65535 個字符的字符串。
SCRIPTDATASTRING 定義如下:
字段 | 類型 | 說明 |
---|---|---|
StringLength | UI16 | StringData 字段的長度,單位字節。 |
StringData | STRING | 字符串實際數據,注意不帶結束符 NUL。 |
typedef struct {
UI16 StringLength;
STRING StringData;
} SCRIPTDATASTRING;
3.3.4 SCRIPTDATAECMAARRAY
SCRIPTDATAECMAARRAY 記錄存儲 ECMA 數組(下表中的 Variables 字段)。ECMA 數組是一個關聯數組,應在 ActionScript 數組包含無序索引時使用。所有索引(無序或有序)都是字符串而不是整數。出於序列化的目的,SCRIPTDATAECMAARRAY 類型與匿名 ActionScript 對象非常相似。
SCRIPTDATAECMAARRAY 定義如下:
字段 | 類型 | 說明 |
---|---|---|
ECMAArrayLength | UI32 | ECMA 數組元素數量(近似) |
Variables | SCRIPTDATAOBJECTPROPERTY[] | 變量名和變量值的列表,即 ECMA 數組 |
ListTerminator | SCRIPTDATAOBJECTEND | 列表終止符 |
typedef struct {
UI32 ECMAArrayLength;
SCRIPTDATAOBJECTPROPERTY[] Variables;
SCRIPTDATAOBJECTEND ListTerminator;
} SCRIPTDATAECMAARRAY;
其中,SCRIPTDATAOBJECTPROPERTY 類型定義了 ActionScript 對象或關聯數組變量的對象屬性。
SCRIPTDATAOBJECTPROPERTY 定義如下:
字段 | 類型 | 說明 |
---|---|---|
PropertyName | SCRIPTDATASTRING | 對象屬性或變量的名稱 |
PropertyData | SCRIPTDATAVALUE | 對象屬性或變量的值和類型 |
typedef struct {
SCRIPTDATASTRING PropertyName;
SCRIPTDATAVALUE PropertyData;
} SCRIPTDATAOBJECTPROPERTY;
3.3.5 實例:onMetaData 對象
FLV 元數據對象應在名為 onMetadata 的 SCRIPTDATA 標簽中攜帶。各種屬性對通過 NetStream.onMetaData 屬性運行的 ActionScript 程序有效。可用的屬性根據創建 FLV 文件的軟件而有所不同。典型屬性包括:
字段 | 類型 | 說明 |
---|---|---|
audiocodecid | Number | 音頻編解碼器 ID |
audiodatarate | Number | 音頻碼率,單位 kbps |
audiodelay | Number | 由音頻編解碼器引入的延時,單位秒 |
audiosamplerate | Number | 音頻采樣率 |
audiosamplesize | Number | 音頻采樣點尺寸 |
canSeekToEnd | Boolean | 指示最后一個視頻幀是否是關鍵幀 |
creationdate | String | 創建日期與時間 |
duration | Number | 文件總時長,單位秒 |
filesize | Number | 文件總長度,單位字節 |
framerate | Number | 視頻幀率 |
height | Number | 視頻高度,單位像素 |
stereo | Boolean | 音頻立體聲標志 |
videocodecid | Number | 視頻編解碼器 ID |
videodatarate | Number | 視頻碼率,單位 kbps |
width | Number | 視頻寬度,單位像素 |
onMetaData 標簽通常會成為 FLV Body 中的第一個標簽,緊跟在 FLV Header 之后。onMetaData 標簽中存儲的是一些視頻、音頻及文件相關的元數據信息:如視頻幀率,音頻采樣率、文件長度等。
結合 3.3.1 節,onMetaData 標簽的 Name 字段主要就是存儲 “onMetaData” 字符串。具體為:第 1 個字節值是 0x02,表示 Name 字段是字符串類型。第 2-3 個字節為 UI16 類型值,標識字符串的長度,值為 0x000A (“onMetaData” 這個字符串的長度)。后面跟着的數據為具體的字符串,值為 “onMetaData”。
onMetaData 標簽的 Value 字段存儲上表所示的各屬性鍵值對。具體為:第 1 個字節值是 0x08,表示 Value 字段是數組類型。第 2-5 個字節為UI32類型值,表示數組元素個數。后面緊跟着數組,數組元素為屬性名稱和值組成的對(鍵值對)。最后是數組的結束符。
ScriptTagBody onMetaData;
onMetaData.Name.Type == 0x02
onMetaData.Name.ScriptDataValue.StringLength == 0x000A
onMetaData.Name.ScriptDataValue.StringData == "onMetaData"
onMetaData.Value.Type == 0x08
onMetaData.Value.ScriptDataValue.ECMAArrayLength ==
onMetaData.Value.ScriptDataValue.Variables[0].PropertyName == {0x0005, "width"} // SCRIPTDATASTRING 類型
onMetaData.Value.ScriptDataValue.Variables[0].PropertyData == {0x00, 1280.0} // SCRIPTDATAVALUE 類型
onMetaData.Value.ScriptDataValue.Variables[1].PropertyName == {0x0005, "height"} // SCRIPTDATASTRING 類型
onMetaData.Value.ScriptDataValue.Variables[1].PropertyData == {0x00, 720.0} // SCRIPTDATAVALUE 類型
...
4. 總結
FLV 結構如下圖所示:
在 C 語言中定義 FLV 文件結構,一目了然:
/*
* @brief flv file header 9 bytes
*/
typedef struct flv_header {
uint8_t signature[3];
uint8_t version;
uint8_t type_flags;
uint32_t data_offset; // header size, always 9
} __attribute__((__packed__)) flv_header_t;
/*
* @brief flv tag general header 11 bytes
*/
typedef struct flv_tag {
uint8_t tag_type;
uint32_t data_size;
uint32_t timestamp;
uint8_t timestamp_ext;
uint32_t stream_id;
void *data; // will point to an audio_tag or video_tag
} flv_tag_t;
typedef struct audio_tag {
uint8_t sound_format; // 0 - raw, 1 - ADPCM, 2 - MP3, 4 - Nellymoser 16 KHz mono, 5 - Nellymoser 8 KHz mono, 10 - AAC, 11 - Speex
uint8_t sound_rate; // 0 - 5.5 KHz, 1 - 11 KHz, 2 - 22 KHz, 3 - 44 KHz
uint8_t sound_size; // 0 - 8 bit, 1 - 16 bit
uint8_t sound_type; // 0 - mono, 1 - stereo
void *data;
} audio_tag_t;
typedef struct video_tag {
uint8_t frame_type;
uint8_t codec_id;
void *data;
} video_tag_t;
typedef struct avc_video_tag {
uint8_t avc_packet_type; // 0x00 - AVC sequence header, 0x01 - AVC NALU
uint32_t composition_time;
uint32_t nalu_len;
void *data;
} avc_video_tag_t;
5. 參考資料
[1] Adobe Flash Video File Format Specification Version 10.1, "Annex E. The FLV File Format"
[2] FLV 存在 B 幀情況下的 DTS 和 PTS, https://www.jianshu.com/p/1cbe31baa711
6. 修改記錄
2019-03-30 V1.0 初稿