mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
import { join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import type { Payload } from 'payload'
|
|
import { getPayload } from 'payload'
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
|
// The Media afterChange hook revalidates its document tag, which needs the Next
|
|
// runtime — neuter it (the revalidation itself is pinned elsewhere).
|
|
vi.mock('next/cache', () => ({
|
|
revalidatePath: () => undefined,
|
|
revalidateTag: () => undefined,
|
|
}))
|
|
|
|
import { buildSpeechPosterFfmpegArgs, speechPosterFilename } from '@/lib/speechPoster'
|
|
import config from '@/payload.config'
|
|
import type { SpeechImportBundle } from '@/utilities/speech/speechImport'
|
|
import { upsertSpeechBundle } from '@/utilities/speech/speechImport'
|
|
import { runFfmpeg } from '@/utilities/speech/speechMediaPipeline'
|
|
import { ensureSpeechPoster, findSpeechPosterMedia } from '@/utilities/speech/speechPosterJob'
|
|
|
|
import { camaraVodStub, PLAYBACK_URL } from '../helpers/camaraVodStub'
|
|
import { speechBundleFixture } from '../helpers/speechBundleFixture'
|
|
|
|
const FAKE_FFMPEG = fileURLToPath(new URL('../fixtures/fake-ffmpeg.mjs', import.meta.url))
|
|
|
|
const hasBinary = (name: string): boolean => spawnSync(name, ['-version']).status === 0
|
|
const hasFfmpeg = hasBinary('ffmpeg') && hasBinary('ffprobe')
|
|
|
|
let payload: Payload
|
|
const createdSourceKeys = new Set<string>()
|
|
const createdMediaIds = new Set<number>()
|
|
|
|
const withEnv = (key: string, value: string | undefined): (() => void) => {
|
|
const previous = process.env[key]
|
|
if (value === undefined) delete process.env[key]
|
|
else process.env[key] = value
|
|
return () => {
|
|
if (previous === undefined) delete process.env[key]
|
|
else process.env[key] = previous
|
|
}
|
|
}
|
|
|
|
/** Stub that records every URL it answered, so cache hits are observable. */
|
|
const countingCamaraStub = (options?: Parameters<typeof camaraVodStub>[0]) => {
|
|
const answered: string[] = []
|
|
const base = camaraVodStub(options)
|
|
const stub = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
answered.push(String(input))
|
|
return base(input as RequestInfo, init)
|
|
}) as typeof fetch
|
|
return { stub, answered }
|
|
}
|
|
|
|
const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
|
|
const bundle = speechBundleFixture(overrides)
|
|
createdSourceKeys.add(bundle.sourceKey)
|
|
await upsertSpeechBundle(payload, bundle)
|
|
const found = await payload.find({
|
|
collection: 'speech',
|
|
where: { sourceKey: { equals: bundle.sourceKey } },
|
|
depth: 0,
|
|
limit: 1,
|
|
overrideAccess: true,
|
|
})
|
|
const speech = found.docs[0]
|
|
if (!speech) throw new Error('fixture speech was not created')
|
|
return speech.id
|
|
}
|
|
|
|
describe('speech poster cache (C182)', () => {
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
for (const mediaId of createdMediaIds) {
|
|
await payload
|
|
.delete({ collection: 'media', id: mediaId, overrideAccess: true })
|
|
.catch(() => undefined)
|
|
}
|
|
for (const sourceKey of createdSourceKeys) {
|
|
const found = await payload.find({
|
|
collection: 'speech',
|
|
where: { sourceKey: { equals: sourceKey } },
|
|
depth: 0,
|
|
limit: 1,
|
|
overrideAccess: true,
|
|
})
|
|
const speech = found.docs[0]
|
|
if (speech) {
|
|
await payload
|
|
.delete({ collection: 'speech', id: speech.id, depth: 0, overrideAccess: true })
|
|
.catch(() => undefined)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('generates the frame once, caches it in media and reuses it', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
const { stub, answered } = countingCamaraStub()
|
|
vi.stubGlobal('fetch', stub)
|
|
|
|
try {
|
|
const speechId = await createSpeech({ durationSeconds: 252 })
|
|
|
|
const media = await ensureSpeechPoster(payload, speechId)
|
|
expect(media?.filename).toBe(speechPosterFilename(speechId))
|
|
expect(media?.alt).toContain('meio da fala')
|
|
if (!media) throw new Error('poster was not generated')
|
|
createdMediaIds.add(media.id)
|
|
|
|
// The stored file is the fake ffmpeg's copy of the downloaded source.
|
|
expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual(
|
|
Buffer.from('camara-mp4-bytes'),
|
|
)
|
|
const sourceRequests = answered.filter((url) => url === PLAYBACK_URL)
|
|
expect(sourceRequests.length).toBeGreaterThan(0)
|
|
|
|
answered.length = 0
|
|
const again = await ensureSpeechPoster(payload, speechId)
|
|
expect(again?.id).toBe(media.id)
|
|
expect(answered).toEqual([])
|
|
|
|
const cached = await findSpeechPosterMedia(payload, speechId)
|
|
expect(cached?.id).toBe(media.id)
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('shares one generation between concurrent requests (single flight)', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
const { stub, answered } = countingCamaraStub()
|
|
vi.stubGlobal('fetch', stub)
|
|
|
|
try {
|
|
const speechId = await createSpeech({ durationSeconds: 252 })
|
|
const [first, second] = await Promise.all([
|
|
ensureSpeechPoster(payload, speechId),
|
|
ensureSpeechPoster(payload, speechId),
|
|
])
|
|
if (!first) throw new Error('poster was not generated')
|
|
createdMediaIds.add(first.id)
|
|
|
|
// Single flight: both callers share the one resolution of the excerpt
|
|
// (without it the Câmara API would be asked twice).
|
|
expect(second?.id).toBe(first.id)
|
|
expect(answered.filter((url) => url.includes('video-sob-demanda'))).toHaveLength(1)
|
|
const rows = await payload.find({
|
|
collection: 'media',
|
|
where: { filename: { equals: speechPosterFilename(speechId) } },
|
|
depth: 0,
|
|
limit: 0,
|
|
pagination: false,
|
|
overrideAccess: true,
|
|
})
|
|
expect(rows.docs).toHaveLength(1)
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('memoizes an unresolvable excerpt instead of asking the Câmara on every view', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
const { stub, answered } = countingCamaraStub({ apiState: 'INDISPONIVEL' })
|
|
vi.stubGlobal('fetch', stub)
|
|
|
|
try {
|
|
const speechId = await createSpeech({ durationSeconds: 252 })
|
|
|
|
expect(await ensureSpeechPoster(payload, speechId)).toBeNull()
|
|
expect(answered.length).toBeGreaterThan(0)
|
|
|
|
answered.length = 0
|
|
expect(await ensureSpeechPoster(payload, speechId)).toBeNull()
|
|
expect(answered).toEqual([])
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('returns null when the Câmara cannot resolve the excerpt or has no coordinates', async () => {
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', FAKE_FFMPEG)
|
|
vi.stubGlobal('fetch', camaraVodStub({ apiState: 'INDISPONIVEL' }))
|
|
|
|
try {
|
|
const unavailable = await createSpeech({ durationSeconds: 252 })
|
|
expect(await ensureSpeechPoster(payload, unavailable)).toBeNull()
|
|
expect(await findSpeechPosterMedia(payload, unavailable)).toBeNull()
|
|
|
|
vi.stubGlobal('fetch', camaraVodStub())
|
|
const noCoordinates = await createSpeech({
|
|
eventId: null,
|
|
audioId: null,
|
|
excerptTMs: null,
|
|
})
|
|
expect(await ensureSpeechPoster(payload, noCoordinates)).toBeNull()
|
|
} finally {
|
|
restoreFfmpeg()
|
|
}
|
|
})
|
|
|
|
it('never caches a frame for an unknown speech id', async () => {
|
|
vi.stubGlobal('fetch', camaraVodStub())
|
|
expect(await ensureSpeechPoster(payload, -1)).toBeNull()
|
|
expect(await ensureSpeechPoster(payload, Number.NaN)).toBeNull()
|
|
})
|
|
|
|
it.skipIf(!hasFfmpeg)('extracts a real JPEG with ffmpeg at the speech midpoint', async () => {
|
|
const dir = resolve(process.cwd(), 'tests', '.tmp-poster')
|
|
const restoreFfmpeg = withEnv('FFMPEG_PATH', undefined)
|
|
try {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
mkdirSync(dir, { recursive: true })
|
|
const source = join(dir, 'source.mp4')
|
|
const output = join(dir, 'poster.jpg')
|
|
const generated = spawnSync(
|
|
'ffmpeg',
|
|
[
|
|
'-nostdin',
|
|
'-hide_banner',
|
|
'-y',
|
|
'-f',
|
|
'lavfi',
|
|
'-i',
|
|
'testsrc=size=320x240:rate=10:duration=4',
|
|
source,
|
|
],
|
|
{ encoding: 'utf8' },
|
|
)
|
|
expect(generated.status).toBe(0)
|
|
|
|
await runFfmpeg(
|
|
buildSpeechPosterFfmpegArgs({ inputPath: source, outputPath: output, atSeconds: 2 }),
|
|
4,
|
|
)
|
|
|
|
const bytes = readFileSync(output)
|
|
expect(bytes.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8]))
|
|
} finally {
|
|
restoreFfmpeg()
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|