mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
/**
|
||
* C182 — pure rules of the acervo list frame: the midpoint of a speech, the
|
||
* deterministic `media` cache name/href and the ffmpeg still command. No I/O
|
||
* and no `server-only`: the view model and the poster job share this module so
|
||
* the "middle of the speech" literal lives in one place.
|
||
*/
|
||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||
import { speechExcerptCoordinates, type SpeechVodCoordinatesSource } from '@/lib/speechVod'
|
||
|
||
/**
|
||
* Offset of the middle of the speech inside its Câmara trecho (the trecho
|
||
* starts at the speech): `durationSeconds / 2`. Null without a usable duration.
|
||
*/
|
||
export const speechPosterOffsetSeconds = (
|
||
durationSeconds: number | null | undefined,
|
||
): number | null => {
|
||
if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds)) return null
|
||
if (durationSeconds <= 0) return null
|
||
return Math.floor(durationSeconds / 2)
|
||
}
|
||
|
||
/** Everything the poster job and the list need to place the frame. */
|
||
export type SpeechPosterTarget = {
|
||
eventId: number
|
||
audioId: number
|
||
excerptTms: number
|
||
offsetSeconds: number
|
||
}
|
||
|
||
type SpeechPosterTargetSource = SpeechVodCoordinatesSource & {
|
||
durationSeconds?: number | null
|
||
}
|
||
|
||
/**
|
||
* The speech has a frame when the Câmara can re-resolve the excerpt
|
||
* (`eventId`/`audioId`/`excerptTMs`) AND the midpoint is known. Unlike the
|
||
* player, a stored VOD link is not required — it is only a cache of a previous
|
||
* resolution. The view model and the job share this one decision.
|
||
*/
|
||
export const speechPosterTarget = (speech: SpeechPosterTargetSource): SpeechPosterTarget | null => {
|
||
const coordinates = speechExcerptCoordinates(speech)
|
||
if (!coordinates) return null
|
||
|
||
const offsetSeconds = speechPosterOffsetSeconds(speech.durationSeconds)
|
||
if (offsetSeconds === null) return null
|
||
|
||
return { ...coordinates, offsetSeconds }
|
||
}
|
||
|
||
/** Deterministic `media` filename: the cache key and the idempotence key. */
|
||
export const speechPosterFilename = (speechId: number): string => `speech-poster-${speechId}.jpg`
|
||
|
||
/** Internal route the list thumbnail points at (never a public contract). */
|
||
export const speechPosterHref = (speechId: number): string =>
|
||
`${CAMPAIGN_COMMUNICATION_ACERVO}/${speechId}/poster`
|
||
|
||
type SpeechPosterFfmpegInput = {
|
||
inputPath: string
|
||
outputPath: string
|
||
atSeconds: number
|
||
}
|
||
|
||
/**
|
||
* One still at `atSeconds`: `-ss` before `-i` seeks fast and `-frames:v 1`
|
||
* takes a single frame; `scale=480:-2` keeps it small (the card slot is
|
||
* 128×80, `object-cover` crops). Args as an array — never a shell string.
|
||
*/
|
||
export const buildSpeechPosterFfmpegArgs = ({
|
||
inputPath,
|
||
outputPath,
|
||
atSeconds,
|
||
}: SpeechPosterFfmpegInput): string[] => {
|
||
const at = Math.max(0, Math.round(atSeconds))
|
||
return [
|
||
'-nostdin',
|
||
'-hide_banner',
|
||
'-y',
|
||
'-ss',
|
||
String(at),
|
||
'-i',
|
||
inputPath,
|
||
'-frames:v',
|
||
'1',
|
||
'-vf',
|
||
'scale=480:-2',
|
||
'-q:v',
|
||
'2',
|
||
'-an',
|
||
outputPath,
|
||
]
|
||
}
|