Files
2026-09-09 14:47:29 +08:00

338 lines
8.9 KiB
JavaScript

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,
}