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 }; })