const REVIEWED_AUDIO_STATUS = 'approved' const REVIEWED_VISUAL_STATUS = 'approved-readable' const PROVISIONAL_VISUAL_STATUS = 'experience-provisional' const PROVISIONAL_VISUAL_TIER = 'experience-provisional' const IMAGE_UNAVAILABLE_MESSAGE = '本页画面暂时没有打开,文字和互动仍可继续。' const AUDIO_FILE_PATTERN = /\.(?:aac|m4a|mp3|ogg|wav)$/i function cleanPath(value) { return typeof value === 'string' ? value.trim() : '' } function isPlayableVisualDeclaration(visual) { if (!visual || typeof visual !== 'object') return false const reviewStatus = cleanPath(visual.reviewStatus).toLowerCase() if (reviewStatus === REVIEWED_VISUAL_STATUS) return true return Boolean( visual.kind === 'page-art' && cleanPath(visual.runtimeTier) === PROVISIONAL_VISUAL_TIER && visual.formalReleaseEligible === false && reviewStatus === PROVISIONAL_VISUAL_STATUS, ) } function isPackagedPath(value) { const path = cleanPath(value) return ( path.startsWith('/assets/') || /^\/package-[a-z0-9-]+\//i.test(path) ) } function actorSheetFocusPercent(person) { const era = String(person && person.eraLabel || '') const characterId = String(person && person.characterId || '') if (characterId === 'qin-xiaoman') { if (/2008/.test(era)) return 16 if (/2026/.test(era)) return 65 } if (characterId === 'tang-mingyuan') { if (/1995/.test(era)) return 11 if (/2001/.test(era)) return 37 if (/2003/.test(era)) return 63 if (/2026/.test(era)) return 88 } if (/1978|1980/.test(era)) return 11 if (/1993|1995|1998/.test(era)) return 31 if (/2001|2003/.test(era)) return 50 if (/2008/.test(era)) return 69 if (/2026/.test(era)) return 88 return 50 } function isSinglePortraitFallback(person) { return String(person && person.characterId || '') === 'female-cook' } function getComicPageImageMode(person, usingActorFallback) { return usingActorFallback && isSinglePortraitFallback(person) ? 'aspectFit' : 'scaleToFill' } function getComicPageImageStyle(person, usingActorFallback) { if (!usingActorFallback || !person) return '' if (isSinglePortraitFallback(person)) return '' const focusX = actorSheetFocusPercent(person) const translateX = 50 - (3.5 * focusX) return [ 'transform-origin:0 0', `transform:translate(${translateX}%,-30%) scale(3.5)`, ].join(';') } function getComicFocusImageStyle(person, usingActorFallback) { if (!usingActorFallback || !person) return '' if (isSinglePortraitFallback(person)) return '' const sourceFocusX = actorSheetFocusPercent(person) const focusX = 25 + (sourceFocusX * 0.5) return [ 'transform:scale(7)', `transform-origin:${focusX}% 12%`, ].join(';') } /** * Selects the first image shown by a comic page. * * A release-manifest localSeed is authoritative and must never wait for the * network. A remote-only illustration starts on the reviewed scene fallback, * so a slow or failed download cannot leave a blank reader. */ function buildComicImageState(page = {}, releaseAsset = null, previous = {}) { const illustrationAsset = cleanPath(page.illustrationAsset) const fallbackAsset = cleanPath(page.fallbackAsset) const playableVisual = isPlayableVisualDeclaration(page.playableVisual) ? page.playableVisual : null const sharedVisualKind = playableVisual && playableVisual.kind !== 'page-art' ? cleanPath(playableVisual.kind) : '' const sharedVisualAsset = sharedVisualKind ? cleanPath(playableVisual.asset) : '' const actorFallbackAsset = cleanPath( ( sharedVisualKind === 'actor-portrait' || sharedVisualKind === 'chapter-character' ) ? sharedVisualAsset : (page.actorFallbackAsset || page.actorPortrait), ) const localSeed = ( releaseAsset && releaseAsset.kind === 'image' ) ? cleanPath(releaseAsset.localSeed) : '' const remotePath = ( releaseAsset && releaseAsset.kind === 'image' ) ? cleanPath(releaseAsset.remotePath) : '' const keepFallback = Boolean( previous.currentPageId === page.pageId && previous.comicImageUsingFallback && fallbackAsset ) const keepActorFallback = Boolean( previous.currentPageId === page.pageId && previous.comicImageUsingFallback && previous.comicImageSource === 'actor-fallback' && actorFallbackAsset ) if ( sharedVisualAsset && ( sharedVisualKind === 'actor-portrait' || sharedVisualKind === 'chapter-character' ) ) { return { src: sharedVisualAsset, fallback: fallbackAsset, actorFallback: sharedVisualAsset, usingFallback: true, usingActorFallback: true, source: 'shared-portrait', error: '', shouldResolveRemote: false, } } if ( sharedVisualAsset && sharedVisualKind === 'evidence-composite' ) { return { src: sharedVisualAsset, fallback: fallbackAsset, actorFallback: '', usingFallback: true, usingActorFallback: false, source: 'shared-evidence', error: '', shouldResolveRemote: false, } } if (keepActorFallback) { return { src: actorFallbackAsset, fallback: fallbackAsset, actorFallback: actorFallbackAsset, usingFallback: true, usingActorFallback: true, source: 'actor-fallback', error: '', shouldResolveRemote: false, } } if (keepFallback) { return { src: fallbackAsset, fallback: fallbackAsset, actorFallback: actorFallbackAsset, usingFallback: true, usingActorFallback: false, source: 'local-fallback', error: '', shouldResolveRemote: false, } } if (localSeed) { return { src: localSeed, fallback: fallbackAsset, actorFallback: actorFallbackAsset, usingFallback: false, usingActorFallback: false, source: 'local-seed', error: '', shouldResolveRemote: false, } } if (remotePath) { const loadingFallback = actorFallbackAsset || fallbackAsset const usingActorFallback = Boolean(actorFallbackAsset) return { src: loadingFallback, fallback: fallbackAsset, actorFallback: actorFallbackAsset, usingFallback: Boolean(loadingFallback), usingActorFallback, source: usingActorFallback ? 'actor-fallback-loading' : (fallbackAsset ? 'fallback-loading' : 'text-fallback-loading'), error: loadingFallback ? '' : IMAGE_UNAVAILABLE_MESSAGE, shouldResolveRemote: true, } } if (releaseAsset && illustrationAsset) { return { src: illustrationAsset, fallback: fallbackAsset, actorFallback: actorFallbackAsset, usingFallback: false, usingActorFallback: false, source: 'declared-local-path', error: '', shouldResolveRemote: false, } } if (actorFallbackAsset) { return { src: actorFallbackAsset, fallback: fallbackAsset, actorFallback: actorFallbackAsset, usingFallback: true, usingActorFallback: true, source: 'actor-fallback', error: '', shouldResolveRemote: false, } } if (fallbackAsset) { return { src: fallbackAsset, fallback: fallbackAsset, actorFallback: '', usingFallback: true, usingActorFallback: false, source: 'local-fallback', error: '', shouldResolveRemote: false, } } return { src: '', fallback: '', actorFallback: '', usingFallback: false, usingActorFallback: false, source: 'text-fallback', error: IMAGE_UNAVAILABLE_MESSAGE, shouldResolveRemote: false, } } /** * Audio is intentionally fail-closed. A non-empty src alone is not enough: * both the story data and immutable release manifest must mark the file as * approved, and the playable src must be the packaged, hash-checked seed. */ function getReviewedAudioSrc(media = {}, manifest = {}) { const status = cleanPath(media.status || media.audioStatus).toLowerCase() const assetId = cleanPath(media.assetId || media.audioAssetId) const declaredSrc = cleanPath(media.src || media.audioSrc) if ( status !== REVIEWED_AUDIO_STATUS || !assetId || !declaredSrc ) { return '' } const releaseAsset = manifest[assetId] if ( !releaseAsset || releaseAsset.kind !== 'audio' || cleanPath(releaseAsset.reviewStatus).toLowerCase() !== REVIEWED_AUDIO_STATUS ) { return '' } const localSeed = cleanPath(releaseAsset.localSeed) if ( !localSeed || localSeed !== declaredSrc || !isPackagedPath(localSeed) || !AUDIO_FILE_PATTERN.test(localSeed) ) { return '' } return localSeed } function buildReviewedAudioState(media = {}, manifest = {}) { const src = getReviewedAudioSrc(media, manifest) return { available: Boolean(src), src, } } module.exports = { AUDIO_FILE_PATTERN, IMAGE_UNAVAILABLE_MESSAGE, REVIEWED_AUDIO_STATUS, buildComicImageState, buildReviewedAudioState, getComicFocusImageStyle, getComicPageImageMode, getComicPageImageStyle, getReviewedAudioSrc, isPackagedPath, }