-
Notifications
You must be signed in to change notification settings - Fork 0
Extract capture date from QuickTime video attachments for saleStart #406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import findQuickTimeIlstStringValue from '../findQuickTimeIlstStringValue'; | ||
| import readMp4BoxHeader from '../readMp4BoxHeader'; | ||
|
|
||
| const box = (type: string, payload: Buffer): Buffer => { | ||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(8 + payload.length, 0); | ||
| header.write(type, 4, 'ascii'); | ||
| return Buffer.concat([header, payload]); | ||
| }; | ||
|
|
||
| const dataBox = (value: string): Buffer => { | ||
| const valueBytes = Buffer.from(value, 'utf8'); | ||
| const payload = Buffer.alloc(8 + valueBytes.length); | ||
| payload.writeUInt32BE(1, 0); // type indicator: UTF-8 text | ||
| payload.writeUInt32BE(0, 4); // locale | ||
| valueBytes.copy(payload, 8); | ||
| return box('data', payload); | ||
| }; | ||
|
|
||
| const ilstBox = ( | ||
| entriesByIndex: { keyIndex: number; value: string }[] | ||
| ): Buffer => { | ||
| const entries = entriesByIndex.map(({ keyIndex, value }) => { | ||
| const data = dataBox(value); | ||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(8 + data.length, 0); | ||
| header.writeUInt32BE(keyIndex, 4); // "type" is the 1-based key index | ||
| return Buffer.concat([header, data]); | ||
| }); | ||
| return box('ilst', Buffer.concat(entries)); | ||
| }; | ||
|
|
||
| describe('findQuickTimeIlstStringValue', () => { | ||
| it('resolves the string value for a matching key index', () => { | ||
| const buffer = ilstBox([ | ||
| { keyIndex: 1, value: 'Apple' }, | ||
| { keyIndex: 2, value: '2026-06-17T12:30:03-0400' }, | ||
| ]); | ||
| const box = readMp4BoxHeader(buffer, 0)!; | ||
|
|
||
| expect(findQuickTimeIlstStringValue(buffer, box, 2)).toBe( | ||
| '2026-06-17T12:30:03-0400' | ||
| ); | ||
| }); | ||
|
|
||
| it('returns undefined when no entry matches the key index', () => { | ||
| const buffer = ilstBox([{ keyIndex: 1, value: 'Apple' }]); | ||
| const box = readMp4BoxHeader(buffer, 0)!; | ||
|
|
||
| expect(findQuickTimeIlstStringValue(buffer, box, 2)).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import findQuickTimeKeyIndex from '../findQuickTimeKeyIndex'; | ||
| import readMp4BoxHeader from '../readMp4BoxHeader'; | ||
|
|
||
| const keysBox = (keyNames: string[]): Buffer => { | ||
| const entries = keyNames.map((name) => { | ||
| const nameBytes = Buffer.from(name, 'utf8'); | ||
| const entry = Buffer.alloc(8 + nameBytes.length); | ||
| entry.writeUInt32BE(8 + nameBytes.length, 0); | ||
| entry.write('mdta', 4, 'ascii'); | ||
| nameBytes.copy(entry, 8); | ||
| return entry; | ||
| }); | ||
|
|
||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(0, 0); // version + flags | ||
| header.writeUInt32BE(keyNames.length, 4); // entry count | ||
| const payload = Buffer.concat([header, ...entries]); | ||
|
|
||
| const boxHeader = Buffer.alloc(8); | ||
| boxHeader.writeUInt32BE(8 + payload.length, 0); | ||
| boxHeader.write('keys', 4, 'ascii'); | ||
| return Buffer.concat([boxHeader, payload]); | ||
| }; | ||
|
|
||
| describe('findQuickTimeKeyIndex', () => { | ||
| it('returns the 1-based index of a matching key', () => { | ||
| const buffer = keysBox([ | ||
| 'com.apple.quicktime.make', | ||
| 'com.apple.quicktime.creationdate', | ||
| ]); | ||
| const box = readMp4BoxHeader(buffer, 0)!; | ||
|
|
||
| expect( | ||
| findQuickTimeKeyIndex(buffer, box, 'com.apple.quicktime.creationdate') | ||
| ).toBe(2); | ||
| }); | ||
|
|
||
| it('returns undefined when the key is absent', () => { | ||
| const buffer = keysBox(['com.apple.quicktime.make']); | ||
| const box = readMp4BoxHeader(buffer, 0)!; | ||
|
|
||
| expect( | ||
| findQuickTimeKeyIndex(buffer, box, 'com.apple.quicktime.creationdate') | ||
| ).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import listMp4ChildBoxes from '../listMp4ChildBoxes'; | ||
|
|
||
| const box = (type: string, payload: Buffer = Buffer.alloc(0)): Buffer => { | ||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(8 + payload.length, 0); | ||
| header.write(type, 4, 'ascii'); | ||
| return Buffer.concat([header, payload]); | ||
| }; | ||
|
|
||
| describe('listMp4ChildBoxes', () => { | ||
| it('walks sibling boxes in order', () => { | ||
| const buffer = Buffer.concat([ | ||
| box('ftyp', Buffer.from('qt ')), | ||
| box('free'), | ||
| box('moov', Buffer.from('x')), | ||
| ]); | ||
|
|
||
| const boxes = listMp4ChildBoxes(buffer, 0, buffer.length); | ||
| expect(boxes.map((b) => b.type)).toEqual(['ftyp', 'free', 'moov']); | ||
| }); | ||
|
|
||
| it('scopes to the given [start, end) range', () => { | ||
| const inner = Buffer.concat([box('keys'), box('ilst')]); | ||
| const buffer = Buffer.concat([box('meta', inner)]); | ||
| const meta = listMp4ChildBoxes(buffer, 0, buffer.length)[0]; | ||
|
|
||
| const children = listMp4ChildBoxes( | ||
| buffer, | ||
| meta.contentStart, | ||
| meta.contentEnd | ||
| ); | ||
| expect(children.map((b) => b.type)).toEqual(['keys', 'ilst']); | ||
| }); | ||
|
|
||
| it('returns an empty array for an empty range', () => { | ||
| expect(listMp4ChildBoxes(Buffer.alloc(0), 0, 0)).toEqual([]); | ||
| }); | ||
|
|
||
| it('stops at the first malformed header instead of throwing', () => { | ||
| const buffer = Buffer.concat([box('free'), Buffer.from('ab')]); | ||
|
|
||
| expect( | ||
| listMp4ChildBoxes(buffer, 0, buffer.length).map((b) => b.type) | ||
| ).toEqual(['free']); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import readMp4BoxHeader from '../readMp4BoxHeader'; | ||
|
|
||
| const box = (type: string, payload: Buffer): Buffer => { | ||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(8 + payload.length, 0); | ||
| header.write(type, 4, 'ascii'); | ||
| return Buffer.concat([header, payload]); | ||
| }; | ||
|
|
||
| describe('readMp4BoxHeader', () => { | ||
| it('reads a standard 32-bit box header', () => { | ||
| const buffer = box('ftyp', Buffer.from('payload')); | ||
|
|
||
| expect(readMp4BoxHeader(buffer, 0)).toEqual({ | ||
| type: 'ftyp', | ||
| start: 0, | ||
| contentStart: 8, | ||
| contentEnd: 15, | ||
| }); | ||
| }); | ||
|
|
||
| it('reads a 64-bit extended-size box (size === 1)', () => { | ||
| const payload = Buffer.from('payload'); | ||
| const buffer = Buffer.alloc(16 + payload.length); | ||
| buffer.writeUInt32BE(1, 0); | ||
| buffer.write('mdat', 4, 'ascii'); | ||
| buffer.writeUInt32BE(0, 8); | ||
| buffer.writeUInt32BE(16 + payload.length, 12); | ||
| payload.copy(buffer, 16); | ||
|
|
||
| expect(readMp4BoxHeader(buffer, 0)).toEqual({ | ||
| type: 'mdat', | ||
| start: 0, | ||
| contentStart: 16, | ||
| contentEnd: 16 + payload.length, | ||
| }); | ||
| }); | ||
|
|
||
| it('treats size === 0 as extending to end of buffer', () => { | ||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(0, 0); | ||
| header.write('mdat', 4, 'ascii'); | ||
| const buffer = Buffer.concat([header, Buffer.from('rest-of-file')]); | ||
|
|
||
| const result = readMp4BoxHeader(buffer, 0); | ||
| expect(result?.contentEnd).toBe(buffer.length); | ||
| }); | ||
|
|
||
| it('returns undefined when fewer than 8 bytes remain', () => { | ||
| expect(readMp4BoxHeader(Buffer.from('ab'), 0)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined when the declared size overruns the buffer', () => { | ||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(999, 0); | ||
| header.write('ftyp', 4, 'ascii'); | ||
|
|
||
| expect(readMp4BoxHeader(header, 0)).toBeUndefined(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import readQuickTimeCreationDate from '../readQuickTimeCreationDate'; | ||
|
|
||
| const box = (type: string, payload: Buffer): Buffer => { | ||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(8 + payload.length, 0); | ||
| header.write(type, 4, 'ascii'); | ||
| return Buffer.concat([header, payload]); | ||
| }; | ||
|
|
||
| const keysBox = (keyNames: string[]): Buffer => { | ||
| const entries = keyNames.map((name) => { | ||
| const nameBytes = Buffer.from(name, 'utf8'); | ||
| const entry = Buffer.alloc(8 + nameBytes.length); | ||
| entry.writeUInt32BE(8 + nameBytes.length, 0); | ||
| entry.write('mdta', 4, 'ascii'); | ||
| nameBytes.copy(entry, 8); | ||
| return entry; | ||
| }); | ||
|
|
||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(0, 0); // version + flags | ||
| header.writeUInt32BE(keyNames.length, 4); // entry count | ||
| return box('keys', Buffer.concat([header, ...entries])); | ||
| }; | ||
|
|
||
| const dataBox = (value: string): Buffer => { | ||
| const valueBytes = Buffer.from(value, 'utf8'); | ||
| const payload = Buffer.alloc(8 + valueBytes.length); | ||
| payload.writeUInt32BE(1, 0); // type indicator: UTF-8 text | ||
| payload.writeUInt32BE(0, 4); // locale | ||
| valueBytes.copy(payload, 8); | ||
| return box('data', payload); | ||
| }; | ||
|
|
||
| const ilstBox = ( | ||
| entriesByIndex: { keyIndex: number; value: string }[] | ||
| ): Buffer => { | ||
| const entries = entriesByIndex.map(({ keyIndex, value }) => { | ||
| const data = dataBox(value); | ||
| const header = Buffer.alloc(8); | ||
| header.writeUInt32BE(8 + data.length, 0); | ||
| header.writeUInt32BE(keyIndex, 4); // "type" is the 1-based key index | ||
| return Buffer.concat([header, data]); | ||
| }); | ||
| return box('ilst', Buffer.concat(entries)); | ||
| }; | ||
|
|
||
| const buildQuickTimeBuffer = ( | ||
| keyNames: string[], | ||
| entriesByIndex: { keyIndex: number; value: string }[] | ||
| ): Buffer => { | ||
| const hdlr = box('hdlr', Buffer.alloc(4)); | ||
| const meta = box( | ||
| 'meta', | ||
| Buffer.concat([hdlr, keysBox(keyNames), ilstBox(entriesByIndex)]) | ||
| ); | ||
| const mvhd = box('mvhd', Buffer.alloc(4)); | ||
| const moov = box('moov', Buffer.concat([mvhd, meta])); | ||
| const ftyp = box('ftyp', Buffer.from('qt ')); | ||
| return Buffer.concat([ftyp, moov]); | ||
| }; | ||
|
|
||
| describe('readQuickTimeCreationDate', () => { | ||
| it('extracts com.apple.quicktime.creationdate from moov/meta/keys+ilst', () => { | ||
| const buffer = buildQuickTimeBuffer( | ||
| ['com.apple.quicktime.make', 'com.apple.quicktime.creationdate'], | ||
| [ | ||
| { keyIndex: 1, value: 'Apple' }, | ||
| { keyIndex: 2, value: '2026-06-17T12:30:03-0400' }, | ||
| ] | ||
| ); | ||
|
|
||
| expect(readQuickTimeCreationDate(buffer)).toBe('2026-06-17T12:30:03-0400'); | ||
| }); | ||
|
|
||
| it('returns undefined when there is no moov box', () => { | ||
| expect(readQuickTimeCreationDate(Buffer.from('not-mp4'))).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined when moov has no meta box', () => { | ||
| const moov = box('moov', box('mvhd', Buffer.alloc(4))); | ||
| expect(readQuickTimeCreationDate(moov)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined when the creationdate key is absent', () => { | ||
| const buffer = buildQuickTimeBuffer( | ||
| ['com.apple.quicktime.make'], | ||
| [{ keyIndex: 1, value: 'Apple' }] | ||
| ); | ||
|
|
||
| expect(readQuickTimeCreationDate(buffer)).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import listMp4ChildBoxes from './listMp4ChildBoxes'; | ||
| import type { Mp4Box } from './readMp4BoxHeader'; | ||
|
|
||
| /** | ||
| * Finds the string value for `keyIndex` inside a QuickTime `ilst` box. Each | ||
| * ilst entry's 4-byte "type" is actually the 1-based key index from the | ||
| * `keys` box, not an ASCII tag; the value itself lives in a nested `data` | ||
| * box (4-byte type indicator, 4-byte locale, then the value bytes). | ||
| */ | ||
| const findQuickTimeIlstStringValue = ( | ||
| buffer: Buffer, | ||
| ilstBox: Mp4Box, | ||
| keyIndex: number | ||
| ): string | undefined => { | ||
| const entries = listMp4ChildBoxes( | ||
| buffer, | ||
| ilstBox.contentStart, | ||
| ilstBox.contentEnd | ||
| ); | ||
| const entry = entries.find( | ||
| (box) => buffer.readUInt32BE(box.start + 4) === keyIndex | ||
| ); | ||
| if (!entry) return undefined; | ||
|
|
||
| const dataBox = listMp4ChildBoxes( | ||
| buffer, | ||
| entry.contentStart, | ||
| entry.contentEnd | ||
| ).find((box) => box.type === 'data'); | ||
| if (!dataBox) return undefined; | ||
|
|
||
| return buffer.toString('utf8', dataBox.contentStart + 8, dataBox.contentEnd); | ||
| }; | ||
|
|
||
| export default findQuickTimeIlstStringValue; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import type { Mp4Box } from './readMp4BoxHeader'; | ||
|
|
||
| /** | ||
| * Finds the 1-based index of `targetKeyName` inside a QuickTime `keys` box. | ||
| * `keys` is a full box: 4-byte version/flags, 4-byte entry count, then | ||
| * entries of [4-byte size][4-byte namespace][key name bytes]. | ||
| */ | ||
| const findQuickTimeKeyIndex = ( | ||
| buffer: Buffer, | ||
| keysBox: Mp4Box, | ||
| targetKeyName: string | ||
| ): number | undefined => { | ||
| let offset = keysBox.contentStart + 4; | ||
| const count = buffer.readUInt32BE(offset); | ||
| offset += 4; | ||
|
|
||
| for (let index = 1; index <= count; index += 1) { | ||
| const entrySize = buffer.readUInt32BE(offset); | ||
| if (entrySize < 8 || offset + entrySize > keysBox.contentEnd) | ||
| return undefined; | ||
| const keyName = buffer.toString('utf8', offset + 8, offset + entrySize); | ||
| if (keyName === targetKeyName) return index; | ||
| offset += entrySize; | ||
| } | ||
|
|
||
| return undefined; | ||
| }; | ||
|
|
||
| export default findQuickTimeKeyIndex; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,26 @@ | ||||||
| import readMp4BoxHeader, { type Mp4Box } from './readMp4BoxHeader'; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Use As per coding guidelines, imports resolving under ♻️ Proposed fix-import readMp4BoxHeader, { type Mp4Box } from './readMp4BoxHeader';
+import readMp4BoxHeader, { type Mp4Box } from '`@/lib/media/readMp4BoxHeader`';📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||
|
|
||||||
| /** | ||||||
| * Walks sibling boxes in [start, end) and returns them in order. Stops on | ||||||
| * the first malformed header rather than throwing, so callers can treat a | ||||||
| * truncated/corrupt atom tree as "no metadata" instead of a hard failure. | ||||||
| */ | ||||||
| const listMp4ChildBoxes = ( | ||||||
| buffer: Buffer, | ||||||
| start: number, | ||||||
| end: number | ||||||
| ): Mp4Box[] => { | ||||||
| const boxes: Mp4Box[] = []; | ||||||
| let offset = start; | ||||||
|
|
||||||
| while (offset + 8 <= end) { | ||||||
| const box = readMp4BoxHeader(buffer, offset); | ||||||
| if (!box || box.contentEnd <= offset || box.contentEnd > end) break; | ||||||
| boxes.push(box); | ||||||
| offset = box.contentEnd; | ||||||
| } | ||||||
|
|
||||||
| return boxes; | ||||||
| }; | ||||||
|
|
||||||
| export default listMp4ChildBoxes; | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use
@/*path alias for imports pointing tosrc/.As per coding guidelines, imports resolving under
src/should use the@/*alias instead of relative paths.♻️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines