@@ -10,7 +10,12 @@ import { spawn } from "child_process";
1010import { copyFileSync , existsSync , linkSync , mkdirSync , readdirSync , rmSync } from "fs" ;
1111import { isAbsolute , join , posix , resolve , sep } from "path" ;
1212import { parseHTML } from "linkedom" ;
13- import { decodeUrlPathVariants , MEDIA_DURATION_CLAMP_EPSILON_SECONDS } from "@hyperframes/core" ;
13+ import {
14+ decodeUrlPathVariants ,
15+ MEDIA_DURATION_CLAMP_EPSILON_SECONDS ,
16+ parseNumeric ,
17+ parseStartExpression ,
18+ } from "@hyperframes/core" ;
1419import { trackChildProcess } from "../utils/processTracker.js" ;
1520import { extractMediaMetadata , type VideoMetadata } from "../utils/ffprobe.js" ;
1621import {
@@ -148,9 +153,102 @@ export interface ExtractionResult {
148153 phaseBreakdown : ExtractionPhaseBreakdown ;
149154}
150155
156+ // Minimal structural DOM shape the reference resolver needs, so it works
157+ // against linkedom (Node) without pulling in lib.dom types.
158+ interface RefResolverEl {
159+ getAttribute ( name : string ) : string | null ;
160+ }
161+ interface RefResolverDoc {
162+ getElementById ( id : string ) : RefResolverEl | null ;
163+ querySelector ( selector : string ) : RefResolverEl | null ;
164+ }
165+
166+ /**
167+ * Find the element a relative `data-start` reference points at — by `id`
168+ * first, then by `data-composition-id` (a sub-composition can be referenced).
169+ * The reference-id grammar (see parseStartExpression) is restricted to
170+ * `[A-Za-z0-9_.:-]`, none of which need escaping inside a quoted attribute
171+ * selector, so no CSS.escape (absent in linkedom) is required.
172+ */
173+ function findReferenceTargetEl ( doc : RefResolverDoc , refId : string ) : RefResolverEl | null {
174+ return doc . getElementById ( refId ) ?? doc . querySelector ( `[data-composition-id="${ refId } "]` ) ;
175+ }
176+
177+ /**
178+ * Resolve an element's absolute start time (seconds) the same way the browser
179+ * runtime's startResolver does, so `<video data-start="intro">` (a relative
180+ * reference to another clip's end) renders at the right time instead of
181+ * producing NaN and compositing blank. Durations come from `data-duration` or
182+ * `data-end` here; the natural-media-duration fallback isn't known at parse
183+ * time, so — exactly like the runtime — an unknown-duration reference falls
184+ * back to the target's start and an unknown target falls back to 0 (never NaN).
185+ */
186+ function resolveReferencedStart (
187+ doc : RefResolverDoc ,
188+ el : RefResolverEl ,
189+ startCache : Map < RefResolverEl , number > ,
190+ visiting : Set < RefResolverEl > ,
191+ ) : number {
192+ const cached = startCache . get ( el ) ;
193+ if ( cached !== undefined ) return cached ;
194+ if ( visiting . has ( el ) ) return 0 ; // cycle guard (A -> B -> A)
195+ visiting . add ( el ) ;
196+ try {
197+ const expression = parseStartExpression ( el . getAttribute ( "data-start" ) ) ;
198+ if ( ! expression ) {
199+ startCache . set ( el , 0 ) ;
200+ return 0 ;
201+ }
202+ if ( expression . kind === "absolute" ) {
203+ const value = Math . max ( 0 , expression . value ) ;
204+ startCache . set ( el , value ) ;
205+ return value ;
206+ }
207+ const target = findReferenceTargetEl ( doc , expression . refId ) ;
208+ if ( ! target ) {
209+ startCache . set ( el , 0 ) ;
210+ return 0 ;
211+ }
212+ const targetStart = resolveReferencedStart ( doc , target , startCache , visiting ) ;
213+ const targetDuration = resolveReferencedDuration ( doc , target , startCache , visiting ) ;
214+ const resolved =
215+ targetDuration != null && targetDuration > 0
216+ ? Math . max ( 0 , targetStart + targetDuration + expression . offset )
217+ : Math . max ( 0 , targetStart + expression . offset ) ;
218+ startCache . set ( el , resolved ) ;
219+ return resolved ;
220+ } finally {
221+ visiting . delete ( el ) ;
222+ }
223+ }
224+
225+ /**
226+ * Duration of a referenced clip, from `data-duration` or `data-end - start`.
227+ * Returns null when only the natural media duration would settle it (unknown
228+ * at parse time) — the caller then treats the reference as duration-0.
229+ */
230+ function resolveReferencedDuration (
231+ doc : RefResolverDoc ,
232+ el : RefResolverEl ,
233+ startCache : Map < RefResolverEl , number > ,
234+ visiting : Set < RefResolverEl > ,
235+ ) : number | null {
236+ const durationAttr = parseNumeric ( el . getAttribute ( "data-duration" ) ) ;
237+ if ( durationAttr != null && durationAttr > 0 ) return durationAttr ;
238+ const endAttr = parseNumeric ( el . getAttribute ( "data-end" ) ) ;
239+ if ( endAttr != null ) {
240+ const start = resolveReferencedStart ( doc , el , startCache , visiting ) ;
241+ const delta = endAttr - start ;
242+ if ( Number . isFinite ( delta ) && delta > 0 ) return delta ;
243+ }
244+ return null ;
245+ }
246+
151247export function parseVideoElements ( html : string ) : VideoElement [ ] {
152248 const videos : VideoElement [ ] = [ ] ;
153249 const { document } = parseHTML ( unwrapTemplate ( html ) ) ;
250+ const startCache = new Map < RefResolverEl , number > ( ) ;
251+ const visiting = new Set < RefResolverEl > ( ) ;
154252
155253 const videoEls = document . querySelectorAll ( "video[src]" ) ;
156254 let autoIdCounter = 0 ;
@@ -170,7 +268,12 @@ export function parseVideoElements(html: string): VideoElement[] {
170268 const mediaStartAttr = el . getAttribute ( "data-media-start" ) ;
171269 const hasAudioAttr = el . getAttribute ( "data-has-audio" ) ;
172270
173- const start = startAttr ? parseFloat ( startAttr ) : 0 ;
271+ // Resolve data-start, including relative references ("intro", "intro + 2")
272+ // to another clip's end — the browser runtime resolves these but a raw
273+ // parseFloat here would yield NaN, placing the clip at NaN so it composites
274+ // blank in the final render. `startAttr` may be a plain number or a
275+ // reference; the resolver handles both.
276+ const start = startAttr ? resolveReferencedStart ( document , el , startCache , visiting ) : 0 ;
174277 // Derive end from data-end → data-start+data-duration → Infinity (natural duration).
175278 // The caller (htmlCompiler) clamps Infinity to the composition's absoluteEnd.
176279 let end = 0 ;
@@ -206,6 +309,8 @@ export interface ImageElement {
206309export function parseImageElements ( html : string ) : ImageElement [ ] {
207310 const images : ImageElement [ ] = [ ] ;
208311 const { document } = parseHTML ( unwrapTemplate ( html ) ) ;
312+ const startCache = new Map < RefResolverEl , number > ( ) ;
313+ const visiting = new Set < RefResolverEl > ( ) ;
209314
210315 const imgEls = document . querySelectorAll ( "img[src]" ) ;
211316 let autoIdCounter = 0 ;
@@ -222,7 +327,9 @@ export function parseImageElements(html: string): ImageElement[] {
222327 const endAttr = el . getAttribute ( "data-end" ) ;
223328 const durationAttr = el . getAttribute ( "data-duration" ) ;
224329
225- const start = startAttr ? parseFloat ( startAttr ) : 0 ;
330+ // Resolve relative data-start references (see parseVideoElements) so a
331+ // referenced image start doesn't become NaN and drop the image from the render.
332+ const start = startAttr ? resolveReferencedStart ( document , el , startCache , visiting ) : 0 ;
226333 let end = 0 ;
227334 if ( endAttr ) {
228335 end = parseFloat ( endAttr ) ;
0 commit comments