diff --git a/packages/lint/src/hevcPreviewLint.ts b/packages/lint/src/hevcPreviewLint.ts index 0346b7743..bdc454dd1 100644 --- a/packages/lint/src/hevcPreviewLint.ts +++ b/packages/lint/src/hevcPreviewLint.ts @@ -3,8 +3,8 @@ import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; import { cleanAssetUrl, - hasUnresolvedTemplatingToken, isRemoteOrInlineUrl, + isUnresolvedAssetPlaceholder, maskNonScannableRanges, resolveExistingLocalAsset, } from "@hyperframes/parsers/asset-resolution"; @@ -87,12 +87,11 @@ export function collectLocalVideoCandidates( let match: RegExpExecArray | null; while ((match = re.exec(scannable)) !== null) { const rawSrc = match[1] ?? ""; - // Check the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token. - if (hasUnresolvedTemplatingToken(rawSrc)) continue; + // Placeholder check runs on the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token. + if (isUnresolvedAssetPlaceholder(rawSrc)) continue; const src = cleanAssetUrl(rawSrc); if (!src) continue; if (isRemoteOrInlineUrl(src)) continue; - if (/^__[A-Z_]+__$/.test(src)) continue; const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src; const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative); if (!resolvedAsset) continue; diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts index 493446435..808175616 100644 --- a/packages/lint/src/project.ts +++ b/packages/lint/src/project.ts @@ -6,8 +6,8 @@ import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-compositi import { parseHTML } from "linkedom"; import { cleanAssetUrl, - hasUnresolvedTemplatingToken, isRemoteOrInlineUrl, + isUnresolvedAssetPlaceholder, isWithinProjectRoot, maskNonScannableRanges, resolveExistingLocalAsset, @@ -283,8 +283,7 @@ function lintAudioSrcNotFound( while ((match = audioSrcRe.exec(html)) !== null) { const src = match[1]!; if (/^(https?:|data:|blob:)/i.test(src)) continue; - if (/^__[A-Z_]+__$/.test(src)) continue; - if (hasUnresolvedTemplatingToken(src)) continue; + if (isUnresolvedAssetPlaceholder(src)) continue; const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src; if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) { missingSrcs.push(src); @@ -326,12 +325,11 @@ function lintMissingLocalAsset( while ((match = re.exec(scannable)) !== null) { const tagName = (match[1] ?? "").toLowerCase(); const rawSrc = match[2] ?? ""; - // Check the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token. - if (hasUnresolvedTemplatingToken(rawSrc)) continue; + // Placeholder check runs on the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token. + if (isUnresolvedAssetPlaceholder(rawSrc)) continue; const src = cleanAssetUrl(rawSrc); if (!src) continue; if (isRemoteOrInlineUrl(src)) continue; - if (/^__[A-Z_]+__$/.test(src)) continue; const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src; const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative); if (resolvedAsset) continue; @@ -379,11 +377,10 @@ function lintTextureMaskAssetNotFound( const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags); while ((match = pattern.exec(cssSource.content)) !== null) { const rawUrl = match[1] ?? match[2] ?? match[3] ?? ""; - // Check the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token. - if (hasUnresolvedTemplatingToken(rawUrl)) continue; + // Placeholder check runs on the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token. + if (isUnresolvedAssetPlaceholder(rawUrl)) continue; const url = cleanAssetUrl(rawUrl); if (!url || isRemoteOrInlineUrl(url)) continue; - if (/^__[A-Z_]+__$/.test(url)) continue; const candidates = resolveCssAssetCandidates( projectDir, @@ -531,8 +528,7 @@ function lintMissingOrEmptySubComposition( while ((match = compositionSrcRe.exec(scannable)) !== null) { const srcPath = (match[1] ?? "").trim(); if (!srcPath) continue; - if (/^__[A-Z_]+__$/.test(srcPath)) continue; // template placeholder - if (hasUnresolvedTemplatingToken(srcPath)) continue; // late-bound templating token + if (isUnresolvedAssetPlaceholder(srcPath)) continue; // __UPPER__ placeholder or late-bound templating token // data-composition-src is always written root-relative (even from a // nested sub-composition) — matches the resolution the renderer uses diff --git a/packages/parsers/src/assetResolution.test.ts b/packages/parsers/src/assetResolution.test.ts index 8bc345cec..95381264c 100644 --- a/packages/parsers/src/assetResolution.test.ts +++ b/packages/parsers/src/assetResolution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { maskNonScannableRanges } from "./assetResolution.js"; +import { isUnresolvedAssetPlaceholder, maskNonScannableRanges } from "./assetResolution.js"; describe("maskNonScannableRanges", () => { it("masks complete comments without changing offsets", () => { @@ -20,3 +20,38 @@ describe("maskNonScannableRanges", () => { expect(masked).toBe(`prefix${" ".repeat(html.length - 12)}suffix`); }); }); + +describe("isUnresolvedAssetPlaceholder", () => { + it("is true for __UPPER__ placeholders (raw or padded)", () => { + for (const src of ["__DURATION__", " __DURATION__ ", "__X__"]) { + expect(isUnresolvedAssetPlaceholder(src)).toBe(true); + } + }); + + it("is true for unresolved templating tokens, including ?/# inside ${...}", () => { + for (const src of [ + "<>", + "{{ videoUrl }}", + "${audioUrl}", + "${asset?.url}", // cleanAssetUrl would chop this to `${asset` — must match on the raw value + "${a ?? b}", + "${u}?v=1", + "audio/${name}.mp3", // embedded token in an otherwise path-shaped value + ]) { + expect(isUnresolvedAssetPlaceholder(src)).toBe(true); + } + }); + + it("is false for real paths and remote URLs (remote handling is left to each caller)", () => { + for (const src of [ + "audio/clip.mp3", + "clip.mp4?v=1", + "https://cdn.example.com/a.mp3", + "//host/a.png", + "", + " ", + ]) { + expect(isUnresolvedAssetPlaceholder(src)).toBe(false); + } + }); +}); diff --git a/packages/parsers/src/assetResolution.ts b/packages/parsers/src/assetResolution.ts index 950b304d1..933be9cb3 100644 --- a/packages/parsers/src/assetResolution.ts +++ b/packages/parsers/src/assetResolution.ts @@ -19,14 +19,29 @@ export function isRemoteOrInlineUrl(url: string): boolean { * substitutes before render. The static linter runs before that substitution, * so it cannot resolve such a value to a file on disk and must not report it as * a missing asset. Check the RAW url, before any `cleanAssetUrl()` step: that - * splits on `?`/`#`, which also chops inside a `${...}` expression. (The older - * `__UPPER__` placeholder shape predates this and keeps its own inline check at - * each call site.) + * splits on `?`/`#`, which also chops inside a `${...}` expression. (The `__UPPER__` + * placeholder shape is combined with this in `isUnresolvedAssetPlaceholder` below, the + * shared predicate asset-src sites skip on.) */ export function hasUnresolvedTemplatingToken(url: string): boolean { return /<<[^<>]+>>|\{\{[^{}]+\}\}|\$\{[^{}]+\}/.test(url); } +/** + * True when an asset src is a build-time placeholder rather than a resolvable path: + * the `__UPPER__` shape (e.g. `__DURATION__`) or an unresolved templating token + * (`<<...>>`, `{{...}}`, `${...}`). Pass the RAW src, before any `cleanAssetUrl()` step — + * cleanAssetUrl splits on `?`/`#`, which would chop inside a `${...}` expression and defeat + * the token match. Remote/inline URL handling is deliberately NOT folded in: call sites + * differ (e.g. audio uses a narrower http/data/blob check), so each keeps its own. + * + * This is the single skip predicate every asset-src lint / codec / compile site should + * route through, so the placeholder rules can't drift apart across call sites. + */ +export function isUnresolvedAssetPlaceholder(rawSrc: string): boolean { + return /^__[A-Z_]+__$/.test(rawSrc.trim()) || hasUnresolvedTemplatingToken(rawSrc); +} + export function cleanAssetUrl(url: string): string { return url.trim().split(/[?#]/, 1)[0] ?? ""; } diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 3e8736e44..d45dd5d39 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -38,6 +38,7 @@ import { checkSubCompositionUsability, type ParsableDocumentLike, } from "@hyperframes/parsers/sub-composition-validity"; +import { isUnresolvedAssetPlaceholder } from "@hyperframes/parsers/asset-resolution"; import { extractMediaMetadata, extractAudioMetadata } from "../utils/ffprobe.js"; import { isPathInside, toExternalAssetKey } from "../utils/paths.js"; import { @@ -168,7 +169,7 @@ function assertSubCompositionsUsable( for (const el of hosts) { const srcPath = el.getAttribute("data-composition-src"); if (!srcPath) continue; - if (/^__[A-Z_]+__$/.test(srcPath)) continue; // template placeholder, not a real reference — matches lint's skip + if (isUnresolvedAssetPlaceholder(srcPath)) continue; // __UPPER__ placeholder or unresolved templating token — not a real reference (shared with lint via @hyperframes/parsers) const filePath = resolve(projectDir, srcPath); // Circular reference guard. parseSubCompositions (below) silently diff --git a/packages/studio-server/src/helpers/mediaCodecMap.ts b/packages/studio-server/src/helpers/mediaCodecMap.ts index cd1c63e29..cd2c7d8ac 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.ts @@ -4,6 +4,7 @@ import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths"; import { cleanAssetUrl, isRemoteOrInlineUrl, + isUnresolvedAssetPlaceholder, maskNonScannableRanges, resolveLocalAssetCandidates, } from "@hyperframes/parsers/asset-resolution"; @@ -228,9 +229,11 @@ function collectLocalVideoAssets( const re = new RegExp(VIDEO_SRC_RE.source, VIDEO_SRC_RE.flags); let match: RegExpExecArray | null; while ((match = re.exec(scannable)) !== null) { - const src = cleanAssetUrl(match[1] ?? ""); + const rawSrc = match[1] ?? ""; + // Placeholder check runs on the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token. + if (isUnresolvedAssetPlaceholder(rawSrc)) continue; + const src = cleanAssetUrl(rawSrc); if (!src || isRemoteOrInlineUrl(src)) continue; - if (/^__[A-Z_]+__$/.test(src)) continue; const rootRelativeSrc = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src; const resolved = resolveExistingLocalAsset(projectDir, rootRelativeSrc); if (!resolved) continue;