@wji.ai/cli 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,863 @@
1
+ <!doctype html>
2
+ <html lang="zh-Hant">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>短劇製作看板</title>
7
+ <style>
8
+ :root {
9
+ --paper: #f3f4f0;
10
+ --surface: #ffffff;
11
+ --ink: #333333;
12
+ --muted: #6d7064;
13
+ --rule: #d1d3cc;
14
+ --olive: #56720a;
15
+ --sage: #7a9a12;
16
+ --lime: #b5d42e;
17
+ --soft: #e8e9e5;
18
+ --rail: #eaebe5;
19
+ --well: #1a1b18;
20
+ --wash: #f7fce8;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ [hidden] { display: none !important; }
24
+ html, body { height: 100%; margin: 0; background: var(--paper); color: var(--ink); }
25
+ body { font: 15px/1.6 "Iowan Old Style", "Songti SC", "Source Han Serif TC", serif; overflow: hidden; }
26
+ :focus-visible { outline: 2px solid var(--olive); outline-offset: 2px; }
27
+ .shell { display: flex; height: 100%; }
28
+ .rail {
29
+ width: 228px; flex: none; overflow: auto;
30
+ background: var(--rail); border-right: 1px solid var(--rule);
31
+ padding: 22px 16px 32px;
32
+ }
33
+ .brand { display: block; padding-bottom: 16px; border-bottom: 1px solid var(--ink); color: inherit; text-decoration: none; }
34
+ .brand b { display: block; font-size: 20px; font-weight: 600; letter-spacing: 0; }
35
+ .brand span { display: block; margin-top: 4px; font-size: 12px; color: var(--muted); font-family: "Avenir Next", "PingFang TC", sans-serif; }
36
+ .rail nav { display: flex; flex-direction: column; padding-top: 8px; }
37
+ .rail nav a {
38
+ display: grid; grid-template-columns: 28px 1fr; gap: 6px; align-items: baseline;
39
+ padding: 9px 0; border-bottom: 1px solid rgba(0,0,0,.08);
40
+ color: inherit; text-decoration: none;
41
+ font-family: "Avenir Next", "PingFang TC", sans-serif; font-size: 13px;
42
+ }
43
+ .rail nav a.ext { grid-template-columns: 1fr; color: var(--olive); }
44
+ .rail nav a.current { color: var(--olive); font-weight: 600; }
45
+ .rail .group { margin: 18px 0 2px; font-size: 12px; color: var(--muted); font-family: "Avenir Next", "PingFang TC", sans-serif; }
46
+ .main-col { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
47
+ #app { position: relative; flex: 1; overflow: auto; padding: 8px 28px 70vh; }
48
+ .page-section { padding-top: 28px; }
49
+ .links a { color: var(--olive); margin-right: 14px; font-family: "Avenir Next", "PingFang TC", sans-serif; font-size: 13px; }
50
+ @media (max-width: 800px) {
51
+ body { overflow: auto; }
52
+ .shell { flex-direction: column; height: auto; }
53
+ .rail { width: auto; max-height: 40vh; }
54
+ .main-col { height: auto; }
55
+ #app { overflow: visible; padding-bottom: 48px; }
56
+ }
57
+ button, input { font: 13px/1.4 "Avenir Next", "PingFang TC", sans-serif; }
58
+ button {
59
+ border: 0; background: var(--olive); color: #fff; border-radius: 4px;
60
+ padding: 7px 12px; cursor: pointer;
61
+ }
62
+ .ghost { background: transparent; color: var(--ink); border: 1px solid var(--rule); }
63
+ .bar {
64
+ display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
65
+ padding: 12px 20px; background: rgba(243,244,240,.94);
66
+ border-bottom: 1px solid var(--rule);
67
+ }
68
+ .note { color: var(--muted); font-size: 12px; font-family: "Avenir Next", "PingFang TC", sans-serif; }
69
+ h1 { font-weight: 500; font-size: 40px; line-height: 1.2; margin: 0; max-width: 16em; }
70
+ h2 {
71
+ font-weight: 500; font-size: 26px; line-height: 1.3; margin: 0 0 14px;
72
+ }
73
+ .facts { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 10px 0 0; color: var(--muted); font-family: "Avenir Next", "PingFang TC", sans-serif; font-size: 13px; }
74
+ .core { max-width: 38em; margin: 14px 0 0; font-size: 18px; line-height: 1.65; }
75
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
76
+ article {
77
+ background: var(--surface); border: 1px solid var(--rule); border-radius: 2px;
78
+ overflow: hidden; display: flex; flex-direction: column;
79
+ }
80
+ .figure { background: var(--soft); display: flex; flex-direction: column; }
81
+ .figure:not(:has(.placeholder)) { background: var(--well); }
82
+ .shot {
83
+ position: relative; display: block; width: 100%; margin: 0; padding: 0;
84
+ border: 0; border-radius: 0; background: transparent; color: inherit; cursor: zoom-in;
85
+ }
86
+ .shot img, .shot video {
87
+ width: 100%; height: 210px; object-fit: contain; display: block;
88
+ background: var(--well); pointer-events: none;
89
+ }
90
+ .enlarge {
91
+ position: absolute; right: 8px; bottom: 8px;
92
+ font: 11px/1.4 "Avenir Next", "PingFang TC", sans-serif;
93
+ color: #f3f4f0; background: rgba(26, 27, 24, .62);
94
+ border-radius: 2px; padding: 2px 6px; pointer-events: none;
95
+ }
96
+ .placeholder {
97
+ margin: 22px 16px; text-align: center; color: var(--muted);
98
+ border: 1px dashed var(--rule); border-radius: 2px; padding: 28px 10px;
99
+ font-family: "Avenir Next", "PingFang TC", sans-serif; font-size: 13px;
100
+ }
101
+ .sheet { aspect-ratio: 16 / 9; }
102
+ .sheet .placeholder {
103
+ margin: 0; flex: 1; display: flex; align-items: center; justify-content: center;
104
+ border: 0; border-radius: 0; padding: 0;
105
+ }
106
+ .sheet .shot { flex: 1; min-height: 0; }
107
+ .sheet .shot img, .sheet .shot video { position: absolute; inset: 0; width: 100%; height: 100%; }
108
+ .body { padding: 12px 14px 16px; }
109
+ .kicker { font-family: "Avenir Next", "PingFang TC", sans-serif; font-size: 12px; color: var(--sage); }
110
+ h3 { margin: 2px 0 6px; font-size: 20px; font-weight: 500; }
111
+ p { margin: 0 0 8px; }
112
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
113
+ .chip {
114
+ font-family: "Avenir Next", "PingFang TC", sans-serif; font-size: 12px;
115
+ background: var(--rail); color: var(--olive); border-radius: 2px; padding: 2px 8px;
116
+ }
117
+ .aside { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
118
+ .prose { background: var(--surface); border: 1px solid var(--rule); border-radius: 2px; padding: 16px 18px; max-width: 42em; }
119
+ .prose b, .script-scene header em {
120
+ font-style: normal;
121
+ }
122
+ .prose b {
123
+ display: block; margin: 12px 0 2px;
124
+ font-family: "Avenir Next", "PingFang TC", sans-serif; font-weight: 600; font-size: 12px; color: var(--olive);
125
+ }
126
+ .prose p:first-child b { margin-top: 0; }
127
+ .prose li b { display: inline; margin: 0 6px 0 0; }
128
+ .prose ul { margin: 0 0 8px; padding-left: 1.2em; }
129
+ .prose li { margin: 0 0 4px; }
130
+ .script { margin: 14px 0; border: 1px solid var(--rule); border-radius: 2px; background: var(--surface); max-width: 42em; }
131
+ .script > summary { cursor: pointer; padding: 12px 16px; font-family: "Avenir Next", "PingFang TC", sans-serif; color: var(--olive); }
132
+ .script-body { padding: 0 16px 16px; display: flex; flex-direction: column; gap: 18px; }
133
+ .script-scene header { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; border-bottom: 1px solid var(--rule); padding-bottom: 6px; margin-bottom: 8px; }
134
+ .script-scene h4 { margin: 0; font-size: 18px; font-weight: 500; }
135
+ .script-scene header span, .script-scene header em { color: var(--sage); font-size: 12px; font-family: "Avenir Next", "PingFang TC", sans-serif; }
136
+ .beat { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; margin: 0 0 8px; max-width: 40em; }
137
+ .beat.line { padding-left: 1.5em; }
138
+ .beat .tri { color: var(--olive); flex: none; }
139
+ .marks { display: inline-flex; gap: 4px; }
140
+ .marks i { font-style: normal; font-size: 11px; font-family: "Avenir Next", "PingFang TC", sans-serif; border: 1px solid var(--lime); color: var(--olive); border-radius: 2px; padding: 0 6px; }
141
+ .empty { padding: 48px 8px; color: var(--muted); max-width: 40em; }
142
+ .err { color: #8a3b12; font-family: "Avenir Next", "PingFang TC", sans-serif; font-size: 13px; }
143
+ dialog {
144
+ border: 1px solid var(--rule); border-radius: 4px; background: var(--surface); color: var(--ink);
145
+ padding: 20px 22px; width: min(420px, calc(100vw - 32px));
146
+ }
147
+ dialog::backdrop { background: rgba(26, 27, 24, .45); }
148
+ dialog h3 { margin: 0 0 8px; font-size: 22px; font-weight: 500; }
149
+ dialog p { color: var(--muted); font-size: 13px; font-family: "Avenir Next", "PingFang TC", sans-serif; }
150
+ dialog label { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; font-size: 12px; color: var(--muted); font-family: "Avenir Next", "PingFang TC", sans-serif; }
151
+ dialog input { border: 1px solid var(--rule); border-radius: 4px; padding: 8px; color: var(--ink); background: var(--surface); }
152
+ .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
153
+ .viewer {
154
+ width: min(1080px, calc(100vw - 24px));
155
+ max-height: calc(100vh - 24px);
156
+ padding: 12px;
157
+ background: var(--well);
158
+ color: #f3f4f0;
159
+ border: 0;
160
+ }
161
+ .viewer::backdrop { background: rgba(26, 27, 24, .72); }
162
+ .viewer-stage { display: flex; align-items: center; justify-content: center; min-height: 36vh; }
163
+ .viewer-stage img, .viewer-stage video { max-width: 100%; max-height: calc(100vh - 132px); object-fit: contain; }
164
+ .viewer-bar { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-top: 10px; }
165
+ .viewer-bar p { margin: 0; color: #f3f4f0; font-size: 14px; }
166
+ .viewer-actions { display: flex; gap: 8px; flex: none; }
167
+ .viewer button { background: transparent; color: #f3f4f0; border: 1px solid rgba(243, 244, 240, .4); }
168
+ .viewer button:focus-visible { outline-color: var(--lime); }
169
+ @media (prefers-reduced-motion: reduce) {
170
+ * { scroll-behavior: auto !important; }
171
+ }
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <div class="shell">
176
+ <aside class="rail">
177
+ <a class="brand" href="#overview" data-target="overview"><b id="brand">短劇</b><span>製作看板</span></a>
178
+ <nav id="nav"></nav>
179
+ </aside>
180
+ <div class="main-col">
181
+ <header class="bar">
182
+ <button type="button" id="pick">選擇專案</button>
183
+ <input id="dir" type="file" webkitdirectory multiple hidden />
184
+ <button type="button" id="login">登入</button>
185
+ <button type="button" id="reload" class="ghost">重新載入</button>
186
+ <span class="note" id="status"></span>
187
+ </header>
188
+ <main id="app"></main>
189
+ </div>
190
+ </div>
191
+ <dialog id="login-dialog">
192
+ <form id="login-form">
193
+ <h3>登入 WJI</h3>
194
+ <p>貼上 API key(sk_ 開頭)或 <code>wji auth token</code>。儲存後只留在這台瀏覽器的 localStorage,不會寫進專案檔。</p>
195
+ <label>API 位址<input id="api" type="text" spellcheck="false" autocomplete="off" /></label>
196
+ <label>API Key<input id="token" type="password" spellcheck="false" autocomplete="off" /></label>
197
+ <div class="dialog-actions">
198
+ <button type="button" id="login-clear" class="ghost">清除</button>
199
+ <button type="submit">儲存</button>
200
+ </div>
201
+ </form>
202
+ </dialog>
203
+ <dialog id="viewer" class="viewer">
204
+ <div class="viewer-stage">
205
+ <img id="viewer-img" alt="" hidden />
206
+ <video id="viewer-video" controls playsinline hidden></video>
207
+ </div>
208
+ <div class="viewer-bar">
209
+ <p id="viewer-caption"></p>
210
+ <div class="viewer-actions">
211
+ <button type="button" id="viewer-prev">上一張</button>
212
+ <button type="button" id="viewer-next">下一張</button>
213
+ <button type="button" id="viewer-close">關閉</button>
214
+ </div>
215
+ </div>
216
+ </dialog>
217
+ <script>
218
+ const ID_RE = /^[a-f0-9]{24}$/i
219
+ const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?|$)/i
220
+ const API_KEY = 'wji-short-drama-api'
221
+ const TOKEN_KEY = 'wji-short-drama-token'
222
+ const SLOT_KEYS = ['sheet', 'headshot', 'keyframe', 'poster', 'video']
223
+ const BRIEF_LABELS = {
224
+ logline: '一句話',
225
+ audience: '觀眾',
226
+ satisfactionEngine: '滿足方式',
227
+ mainHook: '主鉤子',
228
+ protagonistPressure: '主角壓力',
229
+ antagonistPressure: '對立壓力',
230
+ emotionalLine: '情緒線',
231
+ promise: '承諾',
232
+ }
233
+ const GUIDE_LABELS = {
234
+ tone: '語氣',
235
+ emotionalIntensity: '情緒強度',
236
+ dialogueStyle: '對白',
237
+ visualStyle: '畫面',
238
+ productionLimits: '製作限制',
239
+ }
240
+ const CONDITION_LABELS = { new: '全新', 'lived-in': '有人住過', worn: '用舊' }
241
+ const TIER_LABELS = { lead: '主角', support: '配角', functional: '功能' }
242
+ const PHASE_LABELS = { golden: '黃金開場', escalation: '升級', climax: '收束' }
243
+
244
+ const statusEl = document.querySelector('#status')
245
+ const app = document.querySelector('#app')
246
+ const loginDialog = document.querySelector('#login-dialog')
247
+ const viewer = document.querySelector('#viewer')
248
+ const viewerImg = document.querySelector('#viewer-img')
249
+ const viewerVideo = document.querySelector('#viewer-video')
250
+ const viewerCaption = document.querySelector('#viewer-caption')
251
+
252
+ function readSession() {
253
+ return {
254
+ api: (localStorage.getItem(API_KEY) || 'https://api.wji.ai').replace(/\/$/, ''),
255
+ token: (localStorage.getItem(TOKEN_KEY) || '').trim(),
256
+ }
257
+ }
258
+
259
+ function paintLogin() {
260
+ const button = document.querySelector('#login')
261
+ const { token } = readSession()
262
+ button.textContent = token ? `已登入 ····${token.slice(-4)}` : '登入'
263
+ }
264
+
265
+ function openLogin() {
266
+ const session = readSession()
267
+ document.querySelector('#api').value = session.api
268
+ document.querySelector('#token').value = session.token
269
+ loginDialog.showModal()
270
+ }
271
+
272
+ paintLogin()
273
+ document.querySelector('#login').onclick = openLogin
274
+ document.querySelector('#login-form').onsubmit = (event) => {
275
+ event.preventDefault()
276
+ const api = document.querySelector('#api').value.trim() || 'https://api.wji.ai'
277
+ const token = document.querySelector('#token').value.trim()
278
+ localStorage.setItem(API_KEY, api)
279
+ localStorage.setItem(TOKEN_KEY, token)
280
+ paintLogin()
281
+ loginDialog.close()
282
+ boot()
283
+ }
284
+ document.querySelector('#login-clear').onclick = () => {
285
+ localStorage.removeItem(TOKEN_KEY)
286
+ localStorage.removeItem(API_KEY)
287
+ document.querySelector('#api').value = 'https://api.wji.ai'
288
+ document.querySelector('#token').value = ''
289
+ paintLogin()
290
+ loginDialog.close()
291
+ boot()
292
+ }
293
+
294
+ document.querySelector('#pick').onclick = () => document.querySelector('#dir').click()
295
+ document.querySelector('#dir').onchange = (event) => {
296
+ const list = event.target.files
297
+ if (!list?.length) return
298
+ const files = new Map()
299
+ for (const file of list) {
300
+ const rel = (file.webkitRelativePath || file.name).split('/').slice(1).join('/')
301
+ if (rel) files.set(rel, file)
302
+ }
303
+ load({ files }).catch(fail)
304
+ }
305
+ document.querySelector('#reload').onclick = () => boot()
306
+
307
+ function fail(error) {
308
+ statusEl.textContent = ''
309
+ app.innerHTML = `<p class="err">${esc(error.message || error)}</p>`
310
+ }
311
+
312
+ function esc(value) {
313
+ return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
314
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
315
+ }[ch]))
316
+ }
317
+
318
+ function refOf(value) {
319
+ if (!value) return null
320
+ if (typeof value === 'string') {
321
+ if (/^https?:\/\//i.test(value) || value.startsWith('data:')) return { url: value }
322
+ if (ID_RE.test(value)) return { id: value }
323
+ if (value && !value.includes('://')) return { file: value.replace(/^\.\//, '') }
324
+ return null
325
+ }
326
+ if (typeof value === 'object') {
327
+ const url = value.url || value.src
328
+ if (typeof url === 'string' && (/^https?:\/\//i.test(url) || url.startsWith('data:'))) return { url }
329
+ const id = value.id || value.assetId
330
+ if (id && ID_RE.test(String(id))) return { id: String(id) }
331
+ if (typeof url === 'string' && url && !url.includes('://')) return { file: url.replace(/^\.\//, '') }
332
+ }
333
+ return null
334
+ }
335
+
336
+ function slotsOf(entity) {
337
+ const assets = entity?.wji?.assets || {}
338
+ const slots = []
339
+ const add = (label, value) => {
340
+ const ref = refOf(value)
341
+ if (ref) slots.push({ label, ...ref })
342
+ }
343
+ add('image', entity?.image)
344
+ add('video', entity?.video)
345
+ for (const key of SLOT_KEYS) add(key, assets[key])
346
+ for (const [key, value] of Object.entries(assets)) {
347
+ if (key.startsWith('lighting:') || key.startsWith('state:')) add(key, value)
348
+ }
349
+ return slots
350
+ }
351
+
352
+ function collectIds(project, episodes) {
353
+ const ids = new Set()
354
+ const take = (entity) => {
355
+ for (const slot of slotsOf(entity)) if (slot.id) ids.add(slot.id)
356
+ }
357
+ for (const list of [project.characters, project.scenes, project.props]) {
358
+ for (const entity of list || []) take(entity)
359
+ }
360
+ for (const episode of Object.values(episodes)) {
361
+ take(episode)
362
+ for (const segment of episode.storyboard?.segments || []) take(segment)
363
+ }
364
+ return [...ids]
365
+ }
366
+
367
+ async function resolveAssets(ids) {
368
+ const map = {}
369
+ if (!ids.length) return map
370
+ const { token, api } = readSession()
371
+ if (!token) return map
372
+ const base = api
373
+ for (let i = 0; i < ids.length; i += 100) {
374
+ const chunk = ids.slice(i, i + 100)
375
+ const res = await fetch(`${base}/assets/check-status`, {
376
+ method: 'POST',
377
+ headers: {
378
+ 'Content-Type': 'application/json',
379
+ Authorization: `Bearer ${token}`,
380
+ },
381
+ body: JSON.stringify({ ids: chunk }),
382
+ })
383
+ if (!res.ok) throw new Error(`查詢素材網址失敗(${res.status})。請在登入框貼上 API key 或 wji auth token。接口是 POST /assets/check-status,與 GET /assets/list/:id 回傳的 url 相同。`)
384
+ const rows = await res.json()
385
+ const list = Array.isArray(rows) ? rows : (rows.data || [])
386
+ for (const asset of list) {
387
+ if (asset?.id && asset.url) map[asset.id] = asset
388
+ }
389
+ }
390
+ return map
391
+ }
392
+
393
+ function mediaOf(entity, assets, files) {
394
+ let image = null
395
+ let video = null
396
+ for (const slot of slotsOf(entity)) {
397
+ let url = slot.url || null
398
+ let kind = slot.label === 'video' || (url && VIDEO_RE.test(url)) ? 'video' : 'image'
399
+ if (!url && slot.id && assets[slot.id]?.url) {
400
+ url = assets[slot.id].url
401
+ const category = String(assets[slot.id].category || assets[slot.id].type || '')
402
+ if (category === 'video' || VIDEO_RE.test(url)) kind = 'video'
403
+ }
404
+ if (!url && slot.file && files?.has(slot.file)) {
405
+ url = URL.createObjectURL(files.get(slot.file))
406
+ if (VIDEO_RE.test(slot.file)) kind = 'video'
407
+ }
408
+ if (!url) continue
409
+ if (kind === 'video') video ||= url
410
+ else image ||= url
411
+ }
412
+ if (image) return { kind: 'image', url: image, video }
413
+ if (video) return { kind: 'video', url: video }
414
+ return null
415
+ }
416
+
417
+ function shotButton(url, kind, caption) {
418
+ const media = kind === 'video'
419
+ ? `<video src="${esc(url)}" muted playsinline preload="metadata"></video>`
420
+ : `<img src="${esc(url)}" alt="${esc(caption)}" />`
421
+ return `<button type="button" class="shot" data-src="${esc(url)}" data-kind="${kind}" data-caption="${esc(caption)}" aria-label="放大 ${esc(caption)}">${media}<span class="enlarge">放大</span></button>`
422
+ }
423
+
424
+ function figure(entity, assets, files, frame) {
425
+ const media = mediaOf(entity, assets, files)
426
+ const caption = entity.name || entity.id || ''
427
+ const cls = frame ? `figure ${frame}` : 'figure'
428
+ if (!media) return `<div class="${cls}"><div class="placeholder">圖片佔位</div></div>`
429
+ const parts = [shotButton(media.url, media.kind, caption)]
430
+ if (media.kind === 'image' && media.video) parts.push(shotButton(media.video, 'video', caption))
431
+ return `<div class="${cls}">${parts.join('')}</div>`
432
+ }
433
+
434
+ function shots() {
435
+ return [...document.querySelectorAll('#app .shot')]
436
+ }
437
+
438
+ function openShot(button) {
439
+ const src = button.dataset.src
440
+ const kind = button.dataset.kind
441
+ const caption = button.dataset.caption || ''
442
+ viewerCaption.textContent = caption
443
+ viewer.dataset.current = String(shots().indexOf(button))
444
+ if (kind === 'video') {
445
+ viewerImg.hidden = true
446
+ viewerImg.removeAttribute('src')
447
+ viewerVideo.hidden = false
448
+ viewerVideo.src = src
449
+ viewerVideo.play().catch(() => {})
450
+ } else {
451
+ viewerVideo.pause()
452
+ viewerVideo.removeAttribute('src')
453
+ viewerVideo.load()
454
+ viewerVideo.hidden = true
455
+ viewerImg.hidden = false
456
+ viewerImg.src = src
457
+ viewerImg.alt = caption
458
+ }
459
+ if (!viewer.open) viewer.showModal()
460
+ }
461
+
462
+ function stepShot(delta) {
463
+ const list = shots()
464
+ if (!list.length) return
465
+ let index = Number(viewer.dataset.current || 0) + delta
466
+ if (index < 0) index = list.length - 1
467
+ if (index >= list.length) index = 0
468
+ openShot(list[index])
469
+ }
470
+
471
+ app.addEventListener('click', (event) => {
472
+ const shot = event.target.closest('.shot')
473
+ if (!shot) return
474
+ openShot(shot)
475
+ })
476
+ document.querySelector('#viewer-prev').onclick = () => stepShot(-1)
477
+ document.querySelector('#viewer-next').onclick = () => stepShot(1)
478
+ document.querySelector('#viewer-close').onclick = () => viewer.close()
479
+ viewer.addEventListener('click', (event) => {
480
+ if (event.target === viewer) viewer.close()
481
+ })
482
+ viewer.addEventListener('close', () => {
483
+ viewerVideo.pause()
484
+ viewerVideo.removeAttribute('src')
485
+ viewerVideo.load()
486
+ })
487
+ viewer.addEventListener('keydown', (event) => {
488
+ if (event.target === viewerVideo) return
489
+ if (event.key === 'ArrowRight') {
490
+ event.preventDefault()
491
+ stepShot(1)
492
+ }
493
+ if (event.key === 'ArrowLeft') {
494
+ event.preventDefault()
495
+ stepShot(-1)
496
+ }
497
+ })
498
+
499
+ const WEB = 'https://app.wji.ai'
500
+
501
+ function isBound(value) {
502
+ return ID_RE.test(String(value || ''))
503
+ }
504
+
505
+ function canvasHref(workspaceId, canvasId, nodeId) {
506
+ if (!isBound(workspaceId) || !isBound(canvasId)) return ''
507
+ const query = new URLSearchParams({ app: 'InfiniteCanvas', canvas: canvasId })
508
+ if (isBound(nodeId)) query.set('node', nodeId)
509
+ return `${WEB}/app/workspace/${workspaceId}?${query}`
510
+ }
511
+
512
+ function datasetHref(workspaceId, datasetId) {
513
+ if (!isBound(workspaceId) || !isBound(datasetId)) return ''
514
+ const query = new URLSearchParams({ app: 'Dataset', dataset: datasetId })
515
+ return `${WEB}/app/workspace/${workspaceId}?${query}`
516
+ }
517
+
518
+ function firstNode(nodes) {
519
+ if (!nodes || typeof nodes !== 'object') return ''
520
+ for (const value of Object.values(nodes)) {
521
+ if (isBound(value)) return String(value)
522
+ if (Array.isArray(value)) {
523
+ const hit = value.find((item) => isBound(item))
524
+ if (hit) return String(hit)
525
+ }
526
+ }
527
+ return ''
528
+ }
529
+
530
+ function wjiLinks(workspaceId, opts) {
531
+ const links = []
532
+ const canvas = canvasHref(workspaceId, opts.canvasId, opts.nodeId)
533
+ const dataset = datasetHref(workspaceId, opts.datasetId)
534
+ if (canvas) links.push(`<a href="${esc(canvas)}" target="_blank" rel="noopener">畫布</a>`)
535
+ if (dataset) links.push(`<a href="${esc(dataset)}" target="_blank" rel="noopener">數據集</a>`)
536
+ return links.length ? `<p class="links">${links.join('')}</p>` : ''
537
+ }
538
+
539
+ function navItem(id, index, label) {
540
+ return `<a href="#${id}" data-target="${id}"><span>${index}</span>${esc(label)}</a>`
541
+ }
542
+
543
+ function extItem(href, label) {
544
+ if (!href) return ''
545
+ return `<a class="ext" href="${esc(href)}" target="_blank" rel="noopener">${esc(label)}</a>`
546
+ }
547
+
548
+ let navLock = false
549
+
550
+ function scrollToId(id) {
551
+ const el = document.getElementById(id)
552
+ if (!el) return
553
+ const main = document.querySelector('#app')
554
+ const mainScrolls = main.scrollHeight > main.clientHeight + 8
555
+ const behavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
556
+ navLock = true
557
+ if (mainScrolls) main.scrollTo({ top: Math.max(0, el.offsetTop - 12), behavior })
558
+ else el.scrollIntoView({ behavior, block: 'start' })
559
+ setCurrent(id)
560
+ window.setTimeout(() => { navLock = false }, 500)
561
+ }
562
+
563
+ function currentFromScroll() {
564
+ if (navLock) return
565
+ const main = document.querySelector('#app')
566
+ const sections = [...main.querySelectorAll('.page-section')]
567
+ const line = main.scrollTop + 24
568
+ let id = sections[0]?.id
569
+ for (const section of sections) {
570
+ if (section.offsetTop <= line) id = section.id
571
+ }
572
+ if (id) setCurrent(id)
573
+ }
574
+
575
+ function setCurrent(id) {
576
+ for (const link of document.querySelectorAll('#nav a[data-target]')) {
577
+ link.classList.toggle('current', link.dataset.target === id)
578
+ }
579
+ }
580
+
581
+ function bindNav() {
582
+ const main = document.querySelector('#app')
583
+ if (main.dataset.navBound) return
584
+ main.dataset.navBound = '1'
585
+ main.addEventListener('scroll', currentFromScroll, { passive: true })
586
+ }
587
+
588
+ document.querySelector('#nav').addEventListener('click', (event) => {
589
+ const link = event.target.closest('a[data-target]')
590
+ if (!link) return
591
+ event.preventDefault()
592
+ scrollToId(link.dataset.target)
593
+ })
594
+ document.querySelector('.brand').addEventListener('click', (event) => {
595
+ event.preventDefault()
596
+ scrollToId('overview')
597
+ })
598
+
599
+ function chips(pairs) {
600
+ const items = pairs.filter(([, value]) => value != null && value !== '')
601
+ if (!items.length) return ''
602
+ return `<div class="chips">${items.map(([label, value]) => `<span class="chip">${esc(label)} ${esc(value)}</span>`).join('')}</div>`
603
+ }
604
+
605
+ function conditionText(value) {
606
+ return CONDITION_LABELS[value] || value || ''
607
+ }
608
+
609
+ function tierText(value) {
610
+ return TIER_LABELS[value] || value || ''
611
+ }
612
+
613
+ function aside(text) {
614
+ if (!text) return ''
615
+ return `<p class="aside">${esc(text)}</p>`
616
+ }
617
+
618
+ function episodeLabel(episode) {
619
+ const raw = String(episode.change || '').split(/[,。;,]/)[0].trim()
620
+ return raw.slice(0, 10) || '分集'
621
+ }
622
+
623
+ function labeled(labels, source) {
624
+ if (!source) return ''
625
+ return Object.entries(labels)
626
+ .filter(([key]) => source[key])
627
+ .map(([key, label]) => `<p><b>${esc(label)}</b>${esc(source[key])}</p>`)
628
+ .join('')
629
+ }
630
+
631
+ function bulletBlock(label, items) {
632
+ if (!Array.isArray(items) || !items.length) return ''
633
+ return `<p><b>${esc(label)}</b></p><ul>${items.map((item) => `<li>${esc(item)}</li>`).join('')}</ul>`
634
+ }
635
+
636
+ function structureOf(structure) {
637
+ if (!Array.isArray(structure) || !structure.length) return ''
638
+ const rows = structure.map((phase) => {
639
+ const name = PHASE_LABELS[phase.phase] || phase.phase || ''
640
+ return `<li><b>${esc(name)}</b> ${esc(phase.task || '')}</li>`
641
+ }).join('')
642
+ return `<p><b>結構</b></p><ul>${rows}</ul>`
643
+ }
644
+
645
+ function speakerName(project, speaker) {
646
+ if (!speaker) return ''
647
+ if (speaker === 'VO') return '畫外'
648
+ const character = (project.characters || []).find((item) => item.id === speaker)
649
+ return character?.name || speaker
650
+ }
651
+
652
+ function beatMarks(beat) {
653
+ const marks = []
654
+ if (beat.turn) marks.push('轉折')
655
+ if (beat.gag) marks.push(beat.gag === true ? '笑點' : `笑點 ${beat.gag}`)
656
+ if (beat.clue) {
657
+ const clue = Array.isArray(beat.clue) ? beat.clue.join(' ') : beat.clue
658
+ marks.push(`線索 ${clue}`)
659
+ }
660
+ if (!marks.length) return ''
661
+ return `<span class="marks">${marks.map((mark) => `<i>${esc(mark)}</i>`).join('')}</span>`
662
+ }
663
+
664
+ function scriptOf(project, episode) {
665
+ const scenes = episode.scenes || []
666
+ if (!scenes.some((scene) => (scene.flow || []).length)) return ''
667
+ const sceneName = (id) => (project.scenes || []).find((scene) => scene.id === id)?.name || id || ''
668
+ const blocks = scenes.map((scene, index) => {
669
+ const flow = (scene.flow || []).map((beat) => {
670
+ const marks = beatMarks(beat)
671
+ if (beat.line || beat.speaker) {
672
+ const name = speakerName(project, beat.speaker)
673
+ const delivery = beat.delivery ? `(${esc(beat.delivery)})` : ''
674
+ return `<p class="beat line"><b>${esc(name)}${delivery}:</b>${esc(beat.line || '')}${marks}</p>`
675
+ }
676
+ if (beat.action) return `<p class="beat action"><span class="tri">△</span><span>${esc(beat.action)}</span>${marks}</p>`
677
+ return ''
678
+ }).join('')
679
+ return `<div class="script-scene"><header><span>${esc(scene.sceneId || index + 1)}</span><h4>${esc(sceneName(scene.sceneId))}</h4><em>${esc(scene.lighting || '')}</em></header>${flow}</div>`
680
+ }).join('')
681
+ return `<details class="script"><summary>劇本</summary><div class="script-body">${blocks}</div></details>`
682
+ }
683
+
684
+ function card(entity, assets, files, extra, frame) {
685
+ return `<article>${figure(entity, assets, files, frame)}<div class="body"><div class="kicker">${esc(entity.id || '')}</div><h3>${esc(entity.name || entity.id || '')}</h3>${extra || ''}</div></article>`
686
+ }
687
+
688
+ function note(label, text) {
689
+ if (!text) return ''
690
+ return `<p><b>${esc(label)}</b>${esc(text)}</p>`
691
+ }
692
+
693
+ function render(project, episodes, assets, files) {
694
+ const params = project.params || {}
695
+ const brief = project.outline?.brief || {}
696
+ const guideline = project.outline?.guideline || {}
697
+ const characters = project.characters || []
698
+ const scenes = project.scenes || []
699
+ const props = project.props || []
700
+ const eps = Object.values(episodes).sort((a, b) => (a.ep || 0) - (b.ep || 0))
701
+ const wji = project.wji || {}
702
+ const workspaceId = wji.workspaceId
703
+ const canvases = wji.canvases || {}
704
+ const facts = [
705
+ params.genre, params.register, params.ratio,
706
+ params.episodes ? `${params.episodes} 集` : '',
707
+ params.video, params.videoResolution,
708
+ ].filter(Boolean)
709
+
710
+ const story = [
711
+ labeled(BRIEF_LABELS, brief),
712
+ structureOf(project.outline?.structure),
713
+ labeled(GUIDE_LABELS, guideline),
714
+ bulletBlock('不能做', guideline.forbidden),
715
+ bulletBlock('優先', guideline.priorities),
716
+ ].join('')
717
+
718
+ const characterCards = characters.map((character) => card(character, assets, files,
719
+ `<p>${esc(character.oneLiner || character.role || '')}</p>${chips([['角色', character.role], ['層級', tierText(character.tier)]])}${wjiLinks(workspaceId, { canvasId: canvases.characters, nodeId: firstNode(character.wji?.nodes), datasetId: character.wji?.datasetId })}`,
720
+ 'sheet'
721
+ )).join('')
722
+
723
+ const sceneCards = scenes.map((scene) => card(scene, assets, files,
724
+ `<p>${esc(scene.summary || '')}</p>${chips([['狀態', conditionText(scene.condition)]])}${aside(scene.conditionReason)}${wjiLinks(workspaceId, { canvasId: canvases.scenes, nodeId: firstNode(scene.wji?.nodes), datasetId: scene.wji?.datasetId })}`,
725
+ 'sheet'
726
+ )).join('')
727
+
728
+ const propCards = props.map((prop) => card(prop, assets, files,
729
+ `<p>${esc(prop.summary || '')}</p>${chips([['狀態', conditionText(prop.condition)]])}${aside(prop.conditionReason)}${wjiLinks(workspaceId, { canvasId: canvases.props, nodeId: firstNode(prop.wji?.nodes), datasetId: prop.wji?.datasetId })}`,
730
+ 'sheet'
731
+ )).join('')
732
+
733
+ const episodeBlocks = eps.map((episode) => {
734
+ const ep = episode.ep
735
+ const canvasId = episode.wji?.canvasId || canvases.episodes?.[ep] || canvases.episodes?.[String(ep)]
736
+ const segments = (episode.storyboard?.segments || []).map((segment) => card(segment, assets, files,
737
+ `<p>${esc((segment.prompt || '').slice(0, 220))}</p>${chips([['場景', segment.sceneIndex]])}${wjiLinks(workspaceId, { canvasId, nodeId: firstNode(segment.wji?.nodes), datasetId: segment.wji?.datasetId })}`
738
+ )).join('')
739
+ return `<section id="ep-${ep}" class="page-section">
740
+ <h2>E${String(ep || '').padStart(2, '0')}</h2>
741
+ ${wjiLinks(workspaceId, { canvasId, datasetId: episode.wji?.screenplayDatasetId })}
742
+ <div class="prose">
743
+ ${note('本集變化', episode.change)}
744
+ ${note('開場鉤子', episode.hook)}
745
+ ${note('結尾懸念', episode.cliff)}
746
+ </div>
747
+ ${scriptOf(project, episode)}
748
+ <div class="grid">${segments}</div>
749
+ </section>`
750
+ }).join('')
751
+
752
+ const wjiNav = [
753
+ extItem(canvasHref(workspaceId, canvases.characters), '人物畫布'),
754
+ extItem(canvasHref(workspaceId, canvases.scenes), '場景畫布'),
755
+ extItem(canvasHref(workspaceId, canvases.props), '道具畫布'),
756
+ extItem(datasetHref(workspaceId, wji.outlineDatasetId), '大綱數據集'),
757
+ extItem(datasetHref(workspaceId, wji.guidelineDatasetId), '創作指引數據集'),
758
+ ...Object.entries(wji.datasetFolders || {}).map(([name, id]) => extItem(datasetHref(workspaceId, id), `${name} 數據集`)),
759
+ ].filter(Boolean).join('')
760
+
761
+ document.querySelector('#brand').textContent = project.title || '短劇'
762
+ document.querySelector('#nav').innerHTML = [
763
+ navItem('overview', '01', '製作總覽'),
764
+ navItem('story', '02', '故事設計'),
765
+ navItem('characters', '03', '人物設定'),
766
+ navItem('world', '04', '場景與道具'),
767
+ navItem('episodes', '05', '分集劇本'),
768
+ ...eps.map((episode) => navItem(`ep-${episode.ep}`, 'E' + String(episode.ep).padStart(2, '0'), episodeLabel(episode))),
769
+ wjiNav ? `<div class="group">WJI</div>${wjiNav}` : '',
770
+ ].join('')
771
+
772
+ app.innerHTML = `
773
+ <section id="overview" class="page-section">
774
+ <h1>${esc(project.title || '未命名')}</h1>
775
+ <p class="facts">${facts.map((item) => `<span>${esc(item)}</span>`).join('')}</p>
776
+ <p class="core">${esc(project.outline?.core || '')}</p>
777
+ ${wjiLinks(workspaceId, { canvasId: canvases.characters || canvases.scenes, datasetId: wji.outlineDatasetId })}
778
+ </section>
779
+ <section id="story" class="page-section">
780
+ <h2>故事設計</h2>
781
+ <div class="prose">${story}</div>
782
+ </section>
783
+ <section id="characters" class="page-section">
784
+ <h2>人物設定</h2>
785
+ <div class="grid">${characterCards || '<p class="empty">尚無人物</p>'}</div>
786
+ </section>
787
+ <section id="world" class="page-section">
788
+ <h2>場景與道具</h2>
789
+ <div class="grid">${sceneCards}${propCards}</div>
790
+ </section>
791
+ <section id="episodes" class="page-section">
792
+ <h2>分集劇本與分鏡</h2>
793
+ ${episodeBlocks ? '' : '<p class="empty">尚無分集</p>'}
794
+ </section>
795
+ ${episodeBlocks}
796
+ `
797
+ setCurrent('overview')
798
+ bindNav()
799
+ }
800
+
801
+ async function readProject(source) {
802
+ if (source.files) {
803
+ const file = source.files.get('project.json')
804
+ if (!file) throw new Error('這個資料夾裡沒有 project.json')
805
+ const project = JSON.parse(await file.text())
806
+ const episodes = {}
807
+ for (const [path, episodeFile] of source.files) {
808
+ const match = path.match(/^episodes\/E(\d+)\.json$/i)
809
+ if (!match) continue
810
+ const episode = JSON.parse(await episodeFile.text())
811
+ episodes[episode.ep || Number(match[1])] = episode
812
+ }
813
+ return { project, episodes, files: source.files }
814
+ }
815
+ const response = await fetch('project.json')
816
+ if (!response.ok) throw new Error('讀不到旁邊的 project.json')
817
+ const project = await response.json()
818
+ const episodes = {}
819
+ const count = Number(project.params?.episodes) || 0
820
+ for (let ep = 1; ep <= count; ep += 1) {
821
+ const episodeResponse = await fetch(`episodes/E${String(ep).padStart(2, '0')}.json`)
822
+ if (!episodeResponse.ok) continue
823
+ const episode = await episodeResponse.json()
824
+ episodes[episode.ep || ep] = episode
825
+ }
826
+ return { project, episodes, files: null }
827
+ }
828
+
829
+ async function load(source) {
830
+ statusEl.textContent = '讀取 project.json…'
831
+ const { project, episodes, files } = await readProject(source)
832
+ if (!localStorage.getItem(API_KEY) && project.wji?.endpoint) {
833
+ localStorage.setItem(API_KEY, project.wji.endpoint)
834
+ }
835
+ const ids = collectIds(project, episodes)
836
+ statusEl.textContent = ids.length ? `向 /assets/check-status 查詢 ${ids.length} 個素材網址…` : ''
837
+ const assets = await resolveAssets(ids)
838
+ render(project, episodes, assets, files)
839
+ const resolved = Object.keys(assets).length
840
+ statusEl.textContent = ids.length
841
+ ? (readSession().token
842
+ ? `已解析 ${resolved} / ${ids.length} 個素材網址,圖片由瀏覽器直接讀 CDN。`
843
+ : '專案已載入。按登入貼上 API key 或 wji auth token,才會向 WJI 查素材網址。')
844
+ : '專案已載入。'
845
+ }
846
+
847
+ async function boot() {
848
+ try {
849
+ await load({})
850
+ } catch {
851
+ app.innerHTML = `<div class="empty">
852
+ <p>這是靜態頁,不會生成,也不會把圖片下載到專案裡。</p>
853
+ <p>選擇專案資料夾,或把本檔放在專案目錄旁,用靜態伺服器打開,它會讀 <code>project.json</code> 和 <code>episodes/E01.json</code>。</p>
854
+ <p>素材 id 透過 <code>POST /assets/check-status</code> 取回 <code>url</code>(與 <code>GET /assets/list/:id</code> 的檔案地址相同)。按登入貼上 API key 或 <code>wji auth token</code>,會記在 localStorage。點圖片或影片可以放大。</p>
855
+ </div>`
856
+ statusEl.textContent = ''
857
+ }
858
+ }
859
+
860
+ boot()
861
+ </script>
862
+ </body>
863
+ </html>