gengxin
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user