338 lines
8.9 KiB
JavaScript
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,
|
|
}
|