* Compile a single HTML file: static pass + ffprobe for unresolved media. * Returns compiled HTML and any unresolved composition elements that need browser resolution.
( html: string, baseDir: string, downloadDir: string, log?: ProducerLogger, )
| 338 | * Returns compiled HTML and any unresolved composition elements that need browser resolution. |
| 339 | */ |
| 340 | async function compileHtmlFile( |
| 341 | html: string, |
| 342 | baseDir: string, |
| 343 | downloadDir: string, |
| 344 | log?: ProducerLogger, |
| 345 | ): Promise<{ html: string; unresolvedCompositions: UnresolvedElement[] }> { |
| 346 | const { html: staticCompiled, unresolved } = compileTimingAttrs(html); |
| 347 | |
| 348 | const mediaUnresolved = unresolved.filter( |
| 349 | (el) => (el.tagName === "video" || el.tagName === "audio") && el.src, |
| 350 | ); |
| 351 | |
| 352 | const unresolvedCompositions = unresolved.filter((el) => el.tagName === "div"); |
| 353 | |
| 354 | // Phase 1: Resolve missing durations (parallel ffprobe) |
| 355 | const resolvedResults = await Promise.all( |
| 356 | mediaUnresolved.map((el) => |
| 357 | resolveMediaDuration(el.src!, el.mediaStart, baseDir, downloadDir, el.tagName).then( |
| 358 | ({ duration }) => ({ id: el.id, duration }), |
| 359 | ), |
| 360 | ), |
| 361 | ); |
| 362 | const resolutions: ResolvedDuration[] = resolvedResults.filter((r) => r.duration > 0); |
| 363 | |
| 364 | let compiledHtml = |
| 365 | resolutions.length > 0 ? injectDurations(staticCompiled, resolutions) : staticCompiled; |
| 366 | |
| 367 | // Phase 2: Validate pre-resolved media — clamp data-duration to actual source duration (parallel ffprobe) |
| 368 | const preResolved = extractResolvedMedia(compiledHtml); |
| 369 | const clampResults = await Promise.all( |
| 370 | preResolved |
| 371 | .filter((el) => !!el.src && !el.loop) |
| 372 | .map(async (el) => { |
| 373 | const { duration: maxDuration } = await resolveMediaDuration( |
| 374 | el.src!, |
| 375 | el.mediaStart, |
| 376 | baseDir, |
| 377 | downloadDir, |
| 378 | el.tagName, |
| 379 | ); |
| 380 | return { id: el.id, tagName: el.tagName, duration: el.duration, maxDuration, src: el.src! }; |
| 381 | }), |
| 382 | ); |
| 383 | const clampList: ResolvedDuration[] = []; |
| 384 | for (const r of clampResults) { |
| 385 | if (r.maxDuration > 0 && shouldClampMediaDuration(r.duration, r.maxDuration)) { |
| 386 | clampList.push({ id: r.id, duration: r.maxDuration }); |
| 387 | // This clip's `data-duration` is being silently shortened to its source. |
| 388 | // Surface it so the author can confirm the longer slot wasn't intended. |
| 389 | // ponytail: top-level only — sub-composition clips still get clamped (and |
| 390 | // videos still hold the last frame); thread `log` through |
| 391 | // parseSubCompositions to warn for them too. |
| 392 | const kind = r.tagName === "audio" ? "Audio" : "Video"; |
| 393 | log?.warn( |
| 394 | `[compile] ${kind} "${r.id}" (${r.src}) is ${r.maxDuration.toFixed(2)}s but its ` + |
| 395 | `data-duration is ${r.duration.toFixed(2)}s — the slot is shortened to the media ` + |
| 396 | `length. Set data-duration to ~${r.maxDuration.toFixed(2)}s, trim data-media-start, ` + |
| 397 | `or use a longer/looping source if that isn't intended.`, |
no test coverage detected