2026-07-30 08:58:38 +08:00
window . _ _demoRuntimeErrors = [ ] ;
document . documentElement . dataset . runtimeErrors = "0" ;
function recordRuntimeError ( message ) {
window . _ _demoRuntimeErrors . push ( message ) ;
document . documentElement . dataset . runtimeErrors = String ( window . _ _demoRuntimeErrors . length ) ;
if ( window . _ _auditReady ) logClientOperation ( "frontend.runtime_error" , { message : String ( message || "" ) . slice ( 0 , 500 ) } ) ;
}
window . addEventListener ( "error" , ( event ) => recordRuntimeError ( event . message || "Unknown runtime error" ) ) ;
window . addEventListener ( "unhandledrejection" , ( event ) => recordRuntimeError ( String ( event . reason || "Unhandled rejection" ) ) ) ;
const PENDING _REVIEW _KEY = "yingxiangli.pendingAssetReview" ;
2026-08-05 16:27:16 +08:00
const PENDING _VIDEO _ASSET _KEY = "yingxiangli.pendingVideoAssetReview.v1" ;
2026-07-30 08:58:38 +08:00
const PENDING _VIDEO _KEY = "yingxiangli.pendingVideoTask.v1" ;
const LEGACY _GROUP _KEY = "yingxiangli.selectedAssetGroup" ;
const SCENE _ROLES _KEY = "yingxiangli.sceneRoles.v1" ;
const CHARACTER _LIBRARY _KEY = "yingxiangli.characterLibrary.v1" ;
const DRAMA _PROJECT _KEY = "yingxiangli.dramaProject.v1" ;
2026-07-30 09:12:42 +08:00
const VIDEO _KEY _STORAGE = "yingxiangli.videoKey.v1" ;
2026-07-30 08:58:38 +08:00
const AUDIT _CLIENT _KEY = "yingxiangli.auditClientId.v1" ;
const AUDIT _SESSION _KEY = "yingxiangli.auditSessionId.v1" ;
const VIEW _LABELS = [ "正面" , "侧面" , "背面" ] ;
const ROLE _LETTERS = [ "A" , "B" , "C" ] ;
const API _BASE = location . protocol === "file:" ? "http://localhost:4173" : "" ;
const VIDEO _POLL _INTERVAL _MS = 2500 ;
const VIDEO _POLL _MAX _MS = 30 * 60 * 1000 ;
const DEFAULT _SHOT _PROMPT = "竖屏双人对话短剧镜头:角色 A 站在办公楼电梯口,先低头查看手机消息;角色 B 从走廊走近并停在她身侧。角色 B 说:“你真的决定了吗?”角色 A 抬头回答:“这一次,我自己做决定。”保持两名角色外观与各自参考图一致,避免交换五官、服装或身份。镜头从双人中景缓慢推进至角色 A 近景,口型与中文台词同步,环境声自然。" ;
const state = {
config : null ,
roles : [ ] ,
activeRoleIndex : 0 ,
pollTimer : null ,
reviewStartedAt : 0 ,
assets : [ ] ,
2026-08-05 16:27:16 +08:00
videoAssets : [ ] ,
2026-07-30 08:58:38 +08:00
characters : [ ] ,
characterDraftReferences : new Set ( ) ,
activeModule : document . body . dataset . activeModule || "library" ,
projectName : "未命名短剧" ,
shots : [ ] ,
activeShotId : null ,
activeGenerationShotId : null ,
activeGenerationVersionId : null ,
} ;
const el = Object . fromEntries ( [
"connectionState" , "assetForm" , "assetFrontUrl" , "assetSideUrl" , "assetBackUrl" , "assetName" ,
"assetStatus" , "assetResult" , "assetReferences" , "assetList" , "refreshAssets" , "videoForm" ,
"videoStatus" , "prompt" , "referenceMap" , "model" , "ratio" , "duration" , "resolution" ,
"generateAudio" , "watermark" , "generationResult" , "generationTitle" , "generationTaskId" ,
"generationProgress" , "progressBar" , "generationElapsed" , "generationLastChecked" , "videoStage" , "toast" , "reviewButton" ,
"generateButton" , "reviewElapsed" , "reviewFront" , "reviewSide" , "reviewBack" , "mockHint" ,
"sceneRoles" , "addRoleButton" , "activeRoleLabel" ,
"characterList" , "createCharacterButton" , "characterEditor" , "characterName" , "assetPickList" ,
"cancelCharacterButton" , "saveCharacterButton" , "assetHistoryCount" , "extraImageInputs" ,
"referenceVideoInputs" , "addExtraImageButton" , "addReferenceVideoButton" , "inputQuota" ,
"addShotButton" , "shotList" , "timelineSummary" , "shotProgressBar" , "continuityMode" , "versionCount" ,
"versionList" , "assemblyPanel" , "assemblyList" , "assemblyHint" , "closeAssemblyButton" ,
2026-07-30 09:12:42 +08:00
"videoKeyGate" , "videoKeyForm" , "videoKeyInput" , "videoKeyError" , "changeVideoKeyButton" , "cancelVideoKeyButton" ,
2026-08-05 16:27:16 +08:00
"videoAssetForm" , "videoAssetUrl" , "videoAssetPurpose" , "videoAssetSubmit" , "videoAssetStatus" ,
"videoAssetResult" , "videoAssetResultText" , "videoAssetList" , "videoAssetHistoryCount" , "refreshVideoAssets" ,
2026-07-30 08:58:38 +08:00
] . map ( ( id ) => [ id , document . getElementById ( id ) ] ) ) ;
const reviewRows = [ el . reviewFront , el . reviewSide , el . reviewBack ] ;
const auditClientId = getOrCreateAuditId ( localStorage , AUDIT _CLIENT _KEY , "client" ) ;
const auditSessionId = getOrCreateAuditId ( sessionStorage , AUDIT _SESSION _KEY , "session" ) ;
const auditQueue = [ ] ;
const auditInputTimers = new Map ( ) ;
let auditFlushTimer = null ;
setupClientAudit ( ) ;
2026-07-30 09:12:42 +08:00
setupVideoKeyGate ( ) ;
2026-07-30 08:58:38 +08:00
init ( ) ;
document . querySelectorAll ( ".module-tab" ) . forEach ( ( button ) => {
button . addEventListener ( "click" , ( ) => switchModule ( button . dataset . module ) ) ;
} ) ;
async function init ( ) {
try {
state . config = await api ( "/api/config" ) ;
restoreSceneRoles ( ) ;
restoreCharacterLibrary ( ) ;
restoreDramaProject ( ) ;
renderConnection ( ) ;
2026-07-30 09:35:06 +08:00
if ( ! state . config . mockMode && ! currentVideoKey ( ) ) {
showVideoKeyGate ( false ) ;
return ;
}
2026-07-30 08:58:38 +08:00
await loadAssets ( ) ;
loadActiveShotIntoForm ( ) ;
renderScene ( ) ;
renderStoryboard ( ) ;
resumePendingReview ( ) ;
2026-08-05 16:27:16 +08:00
resumePendingVideoAsset ( ) ;
2026-07-30 09:12:42 +08:00
if ( state . config . mockMode || currentVideoKey ( ) ) resumePendingVideo ( ) ;
2026-07-30 08:58:38 +08:00
} catch ( error ) {
showToast ( error . message , true ) ;
el . connectionState . textContent = "本地服务连接失败" ;
el . connectionState . className = "connection blocked" ;
}
}
2026-07-30 09:12:42 +08:00
function setupVideoKeyGate ( ) {
el . videoKeyForm . addEventListener ( "submit" , ( event ) => {
event . preventDefault ( ) ;
const key = el . videoKeyInput . value . trim ( ) ;
if ( ! /^sk-[A-Za-z0-9_-]{8,}$/ . test ( key ) ) {
2026-07-30 09:35:06 +08:00
el . videoKeyError . textContent = "请输入正确格式的客户令牌( sk-...) " ;
2026-07-30 09:12:42 +08:00
el . videoKeyError . classList . remove ( "hidden" ) ;
return ;
}
localStorage . setItem ( VIDEO _KEY _STORAGE , key ) ;
logClientOperation ( "video_key.saved" , { keySuffix : key . slice ( - 4 ) , keyLength : key . length } ) ;
location . reload ( ) ;
} ) ;
el . changeVideoKeyButton . addEventListener ( "click" , ( ) => showVideoKeyGate ( true ) ) ;
el . cancelVideoKeyButton . addEventListener ( "click" , hideVideoKeyGate ) ;
}
function currentVideoKey ( ) {
try { return localStorage . getItem ( VIDEO _KEY _STORAGE ) ? . trim ( ) || "" ; }
catch { return "" ; }
}
function showVideoKeyGate ( canCancel = Boolean ( currentVideoKey ( ) ) ) {
el . videoKeyInput . value = "" ;
el . videoKeyError . textContent = "" ;
el . videoKeyError . classList . add ( "hidden" ) ;
el . cancelVideoKeyButton . classList . toggle ( "hidden" , ! canCancel ) ;
el . videoKeyGate . classList . remove ( "hidden" ) ;
requestAnimationFrame ( ( ) => el . videoKeyInput . focus ( ) ) ;
}
function hideVideoKeyGate ( ) {
if ( ! currentVideoKey ( ) && ! state . config ? . mockMode ) return ;
el . videoKeyGate . classList . add ( "hidden" ) ;
}
2026-07-30 08:58:38 +08:00
el . addRoleButton . addEventListener ( "click" , ( ) => {
if ( state . roles . length >= 3 ) return showToast ( "当前 Demo 最多支持三个场景角色,人物图片总数不超过 9 张" , true ) ;
const role = createRole ( ` 角色 ${ ROLE _LETTERS [ state . roles . length ] } ` ) ;
state . roles . push ( role ) ;
if ( activeShot ( ) ) activeShot ( ) . roleIds . push ( role . id ) ;
state . activeRoleIndex = state . roles . length - 1 ;
renderScene ( ) ;
renderStoryboard ( ) ;
saveDramaProject ( ) ;
} ) ;
el . assetName . addEventListener ( "input" , ( ) => {
activeRole ( ) . name = el . assetName . value . trim ( ) || ` 角色 ${ ROLE _LETTERS [ state . activeRoleIndex ] } ` ;
renderRoleCards ( ) ;
renderSceneSummary ( ) ;
saveSceneRoles ( ) ;
} ) ;
el . assetForm . addEventListener ( "submit" , async ( event ) => {
event . preventDefault ( ) ;
2026-08-05 16:27:16 +08:00
const images = [
{ view : "front" , url : el . assetFrontUrl . value . trim ( ) } ,
{ view : "side" , url : el . assetSideUrl . value . trim ( ) } ,
{ view : "back" , url : el . assetBackUrl . value . trim ( ) } ,
] . filter ( ( item ) => item . url ) ;
if ( ! images . length ) return showToast ( "正面、侧面、背面请至少填写一张" , true ) ;
2026-07-30 08:58:38 +08:00
const role = activeRole ( ) ;
role . name = el . assetName . value . trim ( ) || ` 角色 ${ ROLE _LETTERS [ state . activeRoleIndex ] } ` ;
setAssetStatus ( "processing" , "审核中" ) ;
state . reviewStartedAt = Date . now ( ) ;
2026-08-05 16:27:16 +08:00
preparePersonReviewRows ( images ) ;
2026-07-30 08:58:38 +08:00
updateReviewElapsed ( ) ;
el . reviewButton . disabled = true ;
try {
let result = await api ( "/api/assets/review" , {
method : "POST" ,
body : JSON . stringify ( {
name : role . name ,
2026-08-05 16:27:16 +08:00
images ,
2026-07-30 08:58:38 +08:00
} ) ,
} ) ;
if ( Number ( result . code ) === 202 || normalizeAssetTaskStatus ( result ) === "processing" ) {
const taskId = result . task _id || result . data ? . task _id || result . id ;
if ( ! taskId ) throw new Error ( "审核接口已受理,但没有返回任务 ID" ) ;
savePendingReview ( taskId , role . id ) ;
result = await pollAssetTask ( taskId ) ;
}
await finishAssetReview ( result , role . id , role . name ) ;
} catch ( error ) {
if ( error . pending ) {
setAssetStatus ( "processing" , "仍在审核" ) ;
el . reviewElapsed . textContent = "仍在审核,可稍后回来查看" ;
} else {
setAssetStatus ( "error" , "审核失败" ) ;
markUnresolvedRowsFailed ( error . message ) ;
clearPendingReview ( ) ;
}
showToast ( error . message , true ) ;
} finally {
el . reviewButton . disabled = false ;
}
} ) ;
2026-08-05 16:27:16 +08:00
el . videoAssetForm . addEventListener ( "submit" , submitVideoAsset ) ;
2026-07-30 08:58:38 +08:00
el . videoForm . addEventListener ( "submit" , async ( event ) => {
event . preventDefault ( ) ;
updateActiveShotFromForm ( ) ;
const quota = mediaQuota ( ) ;
if ( ! activeShotRoles ( ) . every ( ( role ) => role . references . length > 0 ) ) return showToast ( "请先为本片段每个出场角色选择人物素材" , true ) ;
if ( ! quota . valid ) return showToast ( quota . message , true ) ;
if ( activeShot ( ) . continuityMode === "previous_video" && previousShot ( ) && ! continuityReference ( ) ) {
return showToast ( "上一片段还没有可用的选定版本,请先完成上一段或调整衔接方式" , true ) ;
}
clearTimeout ( state . pollTimer ) ;
el . generateButton . disabled = true ;
setVideoStatus ( "processing" , "生成中" ) ;
setStep ( 4 ) ;
el . generationResult . classList . remove ( "hidden" ) ;
el . generationTaskId . textContent = "正在获取任务 ID…" ;
el . generationElapsed . textContent = "已等待 0 秒" ;
el . generationLastChecked . textContent = "尚未收到首次查询结果" ;
updateProgress ( 5 , "任务正在提交" ) ;
resetVideoStage ( ) ;
const shot = activeShot ( ) ;
const version = {
id : ` version- ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 7 ) } ` ,
status : "running" ,
taskId : "" ,
videoUrl : "" ,
lastFrameUrl : "" ,
createdAt : Date . now ( ) ,
lastCheckedAt : null ,
progress : 5 ,
2026-07-30 09:55:29 +08:00
ratio : shot . ratio ,
2026-07-30 08:58:38 +08:00
} ;
shot . versions . push ( version ) ;
state . activeGenerationShotId = shot . id ;
state . activeGenerationVersionId = version . id ;
saveDramaProject ( ) ;
renderStoryboard ( ) ;
try {
const result = await api ( "/api/videos" , {
method : "POST" ,
body : JSON . stringify ( buildVideoClientBody ( ) ) ,
} ) ;
const taskId = result . task _id || result . id || result . data ? . task _id || result . data ? . id ;
if ( ! taskId ) throw new Error ( "视频接口未返回任务 ID" ) ;
el . generationTaskId . textContent = taskId ;
version . taskId = taskId ;
saveDramaProject ( ) ;
renderVersionList ( ) ;
savePendingVideo ( taskId , shot . id , version . id ) ;
await pollVideoTask ( taskId , shot . id , version . id ) ;
} catch ( error ) {
const message = contextualizeGenerationError ( error . message ) ;
if ( error . pending ) {
if ( activeShot ( ) ? . id === shot . id ) {
setVideoStatus ( "processing" , "仍在生成" ) ;
updateProgress ( version . progress || 20 , "仍在生成,正在自动查询结果" ) ;
}
showToast ( message ) ;
} else {
clearPendingVideo ( ) ;
failShotVersion ( shot . id , version . id , message ) ;
if ( activeShot ( ) ? . id === shot . id ) {
setVideoStatus ( "error" , "生成失败" ) ;
renderGenerationError ( message ) ;
}
showToast ( message , true ) ;
}
} finally {
finishGenerationUi ( shot , version ) ;
}
} ) ;
el . refreshAssets . addEventListener ( "click" , loadAssets ) ;
2026-08-05 16:27:16 +08:00
el . refreshVideoAssets . addEventListener ( "click" , loadAssets ) ;
2026-07-30 08:58:38 +08:00
el . createCharacterButton . addEventListener ( "click" , openCharacterEditor ) ;
el . cancelCharacterButton . addEventListener ( "click" , closeCharacterEditor ) ;
el . saveCharacterButton . addEventListener ( "click" , saveCharacterFromDraft ) ;
[ el . prompt , el . extraImageInputs , el . referenceVideoInputs ] . forEach ( ( input ) => input . addEventListener ( "input" , ( ) => {
updateActiveShotFromForm ( ) ;
updateVideoAvailability ( ) ;
} ) ) ;
el . addExtraImageButton . addEventListener ( "click" , ( ) => addMediaInput ( "image" ) ) ;
el . addReferenceVideoButton . addEventListener ( "click" , ( ) => addMediaInput ( "video" ) ) ;
[ el . extraImageInputs , el . referenceVideoInputs ] . forEach ( ( list ) => list . addEventListener ( "click" , ( event ) => {
const removeButton = event . target . closest ( "[data-remove-media]" ) ;
if ( ! removeButton ) return ;
removeMediaInput ( removeButton ) ;
} ) ) ;
el . continuityMode . addEventListener ( "change" , ( ) => {
updateActiveShotFromForm ( ) ;
updateVideoAvailability ( ) ;
} ) ;
el . addShotButton . addEventListener ( "click" , addShot ) ;
el . closeAssemblyButton . addEventListener ( "click" , ( ) => el . assemblyPanel . classList . add ( "hidden" ) ) ;
el . model . addEventListener ( "change" , ( ) => {
if ( el . model . value . includes ( "4k" ) ) el . resolution . value = "4k" ;
else if ( el . model . value . includes ( "1080" ) ) el . resolution . value = "1080p" ;
else el . resolution . value = "720p" ;
updateActiveShotFromForm ( ) ;
} ) ;
[ el . ratio , el . duration , el . resolution , el . generateAudio , el . watermark ] . forEach ( ( input ) => {
input . addEventListener ( "change" , updateActiveShotFromForm ) ;
} ) ;
async function loadAssets ( ) {
try {
const items = [ ] ;
const pageSize = 100 ;
for ( let page = 1 ; page <= 50 ; page += 1 ) {
const result = await api ( ` /api/assets?page= ${ page } &page_size= ${ pageSize } &sort_order=Desc ` ) ;
const pageItems = result . data ? . items || result . data ? . Items || [ ] ;
items . push ( ... pageItems ) ;
const total = Number ( result . data ? . total ? ? result . data ? . TotalCount ? ? items . length ) ;
if ( ! pageItems . length || items . length >= total || pageItems . length < pageSize ) break ;
}
2026-08-05 16:27:16 +08:00
state . assets = items . filter ( ( item ) => assetItemType ( item ) !== "video" ) ;
state . videoAssets = items . filter ( ( item ) => assetItemType ( item ) === "video" ) ;
ingestHistoricalCharacters ( state . assets ) ;
2026-07-30 08:58:38 +08:00
renderCharacterLibrary ( ) ;
renderAssetPicker ( ) ;
2026-08-05 16:27:16 +08:00
renderVideoAssetLibrary ( ) ;
el . assetHistoryCount . textContent = String ( state . assets . length ) ;
2026-07-30 08:58:38 +08:00
el . assetList . innerHTML = "" ;
2026-08-05 16:27:16 +08:00
if ( ! state . assets . length ) {
el . assetList . innerHTML = '<div class="empty-small">素材库暂无人物图片素材</div>' ;
2026-07-30 08:58:38 +08:00
return ;
}
2026-08-05 16:27:16 +08:00
for ( const item of state . assets ) {
2026-07-30 08:58:38 +08:00
const reference = normalizeAssetReference ( item ) ;
const position = referencePosition ( reference ) ;
const button = document . createElement ( "button" ) ;
button . className = ` asset-item asset-card ${ position ? " selected" : "" } ` ;
button . type = "button" ;
if ( position ) button . dataset . order = ` ${ ROLE _LETTERS [ position . roleIndex ] } ${ position . viewIndex + 1 } ` ;
const imageUrl = item . original _url || item . URL || "" ;
if ( imageUrl ) {
const image = document . createElement ( "img" ) ;
image . src = imageUrl ;
image . alt = item . purpose || item . Name || "素材预览" ;
image . loading = "lazy" ;
image . addEventListener ( "error" , ( ) => image . classList . add ( "image-error" ) ) ;
button . appendChild ( image ) ;
}
const name = item . purpose || item . Name || fileNameFromUrl ( imageUrl ) || "未命名素材" ;
const info = document . createElement ( "div" ) ;
info . className = "asset-info" ;
info . innerHTML = ` <strong> ${ escapeHtml ( name ) } </strong><span> ${ escapeHtml ( reference ) } </span> ` ;
button . appendChild ( info ) ;
button . addEventListener ( "click" , ( ) => toggleHistoricalAsset ( reference ) ) ;
el . assetList . appendChild ( button ) ;
}
} catch ( error ) {
2026-07-30 09:19:20 +08:00
const message = /unauthorized|invalid token/i . test ( String ( error . message || "" ) )
2026-07-30 09:35:06 +08:00
? "客户令牌无效或已过期,请重新输入"
2026-07-30 09:19:20 +08:00
: error . message ;
el . assetList . innerHTML = ` <div class="empty-small"> ${ escapeHtml ( message ) } </div> ` ;
2026-08-05 16:27:16 +08:00
el . videoAssetList . innerHTML = ` <div class="empty-small"> ${ escapeHtml ( message ) } </div> ` ;
2026-07-30 09:19:20 +08:00
if ( ! state . config ? . mockMode ) {
el . connectionState . textContent = "素材库连接失败" ;
el . connectionState . className = "connection blocked" ;
setAssetStatus ( "error" , "素材库不可用" ) ;
}
2026-07-30 08:58:38 +08:00
}
}
2026-08-05 16:27:16 +08:00
function assetItemType ( item ) {
return String ( item ? . type || item ? . asset _type || item ? . AssetType || "image" ) . toLowerCase ( ) ;
}
function renderVideoAssetLibrary ( ) {
el . videoAssetHistoryCount . textContent = String ( state . videoAssets . length ) ;
el . videoAssetList . innerHTML = "" ;
if ( ! state . videoAssets . length ) {
el . videoAssetList . innerHTML = '<div class="empty-small">暂无已就绪视频素材</div>' ;
return ;
}
for ( const item of state . videoAssets ) {
const reference = normalizeAssetReference ( item ) ;
const videoUrl = item . original _url || item . URL || "" ;
const name = item . purpose || item . Name || fileNameFromUrl ( videoUrl ) || "未命名视频素材" ;
const card = document . createElement ( "article" ) ;
card . className = "video-asset-card" ;
const preview = document . createElement ( "div" ) ;
preview . className = "video-asset-preview" ;
if ( videoUrl ) {
const video = document . createElement ( "video" ) ;
video . src = videoUrl ;
video . controls = true ;
video . playsInline = true ;
video . preload = "metadata" ;
preview . appendChild ( video ) ;
} else preview . textContent = "暂无视频预览" ;
const copy = document . createElement ( "div" ) ;
copy . className = "video-asset-copy" ;
copy . innerHTML = ` <strong> ${ escapeHtml ( name ) } </strong><span> ${ escapeHtml ( reference ) } </span> ` ;
const actions = document . createElement ( "div" ) ;
actions . className = "video-asset-actions" ;
const useButton = document . createElement ( "button" ) ;
useButton . type = "button" ;
useButton . textContent = "用于当前片段" ;
useButton . addEventListener ( "click" , ( ) => useVideoAssetInActiveShot ( reference , name ) ) ;
const copyButton = document . createElement ( "button" ) ;
copyButton . type = "button" ;
copyButton . textContent = "复制素材引用" ;
copyButton . addEventListener ( "click" , async ( ) => {
try { await navigator . clipboard . writeText ( reference ) ; } catch { fallbackCopy ( reference ) ; }
showToast ( "视频素材引用已复制" ) ;
} ) ;
actions . append ( useButton , copyButton ) ;
card . append ( preview , copy , actions ) ;
el . videoAssetList . appendChild ( card ) ;
}
}
function useVideoAssetInActiveShot ( reference , name ) {
const shot = activeShot ( ) ;
const current = parseMediaLines ( shot . referenceVideoUrls ) ;
if ( ! current . includes ( reference ) && current . length >= 3 ) return showToast ( "当前片段最多使用 3 条参考视频" , true ) ;
shot . referenceVideoUrls = [ ... new Set ( [ ... current , reference ] ) ] . join ( "\n" ) ;
saveDramaProject ( ) ;
switchModule ( "production" ) ;
loadActiveShotIntoForm ( ) ;
renderStoryboard ( ) ;
showToast ( ` 已将「 ${ name } 」用于当前片段 ` ) ;
}
async function submitVideoAsset ( event ) {
event . preventDefault ( ) ;
const url = el . videoAssetUrl . value . trim ( ) ;
const purpose = el . videoAssetPurpose . value . trim ( ) ;
setVideoAssetStatus ( "processing" , "审核中" ) ;
el . videoAssetResult . classList . add ( "hidden" ) ;
el . videoAssetSubmit . disabled = true ;
try {
let result = await api ( "/api/assets/upload" , {
method : "POST" ,
body : JSON . stringify ( { type : "video" , url , purpose } ) ,
} ) ;
if ( Number ( result . code ) === 202 || normalizeAssetTaskStatus ( result ) === "processing" ) {
const taskId = result . task _id || result . data ? . task _id || result . id ;
if ( ! taskId ) throw new Error ( "视频素材接口已受理,但没有返回任务 ID" ) ;
savePendingVideoAsset ( taskId , purpose ) ;
result = await pollAssetTask ( taskId , false ) ;
}
await finishVideoAssetUpload ( result ) ;
} catch ( error ) {
setVideoAssetStatus ( error . pending ? "processing" : "error" , error . pending ? "仍在审核" : "审核失败" ) ;
showToast ( error . message , ! error . pending ) ;
} finally {
el . videoAssetSubmit . disabled = false ;
}
}
async function finishVideoAssetUpload ( result ) {
const items = getReviewItems ( result ) ;
if ( items . length !== 1 ) throw new Error ( result . message || "视频素材接口未返回有效结果" ) ;
const item = items [ 0 ] ;
if ( Number ( item . submit _review _status ) !== 1 ) throw new Error ( item . error _message || "视频素材未通过审核" ) ;
const reference = normalizeAssetReference ( item ) ;
if ( ! reference ) throw new Error ( "审核通过,但未返回可用的 asset:// ID" ) ;
clearPendingVideoAsset ( ) ;
setVideoAssetStatus ( "success" , "已入库" ) ;
el . videoAssetResultText . textContent = ` 素材引用: ${ reference } ` ;
el . videoAssetResult . classList . remove ( "hidden" ) ;
el . videoAssetForm . reset ( ) ;
await loadAssets ( ) ;
showToast ( "视频素材已通过审核并入库" ) ;
}
function savePendingVideoAsset ( taskId , purpose ) {
localStorage . setItem ( PENDING _VIDEO _ASSET _KEY , JSON . stringify ( { taskId , purpose , startedAt : Date . now ( ) } ) ) ;
}
function clearPendingVideoAsset ( ) { localStorage . removeItem ( PENDING _VIDEO _ASSET _KEY ) ; }
async function resumePendingVideoAsset ( ) {
let pending ;
try { pending = JSON . parse ( localStorage . getItem ( PENDING _VIDEO _ASSET _KEY ) || "null" ) ; } catch { pending = null ; }
if ( ! pending ? . taskId ) return ;
setVideoAssetStatus ( "processing" , "继续查询" ) ;
try {
const result = await pollAssetTask ( pending . taskId , false ) ;
await finishVideoAssetUpload ( result ) ;
} catch ( error ) {
setVideoAssetStatus ( error . pending ? "processing" : "error" , error . pending ? "仍在审核" : "审核失败" ) ;
if ( ! error . pending ) clearPendingVideoAsset ( ) ;
}
}
2026-07-30 08:58:38 +08:00
function ingestHistoricalCharacters ( items ) {
const groups = new Map ( ) ;
for ( const item of items ) {
const purpose = String ( item . purpose || item . Name || "" ) . trim ( ) ;
const match = purpose . match ( /^(.+?)\s*[·・]\s*(正面|侧面|背面)$/ ) ;
if ( ! match ) continue ;
const reference = normalizeAssetReference ( item ) ;
if ( ! reference ) continue ;
if ( ! groups . has ( match [ 1 ] ) ) groups . set ( match [ 1 ] , [ ] ) ;
groups . get ( match [ 1 ] ) . push ( { reference , view : match [ 2 ] } ) ;
}
for ( const [ name , entries ] of groups ) {
const order = { 正面 : 0 , 侧面 : 1 , 背面 : 2 } ;
const references = entries . sort ( ( a , b ) => order [ a . view ] - order [ b . view ] ) . map ( ( entry ) => entry . reference ) ;
upsertCharacter ( name , references , "history" , false ) ;
}
saveCharacterLibrary ( ) ;
}
function renderCharacterLibrary ( ) {
el . characterList . innerHTML = "" ;
if ( ! state . characters . length ) {
el . characterList . innerHTML = '<div class="empty-small">暂无已组合人物,可从历史素材新建</div>' ;
return ;
}
for ( const character of state . characters ) {
const card = document . createElement ( "div" ) ;
const used = state . roles . some ( ( role ) => sameReferences ( role . references , character . references ) ) ;
card . className = ` character-card ${ used ? " selected" : "" } ` ;
const head = document . createElement ( "div" ) ;
head . className = "character-head" ;
head . innerHTML = ` <strong> ${ escapeHtml ( character . name ) } </strong><span> ${ character . references . length } 张人物素材</span> ` ;
const thumbs = document . createElement ( "div" ) ;
thumbs . className = "character-thumbs" ;
for ( const reference of character . references . slice ( 0 , 5 ) ) {
const asset = state . assets . find ( ( item ) => normalizeAssetReference ( item ) === reference ) ;
const imageUrl = asset ? . original _url || asset ? . URL || "" ;
if ( imageUrl ) {
const image = document . createElement ( "img" ) ;
image . src = imageUrl ;
image . alt = character . name ;
thumbs . appendChild ( image ) ;
} else {
const placeholder = document . createElement ( "span" ) ;
placeholder . className = "character-thumb-placeholder" ;
thumbs . appendChild ( placeholder ) ;
}
}
const actions = document . createElement ( "div" ) ;
actions . className = "character-actions" ;
const useButton = document . createElement ( "button" ) ;
useButton . type = "button" ;
useButton . textContent = state . activeModule === "library" ? "用于短剧生产" : ` 用于角色 ${ ROLE _LETTERS [ state . activeRoleIndex ] } ` ;
useButton . addEventListener ( "click" , ( ) => bindCharacterToActiveRole ( character ) ) ;
actions . appendChild ( useButton ) ;
if ( character . source === "manual" && state . activeModule === "library" ) {
const removeButton = document . createElement ( "button" ) ;
removeButton . type = "button" ;
removeButton . className = "danger-link" ;
removeButton . textContent = "删除组合" ;
removeButton . addEventListener ( "click" , ( ) => {
state . characters = state . characters . filter ( ( item ) => item . id !== character . id ) ;
saveCharacterLibrary ( ) ;
renderCharacterLibrary ( ) ;
} ) ;
actions . appendChild ( removeButton ) ;
}
card . append ( head , thumbs , actions ) ;
el . characterList . appendChild ( card ) ;
}
}
function bindCharacterToActiveRole ( character ) {
const otherRoleImages = state . roles . reduce ( ( sum , role , index ) => sum + ( index === state . activeRoleIndex ? 0 : role . references . length ) , 0 ) ;
const extraImages = collectMediaInputUrls ( "image" ) . length ;
if ( otherRoleImages + character . references . length + extraImages > 9 ) {
showToast ( ` 绑定后图片总数将超过 9 张;当前其他角色与场景图已占 ${ otherRoleImages + extraImages } 张 ` , true ) ;
return ;
}
const role = activeRole ( ) ;
role . name = character . name ;
role . characterId = character . id ;
role . references = [ ... character . references ] ;
2026-08-05 16:27:16 +08:00
role . referenceViews = [ ] ;
2026-07-30 08:58:38 +08:00
if ( activeShot ( ) && ! activeShot ( ) . roleIds . includes ( role . id ) ) activeShot ( ) . roleIds . push ( role . id ) ;
renderScene ( ) ;
if ( state . activeModule === "library" ) switchModule ( "production" ) ;
showToast ( ` 已将「 ${ character . name } 」用于角色 ${ ROLE _LETTERS [ state . activeRoleIndex ] } ` ) ;
}
function switchModule ( module ) {
2026-08-05 16:27:16 +08:00
if ( ! [ "library" , "video-library" , "production" ] . includes ( module ) ) return ;
2026-07-30 08:58:38 +08:00
state . activeModule = module ;
document . body . dataset . activeModule = module ;
document . querySelectorAll ( '.module-tab' ) . forEach ( ( button ) => {
button . classList . toggle ( 'active' , button . dataset . module === module ) ;
} ) ;
renderCharacterLibrary ( ) ;
}
function createShot ( seed = { } , index = state . shots . length ) {
2026-07-31 13:41:09 +08:00
const seedDuration = Number ( seed . duration ) ;
const duration = Number . isFinite ( seedDuration ) ? Math . min ( 14 , Math . max ( 5 , Math . round ( seedDuration ) ) ) : 5 ;
2026-07-30 08:58:38 +08:00
return {
id : seed . id || ` shot- ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 7 ) } ` ,
title : seed . title || ( index === 0 ? "电梯口对话" : ` 片段 ${ String ( index + 1 ) . padStart ( 2 , "0" ) } ` ) ,
prompt : seed . prompt || DEFAULT _SHOT _PROMPT ,
extraImageUrls : seed . extraImageUrls || "" ,
referenceVideoUrls : seed . referenceVideoUrls || "" ,
continuityMode : index === 0 ? "none" : ( seed . continuityMode === "none" ? "none" : "previous_video" ) ,
roleIds : Array . isArray ( seed . roleIds ) ? [ ... seed . roleIds ] : state . roles . map ( ( role ) => role . id ) ,
model : seed . model || "doubao-seedance-2-0-260128" ,
ratio : seed . ratio || "9:16" ,
2026-07-31 13:41:09 +08:00
duration ,
2026-07-30 08:58:38 +08:00
resolution : seed . resolution || "720p" ,
generateAudio : seed . generateAudio !== false ,
watermark : Boolean ( seed . watermark ) ,
versions : Array . isArray ( seed . versions ) ? seed . versions : [ ] ,
selectedVersionId : seed . selectedVersionId || null ,
} ;
}
function restoreDramaProject ( ) {
let saved ;
try { saved = JSON . parse ( localStorage . getItem ( DRAMA _PROJECT _KEY ) || "null" ) ; } catch { saved = null ; }
state . projectName = String ( saved ? . name || "未命名短剧" ) ;
state . shots = Array . isArray ( saved ? . shots ) && saved . shots . length
? saved . shots . slice ( 0 , 30 ) . map ( ( shot , index ) => createShot ( shot , index ) )
: [ createShot ( ) ] ;
const roleIds = new Set ( state . roles . map ( ( role ) => role . id ) ) ;
state . shots . forEach ( ( shot ) => {
shot . roleIds = shot . roleIds . filter ( ( id ) => roleIds . has ( id ) ) ;
if ( ! shot . roleIds . length ) shot . roleIds = state . roles . map ( ( role ) => role . id ) ;
} ) ;
state . activeShotId = state . shots . some ( ( shot ) => shot . id === saved ? . activeShotId ) ? saved . activeShotId : state . shots [ 0 ] . id ;
}
function saveDramaProject ( ) {
localStorage . setItem ( DRAMA _PROJECT _KEY , JSON . stringify ( {
name : state . projectName ,
activeShotId : state . activeShotId ,
shots : state . shots ,
} ) ) ;
}
function activeShot ( ) { return state . shots . find ( ( shot ) => shot . id === state . activeShotId ) || state . shots [ 0 ] ; }
function shotIndex ( shot = activeShot ( ) ) { return Math . max ( 0 , state . shots . findIndex ( ( item ) => item . id === shot ? . id ) ) ; }
function previousShot ( shot = activeShot ( ) ) { const index = shotIndex ( shot ) ; return index > 0 ? state . shots [ index - 1 ] : null ; }
function selectedShotVersion ( shot ) { return shot ? . versions ? . find ( ( version ) => version . id === shot . selectedVersionId ) || null ; }
function updateActiveShotFromForm ( ) {
const shot = activeShot ( ) ;
if ( ! shot ) return ;
shot . prompt = el . prompt . value ;
shot . extraImageUrls = collectMediaInputUrls ( "image" ) . join ( "\n" ) ;
shot . referenceVideoUrls = collectMediaInputUrls ( "video" ) . join ( "\n" ) ;
shot . continuityMode = el . continuityMode . value ;
shot . model = el . model . value ;
shot . ratio = el . ratio . value ;
shot . duration = Number ( el . duration . value ) ;
shot . resolution = el . resolution . value ;
shot . generateAudio = el . generateAudio . checked ;
shot . watermark = el . watermark . checked ;
saveDramaProject ( ) ;
renderShotList ( ) ;
renderProjectSummary ( ) ;
}
function loadActiveShotIntoForm ( ) {
const shot = activeShot ( ) ;
if ( ! shot ) return ;
el . prompt . value = shot . prompt ;
renderMediaInputList ( "image" , shot . extraImageUrls ) ;
renderMediaInputList ( "video" , shot . referenceVideoUrls ) ;
el . continuityMode . value = shotIndex ( shot ) === 0 ? "none" : shot . continuityMode ;
el . continuityMode . disabled = shotIndex ( shot ) === 0 ;
el . model . value = shot . model ;
el . ratio . value = shot . ratio ;
el . duration . value = String ( shot . duration ) ;
el . resolution . value = shot . resolution ;
el . generateAudio . checked = shot . generateAudio ;
el . watermark . checked = shot . watermark ;
renderActiveShotGenerationState ( ) ;
}
function renderStoryboard ( ) {
renderProjectSummary ( ) ;
renderShotList ( ) ;
const shot = activeShot ( ) ;
if ( ! shot ) return ;
el . continuityMode . value = shotIndex ( shot ) === 0 ? "none" : shot . continuityMode ;
el . continuityMode . disabled = shotIndex ( shot ) === 0 ;
renderVersionList ( ) ;
if ( ! el . assemblyPanel . classList . contains ( "hidden" ) ) renderAssembly ( ) ;
}
function renderProjectSummary ( ) {
const selected = state . shots . filter ( ( shot ) => selectedShotVersion ( shot ) ) . length ;
const seconds = state . shots . reduce ( ( sum , shot ) => sum + Number ( shot . duration || 0 ) , 0 ) ;
el . timelineSummary . textContent = ` ${ selected } / ${ state . shots . length } 已选定 · 预计 ${ seconds } 秒 ` ;
el . shotProgressBar . style . width = ` ${ state . shots . length ? ( selected / state . shots . length ) * 100 : 0 } % ` ;
}
function renderShotList ( ) {
el . shotList . innerHTML = "" ;
state . shots . forEach ( ( shot , index ) => {
const selected = selectedShotVersion ( shot ) ;
const running = shot . versions . some ( ( version ) => version . status === "running" ) ;
const failed = shot . versions . length > 0 && shot . versions . every ( ( version ) => version . status === "failed" ) ;
const card = document . createElement ( "div" ) ;
card . className = ` shot-card ${ shot . id === state . activeShotId ? " active" : "" } ` ;
const stateClass = selected ? "ready" : running ? "running" : failed ? "failed" : "" ;
card . innerHTML = ` <button type="button" class="shot-card-select" aria-label="打开片段 ${ index + 1 } "><span class="shot-number"> ${ String ( index + 1 ) . padStart ( 2 , "0" ) } </span><span class="shot-card-copy"><strong> ${ escapeHtml ( shot . title ) } </strong><small> ${ shot . duration } 秒 · ${ shot . versions . length } 个候选</small></span><i class="shot-state ${ stateClass } "></i></button> ${ state . shots . length > 1 ? ` <button type="button" class="shot-delete-button" aria-label="删除片段 ${ index + 1 } " title="删除片段">× </button> ` : "" } ` ;
card . querySelector ( ".shot-card-select" ) . addEventListener ( "click" , ( ) => selectShot ( shot . id ) ) ;
card . querySelector ( ".shot-delete-button" ) ? . addEventListener ( "click" , ( ) => deleteShotById ( shot . id ) ) ;
el . shotList . appendChild ( card ) ;
} ) ;
}
function selectShot ( id ) {
updateActiveShotFromForm ( ) ;
state . activeShotId = id ;
loadActiveShotIntoForm ( ) ;
renderStoryboard ( ) ;
renderSceneSummary ( ) ;
updateVideoAvailability ( ) ;
saveDramaProject ( ) ;
}
function addShot ( ) {
updateActiveShotFromForm ( ) ;
const shot = createShot ( { prompt : "请描述本片段的场景、出场人物、动作、台词、镜头与声音。" } ) ;
state . shots . push ( shot ) ;
state . activeShotId = shot . id ;
loadActiveShotIntoForm ( ) ;
renderStoryboard ( ) ;
renderSceneSummary ( ) ;
updateVideoAvailability ( ) ;
saveDramaProject ( ) ;
}
function duplicateActiveShot ( ) {
updateActiveShotFromForm ( ) ;
const source = activeShot ( ) ;
const index = shotIndex ( source ) ;
const copy = createShot ( { ... source , id : null , title : ` ${ source . title } · 副本 ` , versions : [ ] , selectedVersionId : null } ) ;
state . shots . splice ( index + 1 , 0 , copy ) ;
state . activeShotId = copy . id ;
loadActiveShotIntoForm ( ) ;
renderStoryboard ( ) ;
updateVideoAvailability ( ) ;
saveDramaProject ( ) ;
}
function deleteShotById ( id ) {
if ( state . shots . length <= 1 ) return showToast ( "至少保留一个片段" , true ) ;
const index = state . shots . findIndex ( ( shot ) => shot . id === id ) ;
if ( index < 0 ) return ;
const shot = state . shots [ index ] ;
if ( ! window . confirm ( ` 确定删除片段 ${ String ( index + 1 ) . padStart ( 2 , "0" ) } 「 ${ shot . title } 」吗? \n 该片段的候选视频记录也会一起删除。 ` ) ) return ;
updateActiveShotFromForm ( ) ;
let pending = null ;
try { pending = JSON . parse ( localStorage . getItem ( PENDING _VIDEO _KEY ) || "null" ) ; } catch { pending = null ; }
if ( pending ? . shotId === id ) clearPendingVideo ( ) ;
if ( state . activeGenerationShotId === id ) {
clearInterval ( state . pollTimer ) ;
state . pollTimer = null ;
state . activeGenerationShotId = null ;
state . activeGenerationVersionId = null ;
}
state . shots . splice ( index , 1 ) ;
if ( state . activeShotId === id ) state . activeShotId = state . shots [ Math . min ( index , state . shots . length - 1 ) ] . id ;
state . shots [ 0 ] . continuityMode = "none" ;
loadActiveShotIntoForm ( ) ;
renderStoryboard ( ) ;
renderSceneSummary ( ) ;
updateVideoAvailability ( ) ;
saveDramaProject ( ) ;
logClientOperation ( "shot.delete" , {
deletedShotId : id ,
deletedIndex : index + 1 ,
hadRunningVersion : shot . versions . some ( ( version ) => version . status === "running" ) ,
versionCount : shot . versions . length ,
remainingShotCount : state . shots . length ,
} ) ;
showToast ( "片段已删除" ) ;
}
function moveActiveShot ( direction ) {
const index = shotIndex ( ) ;
const target = index + direction ;
if ( target < 0 || target >= state . shots . length ) return ;
const [ shot ] = state . shots . splice ( index , 1 ) ;
state . shots . splice ( target , 0 , shot ) ;
if ( target === 0 ) shot . continuityMode = "none" ;
loadActiveShotIntoForm ( ) ;
renderStoryboard ( ) ;
saveDramaProject ( ) ;
}
function activeShotRoles ( ) {
return state . roles ;
}
function continuityReference ( shot = activeShot ( ) ) {
const previous = previousShot ( shot ) ;
const version = selectedShotVersion ( previous ) ;
if ( ! previous || shot . continuityMode === "none" ) return null ;
if ( shot . continuityMode === "previous_video" ) return version ? . videoUrl ? { type : "video" , url : version . videoUrl , version , previous } : null ;
return null ;
}
function effectiveVideoReferences ( ) {
const manual = collectMediaInputUrls ( "video" ) ;
const continuity = continuityReference ( ) ;
return [ ... new Set ( [ ... ( continuity ? . type === "video" ? [ continuity . url ] : [ ] ) , ... manual ] ) ] ;
}
function effectiveExtraImageReferences ( ) {
const manual = collectMediaInputUrls ( "image" ) ;
return [ ... new Set ( manual ) ] ;
}
function renderVersionList ( ) {
const shot = activeShot ( ) ;
const versions = shot ? . versions || [ ] ;
el . versionCount . textContent = versions . length ? ` ${ versions . length } 个候选 ` : "暂无候选" ;
el . versionList . innerHTML = "" ;
if ( ! versions . length ) {
el . versionList . innerHTML = '<div class="empty-small">生成当前片段后,候选视频会出现在这里</div>' ;
return ;
}
versions . slice ( ) . reverse ( ) . forEach ( ( version , reverseIndex ) => {
const number = versions . length - reverseIndex ;
const card = document . createElement ( "div" ) ;
card . className = ` version-card ${ shot . selectedVersionId === version . id ? " selected" : "" } ` ;
const preview = document . createElement ( "div" ) ;
preview . className = "version-preview" ;
2026-07-30 09:55:29 +08:00
setVersionPreviewRatio ( preview , version . ratio || shot . ratio ) ;
2026-07-30 08:58:38 +08:00
if ( version . videoUrl ) {
const video = document . createElement ( "video" ) ;
video . src = version . videoUrl ;
video . controls = true ;
2026-07-30 09:55:29 +08:00
video . playsInline = true ;
video . preload = "metadata" ;
video . addEventListener ( "loadedmetadata" , ( ) => {
if ( video . videoWidth && video . videoHeight ) setVersionPreviewRatio ( preview , ` ${ video . videoWidth } : ${ video . videoHeight } ` ) ;
} , { once : true } ) ;
2026-07-30 08:58:38 +08:00
preview . appendChild ( video ) ;
} else preview . textContent = version . status === "running" ? "生成中" : version . status === "failed" ? "生成失败" : "暂无预览" ;
const details = document . createElement ( "div" ) ;
details . className = "version-copy" ;
details . innerHTML = ` <strong>候选 ${ number } · ${ version . status === "completed" ? "已完成" : version . status === "running" ? "生成中" : "失败" } </strong><span> ${ escapeHtml ( version . taskId || version . error || "等待任务 ID" ) } </span> ` ;
const actions = document . createElement ( "div" ) ;
actions . className = "version-actions" ;
const selectButton = document . createElement ( "button" ) ;
selectButton . type = "button" ;
selectButton . className = "select-version-button" ;
selectButton . disabled = version . status !== "completed" ;
selectButton . textContent = shot . selectedVersionId === version . id ? "已选定" : "选为正式版本" ;
selectButton . addEventListener ( "click" , ( ) => selectShotVersion ( shot . id , version . id ) ) ;
actions . appendChild ( selectButton ) ;
if ( version . status === "completed" && version . taskId && ! version . mock ) {
const download = document . createElement ( "a" ) ;
download . className = "video-action-link" ;
download . href = version . videoUrl ;
download . textContent = "下载视频" ;
download . setAttribute ( "download" , "" ) ;
download . target = "_blank" ;
download . rel = "noopener" ;
const copyLink = document . createElement ( "button" ) ;
copyLink . type = "button" ;
copyLink . className = "video-action-link" ;
copyLink . textContent = "复制视频链接" ;
copyLink . addEventListener ( "click" , ( ) => copyGeneratedVideoLink ( version . taskId ) ) ;
actions . append ( download , copyLink ) ;
}
card . append ( preview , details , actions ) ;
el . versionList . appendChild ( card ) ;
} ) ;
}
2026-07-30 09:55:29 +08:00
function setVersionPreviewRatio ( preview , ratio ) {
const [ width , height ] = String ( ratio || "16:9" ) . split ( ":" ) . map ( Number ) ;
const safeWidth = width > 0 ? width : 16 ;
const safeHeight = height > 0 ? height : 9 ;
preview . style . setProperty ( "--preview-ratio" , ` ${ safeWidth } / ${ safeHeight } ` ) ;
preview . classList . toggle ( "portrait" , safeHeight > safeWidth ) ;
preview . classList . toggle ( "square" , safeHeight === safeWidth ) ;
}
2026-07-30 08:58:38 +08:00
function videoProxyUrl ( taskId ) {
const base = API _BASE || location . origin ;
return ` ${ base } /api/videos/ ${ encodeURIComponent ( taskId ) } /video.mp4 ` ;
}
async function copyGeneratedVideoLink ( taskId ) {
const url = videoProxyUrl ( taskId ) ;
try {
if ( navigator . clipboard ? . writeText ) await navigator . clipboard . writeText ( url ) ;
else fallbackCopy ( url ) ;
showToast ( "视频链接已复制,可直接用作参考视频" ) ;
} catch {
fallbackCopy ( url ) ;
showToast ( "视频链接已复制,可直接用作参考视频" ) ;
}
}
function selectShotVersion ( shotId , versionId ) {
const shot = state . shots . find ( ( item ) => item . id === shotId ) ;
if ( ! shot ) return ;
shot . selectedVersionId = versionId ;
saveDramaProject ( ) ;
renderStoryboard ( ) ;
showToast ( ` 已选定「 ${ shot . title } 」的正式版本 ` ) ;
}
function renderAssembly ( ) {
el . assemblyList . innerHTML = "" ;
let ready = 0 ;
state . shots . forEach ( ( shot , index ) => {
const version = selectedShotVersion ( shot ) ;
if ( version ) ready += 1 ;
const row = document . createElement ( "div" ) ;
row . className = "assembly-row" ;
row . innerHTML = ` <b> ${ String ( index + 1 ) . padStart ( 2 , "0" ) } </b><strong> ${ escapeHtml ( shot . title ) } </strong><span> ${ version ? ` ${ shot . duration } 秒 · 已选版本 ` : "待选版本" } </span> ` ;
el . assemblyList . appendChild ( row ) ;
} ) ;
el . assemblyHint . textContent = ready === state . shots . length
? ` 全部 ${ ready } 个片段已选定,可进入服务端拼接与导出。 `
: ` 还差 ${ state . shots . length - ready } 个片段选定正式版本。 ` ;
}
function openCharacterEditor ( ) {
state . characterDraftReferences = new Set ( ) ;
el . characterName . value = "" ;
el . characterEditor . classList . remove ( "hidden" ) ;
renderAssetPicker ( ) ;
}
function closeCharacterEditor ( ) {
el . characterEditor . classList . add ( "hidden" ) ;
state . characterDraftReferences . clear ( ) ;
}
function renderAssetPicker ( ) {
if ( ! el . assetPickList ) return ;
el . assetPickList . innerHTML = "" ;
if ( ! state . assets . length ) {
el . assetPickList . innerHTML = '<div class="empty-small">暂无历史素材</div>' ;
return ;
}
for ( const item of state . assets ) {
const reference = normalizeAssetReference ( item ) ;
const imageUrl = item . original _url || item . URL || "" ;
const label = item . purpose || item . Name || reference ;
const row = document . createElement ( "label" ) ;
row . className = "asset-pick" ;
const checkbox = document . createElement ( "input" ) ;
checkbox . type = "checkbox" ;
checkbox . checked = state . characterDraftReferences . has ( reference ) ;
checkbox . addEventListener ( "change" , ( ) => {
if ( checkbox . checked && state . characterDraftReferences . size >= 9 ) {
checkbox . checked = false ;
return showToast ( "一个人物素材组最多选择 9 张图片" , true ) ;
}
if ( checkbox . checked ) state . characterDraftReferences . add ( reference ) ;
else state . characterDraftReferences . delete ( reference ) ;
} ) ;
const image = document . createElement ( "img" ) ;
image . src = imageUrl ;
image . alt = label ;
const text = document . createElement ( "span" ) ;
text . textContent = label ;
row . append ( checkbox , image , text ) ;
el . assetPickList . appendChild ( row ) ;
}
}
function saveCharacterFromDraft ( ) {
const name = el . characterName . value . trim ( ) ;
const references = [ ... state . characterDraftReferences ] ;
if ( ! name ) return showToast ( "请填写人物名称" , true ) ;
if ( ! references . length ) return showToast ( "请至少选择一张人物素材" , true ) ;
upsertCharacter ( name , references , "manual" , true ) ;
closeCharacterEditor ( ) ;
renderCharacterLibrary ( ) ;
showToast ( ` 人物「 ${ name } 」已保存,共 ${ references . length } 张素材 ` ) ;
}
function upsertCharacter ( name , references , source = "manual" , persist = true ) {
const existing = state . characters . find ( ( item ) => item . name === name ) ;
if ( existing ) {
existing . references = [ ... new Set ( [ ... existing . references , ... references ] ) ] . slice ( 0 , 9 ) ;
if ( source === "manual" ) existing . source = "manual" ;
} else {
state . characters . push ( {
id : ` character- ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 7 ) } ` ,
name ,
references : [ ... new Set ( references ) ] . slice ( 0 , 9 ) ,
source ,
} ) ;
}
if ( persist ) saveCharacterLibrary ( ) ;
}
function sameReferences ( left = [ ] , right = [ ] ) {
return left . length === right . length && left . every ( ( reference , index ) => reference === right [ index ] ) ;
}
function toggleHistoricalAsset ( reference ) {
const existing = referencePosition ( reference ) ;
if ( existing ) {
state . roles [ existing . roleIndex ] . references . splice ( existing . viewIndex , 1 ) ;
state . activeRoleIndex = existing . roleIndex ;
} else {
const role = activeRole ( ) ;
if ( sceneReferences ( ) . length >= 9 ) {
showToast ( "本次人物图片已达到 9 张上限;请先取消其他图片" , true ) ;
return ;
}
role . references . push ( reference ) ;
}
renderScene ( ) ;
}
function renderScene ( ) {
if ( ! state . roles . length ) state . roles . push ( createRole ( "角色 A" ) ) ;
state . activeRoleIndex = Math . min ( state . activeRoleIndex , state . roles . length - 1 ) ;
renderRoleCards ( ) ;
renderActiveRole ( ) ;
renderSceneSummary ( ) ;
renderCharacterLibrary ( ) ;
refreshAssetSelectionStyles ( ) ;
updateVideoAvailability ( ) ;
saveSceneRoles ( ) ;
if ( state . shots . length ) renderStoryboard ( ) ;
}
function renderRoleCards ( ) {
el . sceneRoles . innerHTML = "" ;
state . roles . forEach ( ( role , index ) => {
const card = document . createElement ( "div" ) ;
card . className = ` role-card ${ index === state . activeRoleIndex ? " active" : "" } ` ;
const select = document . createElement ( "button" ) ;
select . type = "button" ;
select . className = "role-select" ;
select . innerHTML = ` <span class="role-letter"> ${ ROLE _LETTERS [ index ] } </span><span class="role-copy"><strong> ${ escapeHtml ( role . name ) } </strong><small> ${ role . references . length } 张人物素材</small></span> ` ;
select . addEventListener ( "click" , ( ) => {
state . activeRoleIndex = index ;
renderScene ( ) ;
} ) ;
card . appendChild ( select ) ;
if ( state . roles . length > 1 ) {
const remove = document . createElement ( "button" ) ;
remove . type = "button" ;
remove . className = "role-remove" ;
remove . textContent = "× " ;
remove . title = ` 删除角色 ${ ROLE _LETTERS [ index ] } ` ;
remove . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
const removedId = state . roles [ index ] . id ;
state . roles . splice ( index , 1 ) ;
state . shots . forEach ( ( shot ) => {
shot . roleIds = shot . roleIds . filter ( ( id ) => id !== removedId ) ;
if ( ! shot . roleIds . length && state . roles [ 0 ] ) shot . roleIds = [ state . roles [ 0 ] . id ] ;
} ) ;
state . activeRoleIndex = Math . max ( 0 , Math . min ( state . activeRoleIndex , state . roles . length - 1 ) ) ;
renderScene ( ) ;
renderStoryboard ( ) ;
saveDramaProject ( ) ;
} ) ;
card . appendChild ( remove ) ;
}
el . sceneRoles . appendChild ( card ) ;
} ) ;
el . addRoleButton . disabled = state . roles . length >= 3 ;
}
function renderActiveRole ( ) {
const role = activeRole ( ) ;
const letter = ROLE _LETTERS [ state . activeRoleIndex ] ;
el . activeRoleLabel . textContent = ` 角色 ${ letter } · 人物素材 ` ;
el . assetName . value = role . name ;
reviewRows . forEach ( ( _ , index ) => {
2026-08-05 16:27:16 +08:00
const view = [ "front" , "side" , "back" ] [ index ] ;
const hasViewMap = Array . isArray ( role . referenceViews ) && role . referenceViews . length > 0 ;
const mappedIndex = hasViewMap ? role . referenceViews . indexOf ( view ) : index ;
const reference = mappedIndex >= 0 ? role . references [ mappedIndex ] : "" ;
setReviewRow ( index , reference ? "success" : "neutral" , reference ? "已选择" : "未上传" , reference || ` ${ VIEW _LABELS [ index ] } 图为可选素材 ` ) ;
2026-07-30 08:58:38 +08:00
} ) ;
el . reviewElapsed . textContent = ` 角色 ${ letter } · 已选 ${ role . references . length } 张 ` ;
}
function renderSceneSummary ( ) {
const completeRoles = state . roles . filter ( ( role ) => role . references . length > 0 ) ;
el . assetReferences . innerHTML = "" ;
for ( const role of completeRoles ) {
const index = state . roles . indexOf ( role ) ;
const title = document . createElement ( "div" ) ;
title . className = "role-reference-title" ;
title . textContent = ` 角色 ${ ROLE _LETTERS [ index ] } · ${ role . name } ` ;
el . assetReferences . appendChild ( title ) ;
role . references . forEach ( ( reference , viewIndex ) => {
const row = document . createElement ( "div" ) ;
row . className = "reference-row" ;
row . innerHTML = ` <span> ${ VIEW _LABELS [ viewIndex ] || ` 素材 ${ viewIndex + 1 } ` } </span><code> ${ escapeHtml ( reference ) } </code> ` ;
el . assetReferences . appendChild ( row ) ;
} ) ;
}
el . assetResult . classList . toggle ( "hidden" , completeRoles . length === 0 ) ;
const selected = state . roles . reduce ( ( sum , role ) => sum + role . references . length , 0 ) ;
setAssetStatus ( sceneReady ( ) ? "success" : "processing" , sceneReady ( ) ? ` ${ state . roles . length } 个角色 · ${ selected } 张人物图 ` : "请为每个角色选择人物素材" ) ;
el . referenceMap . textContent = buildReferenceMap ( ) ;
renderInputQuota ( ) ;
}
function updateVideoAvailability ( ) {
const quota = mediaQuota ( ) ;
renderInputQuota ( ) ;
const running = runningShotVersion ( activeShot ( ) ) ;
if ( running ) {
el . videoForm . classList . remove ( "disabled-section" ) ;
el . generateButton . disabled = true ;
setVideoStatus ( "processing" , "生成中" ) ;
setStep ( 4 ) ;
renderActiveShotGenerationState ( ) ;
return ;
}
el . generateButton . disabled = false ;
if ( sceneReady ( ) && quota . valid && state . config . videoReady ) {
el . videoForm . classList . remove ( "disabled-section" ) ;
setVideoStatus ( "neutral" , "可提交" ) ;
setStep ( 3 ) ;
} else {
el . videoForm . classList . add ( "disabled-section" ) ;
2026-07-30 09:35:06 +08:00
const label = ! state . config . videoReady ? "请输入客户令牌" : ! sceneReady ( ) ? "等待角色" : quota . message ;
2026-07-30 08:58:38 +08:00
setVideoStatus ( ! state . config . videoReady || ! quota . valid ? "error" : "neutral" , label ) ;
setStep ( sceneReady ( ) ? 2 : 1 ) ;
}
}
function runningShotVersion ( shot ) {
return shot ? . versions ? . slice ( ) . reverse ( ) . find ( ( version ) => version . status === "running" ) || null ;
}
function formatElapsed ( startedAt ) {
const seconds = Math . max ( 0 , Math . floor ( ( Date . now ( ) - Number ( startedAt || Date . now ( ) ) ) / 1000 ) ) ;
const minutes = Math . floor ( seconds / 60 ) ;
const remainder = seconds % 60 ;
return minutes ? ` ${ minutes } 分 ${ remainder } 秒 ` : ` ${ remainder } 秒 ` ;
}
function formatCheckedAt ( value ) {
if ( ! value ) return "尚未收到首次查询结果" ;
return ` 最近查询 ${ new Date ( value ) . toLocaleTimeString ( "zh-CN" , { hour12 : false } )} ` ;
}
function renderGenerationMeta ( version ) {
el . generationElapsed . textContent = ` 已等待 ${ formatElapsed ( version . createdAt ) } ` ;
el . generationLastChecked . textContent = formatCheckedAt ( version . lastCheckedAt ) ;
}
function renderActiveShotGenerationState ( ) {
clearInterval ( state . pollTimer ) ;
state . pollTimer = null ;
const version = runningShotVersion ( activeShot ( ) ) ;
if ( ! version ) {
el . generationResult . classList . add ( "hidden" ) ;
el . generationElapsed . textContent = "" ;
el . generationLastChecked . textContent = "" ;
return ;
}
el . generationResult . classList . remove ( "hidden" ) ;
el . generationTaskId . textContent = version . taskId || "正在获取任务 ID…" ;
resetVideoStage ( ) ;
updateProgress ( version . progress || 20 , "正在自动查询生成结果" ) ;
renderGenerationMeta ( version ) ;
state . pollTimer = setInterval ( ( ) => {
if ( activeShot ( ) ? . id !== state . activeGenerationShotId && version . id !== runningShotVersion ( activeShot ( ) ) ? . id ) return ;
renderGenerationMeta ( version ) ;
} , 1000 ) ;
}
function finishGenerationUi ( shot , version ) {
renderStoryboard ( ) ;
if ( activeShot ( ) ? . id !== shot ? . id ) {
updateVideoAvailability ( ) ;
return ;
}
if ( version ? . status === "running" ) {
updateVideoAvailability ( ) ;
return ;
}
el . generateButton . disabled = false ;
setVideoStatus ( version ? . status === "completed" ? "success" : "error" , version ? . status === "completed" ? "生成完成" : "生成失败" ) ;
}
function refreshAssetSelectionStyles ( ) {
for ( const button of el . assetList . querySelectorAll ( ".asset-card" ) ) {
const position = referencePosition ( button . textContent ) ;
button . classList . toggle ( "selected" , Boolean ( position ) ) ;
if ( position ) button . dataset . order = ` ${ ROLE _LETTERS [ position . roleIndex ] } ${ position . viewIndex + 1 } ` ;
else delete button . dataset . order ;
}
}
function referencePosition ( value ) {
const text = String ( value || "" ) . toLowerCase ( ) ;
for ( let roleIndex = 0 ; roleIndex < state . roles . length ; roleIndex += 1 ) {
const viewIndex = state . roles [ roleIndex ] . references . findIndex ( ( reference ) => text . includes ( String ( reference ) . toLowerCase ( ) ) ) ;
if ( viewIndex >= 0 ) return { roleIndex , viewIndex } ;
}
return null ;
}
function buildReferenceMap ( ) {
let start = 1 ;
const parts = [ ] ;
for ( const role of activeShotRoles ( ) ) {
const index = state . roles . indexOf ( role ) ;
if ( ! role . references . length ) continue ;
const end = start + role . references . length - 1 ;
const range = start === end ? ` 图 ${ start } ` : ` 图 ${ start } – ${ end } ` ;
const detail = role . references . length === 3 ? "正面、侧面、背面" : ` ${ role . references . length } 张人物参考图 ` ;
parts . push ( ` ${ range } :角色 ${ ROLE _LETTERS [ index ] } 「 ${ role . name } 」的 ${ detail } ` ) ;
start = end + 1 ;
}
const extraImages = effectiveExtraImageReferences ( ) ;
if ( extraImages . length ) {
const end = start + extraImages . length - 1 ;
parts . push ( ` ${ start === end ? ` 图 ${ start } ` : ` 图 ${ start } – ${ end } ` } :场景、道具或其他非人物参考图 ` ) ;
}
return parts . length ? parts . join ( "; " ) : "系统会根据本场角色自动生成图号映射。" ;
}
function buildMappedPrompt ( ) {
const videoReferences = effectiveVideoReferences ( ) ;
const videoMap = videoReferences . length ? ` \n 参考视频映射: 视频1– ${ videoReferences . length } 为运镜、动作或节奏参考。 ` : "" ;
const continuity = continuityReference ( ) ;
const continuityHint = continuity ? ` \n 连续性要求:本片段需承接上一片段「 ${ continuity . previous . title } 」的 ${ continuity . type === "video" ? "动作、构图与状态" : "尾帧画面状态" } 。 ` : "" ;
return ` 参考图角色映射: ${ buildReferenceMap ( ) } 。 \n 人物一致性要求:每个角色只参考自己的人物素材组,禁止交换五官、发型、服装、体型或身份。 ${ videoMap } ${ continuityHint } \n ${ el . prompt . value . trim ( ) } ` ;
}
function buildVideoClientBody ( ) {
return {
assetReferences : sceneReferences ( ) ,
extraImageReferences : effectiveExtraImageReferences ( ) ,
videoReferences : effectiveVideoReferences ( ) ,
prompt : buildMappedPrompt ( ) ,
model : el . model . value ,
ratio : el . ratio . value ,
duration : Number ( el . duration . value ) ,
resolution : el . resolution . value ,
generateAudio : el . generateAudio . checked ,
watermark : el . watermark . checked ,
} ;
}
function sceneReferences ( ) {
return activeShotRoles ( ) . flatMap ( ( role ) => role . references ) ;
}
function sceneReady ( ) {
const roles = activeShotRoles ( ) ;
return roles . length > 0 && roles . every ( ( role ) => role . references . length > 0 ) && mediaQuota ( ) . valid ;
}
function activeRole ( ) {
return state . roles [ state . activeRoleIndex ] ;
}
function createRole ( name ) {
2026-08-05 16:27:16 +08:00
return { id : ` role- ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 7 ) } ` , name , references : [ ] , referenceViews : [ ] } ;
2026-07-30 08:58:38 +08:00
}
function saveSceneRoles ( ) {
localStorage . setItem ( SCENE _ROLES _KEY , JSON . stringify ( { roles : state . roles , activeRoleIndex : state . activeRoleIndex } ) ) ;
}
function saveCharacterLibrary ( ) {
localStorage . setItem ( CHARACTER _LIBRARY _KEY , JSON . stringify ( state . characters ) ) ;
}
function restoreCharacterLibrary ( ) {
let saved ;
try { saved = JSON . parse ( localStorage . getItem ( CHARACTER _LIBRARY _KEY ) || "[]" ) ; } catch { saved = [ ] ; }
state . characters = Array . isArray ( saved ) ? saved . map ( ( character ) => ( {
id : character . id || ` character- ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 7 ) } ` ,
name : String ( character . name || "未命名人物" ) ,
2026-07-30 09:19:20 +08:00
references : Array . isArray ( character . references ) ? character . references . map ( String ) . filter ( ( value ) => state . config . mockMode || /^asset:\/\/(?:mat_|asset-)/i . test ( value ) ) . slice ( 0 , 9 ) : [ ] ,
2026-07-30 08:58:38 +08:00
source : character . source || "manual" ,
} ) ) . filter ( ( character ) => character . references . length ) : [ ] ;
}
function parseMediaLines ( value ) {
return [ ... new Set ( String ( value || "" ) . split ( /\r?\n/ ) . map ( ( line ) => line . trim ( ) ) . filter ( Boolean ) ) ] ;
}
function mediaInputConfig ( type ) {
return type === "video"
2026-08-05 16:27:16 +08:00
? { list : el . referenceVideoInputs , button : el . addReferenceVideoButton , limit : 3 , placeholder : "公网 MP4/MOV 或 asset://mat..." }
2026-07-30 08:58:38 +08:00
: { list : el . extraImageInputs , button : el . addExtraImageButton , limit : 9 , placeholder : "https://example.com/reference.jpg" } ;
}
function createMediaInputRow ( type , value = "" ) {
const { placeholder } = mediaInputConfig ( type ) ;
const row = document . createElement ( "div" ) ;
row . className = "media-url-row" ;
const input = document . createElement ( "input" ) ;
2026-08-05 16:27:16 +08:00
input . type = type === "video" ? "text" : "url" ;
2026-07-30 08:58:38 +08:00
input . value = value ;
input . placeholder = placeholder ;
input . setAttribute ( "aria-label" , type === "video" ? "参考视频链接" : "其他参考图片链接" ) ;
const remove = document . createElement ( "button" ) ;
remove . type = "button" ;
remove . className = "media-remove-button" ;
remove . dataset . removeMedia = type ;
remove . setAttribute ( "aria-label" , "删除该链接" ) ;
remove . textContent = "× " ;
row . append ( input , remove ) ;
return row ;
}
function renderMediaInputList ( type , value ) {
const { list } = mediaInputConfig ( type ) ;
const values = parseMediaLines ( value ) ;
list . replaceChildren ( ... ( values . length ? values : [ "" ] ) . map ( ( item ) => createMediaInputRow ( type , item ) ) ) ;
refreshMediaAddButton ( type ) ;
}
function collectMediaInputUrls ( type ) {
const { list } = mediaInputConfig ( type ) ;
return [ ... new Set ( [ ... list . querySelectorAll ( "input" ) ] . map ( ( input ) => input . value . trim ( ) ) . filter ( Boolean ) ) ] ;
}
function addMediaInput ( type ) {
const { list , limit } = mediaInputConfig ( type ) ;
if ( list . children . length >= limit ) return ;
const row = createMediaInputRow ( type ) ;
list . append ( row ) ;
row . querySelector ( "input" ) . focus ( ) ;
refreshMediaAddButton ( type ) ;
}
function removeMediaInput ( button ) {
const type = button . dataset . removeMedia ;
const { list } = mediaInputConfig ( type ) ;
if ( list . children . length === 1 ) {
list . querySelector ( "input" ) . value = "" ;
} else {
button . closest ( ".media-url-row" ) . remove ( ) ;
}
refreshMediaAddButton ( type ) ;
updateActiveShotFromForm ( ) ;
updateVideoAvailability ( ) ;
}
function refreshMediaAddButton ( type ) {
const { list , button , limit } = mediaInputConfig ( type ) ;
button . disabled = list . children . length >= limit ;
}
function mediaQuota ( ) {
const characterImages = sceneReferences ( ) . length ;
const extraImages = effectiveExtraImageReferences ( ) . length ;
const videos = effectiveVideoReferences ( ) . length ;
const imageTotal = characterImages + extraImages ;
if ( imageTotal > 9 ) return { valid : false , characterImages , extraImages , videos , imageTotal , message : ` 图片 ${ imageTotal } /9, 已超出上限 ` } ;
if ( videos > 3 ) return { valid : false , characterImages , extraImages , videos , imageTotal , message : ` 参考视频 ${ videos } /3, 已超出 Demo 上限 ` } ;
return { valid : true , characterImages , extraImages , videos , imageTotal , message : "可提交" } ;
}
function renderInputQuota ( ) {
if ( ! el . inputQuota ) return ;
const quota = mediaQuota ( ) ;
el . inputQuota . textContent = ` 人物图片 ${ quota . characterImages } 张 · 其他图片 ${ quota . extraImages } 张 · 图片合计 ${ quota . imageTotal } /9 · 视频 ${ quota . videos } /3 ` ;
el . inputQuota . className = ` input-quota wide ${ quota . valid ? "valid" : "invalid" } ` ;
}
function restoreSceneRoles ( ) {
let saved ;
try { saved = JSON . parse ( localStorage . getItem ( SCENE _ROLES _KEY ) || "null" ) ; } catch { saved = null ; }
if ( Array . isArray ( saved ? . roles ) && saved . roles . length ) {
state . roles = saved . roles . slice ( 0 , 3 ) . map ( ( role , index ) => ( {
id : role . id || createRole ( "" ) . id ,
name : role . name || ` 角色 ${ ROLE _LETTERS [ index ] } ` ,
references : Array . isArray ( role . references )
2026-07-30 09:19:20 +08:00
? role . references . slice ( 0 , 3 ) . map ( String ) . filter ( ( value ) => state . config . mockMode || /^asset:\/\/(?:mat_|asset-)/i . test ( value ) )
2026-07-30 08:58:38 +08:00
: [ ] ,
2026-08-05 16:27:16 +08:00
referenceViews : Array . isArray ( role . referenceViews ) ? role . referenceViews . slice ( 0 , 3 ) . map ( String ) : [ ] ,
2026-07-30 08:58:38 +08:00
} ) ) ;
state . activeRoleIndex = Math . min ( Number ( saved . activeRoleIndex ) || 0 , state . roles . length - 1 ) ;
return ;
}
let legacy ;
try { legacy = JSON . parse ( localStorage . getItem ( LEGACY _GROUP _KEY ) || "null" ) ; } catch { legacy = null ; }
if ( Array . isArray ( legacy ? . references ) && legacy . references . length === 3 ) {
state . roles = [ { ... createRole ( legacy . name || "角色 A" ) , references : state . config . mockMode ? legacy . references . map ( String ) : [ ] } ] ;
localStorage . removeItem ( LEGACY _GROUP _KEY ) ;
} else {
state . roles = [ createRole ( "角色 A" ) ] ;
}
}
async function finishAssetReview ( result , roleId , name ) {
const items = getReviewItems ( result ) ;
renderReviewItems ( items ) ;
2026-08-05 16:27:16 +08:00
if ( items . length < 1 || items . length > 3 ) throw new Error ( result . message || ` 预期返回 1~ 3 张素材,实际返回 ${ items . length } 张 ` ) ;
2026-07-30 08:58:38 +08:00
const failedItem = items . find ( ( item ) => Number ( item . submit _review _status ) !== 1 ) ;
2026-08-05 16:27:16 +08:00
if ( failedItem ) throw new Error ( failedItem . error _message || "人物素材中有图片未通过审核" ) ;
2026-07-30 08:58:38 +08:00
const references = items . map ( normalizeAssetReference ) ;
if ( references . some ( ( reference ) => ! reference ) ) throw new Error ( "审核通过,但有素材未返回可用的 asset:// ID" ) ;
clearPendingReview ( ) ;
const roleIndex = Math . max ( 0 , state . roles . findIndex ( ( role ) => role . id === roleId ) ) ;
state . activeRoleIndex = roleIndex ;
state . roles [ roleIndex ] . name = name ;
state . roles [ roleIndex ] . references = references ;
2026-08-05 16:27:16 +08:00
state . roles [ roleIndex ] . referenceViews = items . map ( ( item ) => String ( item . view || "reference" ) . toLowerCase ( ) ) ;
2026-07-30 08:58:38 +08:00
upsertCharacter ( name , references , "history" , true ) ;
el . reviewElapsed . textContent = "审核完成" ;
await loadAssets ( ) ;
renderScene ( ) ;
2026-08-05 16:27:16 +08:00
showToast ( ` 角色 ${ ROLE _LETTERS [ roleIndex ] } 的 ${ references . length } 张人物素材已全部通过审核 ` ) ;
2026-07-30 08:58:38 +08:00
}
2026-08-05 16:27:16 +08:00
async function pollAssetTask ( taskId , renderItems = true ) {
2026-07-30 08:58:38 +08:00
for ( let attempt = 0 ; attempt < 100 ; attempt += 1 ) {
updateReviewElapsed ( ) ;
await delay ( 3000 ) ;
const result = await api ( ` /api/assets/tasks/ ${ encodeURIComponent ( taskId ) } ` ) ;
const items = getReviewItems ( result ) ;
2026-08-05 16:27:16 +08:00
if ( renderItems && items . length ) renderReviewItems ( items ) ;
2026-07-30 08:58:38 +08:00
const status = normalizeAssetTaskStatus ( result ) ;
if ( status === "completed" || status === "failed" ) return result ;
}
const error = new Error ( "素材仍在审核,任务已保存;稍后重新打开页面会继续查询" ) ;
error . pending = true ;
throw error ;
}
async function resumePendingReview ( ) {
let pending ;
try { pending = JSON . parse ( localStorage . getItem ( PENDING _REVIEW _KEY ) || "null" ) ; } catch { pending = null ; }
if ( ! pending ? . taskId ) return ;
const roleIndex = state . roles . findIndex ( ( role ) => role . id === pending . roleId ) ;
if ( roleIndex >= 0 ) state . activeRoleIndex = roleIndex ;
state . reviewStartedAt = Number ( pending . startedAt ) || Date . now ( ) ;
setAssetStatus ( "processing" , "继续查询" ) ;
setAllReviewRows ( "processing" , "审核中" , "正在恢复上次任务" ) ;
try {
const result = await pollAssetTask ( pending . taskId ) ;
await finishAssetReview ( result , pending . roleId || activeRole ( ) . id , pending . name || activeRole ( ) . name ) ;
} catch ( error ) {
if ( error . pending ) {
setAssetStatus ( "processing" , "仍在审核" ) ;
el . reviewElapsed . textContent = "仍在审核,可稍后回来查看" ;
} else {
setAssetStatus ( "error" , "审核失败" ) ;
markUnresolvedRowsFailed ( error . message ) ;
clearPendingReview ( ) ;
showToast ( error . message , true ) ;
}
}
}
function savePendingReview ( taskId , roleId ) {
localStorage . setItem ( PENDING _REVIEW _KEY , JSON . stringify ( {
taskId , roleId , name : activeRole ( ) . name , startedAt : state . reviewStartedAt || Date . now ( ) ,
} ) ) ;
}
function clearPendingReview ( ) { localStorage . removeItem ( PENDING _REVIEW _KEY ) ; }
function getReviewItems ( result ) { return result . result ? . items || result . data ? . items || result . items || [ ] ; }
function normalizeAssetTaskStatus ( result ) {
const value = String ( result . status || result . data ? . status || result . result ? . status || "" ) . toLowerCase ( ) ;
if ( [ "completed" , "complete" , "succeeded" , "success" , "done" ] . includes ( value ) ) return "completed" ;
if ( [ "failed" , "error" , "cancelled" , "canceled" ] . includes ( value ) ) return "failed" ;
const items = getReviewItems ( result ) ;
if ( items . length && items . every ( ( item ) => [ 0 , 1 ] . includes ( Number ( item . submit _review _status ) ) ) ) return "completed" ;
return "processing" ;
}
function renderReviewItems ( items ) {
2026-08-05 16:27:16 +08:00
items . slice ( 0 , 3 ) . forEach ( ( item , fallbackIndex ) => {
const index = { front : 0 , side : 1 , back : 2 } [ String ( item . view || "" ) . toLowerCase ( ) ] ? ? fallbackIndex ;
2026-07-30 08:58:38 +08:00
const passed = Number ( item . submit _review _status ) === 1 ;
const failed = Number ( item . submit _review _status ) === 0 || item . error _code || item . error _message ;
if ( passed ) setReviewRow ( index , "success" , "已通过" , normalizeAssetReference ( item ) ) ;
else if ( failed ) setReviewRow ( index , "error" , "未通过" , item . error _message || item . error _code || "审核未通过" ) ;
else setReviewRow ( index , "processing" , "审核中" , "等待审核结果" ) ;
} ) ;
}
2026-08-05 16:27:16 +08:00
function preparePersonReviewRows ( images ) {
reviewRows . forEach ( ( _ , index ) => setReviewRow ( index , "neutral" , "未上传" , "" ) ) ;
images . forEach ( ( item , fallbackIndex ) => {
const index = { front : 0 , side : 1 , back : 2 } [ item . view ] ? ? fallbackIndex ;
setReviewRow ( index , "processing" , "审核中" , "等待审核结果" ) ;
} ) ;
}
2026-07-30 08:58:38 +08:00
function setReviewRow ( index , kind , status , detail = "" ) {
const row = reviewRows [ index ] ;
if ( ! row ) return ;
row . className = ` tracker-row ${ kind } ` ;
row . querySelector ( "b" ) . textContent = status ;
row . querySelector ( "small" ) . textContent = detail ;
row . querySelector ( "small" ) . title = detail ;
}
function setAllReviewRows ( kind , status , detail = "" ) { reviewRows . forEach ( ( _ , index ) => setReviewRow ( index , kind , status , detail ) ) ; }
function markUnresolvedRowsFailed ( message ) {
reviewRows . forEach ( ( row , index ) => {
if ( ! row . classList . contains ( "success" ) && ! row . classList . contains ( "error" ) ) setReviewRow ( index , "error" , "查询失败" , message ) ;
} ) ;
el . reviewElapsed . textContent = "已返回结果" ;
}
function updateReviewElapsed ( ) {
const seconds = Math . max ( 0 , Math . floor ( ( Date . now ( ) - state . reviewStartedAt ) / 1000 ) ) ;
el . reviewElapsed . textContent = ` 审核中 · ${ seconds } 秒 ` ;
}
async function pollVideoTask ( taskId , shotId = state . activeGenerationShotId || activeShot ( ) ? . id , versionId = state . activeGenerationVersionId ) {
const startedAt = Date . now ( ) ;
while ( Date . now ( ) - startedAt < VIDEO _POLL _MAX _MS ) {
await delay ( VIDEO _POLL _INTERVAL _MS ) ;
if ( ! state . shots . some ( ( item ) => item . id === shotId ) ) {
logClientOperation ( "video.poll_stopped" , { taskId , reason : "shot_deleted" , deletedShotId : shotId } ) ;
return ;
}
const result = await api ( ` /api/videos/ ${ encodeURIComponent ( taskId ) } ` ) ;
const status = result . _normalized ? . status || normalizeVideoStatus ( result . status || result . data ? . status ) ;
const progress = Number ( result . progress ? ? result . data ? . progress ? ? ( status === "completed" ? 100 : 20 ) ) ;
const shot = state . shots . find ( ( item ) => item . id === shotId ) ;
const version = shot ? . versions . find ( ( item ) => item . id === versionId ) ;
if ( version ) {
version . taskId = taskId ;
version . progress = Math . max ( 10 , progress ) ;
version . lastCheckedAt = Date . now ( ) ;
saveDramaProject ( ) ;
}
if ( activeShot ( ) ? . id === shotId ) {
updateProgress ( Math . max ( 10 , progress ) , status === "completed" ? "视频生成完成" : "正在自动查询生成结果" ) ;
if ( version ) renderGenerationMeta ( version ) ;
}
if ( status === "completed" ) {
const videoUrl = result . _normalized ? . videoUrl || result . video _url || result . output ? . video _url || result . data ? . video _url ;
completeShotVersion ( shotId , versionId , videoUrl , result . mock ) ;
if ( activeShot ( ) ? . id === shotId ) {
renderCompletedVideo ( videoUrl , result . mock ) ;
setVideoStatus ( "success" , "生成完成" ) ;
}
clearPendingVideo ( ) ;
state . activeGenerationShotId = null ;
state . activeGenerationVersionId = null ;
showToast ( "当前片段候选已生成" ) ;
return ;
}
if ( status === "failed" ) throw new Error ( result . error ? . message || result . message || "视频生成失败" ) ;
}
const error = new Error ( "视频仍在服务商侧生成,页面已等待 30 分钟;这不是生成失败,可凭任务 ID 继续查询" ) ;
error . pending = true ;
throw error ;
}
function savePendingVideo ( taskId , shotId , versionId ) {
localStorage . setItem ( PENDING _VIDEO _KEY , JSON . stringify ( { taskId , shotId , versionId , startedAt : Date . now ( ) } ) ) ;
}
function clearPendingVideo ( ) { localStorage . removeItem ( PENDING _VIDEO _KEY ) ; }
async function resumePendingVideo ( ) {
let pending ;
try { pending = JSON . parse ( localStorage . getItem ( PENDING _VIDEO _KEY ) || "null" ) ; } catch { pending = null ; }
if ( ! pending ? . taskId ) return ;
switchModule ( "production" ) ;
let shot = state . shots . find ( ( item ) => item . id === pending . shotId ) ;
if ( ! shot ) shot = activeShot ( ) ;
state . activeShotId = shot . id ;
let version = shot . versions . find ( ( item ) => item . id === pending . versionId ) ;
if ( ! version ) {
version = { id : pending . versionId || ` version- ${ Date . now ( ) } ` , status : "running" , taskId : pending . taskId , videoUrl : "" , lastFrameUrl : "" , createdAt : pending . startedAt || Date . now ( ) , lastCheckedAt : null , progress : 20 } ;
shot . versions . push ( version ) ;
}
state . activeGenerationShotId = shot . id ;
state . activeGenerationVersionId = version . id ;
loadActiveShotIntoForm ( ) ;
renderStoryboard ( ) ;
clearTimeout ( state . pollTimer ) ;
el . generateButton . disabled = true ;
el . generationResult . classList . remove ( "hidden" ) ;
el . generationTaskId . textContent = pending . taskId ;
setVideoStatus ( "processing" , "继续查询" ) ;
setStep ( 4 ) ;
resetVideoStage ( ) ;
updateProgress ( 20 , "正在恢复上次视频任务" ) ;
try {
await pollVideoTask ( pending . taskId , shot . id , version . id ) ;
} catch ( error ) {
const message = contextualizeGenerationError ( error . message ) ;
if ( error . pending ) {
setVideoStatus ( "processing" , "仍在生成" ) ;
updateProgress ( 20 , "仍在生成,可稍后继续查询" ) ;
showToast ( message ) ;
} else {
clearPendingVideo ( ) ;
setVideoStatus ( "error" , "生成失败" ) ;
failShotVersion ( shot . id , version . id , message ) ;
renderGenerationError ( message ) ;
showToast ( message , true ) ;
}
} finally {
finishGenerationUi ( shot , version ) ;
}
}
function renderConnection ( ) {
2026-07-30 09:12:42 +08:00
const videoKey = currentVideoKey ( ) ;
el . changeVideoKeyButton . classList . toggle ( "hidden" , state . config . mockMode || ! videoKey ) ;
2026-07-30 09:35:06 +08:00
if ( videoKey ) el . changeVideoKeyButton . textContent = ` 更换客户令牌 · ${ videoKey . slice ( - 4 ) } ` ;
2026-07-30 08:58:38 +08:00
if ( state . config . mockMode ) {
el . connectionState . textContent = "Mock 模式 · 可评审完整流程" ;
el . connectionState . className = "connection mock" ;
2026-08-05 16:27:16 +08:00
el . reviewButton . textContent = "模拟提交人物素材" ;
2026-07-30 08:58:38 +08:00
el . mockHint . classList . remove ( "hidden" ) ;
} else if ( state . config . assetReady && state . config . videoReady ) {
el . connectionState . textContent = "真实接口已连接" ;
el . connectionState . className = "connection live" ;
el . reviewButton . textContent = "提交真实素材审核" ;
el . mockHint . classList . add ( "hidden" ) ;
} else if ( state . config . assetReady ) {
2026-07-30 09:35:06 +08:00
el . connectionState . textContent = "请输入客户令牌" ;
2026-07-30 08:58:38 +08:00
el . connectionState . className = "connection live" ;
el . reviewButton . textContent = "提交真实素材审核" ;
el . mockHint . classList . add ( "hidden" ) ;
} else {
el . connectionState . textContent = ` 缺少配置: ${ state . config . missing . join ( "、" ) } ` ;
el . connectionState . className = "connection blocked" ;
}
}
function completeShotVersion ( shotId , versionId , videoUrl , mock ) {
const shot = state . shots . find ( ( item ) => item . id === shotId ) ;
const version = shot ? . versions . find ( ( item ) => item . id === versionId ) ;
if ( ! shot || ! version ) return ;
version . status = "completed" ;
version . videoUrl = videoUrl || "" ;
version . mock = Boolean ( mock ) ;
if ( ! shot . selectedVersionId ) shot . selectedVersionId = version . id ;
saveDramaProject ( ) ;
renderStoryboard ( ) ;
}
function failShotVersion ( shotId , versionId , message ) {
const shot = state . shots . find ( ( item ) => item . id === shotId ) ;
const version = shot ? . versions . find ( ( item ) => item . id === versionId ) ;
if ( ! version ) return ;
version . status = "failed" ;
version . error = message ;
saveDramaProject ( ) ;
renderStoryboard ( ) ;
}
function renderCompletedVideo ( videoUrl , mock ) {
if ( videoUrl ) {
const video = document . createElement ( "video" ) ;
video . controls = true ;
2026-07-30 09:52:39 +08:00
video . playsInline = true ;
video . preload = "metadata" ;
2026-07-30 08:58:38 +08:00
video . src = videoUrl ;
2026-07-30 09:52:39 +08:00
el . videoStage . classList . add ( "has-video" ) ;
2026-07-30 08:58:38 +08:00
el . videoStage . replaceChildren ( video ) ;
} else {
2026-07-30 09:52:39 +08:00
el . videoStage . classList . remove ( "has-video" ) ;
2026-07-30 09:35:06 +08:00
el . videoStage . innerHTML = ` <div class="mock-frame"><div class="mock-person"></div><strong> ${ mock ? "Mock 流程已完成" : "任务已完成" } </strong><span> ${ mock ? "输入客户令牌后此处展示真实视频" : "接口响应中未识别到视频 URL, 请稍后重试" } </span></div> ` ;
2026-07-30 08:58:38 +08:00
}
updateProgress ( 100 , "视频生成完成" ) ;
}
function renderGenerationError ( message ) {
2026-07-30 09:52:39 +08:00
el . videoStage . classList . remove ( "has-video" ) ;
2026-07-30 08:58:38 +08:00
const wrapper = document . createElement ( "div" ) ;
wrapper . className = "generation-error" ;
const title = document . createElement ( "strong" ) ;
title . textContent = "视频生成失败" ;
const detail = document . createElement ( "p" ) ;
detail . textContent = message ;
wrapper . append ( title , detail ) ;
el . videoStage . replaceChildren ( wrapper ) ;
updateProgress ( 0 , "任务已失败" ) ;
}
function contextualizeGenerationError ( message ) {
const text = String ( message || "视频生成失败" ) ;
const assetMatch = text . match ( /(?:asset-[a-z0-9-]+|mat_[a-z0-9]+)/i ) ;
if ( ! assetMatch || ! /not found|不存在/i . test ( text ) ) return text ;
const position = referencePosition ( assetMatch [ 0 ] ) ;
const label = position ? ` 角色 ${ ROLE _LETTERS [ position . roleIndex ] } · ${ VIEW _LABELS [ position . viewIndex ] } ` : "某张参考图" ;
2026-07-30 09:35:06 +08:00
return ` ${ label } 在视频接口中不存在。通常表示素材已失效、尚未同步,或不属于当前客户令牌。素材 ID: ${ assetMatch [ 0 ] } ` ;
2026-07-30 08:58:38 +08:00
}
2026-07-30 09:52:39 +08:00
function resetVideoStage ( ) {
el . videoStage . classList . remove ( "has-video" ) ;
el . videoStage . innerHTML = '<div class="mock-frame"><div class="mock-person"></div><span>Seedance 2 正在生成镜头…</span></div>' ;
}
2026-07-30 08:58:38 +08:00
function updateProgress ( value , title ) {
const safe = Math . max ( 0 , Math . min ( 100 , Number ( value ) || 0 ) ) ;
el . generationTitle . textContent = title ;
el . generationProgress . textContent = ` ${ safe } % ` ;
el . progressBar . style . width = ` ${ safe } % ` ;
}
function setAssetStatus ( kind , text ) { el . assetStatus . className = ` status-badge ${ kind } ` ; el . assetStatus . textContent = text ; }
2026-08-05 16:27:16 +08:00
function setVideoAssetStatus ( kind , text ) { el . videoAssetStatus . className = ` status-badge ${ kind } ` ; el . videoAssetStatus . textContent = text ; }
2026-07-30 08:58:38 +08:00
function setVideoStatus ( kind , text ) { el . videoStatus . className = ` status-badge ${ kind } ` ; el . videoStatus . textContent = text ; }
function setStep ( active ) {
document . querySelectorAll ( ".step" ) . forEach ( ( step ) => {
const number = Number ( step . dataset . step ) ;
step . classList . toggle ( "done" , number < active ) ;
step . classList . toggle ( "active" , number === active ) ;
} ) ;
}
function fallbackCopy ( text ) {
const area = document . createElement ( "textarea" ) ;
area . value = text ;
area . style . position = "fixed" ;
area . style . opacity = "0" ;
document . body . appendChild ( area ) ;
area . select ( ) ;
document . execCommand ( "copy" ) ;
area . remove ( ) ;
}
function normalizeAssetReference ( item ) {
const direct = item . asset _url || item . AssetURL || item . assetUrl ;
if ( typeof direct === "string" && /^asset:\/\//i . test ( direct ) ) return ` asset:// ${ direct . slice ( direct . indexOf ( "//" ) + 2 ) } ` ;
const id = item . downstream _asset _id || item . asset _id || item . Id || item . id ;
return id ? ` asset:// ${ id } ` : "" ;
}
function normalizeVideoStatus ( value ) {
const status = String ( value || "" ) . toLowerCase ( ) ;
if ( [ "succeeded" , "success" , "completed" , "done" ] . includes ( status ) ) return "completed" ;
if ( [ "failed" , "error" , "cancelled" , "canceled" ] . includes ( status ) ) return "failed" ;
return status || "processing" ;
}
function getOrCreateAuditId ( storage , key , prefix ) {
try {
let value = storage . getItem ( key ) ;
if ( ! value ) {
value = ` ${ prefix } - ${ crypto . randomUUID ? . ( ) || ` ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` } ` ;
storage . setItem ( key , value ) ;
}
return value ;
} catch {
return ` ${ prefix } - ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
}
}
function setupClientAudit ( ) {
window . _ _auditReady = true ;
logClientOperation ( "page.open" , {
path : location . pathname ,
viewport : ` ${ window . innerWidth } x ${ window . innerHeight } ` ,
referrerOrigin : browserUrlSummary ( document . referrer ) ? . origin || "" ,
} ) ;
document . addEventListener ( "click" , ( event ) => {
const target = event . target instanceof Element ? event . target . closest ( "button, a, [role='button']" ) : null ;
if ( ! target ) return ;
logClientOperation ( "ui.click" , describeAuditControl ( target ) ) ;
} , true ) ;
document . addEventListener ( "change" , ( event ) => {
const target = event . target ;
if ( ! ( target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement ) ) return ;
logClientOperation ( "ui.change" , describeAuditControl ( target ) ) ;
} , true ) ;
document . addEventListener ( "input" , ( event ) => {
const target = event . target ;
if ( ! ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ) ) return ;
const key = target . id || target . name || target . getAttribute ( "aria-label" ) || "anonymous-input" ;
clearTimeout ( auditInputTimers . get ( key ) ) ;
auditInputTimers . set ( key , setTimeout ( ( ) => {
logClientOperation ( "ui.input" , describeAuditControl ( target ) ) ;
auditInputTimers . delete ( key ) ;
} , 900 ) ) ;
} , true ) ;
document . addEventListener ( "submit" , ( event ) => {
logClientOperation ( "ui.submit" , { formId : event . target ? . id || "" , fieldCount : event . target ? . elements ? . length || 0 } ) ;
} , true ) ;
document . addEventListener ( "play" , ( event ) => {
if ( event . target instanceof HTMLVideoElement ) logClientOperation ( "video.play" , { source : browserUrlSummary ( event . target . currentSrc ) } ) ;
} , true ) ;
document . addEventListener ( "pause" , ( event ) => {
if ( event . target instanceof HTMLVideoElement ) logClientOperation ( "video.pause" , { currentTime : Math . round ( event . target . currentTime * 10 ) / 10 } ) ;
} , true ) ;
document . addEventListener ( "visibilitychange" , ( ) => {
logClientOperation ( "page.visibility" , { state : document . visibilityState } ) ;
if ( document . visibilityState === "hidden" ) flushAuditQueue ( true ) ;
} ) ;
window . addEventListener ( "pagehide" , ( ) => {
logClientOperation ( "page.close" , { persisted : false } ) ;
flushAuditQueue ( true ) ;
} ) ;
}
function describeAuditControl ( target ) {
const value = auditControlValue ( target ) ;
return {
elementId : target . id || "" ,
tag : target . tagName ? . toLowerCase ( ) || "" ,
className : String ( target . className || "" ) . slice ( 0 , 180 ) ,
type : target . type || "" ,
ariaLabel : String ( target . getAttribute ? . ( "aria-label" ) || "" ) . slice ( 0 , 120 ) ,
title : String ( target . getAttribute ? . ( "title" ) || "" ) . slice ( 0 , 120 ) ,
moduleTarget : target . dataset ? . module || "" ,
step : target . dataset ? . step || "" ,
removeMedia : target . dataset ? . removeMedia || "" ,
textLength : String ( target . textContent || "" ) . trim ( ) . length ,
href : target instanceof HTMLAnchorElement ? browserUrlSummary ( target . href ) : null ,
value ,
} ;
}
function auditControlValue ( target ) {
if ( target instanceof HTMLInputElement && [ "checkbox" , "radio" ] . includes ( target . type ) ) return { checked : target . checked } ;
if ( target instanceof HTMLSelectElement ) return { selected : String ( target . value ) . slice ( 0 , 120 ) } ;
if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ) {
const value = String ( target . value || "" ) ;
if ( target . type === "url" ) return browserUrlSummary ( value ) ;
return { length : value . length , lines : value ? value . split ( /\r?\n/ ) . length : 0 } ;
}
return null ;
}
function browserUrlSummary ( value ) {
if ( ! value ) return null ;
try {
const url = new URL ( value , location . href ) ;
return { origin : url . origin , pathname : url . pathname . slice ( 0 , 300 ) , hasQuery : Boolean ( url . search ) } ;
} catch {
return { valid : false , length : String ( value ) . length } ;
}
}
function logClientOperation ( operation , details = { } ) {
auditQueue . push ( {
operation ,
timestamp : new Date ( ) . toISOString ( ) ,
module : state . activeModule ,
shotId : state . activeShotId || "" ,
details ,
} ) ;
if ( auditQueue . length >= 20 ) flushAuditQueue ( ) ;
else if ( ! auditFlushTimer ) auditFlushTimer = setTimeout ( ( ) => flushAuditQueue ( ) , 1800 ) ;
}
function flushAuditQueue ( useBeacon = false ) {
clearTimeout ( auditFlushTimer ) ;
auditFlushTimer = null ;
if ( ! auditQueue . length ) return ;
const events = auditQueue . splice ( 0 , 50 ) ;
const payload = JSON . stringify ( { clientId : auditClientId , sessionId : auditSessionId , events } ) ;
const endpoint = ` ${ API _BASE } /api/events ` ;
if ( useBeacon && navigator . sendBeacon ) {
navigator . sendBeacon ( endpoint , new Blob ( [ payload ] , { type : "application/json" } ) ) ;
return ;
}
fetch ( endpoint , {
method : "POST" ,
headers : { "Content-Type" : "application/json" , "X-Session-Id" : auditSessionId } ,
body : payload ,
keepalive : true ,
} ) . catch ( ( ) => {
auditQueue . unshift ( ... events . slice ( - 50 ) ) ;
} ) ;
}
function shouldLogClientApi ( path ) {
return ! /^\/api\/(?:videos|assets\/tasks)\/[^/]+$/ . test ( path ) ;
}
async function api ( path , options = { } ) {
const startedAt = Date . now ( ) ;
const method = options . method || "GET" ;
const shouldLog = shouldLogClientApi ( path ) ;
2026-07-30 09:12:42 +08:00
const videoKey = currentVideoKey ( ) ;
2026-07-30 09:35:06 +08:00
const videoHeaders = videoKey && ( /^\/api\/(?:videos|assets)(?:[/?]|$)/ . test ( path ) || path === "/api/config" )
2026-07-30 09:12:42 +08:00
? { "X-Video-Key" : videoKey }
: { } ;
2026-07-30 08:58:38 +08:00
if ( shouldLog ) logClientOperation ( "api.request" , { method , path } ) ;
try {
const response = await fetch ( ` ${ API _BASE } ${ path } ` , {
... options ,
2026-07-30 09:12:42 +08:00
headers : { "Content-Type" : "application/json" , "X-Session-Id" : auditSessionId , ... videoHeaders , ... ( options . headers || { } ) } ,
2026-07-30 08:58:38 +08:00
} ) ;
const result = await response . json ( ) . catch ( ( ) => ( { error : "服务返回了无法解析的内容" } ) ) ;
if ( shouldLog || ! response . ok ) {
logClientOperation ( "api.response" , {
method ,
path ,
status : response . status ,
durationMs : Date . now ( ) - startedAt ,
requestId : response . headers . get ( "x-request-id" ) || "" ,
} ) ;
}
if ( ! response . ok ) {
const error = new Error ( extractApiError ( result ) || ` 请求失败( ${ response . status } ) ` ) ;
error . payload = result ;
error . status = response . status ;
2026-07-30 09:35:06 +08:00
if ( response . status === 401 && /^\/api\/(?:videos|assets)(?:[/?]|$)/ . test ( path ) ) {
2026-07-30 09:12:42 +08:00
localStorage . removeItem ( VIDEO _KEY _STORAGE ) ;
showVideoKeyGate ( false ) ;
}
2026-07-30 08:58:38 +08:00
throw error ;
}
return result ;
} catch ( error ) {
if ( shouldLog ) logClientOperation ( "api.network_error" , { method , path , durationMs : Date . now ( ) - startedAt , message : String ( error . message || error ) . slice ( 0 , 500 ) } ) ;
throw error ;
}
}
function extractApiError ( value ) {
if ( ! value ) return "" ;
if ( typeof value === "string" ) {
try { return extractApiError ( JSON . parse ( value ) ) || value ; } catch { return value ; }
}
if ( typeof value !== "object" ) return String ( value ) ;
return extractApiError ( value . error ? . message ) || extractApiError ( value . error ) || extractApiError ( value . message ) || extractApiError ( value . detail ) ;
}
function showToast ( message , error = false ) {
el . toast . textContent = message ;
el . toast . className = ` toast ${ error ? " error" : "" } ` ;
clearTimeout ( showToast . timer ) ;
showToast . timer = setTimeout ( ( ) => el . toast . classList . add ( "hidden" ) , 4200 ) ;
}
function delay ( ms ) { return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ; }
function escapeHtml ( value ) { return String ( value ) . replace ( /[&<>'"]/g , ( char ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , "'" : "'" , '"' : """ } [ char ] ) ) ; }
function fileNameFromUrl ( value ) {
try { return decodeURIComponent ( new URL ( value ) . pathname . split ( "/" ) . filter ( Boolean ) . pop ( ) || "" ) ; } catch { return "" ; }
}