From ebfda9ae8593093b81f27888dbe7cb8feeab8809 Mon Sep 17 00:00:00 2001 From: ziad Date: Sun, 12 Jul 2026 12:48:34 +1000 Subject: [PATCH] Extract capture date from QuickTime video attachments for saleStart Telegram bot only computed saleStart from EXIF capture time on image attachments; video (MOV/MP4) always fell back to upload time since exifr doesn't support QuickTime metadata. iPhone-recorded MOV embeds com.apple.quicktime.creationdate (ISO 8601 with UTC offset) in moov/meta/keys+ilst, so add a small MP4 box parser to read it and feed it through the same saleStart path as image EXIF, falling back to upload time when the atom is missing or malformed. Co-Authored-By: Claude Sonnet 5 --- .../findQuickTimeIlstStringValue.test.ts | 53 +++++++++++ .../__tests__/findQuickTimeKeyIndex.test.ts | 47 ++++++++++ .../media/__tests__/listMp4ChildBoxes.test.ts | 47 ++++++++++ .../media/__tests__/readMp4BoxHeader.test.ts | 61 ++++++++++++ .../readQuickTimeCreationDate.test.ts | 94 +++++++++++++++++++ src/lib/media/findQuickTimeIlstStringValue.ts | 35 +++++++ src/lib/media/findQuickTimeKeyIndex.ts | 29 ++++++ src/lib/media/listMp4ChildBoxes.ts | 26 +++++ src/lib/media/readMp4BoxHeader.ts | 43 +++++++++ src/lib/media/readQuickTimeCreationDate.ts | 48 ++++++++++ .../extractQuickTimeCaptureDate.test.ts | 70 ++++++++++++++ .../attachment/extractQuickTimeCaptureDate.ts | 45 +++++++++ .../chat/moment/buildCreateBatchInput.ts | 7 +- .../chat/moment/createMomentsFromGroup.ts | 3 +- 14 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 src/lib/media/__tests__/findQuickTimeIlstStringValue.test.ts create mode 100644 src/lib/media/__tests__/findQuickTimeKeyIndex.test.ts create mode 100644 src/lib/media/__tests__/listMp4ChildBoxes.test.ts create mode 100644 src/lib/media/__tests__/readMp4BoxHeader.test.ts create mode 100644 src/lib/media/__tests__/readQuickTimeCreationDate.test.ts create mode 100644 src/lib/media/findQuickTimeIlstStringValue.ts create mode 100644 src/lib/media/findQuickTimeKeyIndex.ts create mode 100644 src/lib/media/listMp4ChildBoxes.ts create mode 100644 src/lib/media/readMp4BoxHeader.ts create mode 100644 src/lib/media/readQuickTimeCreationDate.ts create mode 100644 src/lib/telegram/chat/attachment/__tests__/extractQuickTimeCaptureDate.test.ts create mode 100644 src/lib/telegram/chat/attachment/extractQuickTimeCaptureDate.ts diff --git a/src/lib/media/__tests__/findQuickTimeIlstStringValue.test.ts b/src/lib/media/__tests__/findQuickTimeIlstStringValue.test.ts new file mode 100644 index 00000000..71aad929 --- /dev/null +++ b/src/lib/media/__tests__/findQuickTimeIlstStringValue.test.ts @@ -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(); + }); +}); diff --git a/src/lib/media/__tests__/findQuickTimeKeyIndex.test.ts b/src/lib/media/__tests__/findQuickTimeKeyIndex.test.ts new file mode 100644 index 00000000..c507d5d0 --- /dev/null +++ b/src/lib/media/__tests__/findQuickTimeKeyIndex.test.ts @@ -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(); + }); +}); diff --git a/src/lib/media/__tests__/listMp4ChildBoxes.test.ts b/src/lib/media/__tests__/listMp4ChildBoxes.test.ts new file mode 100644 index 00000000..14b00bb4 --- /dev/null +++ b/src/lib/media/__tests__/listMp4ChildBoxes.test.ts @@ -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']); + }); +}); diff --git a/src/lib/media/__tests__/readMp4BoxHeader.test.ts b/src/lib/media/__tests__/readMp4BoxHeader.test.ts new file mode 100644 index 00000000..72cb6984 --- /dev/null +++ b/src/lib/media/__tests__/readMp4BoxHeader.test.ts @@ -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(); + }); +}); diff --git a/src/lib/media/__tests__/readQuickTimeCreationDate.test.ts b/src/lib/media/__tests__/readQuickTimeCreationDate.test.ts new file mode 100644 index 00000000..2cb458b0 --- /dev/null +++ b/src/lib/media/__tests__/readQuickTimeCreationDate.test.ts @@ -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(); + }); +}); diff --git a/src/lib/media/findQuickTimeIlstStringValue.ts b/src/lib/media/findQuickTimeIlstStringValue.ts new file mode 100644 index 00000000..1516548f --- /dev/null +++ b/src/lib/media/findQuickTimeIlstStringValue.ts @@ -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; diff --git a/src/lib/media/findQuickTimeKeyIndex.ts b/src/lib/media/findQuickTimeKeyIndex.ts new file mode 100644 index 00000000..7dd6c0f1 --- /dev/null +++ b/src/lib/media/findQuickTimeKeyIndex.ts @@ -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; diff --git a/src/lib/media/listMp4ChildBoxes.ts b/src/lib/media/listMp4ChildBoxes.ts new file mode 100644 index 00000000..ddbeb977 --- /dev/null +++ b/src/lib/media/listMp4ChildBoxes.ts @@ -0,0 +1,26 @@ +import readMp4BoxHeader, { type Mp4Box } from './readMp4BoxHeader'; + +/** + * 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; diff --git a/src/lib/media/readMp4BoxHeader.ts b/src/lib/media/readMp4BoxHeader.ts new file mode 100644 index 00000000..dd8618d8 --- /dev/null +++ b/src/lib/media/readMp4BoxHeader.ts @@ -0,0 +1,43 @@ +export type Mp4Box = { + type: string; + start: number; + contentStart: number; + contentEnd: number; +}; + +/** + * Reads one ISO-BMFF/QuickTime box header at `offset`: 4-byte size, 4-byte + * type, with the standard 64-bit-size (size === 1) and rest-of-file + * (size === 0) extensions. + */ +const readMp4BoxHeader = ( + buffer: Buffer, + offset: number +): Mp4Box | undefined => { + if (offset + 8 > buffer.length) return undefined; + + let size = buffer.readUInt32BE(offset); + const type = buffer.toString('ascii', offset + 4, offset + 8); + let headerSize = 8; + + if (size === 1) { + if (offset + 16 > buffer.length) return undefined; + const high = buffer.readUInt32BE(offset + 8); + const low = buffer.readUInt32BE(offset + 12); + size = high * 2 ** 32 + low; + headerSize = 16; + } else if (size === 0) { + size = buffer.length - offset; + } + + if (size < headerSize || offset + size > buffer.length) return undefined; + + return { + type, + start: offset, + contentStart: offset + headerSize, + contentEnd: offset + size, + }; +}; + +export default readMp4BoxHeader; diff --git a/src/lib/media/readQuickTimeCreationDate.ts b/src/lib/media/readQuickTimeCreationDate.ts new file mode 100644 index 00000000..7c7eccf0 --- /dev/null +++ b/src/lib/media/readQuickTimeCreationDate.ts @@ -0,0 +1,48 @@ +import listMp4ChildBoxes from './listMp4ChildBoxes'; +import findQuickTimeKeyIndex from './findQuickTimeKeyIndex'; +import findQuickTimeIlstStringValue from './findQuickTimeIlstStringValue'; + +const QUICKTIME_CREATIONDATE_KEY = 'com.apple.quicktime.creationdate'; + +/** + * Reads the raw `com.apple.quicktime.creationdate` value that iPhone/ + * QuickTime writes into moov/meta/keys+ilst (e.g. + * "2026-06-17T12:30:03-0400"). Returns the raw string uninterpreted — + * callers own timezone/epoch conversion. Errors (malformed/foreign atom + * tree) propagate to the caller rather than being swallowed here. + */ +const readQuickTimeCreationDate = (buffer: Buffer): string | undefined => { + const moov = listMp4ChildBoxes(buffer, 0, buffer.length).find( + (box) => box.type === 'moov' + ); + if (!moov) return undefined; + + const meta = listMp4ChildBoxes( + buffer, + moov.contentStart, + moov.contentEnd + ).find((box) => box.type === 'meta'); + if (!meta) return undefined; + + // Unlike the ISO-BMFF `meta` box, QuickTime's `meta` has no version/flags + // header — its children start immediately at contentStart. + const metaChildren = listMp4ChildBoxes( + buffer, + meta.contentStart, + meta.contentEnd + ); + const keysBox = metaChildren.find((box) => box.type === 'keys'); + const ilstBox = metaChildren.find((box) => box.type === 'ilst'); + if (!keysBox || !ilstBox) return undefined; + + const keyIndex = findQuickTimeKeyIndex( + buffer, + keysBox, + QUICKTIME_CREATIONDATE_KEY + ); + if (keyIndex === undefined) return undefined; + + return findQuickTimeIlstStringValue(buffer, ilstBox, keyIndex); +}; + +export default readQuickTimeCreationDate; diff --git a/src/lib/telegram/chat/attachment/__tests__/extractQuickTimeCaptureDate.test.ts b/src/lib/telegram/chat/attachment/__tests__/extractQuickTimeCaptureDate.test.ts new file mode 100644 index 00000000..d9ba2339 --- /dev/null +++ b/src/lib/telegram/chat/attachment/__tests__/extractQuickTimeCaptureDate.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/lib/media/readQuickTimeCreationDate', () => ({ + default: vi.fn(), +})); + +import readQuickTimeCreationDate from '@/lib/media/readQuickTimeCreationDate'; +import extractQuickTimeCaptureDate from '@/lib/telegram/chat/attachment/extractQuickTimeCaptureDate'; + +const mockRead = vi.mocked(readQuickTimeCreationDate); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('extractQuickTimeCaptureDate', () => { + it('computes the UTC instant from a negative embedded offset', () => { + mockRead.mockReturnValue('2026-06-17T12:30:03-0400'); + + const result = extractQuickTimeCaptureDate(Buffer.from('mov-bytes')); + + expect(result).toBe( + (Date.UTC(2026, 5, 17, 12, 30, 3) + 4 * 60 * 60_000) / 1000 + ); + }); + + it('computes the UTC instant from a positive embedded offset', () => { + mockRead.mockReturnValue('2026-06-17T12:30:03+09:00'); + + const result = extractQuickTimeCaptureDate(Buffer.from('mov-bytes')); + + expect(result).toBe( + (Date.UTC(2026, 5, 17, 12, 30, 3) - 9 * 60 * 60_000) / 1000 + ); + }); + + it('handles a Z (UTC) offset', () => { + mockRead.mockReturnValue('2026-06-17T12:30:03Z'); + + const result = extractQuickTimeCaptureDate(Buffer.from('mov-bytes')); + + expect(result).toBe(Date.UTC(2026, 5, 17, 12, 30, 3) / 1000); + }); + + it('returns undefined when there is no creationdate atom', () => { + mockRead.mockReturnValue(undefined); + + expect( + extractQuickTimeCaptureDate(Buffer.from('mov-bytes')) + ).toBeUndefined(); + }); + + it('returns undefined when the value does not match the expected pattern', () => { + mockRead.mockReturnValue('not-a-date'); + + expect( + extractQuickTimeCaptureDate(Buffer.from('mov-bytes')) + ).toBeUndefined(); + }); + + it('returns undefined when the atom parser throws (malformed box tree)', () => { + mockRead.mockImplementation(() => { + throw new RangeError('out of bounds'); + }); + + expect( + extractQuickTimeCaptureDate(Buffer.from('mov-bytes')) + ).toBeUndefined(); + }); +}); diff --git a/src/lib/telegram/chat/attachment/extractQuickTimeCaptureDate.ts b/src/lib/telegram/chat/attachment/extractQuickTimeCaptureDate.ts new file mode 100644 index 00000000..39dbb744 --- /dev/null +++ b/src/lib/telegram/chat/attachment/extractQuickTimeCaptureDate.ts @@ -0,0 +1,45 @@ +import readQuickTimeCreationDate from '@/lib/media/readQuickTimeCreationDate'; + +const QUICKTIME_CREATIONDATE_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(Z|[+-]\d{2}:?\d{2})$/; + +/** + * iPhone/QuickTime videos embed `com.apple.quicktime.creationdate` as ISO + * 8601 with the UTC offset baked in (e.g. "...T12:30:03-0400") — unlike + * photo EXIF, there's no separate offset tag to look up. Same on-chain + * saleStart caution as extractExifCaptureDate applies: if the value is + * missing or doesn't parse, skip it entirely and let the caller fall back + * to upload time. + */ +const extractQuickTimeCaptureDate = (buffer: Buffer): number | undefined => { + try { + const raw = readQuickTimeCreationDate(buffer); + if (typeof raw !== 'string') return undefined; + + const match = QUICKTIME_CREATIONDATE_PATTERN.exec(raw); + if (!match) return undefined; + const [, year, month, day, hour, minute, second, offset] = match; + + const localAsUtcMs = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ); + + const offsetMs = + offset === 'Z' + ? 0 + : (offset.startsWith('-') ? -1 : 1) * + (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(-2))) * + 60_000; + + return Math.floor((localAsUtcMs - offsetMs) / 1000); + } catch { + return undefined; + } +}; + +export default extractQuickTimeCaptureDate; diff --git a/src/lib/telegram/chat/moment/buildCreateBatchInput.ts b/src/lib/telegram/chat/moment/buildCreateBatchInput.ts index 7b0cad2c..e56e28c9 100644 --- a/src/lib/telegram/chat/moment/buildCreateBatchInput.ts +++ b/src/lib/telegram/chat/moment/buildCreateBatchInput.ts @@ -20,9 +20,10 @@ const buildCreateBatchInput = ( salesConfig: { type: MomentType.Erc20Mint, pricePerToken: parseUnits('1', 6).toString(), - // Position the moment on the Timeline by when the photo was actually - // taken (EXIF) when available; Telegram strips EXIF from compressed - // "photo" sends, so fall back to upload time in that case. + // Position the moment on the Timeline by when the photo/video was + // actually captured (EXIF / QuickTime creation date) when available; + // Telegram strips this metadata from compressed "photo" sends, so + // fall back to upload time in that case. saleStart: u.captureDate ?? uploadTime, saleEnd: maxUint64.toString(), currency: USDC_ADDRESS[CHAIN_ID], diff --git a/src/lib/telegram/chat/moment/createMomentsFromGroup.ts b/src/lib/telegram/chat/moment/createMomentsFromGroup.ts index 4e78b5f8..aa7b4667 100644 --- a/src/lib/telegram/chat/moment/createMomentsFromGroup.ts +++ b/src/lib/telegram/chat/moment/createMomentsFromGroup.ts @@ -4,6 +4,7 @@ import type { ArtistContext } from '@/types/artist'; import fetchTelegramFile from '@/lib/telegram/chat/attachment/fetchTelegramFile'; import processAttachmentUpload from './processAttachmentUpload'; import extractExifCaptureDate from '@/lib/telegram/chat/attachment/extractExifCaptureDate'; +import extractQuickTimeCaptureDate from '@/lib/telegram/chat/attachment/extractQuickTimeCaptureDate'; import createMomentBatch from '@/lib/moment/createMomentBatch'; import sendReadyMessage from '@/lib/telegram/chat/messaging/sendReadyMessage'; import sendArtistCollage from '@/lib/telegram/chat/messaging/sendArtistCollage'; @@ -51,7 +52,7 @@ const createMomentsFromGroup = async ( ), asset.attachmentType === 'image' ? extractExifCaptureDate(buffer) - : Promise.resolve(undefined), + : extractQuickTimeCaptureDate(buffer), ]); return { ...uploadResult, captureDate }; })