@biunow/agent-biuvibe 1.0.0 → 1.0.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.
package/bin/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  import {
5
5
  createAgentServer,
6
6
  ensureDir,
@@ -14,9 +14,28 @@ import {
14
14
  isIntermediateMp4,
15
15
  readJson,
16
16
  } from '../lib/host.mjs';
17
-
18
17
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const recipeFile = [
19
+ path.join(root, 'studio', 'recipes.mjs'),
20
+ path.resolve(root, '..', '_shared', 'studio', 'recipes.mjs'),
21
+ ].find((file) => fs.existsSync(file));
22
+ if (!recipeFile) throw new Error('找不到风格提示词模块 studio/recipes.mjs');
23
+ const { findRecipe, loadRecipes, recipeView } = await import(pathToFileURL(recipeFile).href);
19
24
  const COLUMNS = readJson(path.join(root, 'data', 'columns.json'), []);
25
+ const RECIPES = loadRecipes(path.join(root, '风格提示词.md'));
26
+
27
+ function presentColumn(column) {
28
+ const found = findRecipe(RECIPES, [column.cn, column.en, column.slug]);
29
+ return {
30
+ ...column,
31
+ recipe: recipeView(found, {
32
+ prompt: found?.prompt || `用${column.cn}讲「把引号里换成你要讲的事」。${column.line || ''}`,
33
+ narration: column.voice ? 'required' : 'off',
34
+ attachment: column.voice || column.music ? 'required' : 'optional',
35
+ hint: column.hint || '',
36
+ }),
37
+ };
38
+ }
20
39
 
21
40
  function normalizeDuration(value) {
22
41
  if (value == null || value === '' || value === 'auto') return null;
@@ -70,7 +89,7 @@ createAgentServer({
70
89
  agent: 'biuvibe',
71
90
  name: manifest.name || 'BiuVibe',
72
91
  description: manifest.description || '',
73
- styles: COLUMNS,
92
+ styles: COLUMNS.map(presentColumn),
74
93
  canExport: cfg.canExport,
75
94
  enginePresent: !!(engineRoot && fs.existsSync(path.join(engineRoot, 'AGENTS.md'))),
76
95
  engineRoot: engineRoot || null,
package/lib/host.mjs CHANGED
@@ -300,11 +300,39 @@ export function seedSkillIntoProject(projectDir, packageRoot, skillId) {
300
300
  return dest;
301
301
  }
302
302
 
303
- /** Lemo 的 out/video.mp4、分段文件是无声中间片,不能当成交付成片。 */
303
+ /** 分段和无声中间片不能当成片。成片改名后(ep55.mp4 → ep55-master.mp4)要能从同目录找回。 */
304
304
  export function isIntermediateMp4(filePath) {
305
- const base = path.basename(filePath).toLowerCase();
306
- if (base === 'merged.mp4' || /^seg_\d+\.mp4$/.test(base)) return true;
307
- return base === 'video.mp4' && path.basename(path.dirname(filePath)).toLowerCase() === 'out';
305
+ const base = path.basename(String(filePath || '')).toLowerCase();
306
+ if (base === 'merged.mp4' || /^seg[-_]\d+\.mp4$/.test(base)) return true;
307
+ if (/^draft([-.]|$)/.test(base)) return true;
308
+ const parts = String(filePath || '').split(/[/\\]/).map((part) => part.toLowerCase());
309
+ if (parts.includes('build') || parts.includes('segments')) return true;
310
+ return base === 'video.mp4' && parts.at(-2) === 'out';
311
+ }
312
+
313
+ /** 记录里的文件被删掉或改名时,在同一目录找「原名开头」的现片。 */
314
+ export function rescueMissingFilm(filePath) {
315
+ const file = String(filePath || '');
316
+ if (!file) return '';
317
+ if (fs.existsSync(file) && !isIntermediateMp4(file)) return file;
318
+ const dir = path.dirname(file);
319
+ if (!fs.existsSync(dir)) return '';
320
+ const stem = path.basename(file, path.extname(file)).toLowerCase();
321
+ if (!stem) return '';
322
+ let best = null;
323
+ let names = [];
324
+ try { names = fs.readdirSync(dir); } catch { return ''; }
325
+ for (const name of names) {
326
+ if (!name.toLowerCase().endsWith('.mp4')) continue;
327
+ if (!name.toLowerCase().startsWith(stem)) continue;
328
+ const full = path.join(dir, name);
329
+ if (isIntermediateMp4(full)) continue;
330
+ let st;
331
+ try { st = fs.statSync(full); } catch { continue; }
332
+ if (!st.isFile()) continue;
333
+ if (!best || st.mtimeMs > best.mtimeMs) best = { path: full, mtimeMs: st.mtimeMs, size: st.size };
334
+ }
335
+ return best ? best.path : '';
308
336
  }
309
337
 
310
338
  function collectMp4(rootDir, maxDepth) {
@@ -344,7 +372,9 @@ function pickFilm(found) {
344
372
  if (ai !== bi) return ai - bi;
345
373
  return b.mtime - a.mtime;
346
374
  });
347
- return found[0];
375
+ const best = found[0];
376
+ if (!best || isIntermediateMp4(best.path)) return null;
377
+ return best;
348
378
  }
349
379
 
350
380
  /** Newest deliverable MP4. Root-level `<name>.mp4` wins over `out/video.mp4`. */
@@ -396,7 +426,34 @@ export function watchForFilm(cfg, jobs, jobId, {
396
426
  const tick = async () => {
397
427
  const job = jobs.get(jobId);
398
428
  if (!job || job.status === 'failed' || job.status === 'cancelled') return;
399
- if (job.outputMp4 && fs.existsSync(job.outputMp4) && !isIntermediateMp4(job.outputMp4)) return;
429
+ const current = String(job.outputMp4 || '');
430
+ if (current && fs.existsSync(current) && !isIntermediateMp4(current)) return;
431
+ if (current && !fs.existsSync(current)) {
432
+ const rescued = rescueMissingFilm(current);
433
+ if (rescued) {
434
+ const message = `成片已就绪 · ${path.basename(rescued)}`;
435
+ const size = fs.statSync(rescued).size;
436
+ jobs.update(jobId, {
437
+ status: 'done',
438
+ progress: 100,
439
+ phase: 'film',
440
+ message,
441
+ outputMp4: rescued,
442
+ artifact: rescued,
443
+ filmBytes: size,
444
+ filmAt: new Date().toISOString(),
445
+ });
446
+ await reportProgress(cfg, {
447
+ taskId: jobs.get(jobId)?.panelTaskId,
448
+ title: title || jobs.get(jobId)?.title,
449
+ progress: 100,
450
+ message,
451
+ done: true,
452
+ artifact: rescued,
453
+ });
454
+ return;
455
+ }
456
+ }
400
457
  const hit = locateJobFilm({ ...job, projectPath: projectPath || job.projectPath, artifact: job.artifact }, cfg);
401
458
  if (hit && !isIntermediateMp4(hit.path)) {
402
459
  const message = `成片已就绪 · ${path.basename(hit.path)}`;
@@ -520,9 +577,14 @@ export function createAgentServer({ root, studioDir, api }) {
520
577
 
521
578
  // 进程重启后:已有成片则收口;否则恢复 watch,避免面板/Studio 任务永远「处理中」
522
579
  for (const j of jobs.list()) {
523
- if (j.status === 'done' && !j.outputMp4) {
524
- const late = locateJobFilm(j, cfg);
525
- if (late && !isIntermediateMp4(late.path)) {
580
+ const filmMissing = j.outputMp4 && !fs.existsSync(j.outputMp4);
581
+ const filmBad = j.outputMp4 && isIntermediateMp4(j.outputMp4);
582
+ if (filmMissing || filmBad || (j.status === 'done' && !j.outputMp4)) {
583
+ const rescued = filmMissing ? rescueMissingFilm(j.outputMp4) : '';
584
+ const late = rescued
585
+ ? { path: rescued, size: fs.statSync(rescued).size }
586
+ : locateJobFilm(j, cfg);
587
+ if (late && late.path && fs.existsSync(late.path) && !isIntermediateMp4(late.path)) {
526
588
  jobs.update(j.id, {
527
589
  status: 'done',
528
590
  progress: 100,
@@ -533,8 +595,9 @@ export function createAgentServer({ root, studioDir, api }) {
533
595
  filmBytes: late.size,
534
596
  filmAt: new Date().toISOString(),
535
597
  });
598
+ continue;
536
599
  }
537
- continue;
600
+ if (j.status === 'done') continue;
538
601
  }
539
602
  const waiting = j.status === 'running' || (j.outputMp4 && isIntermediateMp4(j.outputMp4));
540
603
  if (!waiting) continue;
@@ -615,8 +678,27 @@ export function createAgentServer({ root, studioDir, api }) {
615
678
  if (req.method === 'GET' && /^\/api\/jobs\/[^/]+\/film$/.test(pathname)) {
616
679
  const id = decodeURIComponent(pathname.slice('/api/jobs/'.length, -'/film'.length));
617
680
  const job = jobs.get(id);
618
- const file = job ? String(job.outputMp4 || '') : '';
619
- if (!file || !file.toLowerCase().endsWith('.mp4')) return sendJson(res, 404, { error: '成片还没有' });
681
+ let file = job ? String(job.outputMp4 || '') : '';
682
+ if (job && (!file || !fs.existsSync(file) || isIntermediateMp4(file))) {
683
+ const rescued = file && !fs.existsSync(file) ? rescueMissingFilm(file) : '';
684
+ const late = rescued
685
+ ? { path: rescued, size: fs.statSync(rescued).size }
686
+ : locateJobFilm(job, cfg);
687
+ if (late && late.path && fs.existsSync(late.path) && !isIntermediateMp4(late.path)) {
688
+ jobs.update(job.id, {
689
+ status: 'done',
690
+ progress: 100,
691
+ phase: 'film',
692
+ message: `成片已就绪 · ${path.basename(late.path)}`,
693
+ outputMp4: late.path,
694
+ artifact: late.path,
695
+ filmBytes: late.size,
696
+ filmAt: new Date().toISOString(),
697
+ });
698
+ file = late.path;
699
+ }
700
+ }
701
+ if (!file || !file.toLowerCase().endsWith('.mp4') || !fs.existsSync(file)) return sendJson(res, 404, { error: '成片还没有' });
620
702
  return serveLocalFile(req, res, file);
621
703
  }
622
704
  if (pathname.startsWith('/api/jobs/') && req.method === 'GET') {
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "id": "biuvibe",
4
4
  "name": "BiuVibe",
5
- "version": "1.0.0",
5
+ "version": "1.0.2",
6
6
  "publisher": "Biunow",
7
7
  "description": "BiuVibe 知识短片:选一种画风,写下要讲的事,配上你有权使用的音乐或口播。",
8
8
  "entry": {
@@ -44,8 +44,8 @@
44
44
  "requiresEntitlement": "export"
45
45
  },
46
46
  "integrity": {
47
- "sha256": "32c9e8e00152c4c4c9312dd3e4396d38bd36e57c3f6ef2737c4d78549f2559a7",
48
- "signature": "UN9pXtqK6L6QdQASL8ecc8hOtZxGBgQpQ87scMxn+/0YLmP+Ljiy7JZitSPVn0wqPZ4wczJ9B6yOL75GLez7Bg==",
47
+ "sha256": "7595dd13770062c115893b723e9cffa2c871a58543d5bd1877983ec7e5e9b7d3",
48
+ "signature": "O0sfbytJhfMSBWJSPaeqON8nQ4VcGP8ZCMeS6d/CTSGxM42hmyR12ZEesBdP4y5S9qSG4JpZ+koWR+ab8uqqAg==",
49
49
  "signedBy": "publisher-key"
50
50
  }
51
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biunow/agent-biuvibe",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "BiuVibe",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
package/studio/app.js CHANGED
@@ -44,17 +44,18 @@
44
44
  };
45
45
  }
46
46
 
47
- function applyStyle(slug) {
47
+ function applyStyle(slug, opts) {
48
48
  const style = state.styles.find((item) => item.slug === slug) || null;
49
49
  state.style = style;
50
50
  const needs = style ? columnNeeds(style) : null;
51
51
  if (needs) {
52
- $('needBar').hidden = false;
53
- $('needHint').textContent = needs.hint;
54
52
  $('uploadHint').textContent = needs.label;
55
53
  $('files').setAttribute('accept', needs.accept);
56
- } else {
57
- $('needBar').hidden = true;
54
+ }
55
+ if (style && style.recipe) {
56
+ Composer.paintRecipe(state, style.recipe, needs?.hint || style.recipe.hint, opts && opts.keep);
57
+ } else if ($('needBar')) {
58
+ $('needBar').hidden = !style;
58
59
  }
59
60
  if (style) state.storyboard = style.confirm !== false;
60
61
  syncSb();
@@ -93,7 +94,7 @@
93
94
  }
94
95
  if (job.style && styleMenu) {
95
96
  styleMenu.setValue(job.style, true);
96
- applyStyle(job.style);
97
+ applyStyle(job.style, { keep: true });
97
98
  }
98
99
  if (typeof job.storyboard === 'boolean') {
99
100
  state.storyboard = job.storyboard;
@@ -173,6 +174,9 @@
173
174
  if (!state.style) throw new Error('请选择栏目');
174
175
  const prompt = $('prompt').value.trim();
175
176
  if (!prompt) throw new Error('请填写选题');
177
+ if (state.style.recipe && state.style.recipe.attachment === 'required' && !state.files.length) {
178
+ throw new Error(state.style.hint || '这个栏目必须上传附件');
179
+ }
176
180
  if (state.style.music && !state.files.some((file) => String(file.kind || '').startsWith('audio') || /\.(mp3|wav|m4a|flac|ogg|aac)$/i.test(file.name || ''))) {
177
181
  throw new Error('请上传一首有授权的配乐');
178
182
  }
@@ -233,9 +237,11 @@
233
237
  });
234
238
 
235
239
  const first = state.styles[0];
236
- if (first) state.style = first;
240
+ const want = new URLSearchParams(location.search).get('style');
241
+ const picked = state.styles.find((item) => item.slug === want) || first;
242
+ if (picked) state.style = picked;
237
243
  styleMenu = SelectMenu.mount($('stylePick'), {
238
- value: first?.slug || '',
244
+ value: picked?.slug || '',
239
245
  options: state.styles.map((item) => ({
240
246
  value: item.slug,
241
247
  label: item.cn,
@@ -247,7 +253,7 @@
247
253
  size: 'sm',
248
254
  onChange: (value) => applyStyle(value),
249
255
  });
250
- if (first) applyStyle(first.slug);
256
+ if (picked) applyStyle(picked.slug);
251
257
  await refreshJobs();
252
258
  setInterval(refreshJobs, 2000);
253
259
  void health;
@@ -91,6 +91,13 @@ button:disabled { cursor: not-allowed; opacity: .5; }
91
91
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
92
92
  padding: 12px 16px 8px;
93
93
  }
94
+ .need-flags { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
95
+ .need-flag {
96
+ font-size: 11px; font-weight: 650; border-radius: 99px; padding: 2px 8px;
97
+ background: #f4f4ef; color: var(--ink);
98
+ }
99
+ .need-flag.required { background: var(--ink); color: var(--accent); }
100
+ .need-flag.off { color: var(--muted); }
94
101
  .link-input {
95
102
  flex: 1; min-width: 200px; border: 1px solid var(--line); border-radius: 10px;
96
103
  padding: 8px 12px; font-size: 12px; background: #fafaf7; color: var(--ink); outline: 0;
@@ -282,6 +282,47 @@
282
282
  container.appendChild(c);
283
283
  }
284
284
 
285
+ const RECIPE_LEVEL = { off: '无', optional: '可选', required: '必须' };
286
+
287
+ function paintRecipe(state, recipe, hint, keepText) {
288
+ const prompt = document.getElementById('prompt');
289
+ let bar = document.getElementById('needBar');
290
+ if (!bar && prompt) {
291
+ bar = document.createElement('div');
292
+ bar.id = 'needBar';
293
+ bar.className = 'link-bar';
294
+ prompt.parentNode.insertBefore(bar, prompt);
295
+ }
296
+ if (!bar) return;
297
+ bar.hidden = false;
298
+ let hintEl = document.getElementById('needHint');
299
+ if (!hintEl) {
300
+ hintEl = document.createElement('span');
301
+ hintEl.id = 'needHint';
302
+ hintEl.className = 'muted';
303
+ bar.appendChild(hintEl);
304
+ }
305
+ hintEl.textContent = hint || (recipe && recipe.hint) || '';
306
+ let flags = document.getElementById('needFlags');
307
+ if (!flags) {
308
+ flags = document.createElement('div');
309
+ flags.id = 'needFlags';
310
+ flags.className = 'need-flags';
311
+ bar.appendChild(flags);
312
+ }
313
+ const narr = (recipe && recipe.narration) || 'optional';
314
+ const att = (recipe && recipe.attachment) || 'optional';
315
+ flags.innerHTML =
316
+ `<span class="need-flag ${narr}">旁白 · ${RECIPE_LEVEL[narr] || narr}</span>` +
317
+ `<span class="need-flag ${att}">附件 · ${RECIPE_LEVEL[att] || att}</span>`;
318
+ const text = String((recipe && recipe.prompt) || '').trim();
319
+ if (prompt && text && !keepText) {
320
+ prompt.value = text;
321
+ prompt.dispatchEvent(new Event('input'));
322
+ state.seed = text;
323
+ }
324
+ }
325
+
285
326
  function titleFromPrompt(prompt, fallback) {
286
327
  const line = String(prompt || '')
287
328
  .trim()
@@ -303,5 +344,6 @@
303
344
  renderJobs,
304
345
  addChip,
305
346
  titleFromPrompt,
347
+ paintRecipe,
306
348
  };
307
349
  })(window);
@@ -0,0 +1,86 @@
1
+ import fs from 'node:fs';
2
+
3
+ /** 从《风格提示词.md》读出每条模板的起步提示词。标题用 ## 或 ###。 */
4
+ export function loadRecipes(file) {
5
+ if (!file || !fs.existsSync(file)) return [];
6
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
7
+ const recipes = [];
8
+ let section = '';
9
+ let title = '';
10
+ let blurb = [];
11
+ let buf = [];
12
+ let inCode = false;
13
+
14
+ const flush = () => {
15
+ const prompt = buf.join('\n').trim();
16
+ if (title && prompt) {
17
+ recipes.push({
18
+ section,
19
+ title: title.trim(),
20
+ blurb: blurb.join(' ').replace(/\s+/g, ' ').trim(),
21
+ prompt,
22
+ });
23
+ }
24
+ buf = [];
25
+ blurb = [];
26
+ };
27
+
28
+ for (const line of lines) {
29
+ if (line.startsWith('```')) {
30
+ if (inCode) {
31
+ inCode = false;
32
+ flush();
33
+ title = '';
34
+ } else {
35
+ inCode = true;
36
+ }
37
+ continue;
38
+ }
39
+ if (inCode) {
40
+ buf.push(line);
41
+ continue;
42
+ }
43
+ if (line.startsWith('### ')) {
44
+ title = line.slice(4).trim();
45
+ blurb = [];
46
+ continue;
47
+ }
48
+ if (line.startsWith('## ')) {
49
+ section = line.slice(3).trim();
50
+ title = section;
51
+ blurb = [];
52
+ continue;
53
+ }
54
+ if (line.startsWith('# ')) continue;
55
+ if (title && line.trim()) blurb.push(line.trim());
56
+ }
57
+ return recipes;
58
+ }
59
+
60
+ function norm(value) {
61
+ return String(value || '').trim().toLowerCase().replace(/\s+/g, '');
62
+ }
63
+
64
+ export function findRecipe(recipes, keys) {
65
+ const wants = [...new Set((keys || []).map(norm).filter(Boolean))];
66
+ for (const want of wants) {
67
+ const hit = (recipes || []).find((recipe) => {
68
+ const title = norm(recipe.title);
69
+ const head = norm(String(recipe.title).split('·')[0]);
70
+ return title === want || head === want;
71
+ });
72
+ if (hit) return hit;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ export function recipeView(recipe, extras = {}) {
78
+ const prompt = String(recipe?.prompt || extras.prompt || '').trim();
79
+ return {
80
+ prompt,
81
+ blurb: recipe?.blurb || '',
82
+ narration: extras.narration || 'optional',
83
+ attachment: extras.attachment || 'optional',
84
+ hint: extras.hint || recipe?.blurb || '',
85
+ };
86
+ }
@@ -138,16 +138,33 @@
138
138
  const minWidth = grid ? 404 : 210;
139
139
  const width = Math.min(Math.max(r.width, minWidth), vw - 24);
140
140
  const left = Math.min(Math.max(12, r.left), Math.max(12, vw - width - 12));
141
- const catBlock = this.hasGroups() ? 86 : 0;
142
- const entryBlock = this.entryOpen ? 54 : 0;
143
- const estHeight = grid
144
- ? catBlock + Math.min(430, Math.ceil(visible.length / 3) * 96 + 14)
145
- : Math.min(340, visible.length * 38 + 12) + entryBlock;
146
- this.dropUp = vh - r.bottom < estHeight + 14 && r.top > estHeight + 14;
147
- this.pos = { left, width, top: r.bottom + 6, bottom: vh - r.top + 6 };
141
+ const gap = 6;
142
+ const margin = 12;
143
+ const below = Math.max(0, vh - r.bottom - gap - margin);
144
+ const above = Math.max(0, r.top - gap - margin);
145
+ let chrome = 0;
148
146
  if (this.panel) {
149
147
  this.panel.style.left = `${left}px`;
150
148
  this.panel.style.width = `${width}px`;
149
+ const cats = this.panel.querySelector('.sm-cats');
150
+ const entry = this.panel.querySelector('.sm-entry');
151
+ chrome = (cats ? cats.offsetHeight : 0) + (entry ? entry.offsetHeight : 0);
152
+ }
153
+ const scrollCap = grid ? 430 : 292;
154
+ const naturalScroll = grid
155
+ ? Math.min(scrollCap, Math.ceil(Math.max(visible.length, 1) / 3) * 96 + 14)
156
+ : Math.min(scrollCap, visible.length * 38 + 12);
157
+ const want = Math.min(chrome + naturalScroll, 240);
158
+ this.dropUp = below < want && above > below;
159
+ const available = Math.max(0, this.dropUp ? above : below);
160
+ const room = Math.min(available, chrome + naturalScroll);
161
+ const scrollMax = Math.max(0, Math.min(scrollCap, room - chrome));
162
+ this.pos = { left, width, top: r.bottom + gap, bottom: vh - r.top + gap };
163
+ if (this.panel) {
164
+ this.panel.style.maxHeight = `${room}px`;
165
+ this.panel.style.overflow = scrollMax > 0 ? 'hidden' : 'auto';
166
+ const scroll = this.panel.querySelector('.sm-scroll');
167
+ if (scroll) scroll.style.maxHeight = `${scrollMax}px`;
151
168
  if (this.dropUp) {
152
169
  this.panel.style.bottom = `${this.pos.bottom}px`;
153
170
  this.panel.style.top = 'auto';
@@ -0,0 +1,105 @@
1
+ # BiuVibe 风格提示词
2
+
3
+ 选中栏目后,下面这句话会填进选题。引号里换成你要讲的事,其余不用改。旁白和附件的要求跟栏目走。
4
+
5
+ ## 卡点
6
+
7
+ ### 黑金光影
8
+
9
+ 近黑底、暖色烟雾、金粒子标题。没有口播,配乐必须上传。
10
+
11
+ ```
12
+ 用黑金光影讲「为什么把选择留到最后,反而更难决定」。一句反常识先砸在画面上,金粒子标题跟着配乐的重拍走,全程没有口播。
13
+ ```
14
+
15
+ ### 暗金文献
16
+
17
+ 近黑底、金线图表、衬线字。没有口播,配乐必须上传。
18
+
19
+ ```
20
+ 用暗金文献讲「一个看起来很小的利息,翻过很多年之后为什么会压过人」。金线图表按配乐重拍出现,没有口播。
21
+ ```
22
+
23
+ ### 灯火史话
24
+
25
+ 暖黑夜色和火光。没有口播,配乐必须上传。
26
+
27
+ ```
28
+ 用灯火史话讲「古代驿站为什么能把一封信送到这么远」。火光里先给结论,再给一个能记住的细节,没有口播。
29
+ ```
30
+
31
+ ### 战术屏
32
+
33
+ 游戏 HUD 和发光图表。没有口播,不画真实游戏的角色和枪械。配乐必须上传。
34
+
35
+ ```
36
+ 用战术屏讲「连续失败三次之后,人为什么会开始怪自己的手」。只讲那个瞬间背后的道理,不出现真实游戏的角色和枪械,没有口播。
37
+ ```
38
+
39
+ ### 网点档案
40
+
41
+ 旧纸或藏青夜色,粗宋大字。没有口播,配乐必须上传。
42
+
43
+ ```
44
+ 用网点档案讲「一份被盖过章的旧规定,今天还在影响谁」。大字先出,印章落在配乐重拍上,没有口播。
45
+ ```
46
+
47
+ ## 口播
48
+
49
+ ### 暖纸工作台
50
+
51
+ 暖米色纸面、步骤条。必须上传口播录音。
52
+
53
+ ```
54
+ 用暖纸工作台讲「一件事可以分成哪三步做完」。每一步只留一个动作,口播用你上传的录音。
55
+ ```
56
+
57
+ ### 剪辑台
58
+
59
+ 软件界面一镜到底。必须上传口播录音,截图可以一起附上。
60
+
61
+ ```
62
+ 用剪辑台讲「一个功能从想法到能点下去,中间经过哪几个画面」。按你上传的口播走,产品截图有就放进对应的一步。
63
+ ```
64
+
65
+ ### 像素贴纸
66
+
67
+ 像素小人和贴纸大字。竖屏。必须上传口播录音。
68
+
69
+ ```
70
+ 用像素贴纸讲「一个词就能说清的那件事」。竖屏,贴纸上只留这一个词,口播用你上传的录音。
71
+ ```
72
+
73
+ ### 像素手绘
74
+
75
+ 像素主持人和细线卡片。横屏。必须上传口播录音。
76
+
77
+ ```
78
+ 用像素手绘讲「一个概念要先看见,再记住」。横屏,演播室和旁切交替,口播用你上传的录音。
79
+ ```
80
+
81
+ ### 矢量手绘
82
+
83
+ 带表情的矢量小人。竖屏。必须上传口播录音。
84
+
85
+ ```
86
+ 用矢量手绘讲「一个人听到这句话时,表情是怎么变的」。竖屏,一句一个表情,口播用你上传的录音。
87
+ ```
88
+
89
+ ## 拼贴
90
+
91
+ ### 时间轴长卷
92
+
93
+ 旧纸桌面上的时间尺。没有口播,配乐必须上传。年份图片可以附上。
94
+
95
+ ```
96
+ 用时间轴长卷讲「这件事是从哪一年开始,一步步走到今天的」。时间尺按配乐往前走,没有口播。年份图片有就钉在对应的那一格。
97
+ ```
98
+
99
+ ### 硬切拼贴
100
+
101
+ 黑卡大字和真实素材来回切。必须上传口播,图片或视频也要带上。
102
+
103
+ ```
104
+ 用硬切拼贴讲「两件看起来无关的事,最后为什么是同一件事」。黑卡大字和你上传的图片或视频来回切,口播用你的录音。
105
+ ```