Procházet zdrojové kódy

commit 云函数实现,线上部署完成

Yi Jiarui před 3 měsíci
rodič
revize
b4fc8713b8
67 změnil soubory, kde provedl 10779 přidání a 979 odebrání
  1. 21 0
      angular.json
  2. 122 0
      cloud-functions/01-manifestManager.js
  3. 121 0
      cloud-functions/02-taskManager.js
  4. 98 0
      cloud-functions/03-historyManager.js
  5. 121 0
      cloud-functions/04-resultManager.js
  6. 128 0
      cloud-functions/05-remixManager.js
  7. 169 0
      cloud-functions/06-voiceManager.js
  8. 111 0
      cloud-functions/07-quicklyVideo.js
  9. 132 0
      cloud-functions/08-proxyHub.js
  10. 310 0
      cloud-functions/09-uploadManager.js
  11. 111 0
      cloud-functions/DEPLOY.md
  12. 32 0
      cloud-functions/README.md
  13. 60 0
      cloud-functions/_template.md
  14. 74 0
      cloud-functions/smoke-test.md
  15. 886 2
      package-lock.json
  16. 5 0
      package.json
  17. 95 122
      server.js
  18. 3 2
      src/app/app.config.ts
  19. 319 0
      src/app/app.css
  20. 82 99
      src/app/app.html
  21. 458 290
      src/app/app.ts
  22. 82 0
      src/app/components/app-assistant/app-assistant.component.css
  23. 3 1
      src/app/components/app-assistant/app-assistant.component.html
  24. 12 1
      src/app/components/app-assistant/app-assistant.component.ts
  25. 131 1
      src/app/components/app-sidebar/app-sidebar.component.css
  26. 5 3
      src/app/components/app-sidebar/app-sidebar.component.ts
  27. 96 11
      src/app/components/digital-human-form/digital-human-form.component.ts
  28. 1 1
      src/app/components/portrait-prepare/portrait-prepare.component.ts
  29. 300 0
      src/app/components/session-bar/session-bar.component.css
  30. 256 0
      src/app/components/session-bar/session-bar.component.ts
  31. 1 1
      src/app/components/voice-picker/voice-picker.component.ts
  32. 90 0
      src/app/models/pipeline-draft.model.ts
  33. 646 0
      src/app/pages/drafts/drafts.component.css
  34. 258 0
      src/app/pages/drafts/drafts.component.html
  35. 215 0
      src/app/pages/drafts/drafts.component.ts
  36. 229 5
      src/app/pages/home/home.component.css
  37. 2 1
      src/app/pages/home/home.component.html
  38. 8 0
      src/app/pages/home/home.component.ts
  39. 421 0
      src/app/pages/library/library.component.css
  40. 169 0
      src/app/pages/library/library.component.html
  41. 234 0
      src/app/pages/library/library.component.ts
  42. 9 2
      src/app/pages/pipelines/action-transfer/action-transfer.component.html
  43. 153 64
      src/app/pages/pipelines/action-transfer/action-transfer.component.ts
  44. 41 41
      src/app/pages/pipelines/asset-remix/asset-remix.component.html
  45. 287 29
      src/app/pages/pipelines/asset-remix/asset-remix.component.ts
  46. 327 0
      src/app/pages/pipelines/image-generation/image-generation.component.css
  47. 145 0
      src/app/pages/pipelines/image-generation/image-generation.component.html
  48. 358 0
      src/app/pages/pipelines/image-generation/image-generation.component.ts
  49. 8 1
      src/app/pages/pipelines/image-to-video/image-to-video.component.html
  50. 129 23
      src/app/pages/pipelines/image-to-video/image-to-video.component.ts
  51. 166 0
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.css
  52. 118 2
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.html
  53. 743 34
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts
  54. 0 156
      src/app/pipelines/composite-sse.ts
  55. 12 0
      src/app/pipelines/pipeline-registry.ts
  56. 92 45
      src/app/services/assistant.service.ts
  57. 531 0
      src/app/services/browser-ffmpeg.service.ts
  58. 124 0
      src/app/services/cloud-api.interceptor.ts
  59. 34 0
      src/app/services/cloud-functions.ts
  60. 28 17
      src/app/services/digital-human.service.ts
  61. 117 0
      src/app/services/draft-storage.ts
  62. 18 25
      src/app/services/jimeng.service.ts
  63. 121 0
      src/app/services/llm.service.ts
  64. 26 0
      src/app/services/markdown.service.ts
  65. 89 0
      src/app/services/parse.service.ts
  66. 357 0
      src/app/services/pipeline-session.service.ts
  67. 129 0
      src/app/services/qiniu-upload.service.ts

+ 21 - 0
angular.json

@@ -22,6 +22,16 @@
               {
                 "glob": "**/*",
                 "input": "public"
+              },
+              {
+                "glob": "ffmpeg-core.*",
+                "input": "node_modules/@ffmpeg/core/dist/esm",
+                "output": "assets/ffmpeg"
+              },
+              {
+                "glob": "*.js",
+                "input": "node_modules/@ffmpeg/ffmpeg/dist/esm",
+                "output": "assets/ffmpeg/worker"
               }
             ],
             "styles": [
@@ -30,6 +40,17 @@
           },
           "configurations": {
             "production": {
+              "optimization": {
+                "scripts": true,
+                "styles": {
+                  "minify": true,
+                  "inlineCritical": true,
+                  "removeSpecialComments": true
+                },
+                "fonts": {
+                  "inline": false
+                }
+              },
               "budgets": [
                 {
                   "type": "initial",

+ 122 - 0
cloud-functions/01-manifestManager.js

@@ -0,0 +1,122 @@
+/**
+ * 云函数:manifestManager(视频清单)
+ * 替代:GET/POST/PUT/DELETE /api/manifest[/:videoId]
+ * actions: list | get | create | update | delete
+ */
+async function handler(request, response) {
+  try {
+    await Psql.query(`
+      CREATE TABLE IF NOT EXISTS "Manifest" (
+        "objectId"  VARCHAR(50) PRIMARY KEY,
+        "bizId"     VARCHAR(255),
+        "data"      JSONB NOT NULL DEFAULT '{}',
+        "userId"    VARCHAR(255) DEFAULT '',
+        "category"  VARCHAR(50)  DEFAULT '',
+        "source"    VARCHAR(50)  DEFAULT '',
+        "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
+        "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
+      )
+    `);
+    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_manifest_biz ON "Manifest" ("bizId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_manifest_user ON "Manifest" ("userId")`);
+
+    const action = pickParam(request, 'action') || 'list';
+    const userId = pickParam(request, 'userId') || '';
+
+    if (action === 'list') {
+      const limit = Math.min(parseInt(pickParam(request, 'limit') || '200'), 500);
+      const conds = [], params = [];
+      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
+      const category = pickParam(request, 'category');
+      if (category) { params.push(category); conds.push(`"category" = $${params.length}`); }
+      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
+      params.push(limit);
+      const rows = await Psql.query(
+        `SELECT * FROM "Manifest" ${where} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
+        params
+      );
+      response.json({ code: 200, success: true, data: rows.map(rowToVideo) });
+      return;
+    }
+
+    if (action === 'get') {
+      const bizId = pickParam(request, 'videoId', 'id', 'bizId');
+      if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
+      const rows = await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 LIMIT 1`, [bizId]);
+      if (!rows.length) return response.json({ code: 404, success: false, error: '未找到视频' });
+      response.json({ code: 200, success: true, data: rowToVideo(rows[0]) });
+      return;
+    }
+
+    if (action === 'create') {
+      const body = pickParam(request, 'video', 'data') || request.body || request.params || {};
+      const bizId = body.id || body.bizId;
+      if (!bizId || !body.filename) {
+        return response.json({ code: 400, success: false, error: '缺少 id 或 filename' });
+      }
+      const exists = await Psql.query(`SELECT "objectId" FROM "Manifest" WHERE "bizId" = $1`, [bizId]);
+      if (exists.length) return response.json({ code: 409, success: false, error: `视频 ${bizId} 已存在` });
+
+      const objectId = generateId();
+      await Psql.query(
+        `INSERT INTO "Manifest" ("objectId","bizId","data","userId","category","source")
+         VALUES ($1,$2,$3,$4,$5,$6)`,
+        [objectId, bizId, JSON.stringify(body), userId, body.category || '', body.source || '']
+      );
+      response.json({ code: 200, success: true, data: { ...body, objectId } });
+      return;
+    }
+
+    if (action === 'update') {
+      const bizId = pickParam(request, 'videoId', 'id', 'bizId');
+      if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
+      const patch = pickParam(request, 'video', 'data', 'patch') || request.body || {};
+      const cur = await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 LIMIT 1`, [bizId]);
+      if (!cur.length) return response.json({ code: 404, success: false, error: '未找到视频' });
+      const merged = { ...rowToVideo(cur[0]), ...patch };
+      await Psql.query(
+        `UPDATE "Manifest" SET "data"=$1, "category"=$2, "source"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
+        [JSON.stringify(merged), merged.category || '', merged.source || '', bizId]
+      );
+      response.json({ code: 200, success: true, data: merged });
+      return;
+    }
+
+    if (action === 'delete') {
+      const bizId = pickParam(request, 'videoId', 'id', 'bizId');
+      if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
+      const result = await Psql.query(`DELETE FROM "Manifest" WHERE "bizId" = $1 RETURNING "bizId"`, [bizId]);
+      response.json({ code: 200, success: true, data: { videoId: bizId, deleted: result.length > 0 } });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('❌ manifestManager 失败:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+function rowToVideo(row) {
+  const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
+  return { ...data, objectId: row.objectId, createdAt: row.createdAt, updatedAt: row.updatedAt };
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}
+
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}

+ 121 - 0
cloud-functions/02-taskManager.js

@@ -0,0 +1,121 @@
+/**
+ * 云函数:taskManager(任务管理)
+ * 替代:GET/POST/PUT/DELETE /api/tasks[/:id]
+ * actions: list | get | create | update | delete
+ */
+async function handler(request, response) {
+  try {
+    await Psql.query(`
+      CREATE TABLE IF NOT EXISTS "VideoflowTask" (
+        "objectId"  VARCHAR(50) PRIMARY KEY,
+        "bizId"     VARCHAR(255),
+        "data"      JSONB NOT NULL DEFAULT '{}',
+        "userId"    VARCHAR(255) DEFAULT '',
+        "type"      VARCHAR(50)  DEFAULT '',
+        "status"    VARCHAR(50)  DEFAULT '',
+        "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
+        "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
+      )
+    `);
+    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_videoflow_task_biz ON "VideoflowTask" ("bizId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_task_user ON "VideoflowTask" ("userId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_task_status ON "VideoflowTask" ("status")`);
+
+    const action = pickParam(request, 'action') || 'list';
+    const userId = pickParam(request, 'userId') || '';
+
+    if (action === 'list') {
+      const conds = [], params = [];
+      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
+      const status = pickParam(request, 'status');
+      if (status) { params.push(status); conds.push(`"status" = $${params.length}`); }
+      const type = pickParam(request, 'type');
+      if (type) { params.push(type); conds.push(`"type" = $${params.length}`); }
+      const limit = Math.min(parseInt(pickParam(request, 'limit') || '500'), 1000);
+      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
+      params.push(limit);
+      const rows = await Psql.query(
+        `SELECT * FROM "VideoflowTask" ${where} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
+        params
+      );
+      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+      return;
+    }
+
+    if (action === 'get') {
+      const id = pickParam(request, 'id', 'taskId', 'bizId');
+      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+      const rows = await Psql.query(`SELECT * FROM "VideoflowTask" WHERE "bizId" = $1 LIMIT 1`, [id]);
+      if (!rows.length) return response.json({ code: 404, success: false, error: '未找到任务' });
+      response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
+      return;
+    }
+
+    if (action === 'create') {
+      const body = pickParam(request, 'task', 'data') || request.body || {};
+      const bizId = body.id || body.bizId || generateId();
+      const merged = { ...body, id: bizId, created_at: body.created_at || new Date().toISOString(), updated_at: new Date().toISOString() };
+      const objectId = generateId();
+      await Psql.query(
+        `INSERT INTO "VideoflowTask" ("objectId","bizId","data","userId","type","status")
+         VALUES ($1,$2,$3,$4,$5,$6)
+         ON CONFLICT ("bizId") DO UPDATE SET "data"=$3, "type"=$5, "status"=$6, "updatedAt"=NOW()`,
+        [objectId, bizId, JSON.stringify(merged), userId, merged.type || '', merged.status || '']
+      );
+      response.json({ code: 200, success: true, data: merged });
+      return;
+    }
+
+    if (action === 'update') {
+      const id = pickParam(request, 'id', 'taskId', 'bizId');
+      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+      const patch = pickParam(request, 'task', 'data', 'patch') || request.body || {};
+      const cur = await Psql.query(`SELECT * FROM "VideoflowTask" WHERE "bizId" = $1 LIMIT 1`, [id]);
+      if (!cur.length) return response.json({ code: 404, success: false, error: '未找到任务' });
+      const merged = { ...rowToObj(cur[0]), ...patch, updated_at: new Date().toISOString() };
+      await Psql.query(
+        `UPDATE "VideoflowTask" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
+        [JSON.stringify(merged), merged.type || '', merged.status || '', id]
+      );
+      response.json({ code: 200, success: true, data: merged });
+      return;
+    }
+
+    if (action === 'delete') {
+      const id = pickParam(request, 'id', 'taskId', 'bizId');
+      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+      const r = await Psql.query(`DELETE FROM "VideoflowTask" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
+      response.json({ code: 200, success: true, data: { id, deleted: r.length > 0 } });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('❌ taskManager 失败:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+function rowToObj(row) {
+  const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
+  return { ...data, objectId: row.objectId, createdAt: row.createdAt, updatedAt: row.updatedAt };
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}
+
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}

+ 98 - 0
cloud-functions/03-historyManager.js

@@ -0,0 +1,98 @@
+/**
+ * 云函数:historyManager(历史记录)
+ * 替代:GET/POST/DELETE /api/history[/:id]
+ * actions: list | create | delete | clearAll
+ */
+async function handler(request, response) {
+  try {
+    await Psql.query(`
+      CREATE TABLE IF NOT EXISTS "History" (
+        "objectId"  VARCHAR(50) PRIMARY KEY,
+        "bizId"     VARCHAR(255),
+        "data"      JSONB NOT NULL DEFAULT '{}',
+        "userId"    VARCHAR(255) DEFAULT '',
+        "action"    VARCHAR(100) DEFAULT '',
+        "createdAt" TIMESTAMPTZ  DEFAULT NOW()
+      )
+    `);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_history_user ON "History" ("userId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_history_created ON "History" ("createdAt" DESC)`);
+
+    const action = pickParam(request, 'action') || 'list';
+    const userId = pickParam(request, 'userId') || '';
+
+    if (action === 'list') {
+      const conds = [], params = [];
+      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
+      const limit = Math.min(parseInt(pickParam(request, 'limit') || '500'), 2000);
+      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
+      params.push(limit);
+      const rows = await Psql.query(
+        `SELECT * FROM "History" ${where} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
+        params
+      );
+      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+      return;
+    }
+
+    if (action === 'create') {
+      const body = pickParam(request, 'entry', 'data') || request.body || {};
+      const bizId = body.id || body.bizId || generateId();
+      const merged = { ...body, id: bizId, created_at: body.created_at || new Date().toISOString() };
+      const objectId = generateId();
+      await Psql.query(
+        `INSERT INTO "History" ("objectId","bizId","data","userId","action") VALUES ($1,$2,$3,$4,$5)`,
+        [objectId, bizId, JSON.stringify(merged), userId, body.action || body.type || '']
+      );
+      response.json({ code: 200, success: true, data: merged });
+      return;
+    }
+
+    if (action === 'delete') {
+      const id = pickParam(request, 'id', 'bizId');
+      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+      const r = await Psql.query(`DELETE FROM "History" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
+      response.json({ code: 200, success: true, data: { id, deleted: r.length > 0 } });
+      return;
+    }
+
+    if (action === 'clearAll') {
+      if (userId) {
+        await Psql.query(`DELETE FROM "History" WHERE "userId" = $1`, [userId]);
+      } else {
+        await Psql.query(`DELETE FROM "History"`);
+      }
+      response.json({ code: 200, success: true, message: '已清空历史' });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('❌ historyManager 失败:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+function rowToObj(row) {
+  const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
+  return { ...data, objectId: row.objectId, createdAt: row.createdAt };
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}
+
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}

+ 121 - 0
cloud-functions/04-resultManager.js

@@ -0,0 +1,121 @@
+/**
+ * 云函数:resultManager(结果库)
+ * 替代:GET/POST/PUT/DELETE /api/results[/:id]
+ * actions: list | get | create | update | delete
+ */
+async function handler(request, response) {
+  try {
+    await Psql.query(`
+      CREATE TABLE IF NOT EXISTS "VideoflowResult" (
+        "objectId"  VARCHAR(50) PRIMARY KEY,
+        "bizId"     VARCHAR(255),
+        "data"      JSONB NOT NULL DEFAULT '{}',
+        "userId"    VARCHAR(255) DEFAULT '',
+        "type"      VARCHAR(50)  DEFAULT '',
+        "status"    VARCHAR(50)  DEFAULT '',
+        "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
+        "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
+      )
+    `);
+    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_videoflow_result_biz ON "VideoflowResult" ("bizId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_result_user ON "VideoflowResult" ("userId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_result_type ON "VideoflowResult" ("type")`);
+
+    const action = pickParam(request, 'action') || 'list';
+    const userId = pickParam(request, 'userId') || '';
+
+    if (action === 'list') {
+      const conds = [], params = [];
+      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
+      const type = pickParam(request, 'type');
+      if (type) { params.push(type); conds.push(`"type" = $${params.length}`); }
+      const status = pickParam(request, 'status');
+      if (status) { params.push(status); conds.push(`"status" = $${params.length}`); }
+      const limit = Math.min(parseInt(pickParam(request, 'limit') || '500'), 2000);
+      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
+      params.push(limit);
+      const rows = await Psql.query(
+        `SELECT * FROM "VideoflowResult" ${where} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
+        params
+      );
+      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+      return;
+    }
+
+    if (action === 'get') {
+      const id = pickParam(request, 'id', 'bizId');
+      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+      const rows = await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 LIMIT 1`, [id]);
+      if (!rows.length) return response.json({ code: 404, success: false, error: '未找到结果' });
+      response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
+      return;
+    }
+
+    if (action === 'create') {
+      const body = pickParam(request, 'result', 'data') || request.body || {};
+      const bizId = body.id || body.bizId || generateId();
+      const merged = { ...body, id: bizId, created_at: body.created_at || new Date().toISOString(), updated_at: new Date().toISOString() };
+      const objectId = generateId();
+      await Psql.query(
+        `INSERT INTO "VideoflowResult" ("objectId","bizId","data","userId","type","status")
+         VALUES ($1,$2,$3,$4,$5,$6)
+         ON CONFLICT ("bizId") DO UPDATE SET "data"=$3, "type"=$5, "status"=$6, "updatedAt"=NOW()`,
+        [objectId, bizId, JSON.stringify(merged), userId, merged.type || '', merged.status || '']
+      );
+      response.json({ code: 200, success: true, data: merged });
+      return;
+    }
+
+    if (action === 'update') {
+      const id = pickParam(request, 'id', 'bizId');
+      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+      const patch = pickParam(request, 'result', 'data', 'patch') || request.body || {};
+      const cur = await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 LIMIT 1`, [id]);
+      if (!cur.length) return response.json({ code: 404, success: false, error: '未找到结果' });
+      const merged = { ...rowToObj(cur[0]), ...patch, updated_at: new Date().toISOString() };
+      await Psql.query(
+        `UPDATE "VideoflowResult" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
+        [JSON.stringify(merged), merged.type || '', merged.status || '', id]
+      );
+      response.json({ code: 200, success: true, data: merged });
+      return;
+    }
+
+    if (action === 'delete') {
+      const id = pickParam(request, 'id', 'bizId');
+      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+      const r = await Psql.query(`DELETE FROM "VideoflowResult" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
+      response.json({ code: 200, success: true, data: { id, deleted: r.length > 0 } });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('❌ resultManager 失败:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+function rowToObj(row) {
+  const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
+  return { ...data, objectId: row.objectId, createdAt: row.createdAt, updatedAt: row.updatedAt };
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}
+
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}

+ 128 - 0
cloud-functions/05-remixManager.js

@@ -0,0 +1,128 @@
+/**
+ * 云函数:remixManager(视频重塑记录)
+ * 替代:GET/POST/DELETE /api/remixes[/:videoId[/:remixId]]
+ * actions:
+ *   listAll       → 所有视频的重塑汇总 { [videoId]: Remix[] }
+ *   listByVideo   → 某视频的所有重塑
+ *   upsert        → 创建或更新一条
+ *   delete        → 删除一条
+ */
+async function handler(request, response) {
+  try {
+    await Psql.query(`
+      CREATE TABLE IF NOT EXISTS "Remix" (
+        "objectId"  VARCHAR(50) PRIMARY KEY,
+        "videoId"   VARCHAR(255) NOT NULL,
+        "remixId"   VARCHAR(255) NOT NULL,
+        "data"      JSONB NOT NULL DEFAULT '{}',
+        "userId"    VARCHAR(255) DEFAULT '',
+        "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
+        "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
+      )
+    `);
+    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_remix_pair ON "Remix" ("videoId","remixId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_remix_video ON "Remix" ("videoId")`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_remix_user ON "Remix" ("userId")`);
+
+    const action = pickParam(request, 'action') || 'listAll';
+    const userId = pickParam(request, 'userId') || '';
+
+    if (action === 'listAll') {
+      const conds = [], params = [];
+      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
+      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
+      const rows = await Psql.query(
+        `SELECT * FROM "Remix" ${where} ORDER BY "createdAt" DESC LIMIT 5000`,
+        params
+      );
+      const grouped = {};
+      for (const r of rows) {
+        if (!grouped[r.videoId]) grouped[r.videoId] = [];
+        grouped[r.videoId].push(rowToObj(r));
+      }
+      response.json({ code: 200, success: true, data: grouped });
+      return;
+    }
+
+    if (action === 'listByVideo') {
+      const videoId = pickParam(request, 'videoId');
+      if (!videoId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
+      const rows = await Psql.query(
+        `SELECT * FROM "Remix" WHERE "videoId" = $1 ORDER BY "createdAt" DESC`,
+        [videoId]
+      );
+      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+      return;
+    }
+
+    if (action === 'upsert') {
+      const videoId = pickParam(request, 'videoId');
+      const body = pickParam(request, 'remix', 'data') || request.body || {};
+      const remixId = body.remixId || pickParam(request, 'remixId');
+      if (!videoId || !remixId) {
+        return response.json({ code: 400, success: false, error: '缺少 videoId 或 remixId' });
+      }
+      const now = new Date().toISOString();
+      const merged = { ...body, remixId, videoId, updated_at: now };
+      const exists = await Psql.query(`SELECT "objectId" FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2`, [videoId, remixId]);
+      if (exists.length) {
+        await Psql.query(
+          `UPDATE "Remix" SET "data"=$1, "updatedAt"=NOW() WHERE "videoId"=$2 AND "remixId"=$3`,
+          [JSON.stringify(merged), videoId, remixId]
+        );
+      } else {
+        merged.created_at = now;
+        const objectId = generateId();
+        await Psql.query(
+          `INSERT INTO "Remix" ("objectId","videoId","remixId","data","userId") VALUES ($1,$2,$3,$4,$5)`,
+          [objectId, videoId, remixId, JSON.stringify(merged), userId]
+        );
+      }
+      response.json({ code: 200, success: true, data: merged });
+      return;
+    }
+
+    if (action === 'delete') {
+      const videoId = pickParam(request, 'videoId');
+      const remixId = pickParam(request, 'remixId');
+      if (!videoId || !remixId) {
+        return response.json({ code: 400, success: false, error: '缺少 videoId 或 remixId' });
+      }
+      const r = await Psql.query(
+        `DELETE FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 RETURNING "objectId"`,
+        [videoId, remixId]
+      );
+      response.json({ code: 200, success: true, data: { videoId, remixId, deleted: r.length > 0 } });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('❌ remixManager 失败:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+function rowToObj(row) {
+  const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
+  return { ...data, objectId: row.objectId, createdAt: row.createdAt, updatedAt: row.updatedAt };
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}
+
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}

+ 169 - 0
cloud-functions/06-voiceManager.js

@@ -0,0 +1,169 @@
+/**
+ * 云函数:voiceManager(音色档案 + speaker_id 池)
+ * 替代:
+ *   POST /api/voice/auto-speaker-id   → action=autoSpeakerId
+ *   POST /api/voice-profiles/sync     → action=syncProfile
+ * 新增:
+ *   action=listProfiles  列出所有音色档案
+ *   action=initPool      初始化 speaker_id 池(首次部署用)
+ *
+ * 注意:speaker_id 池原本在 docs/音色创建/speaker_id.md 文件里。
+ * 迁移到云函数后改为存储在 "VoiceSpeakerPool" 表。
+ * 首次部署后请用 action=initPool 把现有池一次性灌入。
+ */
+async function handler(request, response) {
+  try {
+    await Psql.query(`
+      CREATE TABLE IF NOT EXISTS "VoiceProfile" (
+        "objectId"   VARCHAR(50) PRIMARY KEY,
+        "timbreId"   VARCHAR(255),
+        "speakerId"  VARCHAR(255),
+        "name"       VARCHAR(255) DEFAULT '',
+        "data"       JSONB NOT NULL DEFAULT '{}',
+        "userId"     VARCHAR(255) DEFAULT '',
+        "occupied"   BOOLEAN DEFAULT TRUE,
+        "createdAt"  TIMESTAMPTZ DEFAULT NOW(),
+        "updatedAt"  TIMESTAMPTZ DEFAULT NOW()
+      )
+    `);
+    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_timbre ON "VoiceProfile" ("timbreId") WHERE "timbreId" IS NOT NULL AND "timbreId" <> ''`);
+    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_voice_speaker ON "VoiceProfile" ("speakerId")`);
+
+    await Psql.query(`
+      CREATE TABLE IF NOT EXISTS "VoiceSpeakerPool" (
+        "speakerId"  VARCHAR(255) PRIMARY KEY,
+        "trained"    BOOLEAN DEFAULT FALSE,
+        "createdAt"  TIMESTAMPTZ DEFAULT NOW()
+      )
+    `);
+
+    const action = pickParam(request, 'action') || 'listProfiles';
+    const userId = pickParam(request, 'userId') || '';
+
+    if (action === 'autoSpeakerId') {
+      const used = await Psql.query(`
+        SELECT s."speakerId" FROM "VoiceSpeakerPool" s
+        LEFT JOIN "VoiceProfile" v ON v."speakerId" = s."speakerId"
+        WHERE v."speakerId" IS NULL AND s."trained" = FALSE
+        ORDER BY s."createdAt" ASC
+        LIMIT 1
+      `);
+      if (!used.length) {
+        return response.json({ code: 500, success: false, error: 'speaker_id 池已耗尽,请先 initPool 添加' });
+      }
+      response.json({ code: 200, success: true, data: { speakerId: used[0].speakerId } });
+      return;
+    }
+
+    if (action === 'syncProfile') {
+      const body = pickParam(request, 'profile', 'data') || request.body || {};
+      const timbreId = body.timbre_id || body.timbreId || '';
+      const speakerId = body.speaker_id || body.speakerId || '';
+      if (!timbreId && !speakerId) {
+        return response.json({ code: 400, success: false, error: '缺少 timbre_id 或 speaker_id' });
+      }
+      const now = new Date().toISOString();
+      const merged = { ...body, timbre_id: timbreId, speaker_id: speakerId, updated_at: now };
+
+      let existing = [];
+      if (timbreId) {
+        existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "timbreId" = $1 LIMIT 1`, [timbreId]);
+      }
+      if (!existing.length && speakerId) {
+        existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "speakerId" = $1 LIMIT 1`, [speakerId]);
+      }
+
+      if (existing.length) {
+        const old = typeof existing[0].data === 'string' ? JSON.parse(existing[0].data) : (existing[0].data || {});
+        const merged2 = { ...old, ...merged };
+        await Psql.query(
+          `UPDATE "VoiceProfile" SET "timbreId"=$1, "speakerId"=$2, "name"=$3, "data"=$4, "occupied"=$5, "updatedAt"=NOW() WHERE "objectId"=$6`,
+          [timbreId, speakerId, body.name || merged2.name || '', JSON.stringify(merged2), body.occupied !== false, existing[0].objectId]
+        );
+        response.json({ code: 200, success: true, data: { ...merged2, objectId: existing[0].objectId } });
+      } else {
+        merged.created_at = now;
+        const objectId = generateId();
+        await Psql.query(
+          `INSERT INTO "VoiceProfile" ("objectId","timbreId","speakerId","name","data","userId","occupied")
+           VALUES ($1,$2,$3,$4,$5,$6,$7)`,
+          [objectId, timbreId, speakerId, body.name || '', JSON.stringify(merged), userId, body.occupied !== false]
+        );
+        response.json({ code: 200, success: true, data: { ...merged, objectId } });
+      }
+      return;
+    }
+
+    if (action === 'listProfiles') {
+      const conds = [], params = [];
+      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
+      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
+      const rows = await Psql.query(
+        `SELECT * FROM "VoiceProfile" ${where} ORDER BY "createdAt" DESC LIMIT 1000`,
+        params
+      );
+      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+      return;
+    }
+
+    if (action === 'initPool') {
+      const ids = pickParam(request, 'speakerIds') || [];
+      const list = Array.isArray(ids) ? ids : String(ids || '').split(/[\s,]+/).filter(Boolean);
+      if (!list.length) {
+        return response.json({ code: 400, success: false, error: '缺少 speakerIds(数组或逗号分隔字符串)' });
+      }
+      let inserted = 0;
+      for (const sid of list) {
+        const r = await Psql.query(
+          `INSERT INTO "VoiceSpeakerPool" ("speakerId") VALUES ($1) ON CONFLICT DO NOTHING RETURNING "speakerId"`,
+          [String(sid).trim()]
+        );
+        if (r.length) inserted++;
+      }
+      response.json({ code: 200, success: true, data: { total: list.length, inserted } });
+      return;
+    }
+
+    if (action === 'deleteProfile') {
+      const objectId = pickParam(request, 'objectId');
+      const timbreId = pickParam(request, 'timbreId');
+      if (!objectId && !timbreId) {
+        return response.json({ code: 400, success: false, error: '缺少 objectId 或 timbreId' });
+      }
+      const r = objectId
+        ? await Psql.query(`DELETE FROM "VoiceProfile" WHERE "objectId" = $1 RETURNING "objectId"`, [objectId])
+        : await Psql.query(`DELETE FROM "VoiceProfile" WHERE "timbreId" = $1 RETURNING "objectId"`, [timbreId]);
+      response.json({ code: 200, success: true, data: { deleted: r.length > 0 } });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('❌ voiceManager 失败:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+function rowToObj(row) {
+  const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
+  return { ...data, objectId: row.objectId, occupied: row.occupied, createdAt: row.createdAt, updatedAt: row.updatedAt };
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}
+
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}

+ 111 - 0
cloud-functions/07-quicklyVideo.js

@@ -0,0 +1,111 @@
+/**
+ * 云函数:quicklyVideo(一键成片代理)
+ * 替代:
+ *   POST /api/quickly/create  → action=create
+ *   POST /api/quickly/query   → action=query
+ *
+ * 把 server.js 里的签名 + relay 逻辑搬到云函数,
+ * 隐藏 APP_KEY / APP_SECRET,前端只需传 videoUrls。
+ *
+ * ⚠️ 不是参考别项目,是本项目 server.js 1820~1914 行的等价实现。
+ */
+const crypto = require('crypto');
+
+const QUICKLY_APP_KEY = 'ZmNmOGRhNjYzZTAx';
+const QUICKLY_APP_SECRET = 'eaa12154c248cad9159a9d6ea8bedf46';
+const QUICKLY_ACCOUNT_ID = '12859_117409';
+const QUICKLY_CALLBACK_URL = 'https://server.fmode.cn/api/functions/cut/onemerge';
+const QUICKLY_RELAY_URL = 'https://server.fmode.cn/api/functions';
+// 上游筷子代理云函数 ID(fmode 平台已有)
+const UPSTREAM_FN_ID = 'sWvRr8RvPT';
+
+async function handler(request, response) {
+  try {
+    const action = pickParam(request, 'action');
+
+    if (action === 'create') {
+      const videoUrls = pickParam(request, 'videoUrls');
+      const options = pickParam(request, 'options') || {};
+      if (!Array.isArray(videoUrls) || videoUrls.length === 0) {
+        return response.json({ code: 400, success: false, error: '缺少 videoUrls 参数' });
+      }
+
+      const timestamp = Date.now().toString();
+      const sign = crypto.createHash('md5').update(timestamp + '#' + QUICKLY_APP_SECRET).digest('hex');
+
+      const apiBody = {
+        account_id: QUICKLY_ACCOUNT_ID,
+        callback_url: QUICKLY_CALLBACK_URL,
+        material_list: videoUrls.map(url => ({ type: 'video', value: url })),
+        tags: options.tags || '视频,AI生成',
+        proportion: options.proportion || '9:16',
+        video_duration: options.videoDuration || { min: 10, max: 30 },
+        pre_id: timestamp,
+        compose_number: 1,
+        ai_voice: options.aiVoice ?? 1,
+        ai_bgm: options.aiBgm ?? 1,
+        ai_flower: 1,
+        ai_subtitle: options.aiSubtitle ?? 0,
+        original_voice: 0
+      };
+
+      const relayBody = {
+        action: 'relay',
+        relayData: JSON.stringify({
+          apiPath: '/v2/video/vlog/create',
+          apiBody,
+          appKey: QUICKLY_APP_KEY,
+          timestamp,
+          sign
+        })
+      };
+
+      console.log(`📦 quicklyVideo.create timestamp=${timestamp} videos=${videoUrls.length}`);
+
+      const r = await fetch(QUICKLY_RELAY_URL, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(relayBody)
+      });
+      const result = await r.json();
+      response.json({ code: 200, success: true, data: result });
+      return;
+    }
+
+    if (action === 'query') {
+      const taskId = pickParam(request, 'taskId');
+      if (!taskId) return response.json({ code: 400, success: false, error: '缺少 taskId' });
+
+      const r = await fetch(QUICKLY_RELAY_URL, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          id: UPSTREAM_FN_ID,
+          _ApplicationId: 'ncloudmaster',
+          action: 'query',
+          taskId
+        })
+      });
+      const result = await r.json();
+      response.json({ code: 200, success: true, data: result });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('❌ quicklyVideo 失败:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}

+ 132 - 0
cloud-functions/08-proxyHub.js

@@ -0,0 +1,132 @@
+/**
+ * 云函数:proxyHub(LLM 代理中心)
+ * 替代:POST /api/llm/chat → action=llmChat
+ *      POST /api/llm/gemini → action=geminiChat
+ *      流式聊天:action=llmChatStream(SSE 透传,需要 fmode 平台 dispatcher 支持 chunked response)
+ * 注意:/api/video-proxy 不迁(二进制大流量不适合云函数)
+ */
+const LLM_BASE_URL = 'http://server.fmode.cn:9999';
+const LLM_API_KEY = 'sk-MFBOnsAtZiqlwwMgMLKCFmPy55pMohQEGMqsIw3aJrIgvoEO';
+
+async function handler(request, response) {
+  try {
+    const action = pickParam(request, 'action');
+
+    // ============ 流式聊天(SSE 透传) ============
+    if (action === 'llmChatStream') {
+      const body = pickParam(request, 'payload', 'data') || request.body || {};
+      const { model, messages, temperature, max_tokens, ...rest } = body;
+      if (!Array.isArray(messages) || messages.length === 0) {
+        return response.json({ code: 400, success: false, error: '缺少 messages' });
+      }
+      const upstream = await fetch(`${LLM_BASE_URL}/v1/chat/completions`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': `Bearer ${LLM_API_KEY}`
+        },
+        body: JSON.stringify({
+          model: model || 'gpt-4o',
+          messages,
+          temperature: temperature ?? 0.7,
+          max_tokens: max_tokens ?? 4096,
+          ...rest,
+          stream: true
+        })
+      });
+      if (!upstream.ok) {
+        const errText = await upstream.text();
+        return response.json({ code: upstream.status, success: false, error: errText || `upstream ${upstream.status}` });
+      }
+
+      // 把上游 SSE 原样透传给客户端
+      // 注意:fmode 平台 dispatcher 必须放行 chunked response,否则客户端会一次性收到全部内容
+      try {
+        if (typeof response.setHeader === 'function') {
+          response.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+          response.setHeader('Cache-Control', 'no-cache, no-transform');
+          response.setHeader('Connection', 'keep-alive');
+          response.setHeader('X-Accel-Buffering', 'no');
+        }
+        if (typeof response.flushHeaders === 'function') response.flushHeaders();
+      } catch (_) {}
+
+      const reader = upstream.body.getReader();
+      try {
+        while (true) {
+          const { value, done } = await reader.read();
+          if (done) break;
+          if (value && typeof response.write === 'function') {
+            response.write(Buffer.from(value));
+          }
+        }
+      } finally {
+        try { reader.cancel(); } catch (_) {}
+        if (typeof response.end === 'function') response.end();
+      }
+      return;
+    }
+
+    if (action === 'llmChat') {
+      const body = pickParam(request, 'payload', 'data') || request.body || {};
+      const { model, messages, temperature, max_tokens, ...rest } = body;
+      if (!Array.isArray(messages)) {
+        return response.json({ code: 400, success: false, error: '缺少 messages' });
+      }
+      const r = await fetch(`${LLM_BASE_URL}/v1/chat/completions`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': `Bearer ${LLM_API_KEY}`
+        },
+        body: JSON.stringify({
+          model: model || 'gpt-4o',
+          messages,
+          temperature: temperature ?? 0.7,
+          max_tokens: max_tokens ?? 4096,
+          stream: false,
+          ...rest
+        })
+      });
+      const data = await r.json();
+      response.json({ code: r.ok ? 200 : 500, success: r.ok, data });
+      return;
+    }
+
+    if (action === 'geminiChat') {
+      const body = pickParam(request, 'payload', 'data') || request.body || {};
+      const { model, contents, generationConfig, safetySettings, systemInstruction } = body;
+      if (!contents) {
+        return response.json({ code: 400, success: false, error: '缺少 contents' });
+      }
+      const r = await fetch(`${LLM_BASE_URL}/v1beta/models/${model || 'gemini-2.0-flash'}:generateContent`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': `Bearer ${LLM_API_KEY}`
+        },
+        body: JSON.stringify({ contents, generationConfig, safetySettings, systemInstruction })
+      });
+      const data = await r.json();
+      response.json({ code: r.ok ? 200 : 500, success: r.ok, data });
+      return;
+    }
+
+    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('❌ proxyHub 失败:', error.message);
+    response.json({ code: 500, success: false, error: error.message });
+  }
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}

+ 310 - 0
cloud-functions/09-uploadManager.js

@@ -0,0 +1,310 @@
+/**
+ * Cloud function: uploadManager
+ * Signs Qiniu direct-upload tokens. File bytes are uploaded from the browser
+ * directly to Qiniu, so the cloud function never handles large payloads.
+ *
+ * actions:
+ *   createUploadToken -> { uploadUrl, token, key, url, mimeType, kind, expiresAt }
+ */
+async function handler(request, response) {
+  try {
+    const action = pickParam(request, 'action') || 'createUploadToken';
+
+    if (action !== 'createUploadToken') {
+      return response.json({ code: 400, success: false, error: `Unknown action: ${action}` });
+    }
+
+    const filename = String(pickParam(request, 'filename', 'fileName') || `asset-${Date.now()}`);
+    const mimeType = String(pickParam(request, 'mimeType', 'contentType') || 'application/octet-stream');
+    const requestedKind = String(pickParam(request, 'kind') || '').trim();
+    const size = Number(pickParam(request, 'size') || 0);
+    const kind = normalizeKind(requestedKind, mimeType);
+    const key = buildDigitalHumanAssetKey(filename, kind);
+    const { token, deadline } = await buildQiniuUploadToken(key);
+    const url = `${QINIU_CDN_DOMAIN.replace(/\/$/, '')}/${key}`;
+
+    response.json({
+      code: 200,
+      success: true,
+      data: {
+        uploadUrl: QINIU_UPLOAD_URL,
+        token,
+        key,
+        url,
+        mimeType,
+        kind,
+        size,
+        expiresAt: deadline * 1000,
+      },
+    });
+  } catch (error) {
+    console.error('uploadManager failed:', error && error.message ? error.message : error);
+    response.json({
+      code: 500,
+      success: false,
+      error: error && error.message ? error.message : 'uploadManager failed',
+    });
+  }
+}
+
+const QINIU_ACCESS_KEY = readEnv('QINIU_AK') || readEnv('QINIU_ACCESS_KEY') || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
+const QINIU_SECRET_KEY = readEnv('QINIU_SK') || readEnv('QINIU_SECRET_KEY') || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
+const QINIU_BUCKET = readEnv('QINIU_BUCKET') || 'nova-repos';
+const QINIU_CDN_DOMAIN = readEnv('QINIU_CDN_DOMAIN') || 'https://repos.fmode.cn';
+const QINIU_CDN_PREFIX = readEnv('QINIU_CDN_PREFIX') || 'x/openclaw-skills';
+const QINIU_UPLOAD_URL = readEnv('QINIU_UPLOAD_URL') || 'https://up-z2.qiniup.com';
+
+async function buildQiniuUploadToken(key) {
+  if (!QINIU_ACCESS_KEY || !QINIU_SECRET_KEY) {
+    throw new Error('Qiniu AK/SK is not configured');
+  }
+
+  const deadline = Math.floor(Date.now() / 1000) + 3600;
+  const putPolicy = {
+    scope: `${QINIU_BUCKET}:${key}`,
+    deadline,
+  };
+  const encodedPutPolicy = toBase64Url(JSON.stringify(putPolicy));
+  const sign = await hmacSha1Base64Url(QINIU_SECRET_KEY, encodedPutPolicy);
+
+  return {
+    token: `${QINIU_ACCESS_KEY}:${sign}:${encodedPutPolicy}`,
+    deadline,
+  };
+}
+
+function buildDigitalHumanAssetKey(fileName, kind) {
+  const rawName = String(fileName || `asset-${Date.now()}`);
+  const extMatch = rawName.match(/(\.[a-z0-9]+)$/i);
+  const ext = extMatch ? extMatch[1].toLowerCase() : '';
+  const safeExt = ext && /^[.a-z0-9]+$/i.test(ext) ? ext : '';
+  const baseName = rawName
+    .replace(/\\/g, '/')
+    .split('/')
+    .pop()
+    .replace(/\.[^.]*$/, '')
+    .replace(/[^a-zA-Z0-9_-]/g, '_') || `asset-${Date.now()}`;
+  const date = new Date();
+  const yyyy = date.getFullYear();
+  const mm = String(date.getMonth() + 1).padStart(2, '0');
+  const dd = String(date.getDate()).padStart(2, '0');
+  const timestamp = `${yyyy}${mm}${dd}-${Date.now()}`;
+
+  return `${QINIU_CDN_PREFIX}/digital-human/${kind}/${yyyy}${mm}${dd}/${timestamp}-${baseName}${safeExt}`;
+}
+
+function normalizeKind(kind, mimeType) {
+  if (kind === 'audio' || kind === 'video' || kind === 'image') return kind;
+  if (mimeType.startsWith('audio/')) return 'audio';
+  if (mimeType.startsWith('video/')) return 'video';
+  return 'image';
+}
+
+function toBase64Url(input) {
+  return base64UrlEncode(bytesFromString(input));
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      const v = src[n];
+      if (v !== undefined && v !== null && v !== '') return v;
+    }
+  }
+  return null;
+}
+
+function readEnv(name) {
+  if (typeof process !== 'undefined' && process.env && process.env[name]) {
+    return process.env[name];
+  }
+  return '';
+}
+
+async function hmacSha1Base64Url(secret, message) {
+  const subtle = typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle;
+  if (subtle && typeof TextEncoder !== 'undefined') {
+    const encoder = new TextEncoder();
+    const key = await subtle.importKey(
+      'raw',
+      encoder.encode(secret),
+      { name: 'HMAC', hash: 'SHA-1' },
+      false,
+      ['sign']
+    );
+    const signature = await subtle.sign('HMAC', key, encoder.encode(message));
+    return base64UrlEncode(new Uint8Array(signature));
+  }
+
+  if (typeof require === 'function') {
+    const nodeCrypto = require('crypto');
+    return nodeCrypto
+      .createHmac('sha1', secret)
+      .update(message)
+      .digest('base64')
+      .replace(/\+/g, '-')
+      .replace(/\//g, '_');
+  }
+
+  return base64UrlEncode(hmacSha1Bytes(bytesFromString(secret), bytesFromString(message)));
+}
+
+function bytesFromString(input) {
+  if (typeof TextEncoder !== 'undefined') {
+    return new TextEncoder().encode(input);
+  }
+  if (typeof Buffer !== 'undefined') {
+    return Buffer.from(input, 'utf8');
+  }
+  throw new Error('No UTF-8 encoder is available in this cloud runtime');
+}
+
+function base64UrlEncode(bytes) {
+  let base64;
+  if (typeof Buffer !== 'undefined') {
+    base64 = Buffer.from(bytes).toString('base64');
+  } else if (typeof btoa === 'function') {
+    let binary = '';
+    for (let i = 0; i < bytes.length; i++) {
+      binary += String.fromCharCode(bytes[i]);
+    }
+    base64 = btoa(binary);
+  } else {
+    base64 = base64EncodeBytes(bytes);
+  }
+
+  return base64
+    .replace(/\+/g, '-')
+    .replace(/\//g, '_');
+}
+
+function base64EncodeBytes(bytes) {
+  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+  let output = '';
+  for (let i = 0; i < bytes.length; i += 3) {
+    const b1 = bytes[i];
+    const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;
+    const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;
+    const triplet = (b1 << 16) | (b2 << 8) | b3;
+
+    output += alphabet[(triplet >> 18) & 0x3f];
+    output += alphabet[(triplet >> 12) & 0x3f];
+    output += i + 1 < bytes.length ? alphabet[(triplet >> 6) & 0x3f] : '=';
+    output += i + 2 < bytes.length ? alphabet[triplet & 0x3f] : '=';
+  }
+  return output;
+}
+
+function hmacSha1Bytes(secretBytes, messageBytes) {
+  const blockSize = 64;
+  let key = toByteArray(secretBytes);
+  if (key.length > blockSize) {
+    key = sha1Bytes(key);
+  }
+
+  const paddedKey = new Uint8Array(blockSize);
+  paddedKey.set(key);
+
+  const innerPad = new Uint8Array(blockSize);
+  const outerPad = new Uint8Array(blockSize);
+  for (let i = 0; i < blockSize; i++) {
+    innerPad[i] = paddedKey[i] ^ 0x36;
+    outerPad[i] = paddedKey[i] ^ 0x5c;
+  }
+
+  return sha1Bytes(concatBytes(outerPad, sha1Bytes(concatBytes(innerPad, messageBytes))));
+}
+
+function sha1Bytes(inputBytes) {
+  const bytes = toByteArray(inputBytes);
+  const bitLength = bytes.length * 8;
+  const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6);
+  const padded = new Uint8Array(paddedLength);
+  padded.set(bytes);
+  padded[bytes.length] = 0x80;
+
+  const view = new DataView(padded.buffer);
+  view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false);
+  view.setUint32(paddedLength - 4, bitLength >>> 0, false);
+
+  let h0 = 0x67452301;
+  let h1 = 0xefcdab89;
+  let h2 = 0x98badcfe;
+  let h3 = 0x10325476;
+  let h4 = 0xc3d2e1f0;
+  const w = new Uint32Array(80);
+
+  for (let offset = 0; offset < paddedLength; offset += 64) {
+    for (let i = 0; i < 16; i++) {
+      w[i] = view.getUint32(offset + i * 4, false);
+    }
+    for (let i = 16; i < 80; i++) {
+      w[i] = rotateLeft(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
+    }
+
+    let a = h0;
+    let b = h1;
+    let c = h2;
+    let d = h3;
+    let e = h4;
+
+    for (let i = 0; i < 80; i++) {
+      let f;
+      let k;
+      if (i < 20) {
+        f = (b & c) | (~b & d);
+        k = 0x5a827999;
+      } else if (i < 40) {
+        f = b ^ c ^ d;
+        k = 0x6ed9eba1;
+      } else if (i < 60) {
+        f = (b & c) | (b & d) | (c & d);
+        k = 0x8f1bbcdc;
+      } else {
+        f = b ^ c ^ d;
+        k = 0xca62c1d6;
+      }
+
+      const temp = (rotateLeft(a, 5) + f + e + k + w[i]) >>> 0;
+      e = d;
+      d = c;
+      c = rotateLeft(b, 30);
+      b = a;
+      a = temp;
+    }
+
+    h0 = (h0 + a) >>> 0;
+    h1 = (h1 + b) >>> 0;
+    h2 = (h2 + c) >>> 0;
+    h3 = (h3 + d) >>> 0;
+    h4 = (h4 + e) >>> 0;
+  }
+
+  const output = new Uint8Array(20);
+  const outputView = new DataView(output.buffer);
+  outputView.setUint32(0, h0, false);
+  outputView.setUint32(4, h1, false);
+  outputView.setUint32(8, h2, false);
+  outputView.setUint32(12, h3, false);
+  outputView.setUint32(16, h4, false);
+  return output;
+}
+
+function concatBytes(a, b) {
+  const left = toByteArray(a);
+  const right = toByteArray(b);
+  const out = new Uint8Array(left.length + right.length);
+  out.set(left);
+  out.set(right, left.length);
+  return out;
+}
+
+function toByteArray(bytes) {
+  return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
+}
+
+function rotateLeft(value, bits) {
+  return ((value << bits) | (value >>> (32 - bits))) >>> 0;
+}

+ 111 - 0
cloud-functions/DEPLOY.md

@@ -0,0 +1,111 @@
+# 云函数部署指南
+
+本目录包含 8 个云函数,部署到 fmode 平台后可替代 31 个 Express 接口。
+
+## 部署清单
+
+| # | 文件 | 替代的 Express 接口数 | actions |
+|---|---|---|---|
+| 01 | `01-manifestManager.js` | 4(GET/POST/PUT/DELETE /api/manifest) | list / get / create / update / delete |
+| 02 | `02-taskManager.js` | 4(/api/tasks) | list / get / create / update / delete |
+| 03 | `03-historyManager.js` | 3(/api/history) | list / create / delete / clearAll |
+| 04 | `04-resultManager.js` | 4(/api/results) | list / get / create / update / delete |
+| 05 | `05-remixManager.js` | 4(/api/remixes) | listAll / listByVideo / upsert / delete |
+| 06 | `06-voiceManager.js` | 2(/api/voice/*) | autoSpeakerId / syncProfile / listProfiles / initPool / deleteProfile |
+| 07 | `07-quicklyVideo.js` | 2(/api/quickly/*) | create / query |
+| 08 | `08-proxyHub.js` | 2(/api/llm/*) | llmChat / geminiChat |
+
+**合计:8 个云函数覆盖 25 个 CRUD + 6 个代理 = 31 个接口** ✅
+
+> ⚠️ `docs/云函数/筷子一键成片.json` 是**别项目**的参考样本,**不要直接部署**。本目录的 `07-quicklyVideo.js` 才是本项目自己的实现。
+
+## 部署步骤(每个文件)
+
+1. 打开 fmode 云函数管理平台
+2. 新建云函数,命名建议跟文件名一致(如 `manifestManager`)
+3. 粘贴 `.js` 文件的完整代码(**不要包含外层 markdown**)
+4. 保存 → 部署 → **复制返回的函数 ID**(10 位字符串)
+5. 把 ID 填入 `src/app/services/cloud-functions.ts` 对应字段
+
+## 前端依赖安装
+
+部署完云函数后,前端需要安装 SDK:
+
+```bash
+npm install fmode-ng
+```
+
+安装后 `src/app/services/parse.service.ts` 的导入错误会自动消失。
+
+## 验证云函数部署成功
+
+每个云函数支持 `debug` 之外的 action 调用即可验证。示例:
+
+```javascript
+// 在浏览器 console 或一个临时按钮里测试
+const Parse = FmodeParse.initialize({
+  appId: 'ncloudmaster',
+  serverURL: 'https://server.fmode.cn/parse',
+});
+
+// 测试 manifestManager(应该返回空数组 [])
+await Parse.Cloud.function({ id: '你的函数ID', action: 'list' });
+// 期望响应:{ code: 200, success: true, data: [] }
+```
+
+## ⚠️ voiceManager 首次部署后必做
+
+`voiceManager` 需要灌入 speaker_id 池才能工作。原本 `docs/音色创建/speaker_id.md` 里的池要一次性导入:
+
+```javascript
+await Parse.Cloud.function({
+  id: '你的voiceManager函数ID',
+  action: 'initPool',
+  speakerIds: ['S_xxx', 'S_yyy', ...], // 把现有池里所有 speaker_id 传进来
+});
+```
+
+灌入后才能调用 `autoSpeakerId` 自动分配。
+
+## 数据迁移(可选)
+
+新云函数建的是空表。如果想保留现有 `data/*.json` 数据:
+
+1. 把 `data/manifest.json` 内容转成数组 → 循环调 `manifestManager` 的 `create` action
+2. 同理迁移 tasks / history / results / voice-profiles
+3. `remixes/*.json` → 每个 videoId 文件循环调 `remixManager` 的 `upsert`
+
+也可以**不迁移**,直接从云函数版本重新开始用。
+
+## 表设计统一约定
+
+所有 CRUD 类云函数遵循同一表结构:
+
+```sql
+CREATE TABLE "<ResourceName>" (
+  "objectId"  VARCHAR(50) PRIMARY KEY,     -- 10位随机ID(云函数生成)
+  "bizId"     VARCHAR(255),                -- 业务 ID(原 video.id / task.id 等)
+  "data"      JSONB NOT NULL DEFAULT '{}', -- 完整业务字段
+  "userId"    VARCHAR(255) DEFAULT '',     -- 可选,用户隔离
+  ... (常用索引字段)
+  "createdAt" TIMESTAMPTZ,
+  "updatedAt" TIMESTAMPTZ
+)
+```
+
+好处:
+- 业务字段加减不用改 schema(全在 JSONB 里)
+- 公共字段(id / status / type)单独提出来便于查询和索引
+- 跟 Parse 自动 `objectId` 兼容
+
+## 统一响应格式
+
+```json
+// 成功
+{ "code": 200, "success": true, "data": ... }
+
+// 失败
+{ "code": 400 或 500, "success": false, "error": "错误信息" }
+```
+
+前端 `ParseService.callOrThrow()` 已封装错误处理。

+ 32 - 0
cloud-functions/README.md

@@ -0,0 +1,32 @@
+# cloud-functions
+
+后端云函数源码(部署到 fmode 平台)。
+
+详见 [`DEPLOY.md`](./DEPLOY.md) 部署指南,[`_template.md`](./_template.md) 设计约定。
+
+## 文件清单
+
+```
+01-manifestManager.js   视频清单 CRUD
+02-taskManager.js       任务管理 CRUD
+03-historyManager.js    历史记录 CRUD
+04-resultManager.js     结果库 CRUD
+05-remixManager.js      视频重塑记录
+06-voiceManager.js      音色档案 + speaker_id 池
+07-quicklyVideo.js      一键成片代理(本项目自己的实现)
+08-proxyHub.js          LLM / Gemini 代理
+```
+
+> ⚠️ `docs/云函数/筷子一键成片.json` 只是别项目的参考样本,**不要直接部署**。
+
+## 配套前端文件
+
+- `src/app/services/parse.service.ts` — Parse SDK 封装
+- `src/app/services/cloud-functions.ts` — 函数 ID 注册表
+
+## 部署后
+
+1. 把 8 个函数都部署到 fmode 平台
+2. 拿到 8 个函数 ID
+3. 填入 `src/app/services/cloud-functions.ts`
+4. 前端调用:`this.parse.call(CLOUD_FN.task, { action: 'list' })`

+ 60 - 0
cloud-functions/_template.md

@@ -0,0 +1,60 @@
+# 云函数统一设计约定
+
+所有云函数遵循同一套约定(参考 `docs/云函数/筷子一键成片.json`):
+
+## 1. 统一表结构
+
+```sql
+CREATE TABLE "<ResourceName>" (
+  "objectId"  VARCHAR(50) PRIMARY KEY,     -- 10位随机ID(云函数生成)
+  "bizId"     VARCHAR(255),                -- 业务ID(原 video.id / task.id ...)
+  "data"      JSONB NOT NULL DEFAULT '{}', -- 完整业务字段(保留原 JSON 结构)
+  "userId"    VARCHAR(255) DEFAULT '',     -- 用户隔离(可选)
+  "status"    VARCHAR(50)  DEFAULT '',     -- 常用筛选字段
+  "type"      VARCHAR(50)  DEFAULT '',     -- 常用筛选字段
+  "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
+  "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
+)
+```
+
+为常用字段建索引:`bizId` (UNIQUE)、`userId`、`status`、`type`、`createdAt`。
+
+## 2. 统一调用约定
+
+| 字段 | 说明 |
+|---|---|
+| `action` | 操作类型(list/get/create/update/delete/...) |
+| `id` 或业务字段 | 操作目标 |
+| 其它 | 透传到 `data` 字段 |
+
+## 3. 统一响应格式
+
+```json
+// 成功
+{ "code": 200, "success": true, "data": ... }
+
+// 失败
+{ "code": 400, "success": false, "error": "..." }
+```
+
+## 4. 公共工具函数(每个云函数都拷贝一份)
+
+```javascript
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let s = '';
+  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
+  return s;
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const n of names) {
+      if (src[n] !== undefined && src[n] !== null && src[n] !== '') return src[n];
+    }
+  }
+  return null;
+}
+```

+ 74 - 0
cloud-functions/smoke-test.md

@@ -0,0 +1,74 @@
+# 云函数连通性冒烟测试
+
+部署完云函数后,在浏览器 console 跑一遍,确认每个函数都能正常响应。
+
+## 准备
+
+1. `npm run dev` 启动前端
+2. 打开任意页面(如 http://localhost:4200)
+3. F12 → Console,粘贴下面脚本
+
+## 测试脚本
+
+> ⚠️ 浏览器 console 不能直接 `import 'fmode-ng/core'`(bare specifier 解析不了),用 fetch 直调 REST 端点最简单。
+
+```javascript
+const FN = {
+  manifest: 'VWejNK8E5z',
+  task:     'yL6opHxC4D',
+  history:  'cegZcOyOH0',
+  result:   'XPv6Sym2I6',
+  remix:    'LjcaC94Kno',
+  voice:    'LHBMMcDP4n',
+  proxy:    'wzwnEuAhwb',
+};
+
+async function callFn(id, params) {
+  const r = await fetch('https://server.fmode.cn/api/functions', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id, _ApplicationId: 'ncloudmaster', ...params })
+  });
+  return r.json();
+}
+
+async function smoke(name, id, params) {
+  try {
+    const res = await callFn(id, params);
+    const ok = res?.code === 200 && res?.success;
+    console.log(`${ok ? '✅' : '❌'} ${name}:`, res);
+  } catch (e) {
+    console.error(`💥 ${name}:`, e);
+  }
+}
+
+await smoke('manifest.list',      FN.manifest, { action: 'list' });
+await smoke('task.list',          FN.task,     { action: 'list' });
+await smoke('history.list',       FN.history,  { action: 'list' });
+await smoke('result.list',        FN.result,   { action: 'list' });
+await smoke('remix.listAll',      FN.remix,    { action: 'listAll' });
+await smoke('voice.listProfiles', FN.voice,    { action: 'listProfiles' });
+await smoke('proxy.unknownAction',FN.proxy,    { action: 'foo' });
+console.log('🎉 冒烟测试结束');
+```
+
+## 期望输出
+
+每行应类似:
+
+```
+✅ manifest.list: { code: 200, success: true, data: [] }
+✅ task.list:     { code: 200, success: true, data: [] }
+...
+❌ proxy.unknownAction: { code: 400, success: false, error: '未知 action: foo' }  ← 这是预期失败
+```
+
+只要前 6 个全 ✅ 且最后一个返回 400,说明云函数都正确部署并能建表。
+
+## 如果某个失败
+
+| 现象 | 原因 |
+|---|---|
+| `Class not found` / SQL 错误 | 表没建出来 → 检查云函数代码顶部的 `CREATE TABLE IF NOT EXISTS` 是否被注释或修改 |
+| 全部超时 | 网络问题 / Parse SDK 版本不对 |
+| 个别 404 | 函数 ID 写错了,对照 fmode 平台核验 |

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 886 - 2
package-lock.json


+ 5 - 0
package.json

@@ -20,9 +20,14 @@
     "@angular/forms": "^21.2.0",
     "@angular/platform-browser": "^21.2.0",
     "@angular/router": "^21.2.0",
+    "@ffmpeg/core": "^0.12.10",
+    "@ffmpeg/ffmpeg": "^0.12.15",
+    "@ffmpeg/util": "^0.12.2",
     "axios": "^1.15.0",
     "cors": "^2.8.5",
     "express": "^4.21.0",
+    "fmode-ng": "^0.0.246",
+    "marked": "^18.0.4",
     "multer": "^2.1.1",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0"

+ 95 - 122
server.js

@@ -1129,7 +1129,9 @@ app.get('/api/whisper/status', (req, res) => {
 });
 
 // ==================== Manifest 管理 ====================
-
+// 已迁移至云函数 manifestManager(cloud-functions/01-manifestManager.js),由 cloud-api.interceptor 透明转发。
+// 以下 Express 路由保留为备份/降级参考,正常情况下不会被调用。
+/* MIGRATED-TO-CLOUD: manifestManager — START
 // GET /api/manifest — 获取完整 manifest
 app.get('/api/manifest', (req, res) => {
   try {
@@ -1227,6 +1229,7 @@ app.delete('/api/manifest/:videoId', (req, res) => {
     res.status(500).json({ error: `删除视频失败: ${err.message}` });
   }
 });
+MIGRATED-TO-CLOUD: manifestManager — END */
 
 // ==================== 视频上传 ====================
 
@@ -1773,6 +1776,8 @@ function writeRemixes(videoId, data) {
   fs.writeFileSync(path.join(REMIXES_DIR, `${videoId}.json`), JSON.stringify(data, null, 2), 'utf-8');
 }
 
+// 已迁移至云函数 remixManager(cloud-functions/05-remixManager.js)。
+/* MIGRATED-TO-CLOUD: remixManager — START
 // GET /api/remixes/:videoId — 获取某视频的所有重塑记录
 app.get('/api/remixes/:videoId', (req, res) => {
   res.json(readRemixes(req.params.videoId));
@@ -1815,6 +1820,7 @@ app.get('/api/remixes', (req, res) => {
   });
   res.json(all);
 });
+MIGRATED-TO-CLOUD: remixManager — END */
 
 // ==================== 一键成片(Quickly 代理) ====================
 
@@ -2299,128 +2305,15 @@ app.post('/api/video/composite', async (req, res) => {
 });
 
 /**
- * POST /api/video/composite/stream — SSE 流式合成端点
- *
- * 与 /api/video/composite 功能一致,但通过 Server-Sent Events 推送实时进度,
- * 用于 P3/P4 等长耗时合成任务,替换前端的"假定时器进度"。
+ * POST /api/video/composite/stream — [已废弃] SSE 流式合成端点
  *
- * 事件类型:
- *   - event: stage         { stage: 'download'|'compose'|'concat'|'cleanup', message }
- *   - event: progress      { stage, current, total, percent }
- *   - event: done          { videoUrl, filename, size, segments }
- *   - event: error         { error }
+ * 2026-05-22 起,「主题生视频」「素材合成视频」改为浏览器端 ffmpeg.wasm 合成。
+ * 旧路由已删除;仅保留同步版 /api/video/composite 给 app.ts 的旧 vg 模块使用。
  */
 app.post('/api/video/composite/stream', async (req, res) => {
-  const { segments, title } = req.body || {};
-  if (!Array.isArray(segments) || segments.length === 0) {
-    return res.status(400).json({ error: '缺少 segments 参数' });
-  }
-
-  // SSE 头
-  res.setHeader('Content-Type', 'text/event-stream');
-  res.setHeader('Cache-Control', 'no-cache');
-  res.setHeader('Connection', 'keep-alive');
-  res.flushHeaders?.();
-
-  const emit = (event, data) => {
-    res.write(`event: ${event}\n`);
-    res.write(`data: ${JSON.stringify(data)}\n\n`);
-  };
-
-  const jobId = `VG-${Date.now()}`;
-  const jobDir = path.join(COMPOSITE_DIR, jobId);
-  fs.mkdirSync(jobDir, { recursive: true });
-
-  // —— 心跳:长时间静默会被代理 / 浏览器空闲 TCP 关掉。
-  // 每 10 秒写一个 SSE 注释行(`: ...\n\n`),客户端 reader 会忽略它。
-  let finished = false;
-  const heartbeat = setInterval(() => {
-    if (finished) return;
-    try { res.write(`: hb ${Date.now()}\n\n`); } catch {}
-  }, 10000);
-
-  // —— 客户端真正断开的检测:
-  //
-  // ❌ 不能用 `req.on('close')`。Node 16+ 中 IncomingMessage 的 close
-  //    事件会在请求体被 express.json() 完整读取后立即触发(这是 Node 行为变更
-  //    的常见陷阱),导致刚进 loop 就被误判为「客户端断开」。
-  //
-  // ✅ 正确做法:监听 `res.on('close')`。该事件只在响应连接被对端
-  //    在 res.end() 之前提前关闭时触发;服务端主动 res.end() 不会触发。
-  let aborted = false;
-  res.on('close', () => {
-    if (!finished) {
-      aborted = true;
-      console.warn(`⚠️ [SSE] 客户端断开 ${jobId}`);
-    }
+  return res.status(410).json({
+    error: 'deprecated: client now composes in browser via ffmpeg.wasm; see BrowserFfmpegService.composeVideo()',
   });
-
-  const cleanup = () => {
-    finished = true;
-    clearInterval(heartbeat);
-  };
-
-  try {
-    console.log(`🎬 [SSE] 开始合成视频: ${jobId}, ${segments.length} 个片段`);
-    emit('stage', { stage: 'download', message: `开始处理 ${segments.length} 段素材...` });
-
-    const segmentPaths = [];
-    const total = segments.length;
-    for (let i = 0; i < total; i++) {
-      if (aborted) throw new Error('客户端已断开');
-      const seg = segments[i];
-      if (!seg.imageUrl && !seg.videoUrl) {
-        console.warn(`⚠️ 片段 ${seg.id || i} 缺少 imageUrl/videoUrl,跳过`);
-        continue;
-      }
-
-      emit('progress', { stage: 'download', current: i + 1, total, percent: Math.round((i / total) * 100), message: `处理素材 ${i + 1}/${total}` });
-
-      const { path: segPath, duration, mode } = await composeOneSegment({ seg, jobDir, i });
-      segmentPaths.push(segPath);
-
-      const durLabel = duration ? `${duration.toFixed(1)}s` : '原长';
-      emit('progress', { stage: 'compose', current: i + 1, total, percent: Math.round(((i + 1) / total) * 100), message: `已完成 ${i + 1}/${total} 段(${durLabel}, mode=${mode})` });
-    }
-
-    if (segmentPaths.length === 0) {
-      throw new Error('没有有效的素材片段');
-    }
-
-    const safeTitle = String(title || 'video').replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_').substring(0, 50);
-    const finalFilename = `${safeTitle}-${jobId}.mp4`;
-    const finalPath = path.join(COMPOSITE_DIR, finalFilename);
-
-    emit('stage', { stage: 'concat', message: `拼接 ${segmentPaths.length} 个片段...` });
-
-    if (segmentPaths.length === 1) {
-      fs.copyFileSync(segmentPaths[0], finalPath);
-    } else {
-      await concatVideos(segmentPaths, finalPath);
-    }
-
-    emit('stage', { stage: 'cleanup', message: '清理临时文件...' });
-    try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
-
-    const fileSize = fs.statSync(finalPath).size;
-    console.log(`✅ [SSE] 视频合成完成: ${finalFilename} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
-
-    emit('done', {
-      success: true,
-      videoUrl: `/api/video/composite/${finalFilename}`,
-      filename: finalFilename,
-      size: fileSize,
-      segments: segmentPaths.length,
-    });
-    cleanup();
-    res.end();
-  } catch (err) {
-    console.error(`❌ [SSE] 视频合成失败:`, err.message);
-    try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
-    emit('error', { error: `视频合成失败: ${err.message}` });
-    cleanup();
-    res.end();
-  }
 });
 
 // GET /api/video/composite/:filename — 下载合成视频
@@ -2462,6 +2355,8 @@ function collectTrainedSpeakerIdsFromResponse(payload) {
   return found;
 }
 
+// 已迁移至云函数 voiceManager(cloud-functions/06-voiceManager.js)。
+/* MIGRATED-TO-CLOUD: voiceManager — START
 // POST /api/voice/auto-speaker-id — 自动挑选一个未训练 / 未占用的 speaker_id
 app.post('/api/voice/auto-speaker-id', async (req, res) => {
   try {
@@ -2564,9 +2459,11 @@ app.post('/api/voice-profiles/sync', (req, res) => {
     res.status(500).json({ error: err?.message || '保存音色记录失败' });
   }
 });
+MIGRATED-TO-CLOUD: voiceManager — END */
 
 // ==================== 任务管理 ====================
-
+// 已迁移至云函数 taskManager(cloud-functions/02-taskManager.js)。
+/* MIGRATED-TO-CLOUD: taskManager — START
 // GET /api/tasks — 获取所有任务
 app.get('/api/tasks', (req, res) => {
   res.json(readDataFile('tasks'));
@@ -2602,9 +2499,11 @@ app.delete('/api/tasks/:id', (req, res) => {
   writeDataFile('tasks', tasks);
   res.json({ success: true });
 });
+MIGRATED-TO-CLOUD: taskManager — END */
 
 // ==================== 生成历史 ====================
-
+// 已迁移至云函数 historyManager(cloud-functions/03-historyManager.js)。
+/* MIGRATED-TO-CLOUD: historyManager — START
 // GET /api/history — 获取历史记录
 app.get('/api/history', (req, res) => {
   res.json(readDataFile('history'));
@@ -2634,9 +2533,11 @@ app.delete('/api/history', (req, res) => {
   writeDataFile('history', []);
   res.json({ success: true });
 });
+MIGRATED-TO-CLOUD: historyManager — END */
 
 // ==================== 生成结果 ====================
-
+// 已迁移至云函数 resultManager(cloud-functions/04-resultManager.js)。
+/* MIGRATED-TO-CLOUD: resultManager — START
 // GET /api/results — 获取所有结果
 app.get('/api/results', (req, res) => {
   res.json(readDataFile('results'));
@@ -2670,12 +2571,83 @@ app.delete('/api/results/:id', (req, res) => {
   writeDataFile('results', results);
   res.json({ success: true });
 });
+MIGRATED-TO-CLOUD: resultManager — END */
 
 // ==================== LLM 大模型代理 ====================
 
 const LLM_BASE_URL = 'http://server.fmode.cn:9999';
 const LLM_API_KEY = 'sk-MFBOnsAtZiqlwwMgMLKCFmPy55pMohQEGMqsIw3aJrIgvoEO';
 
+// 非流式调用已迁移至云函数 proxyHub(cloud-functions/08-proxyHub.js)。
+// 但流式 SSE 调用必须走本地 Express(云函数不支持流式输出),由前端 fetch 直连。
+// 入口:POST /api/llm/chat/stream
+app.post('/api/llm/chat/stream', async (req, res) => {
+  try {
+    const { model, messages, temperature, max_tokens, ...rest } = req.body || {};
+    if (!Array.isArray(messages) || messages.length === 0) {
+      return res.status(400).json({ error: '缺少 messages 参数' });
+    }
+
+    const payload = {
+      model: model || 'gpt-4o',
+      messages,
+      temperature: temperature ?? 0.7,
+      max_tokens: max_tokens || 4096,
+      ...rest,
+      stream: true,  // 强制流式
+    };
+
+    const url = `${LLM_BASE_URL}/v1/chat/completions`;
+    console.log(`🤖 [Stream] LLM Chat 请求: model=${payload.model}, messages=${messages.length}条`);
+
+    const upstream = await fetch(url, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${LLM_API_KEY}`,
+      },
+      body: JSON.stringify(payload),
+    });
+
+    if (!upstream.ok) {
+      const errText = await upstream.text();
+      console.error('🤖 [Stream] 上游错误:', upstream.status, errText);
+      return res.status(upstream.status).json({ error: errText || `upstream ${upstream.status}` });
+    }
+
+    // 直接把上游 SSE chunk 透传给客户端
+    res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+    res.setHeader('Cache-Control', 'no-cache, no-transform');
+    res.setHeader('Connection', 'keep-alive');
+    res.setHeader('X-Accel-Buffering', 'no'); // nginx 不缓冲
+    res.flushHeaders?.();
+
+    // node-fetch 18+ 的 body 是 web ReadableStream;用 reader 读
+    const reader = upstream.body.getReader();
+    const onAbort = () => { try { reader.cancel(); } catch {} };
+    req.on('close', onAbort);
+
+    try {
+      while (true) {
+        const { value, done } = await reader.read();
+        if (done) break;
+        if (value) res.write(Buffer.from(value));
+      }
+    } finally {
+      req.off('close', onAbort);
+      res.end();
+    }
+  } catch (err) {
+    console.error('🤖 [Stream] 异常:', err.message);
+    if (!res.headersSent) {
+      res.status(500).json({ error: `LLM 流式请求失败: ${err.message}` });
+    } else {
+      try { res.end(); } catch {}
+    }
+  }
+});
+
+/* MIGRATED-TO-CLOUD: proxyHub — START
 // POST /api/llm/chat — OpenAI ChatCompletions 代理
 app.post('/api/llm/chat', async (req, res) => {
   try {
@@ -2790,6 +2762,7 @@ app.post('/api/llm/gemini', async (req, res) => {
     res.status(500).json({ error: `Gemini 请求失败: ${err.message}` });
   }
 });
+MIGRATED-TO-CLOUD: proxyHub — END */
 
 // ==================== 健康检查 ====================
 

+ 3 - 2
src/app/app.config.ts

@@ -1,13 +1,14 @@
 import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
 import { provideRouter } from '@angular/router';
-import { provideHttpClient, withFetch } from '@angular/common/http';
+import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
 
 import { routes } from './app.routes';
+import { cloudApiInterceptor } from './services/cloud-api.interceptor';
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideBrowserGlobalErrorListeners(),
     provideRouter(routes),
-    provideHttpClient(withFetch())
+    provideHttpClient(withFetch(), withInterceptors([cloudApiInterceptor]))
   ]
 };

+ 319 - 0
src/app/app.css

@@ -2670,6 +2670,304 @@ time {
   }
 }
 
+/* ============================================================
+ * 移动端适配总线
+ * ------------------------------------------------------------
+ * 目标:手机上保持同一套桌面信息架构,但把导航、卡片、表单、
+ * 媒体预览和弹层全部压成单列可滚动布局。
+ * ============================================================ */
+@media (max-width: 760px) {
+  html,
+  body {
+    width: 100%;
+    overflow: hidden;
+    -webkit-text-size-adjust: 100%;
+  }
+
+  .app-container {
+    height: 100dvh;
+    min-height: 100dvh;
+    overflow: hidden;
+  }
+
+  .header {
+    height: 48px;
+    padding: 0 12px;
+  }
+
+  .header .logo {
+    min-width: 0;
+    max-width: calc(100vw - 72px);
+    font-size: 15px;
+    gap: 6px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .header .logo small {
+    display: none;
+  }
+
+  .theme-toggle {
+    width: 34px;
+    height: 34px;
+    flex: 0 0 auto;
+  }
+
+  .main-content {
+    height: calc(100dvh - 48px);
+    flex-direction: column;
+    min-width: 0;
+    overflow: hidden;
+  }
+
+  .workbench {
+    flex: 1 1 auto;
+    min-height: 0;
+    width: 100%;
+    padding: 14px;
+    overflow-x: hidden;
+    overflow-y: auto;
+    -webkit-overflow-scrolling: touch;
+  }
+
+  .section-card {
+    padding: 16px;
+    margin-bottom: 16px;
+    border-radius: 12px;
+  }
+
+  .btn,
+  .btn-primary,
+  .btn-accent,
+  .btn-warn {
+    min-height: 42px;
+    padding: 10px 14px;
+    font-size: 14px;
+  }
+
+  .form-field input,
+  .form-field select,
+  .form-field textarea,
+  .dh-input,
+  .dh-textarea {
+    min-width: 0;
+    width: 100%;
+    font-size: 16px;
+  }
+
+  .dh-page {
+    min-height: auto;
+    padding: 18px 14px 48px;
+    border-radius: 0;
+  }
+
+  .dh-page-header {
+    align-items: flex-start;
+    gap: 12px;
+    margin-bottom: 16px;
+  }
+
+  .dh-header-icon {
+    width: 38px;
+    height: 38px;
+    flex: 0 0 38px;
+  }
+
+  .dh-header-text h1,
+  .dh-header-text h2 {
+    font-size: 22px;
+    line-height: 1.18;
+  }
+
+  .dh-header-text p {
+    font-size: 13px;
+    line-height: 1.55;
+  }
+
+  .search-input-group,
+  .monitor-toolbar,
+  .history-controls,
+  .videos-toolbar-row,
+  .voice-selected-card,
+  .vs-select-row,
+  .vs-subcard-header,
+  .vg-step-card__header {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .filter-field,
+  .search-field,
+  .monitor-toolbar .search-field,
+  .videos-toolbar-row .form-field {
+    width: 100%;
+    min-width: 0;
+  }
+
+  .search-btn,
+  .monitor-toolbar .btn,
+  .videos-toolbar-row .btn,
+  .history-actions .btn {
+    width: 100%;
+  }
+
+  .video-grid,
+  .results-grid,
+  .monitor-author-grid,
+  .monitor-works-grid,
+  .tasks-overview,
+  .history-grid,
+  .videos-grid,
+  .pipeline-gallery {
+    grid-template-columns: minmax(0, 1fr) !important;
+  }
+
+  .video-card,
+  .result-card,
+  .task-card,
+  .history-record,
+  .history-card,
+  .video-management-card,
+  .video-item,
+  .monitor-author-card {
+    min-width: 0;
+  }
+
+  .modal,
+  .preview-modal,
+  .remix-modal,
+  .remix-mode-chooser {
+    width: calc(100vw - 24px);
+    max-width: calc(100vw - 24px);
+    max-height: calc(100dvh - 24px);
+    border-radius: 12px;
+  }
+
+  .modal-body,
+  .preview-modal-body,
+  .remix-modal-body {
+    max-height: calc(100dvh - 140px);
+    overflow-y: auto;
+  }
+
+  .pl-page {
+    padding: 0 0 48px;
+  }
+
+  .pl-grid {
+    grid-template-columns: minmax(0, 1fr) !important;
+    gap: 14px;
+  }
+
+  .pl-page .dh-step-col {
+    min-height: auto;
+    border-radius: 14px;
+  }
+
+  .pl-page .dh-step-col > :not(.dh-step-col__head) {
+    padding-left: 14px;
+    padding-right: 14px;
+  }
+
+  .dh-step-col__head {
+    padding: 12px 14px 10px;
+    flex-wrap: wrap;
+  }
+
+  .dh-step-col__body {
+    max-height: none;
+    overflow: visible;
+    padding: 14px;
+  }
+
+  .dh-substep__head {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 6px;
+  }
+
+  .dh-segment {
+    width: 100%;
+    overflow-x: auto;
+  }
+
+  .dh-segment__item {
+    min-height: 38px;
+    white-space: nowrap;
+  }
+
+  .dh-chip-btn,
+  .pl-preview-actions .dh-chip-btn,
+  .t2v-scene-item__actions .dh-chip-btn {
+    min-height: 38px;
+  }
+
+  .pl-cta-primary {
+    width: 100%;
+    min-height: 46px;
+  }
+
+  .pl-progress__text,
+  .pl-preview-meta {
+    flex-direction: column;
+    align-items: flex-start;
+  }
+
+  .pl-result-actions,
+  .dh-preview-actions,
+  .dh-chat-actions {
+    flex-direction: column;
+  }
+
+  .pl-result-actions .btn,
+  .dh-preview-action,
+  .dh-chat-apply {
+    width: 100%;
+  }
+
+  .vg-step-bar {
+    overflow-x: auto;
+    justify-content: flex-start;
+    padding: 10px;
+  }
+
+  .vg-step-item {
+    min-width: 82px;
+  }
+
+  .vg-step-connector {
+    min-width: 24px;
+  }
+
+  .vg-step-card__chip,
+  .vg-step-card__edit,
+  .vg-step-card__toggle {
+    align-self: flex-start;
+  }
+}
+
+@media (max-width: 420px) {
+  .workbench {
+    padding: 10px;
+  }
+
+  .dh-page {
+    padding: 14px 10px 44px;
+  }
+
+  .dh-header-text h1,
+  .dh-header-text h2 {
+    font-size: 20px;
+  }
+
+  .pipeline-card,
+  .section-card {
+    padding: 14px;
+  }
+}
+
 /* 动画效果 */
 @keyframes fadeIn {
   from {
@@ -7302,11 +7600,32 @@ code {
 
 /* Step 2: AI 风格助手折叠面板 */
 .dh-chat-fold { padding: 0; overflow: hidden; }
+/* 展开时整面板限高 + 内部滚动;summary 粘顶不动,下方内容滚 */
+.dh-chat-fold[open] {
+  max-height: 70vh;
+  overflow-y: auto;
+  overflow-x: hidden;
+  scrollbar-width: thin;
+  scrollbar-color: rgba(168, 85, 247, 0.45) transparent;
+}
+.dh-chat-fold[open]::-webkit-scrollbar { width: 8px; }
+.dh-chat-fold[open]::-webkit-scrollbar-track { background: transparent; }
+.dh-chat-fold[open]::-webkit-scrollbar-thumb {
+  background: rgba(168, 85, 247, 0.45);
+  border-radius: 4px;
+}
+.dh-chat-fold[open]::-webkit-scrollbar-thumb:hover {
+  background: rgba(168, 85, 247, 0.7);
+}
 .dh-chat-fold__summary {
   padding: 12px 14px;
   cursor: pointer;
   list-style: none;
   margin: 0 !important;
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  background: var(--dh-card, #fff);
 }
 .dh-chat-fold__summary::-webkit-details-marker { display: none; }
 .dh-chat-fold[open] .dh-chat-fold__summary { border-bottom: 1px solid var(--dh-border); margin-bottom: 0 !important; }

+ 82 - 99
src/app/app.html

@@ -131,6 +131,9 @@
 
       </section>
 
+      <!-- 图生视频 Pipeline (P1) -->
+      <app-image-generation *ngIf="currentTab === 'image-generation'"></app-image-generation>
+
       <!-- 图生视频 Pipeline (P1) -->
       <app-image-to-video *ngIf="currentTab === 'image-to-video'"></app-image-to-video>
 
@@ -143,6 +146,16 @@
       <!-- 主题生视频 Pipeline (P4) -->
       <app-topic-to-video *ngIf="currentTab === 'topic-to-video'"></app-topic-to-video>
 
+      <!-- 我的创作(跨 Pipeline 草稿与 artifact 时间线) -->
+      <app-drafts *ngIf="currentTab === 'drafts'"
+                  (navigateToPipeline)="onDraftsNavigate($event)">
+      </app-drafts>
+
+      <!-- 素材库(跨 Pipeline AI 生成成果聚合) -->
+      <app-library *ngIf="currentTab === 'library'"
+                   (navigateToDraft)="onLibraryNavigate($event)">
+      </app-library>
+
       <!-- 视频发现模块 -->
 
       <section class="dh-page search-page" *ngIf="currentTab === 'search'">
@@ -2647,20 +2660,6 @@
 
 
 
-        <!-- 接口说明 footer -->
-
-        <footer class="vs-api-footer">
-
-          <h4>接口说明</h4>
-
-          <div><strong>音色复刻:</strong><code>POST https://server.fmode.cn/api/volcengine/tts/voice_clone</code></div>
-
-          <div><strong>语音合成:</strong><code>POST https://server.fmode.cn/api/volcengine/tts/unidirectional</code></div>
-
-          <div><strong>计费说明:</strong>音色复刻按次计费,语音合成每次 1 credit(0.05 元)</div>
-
-        </footer>
-
       </section>
 
 
@@ -3137,28 +3136,6 @@
 
 
 
-        <!-- 接口说明 -->
-
-        <div style="margin-top:24px;padding:16px;background:var(--bg-tertiary);border-radius:10px;border:1px solid var(--border-color);font-size:13px;color:var(--text-secondary)">
-
-          <h4 style="margin:0 0 8px;color:var(--text-primary)">
-
-            <span class="icon" style="font-size:16px;vertical-align:middle">info</span> 接口说明
-
-          </h4>
-
-          <div style="display:flex;flex-direction:column;gap:4px">
-
-            <div><strong>音色复刻:</strong><code>POST https://server.fmode.cn/api/volcengine/tts/voice_clone</code></div>
-
-            <div><strong>语音合成:</strong><code>POST https://server.fmode.cn/api/volcengine/tts/unidirectional</code></div>
-
-            <div><strong>计费说明:</strong>音色复刻按次计费,语音合成每次 1 credit(0.05元)</div>
-
-          </div>
-
-        </div>
-
       </section>
 
 
@@ -3483,19 +3460,41 @@
 
                 <ng-container *ngIf="dhAudioSource === 'tts'">
                   <div class="dh-field" style="margin-top:10px">
-                    <label class="dh-field__label"><span>音色 ID(timbreId)<em>*</em></span></label>
-                    <input class="dh-input" type="text" [(ngModel)]="dhTtsTimbreId" placeholder="在「语音合成」标签页复刻音色后获得 objectId">
-                  </div>
-                  <div class="dh-voice-list" *ngIf="vsClonedTimbreList.length > 0">
-                    <button type="button" class="dh-voice-card" *ngFor="let t of vsClonedTimbreList"
-                            [class.is-active]="dhTtsTimbreId === t.objectId"
-                            (click)="dhTtsTimbreId = t.objectId">
-                      <span class="dh-voice-card__dot"></span>
-                      <span class="dh-voice-card__name">{{t.name}}</span>
-                      <span class="dh-voice-card__check">✓</span>
+                    <label class="dh-field__label"><span>选择声音模型 <em>*</em></span></label>
+                    <button type="button" class="vs-voice-picker-trigger"
+                            [class.is-empty]="!dhCurrentVoiceSelection"
+                            [disabled]="dhLoading"
+                            (click)="openDhVoicePicker()">
+                      <ng-container *ngIf="dhCurrentVoiceSelection; else dhVoiceEmpty">
+                        <span class="vs-voice-picker-avatar"
+                              [class.vs-voice-picker-avatar--mine]="dhCurrentVoiceSelection.source === 'cloned'">
+                          {{dhCurrentVoiceSelection.source === 'official' ? '知' : '我'}}
+                        </span>
+                        <span class="vs-voice-picker-info">
+                          <span class="vs-voice-picker-name">{{dhCurrentVoiceSelection.name}}</span>
+                          <span class="vs-voice-picker-src">
+                            {{dhCurrentVoiceSelection.source === 'official' ? '音色库' : '我的音色'}}
+                          </span>
+                        </span>
+                      </ng-container>
+                      <ng-template #dhVoiceEmpty>
+                        <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
+                        <span class="vs-voice-picker-info">
+                          <span class="vs-voice-picker-name">请选择声音模型</span>
+                          <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+                        </span>
+                      </ng-template>
+                      <span class="vs-voice-picker-chevron">›</span>
                     </button>
                   </div>
-                  <p class="dh-step-col__hint" *ngIf="vsClonedTimbreList.length === 0">尚无复刻音色,可前往「语音合成」标签页创建。</p>
+
+                  <app-voice-picker
+                    [open]="dhVoicePickerOpen"
+                    [clonedList]="vsClonedTimbreList"
+                    [currentSelection]="dhCurrentVoiceSelection"
+                    (select)="onDhVoicePicked($event)"
+                    (close)="closeDhVoicePicker()">
+                  </app-voice-picker>
                 </ng-container>
 
                 <ng-container *ngIf="dhAudioSource === 'upload'">
@@ -4372,61 +4371,45 @@
 
           <!-- 音色选择 -->
 
-          <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px;padding:16px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
-
-            <div class="form-field" style="flex:1;min-width:180px">
-
-              <label>音色来源</label>
-
-              <select [(ngModel)]="vgVoiceMode">
-
-                <option value="system">系统音色</option>
-
-                <option value="clone">克隆音色</option>
-
-              </select>
-
-            </div>
-
+          <div style="margin-bottom:20px;padding:16px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
 
+            <div class="form-field">
 
-            <div class="form-field" style="flex:2;min-width:200px" *ngIf="vgVoiceMode === 'system'">
+              <label>选择声音模型</label>
 
-              <label>系统音色</label>
-
-              <select [(ngModel)]="vgVoiceSpeakerId">
-
-                <option value="zh_female_shuangkuai-am_16k">双快女声</option>
-
-                <option value="zh_male_chunhou-am_16k">淳厚男声</option>
-
-                <option value="zh_female_maoxiaoni_emo-am_16k">猫小妮(情感)</option>
-
-                <option value="zh_male_rap-am_16k">说唱男声</option>
-
-                <option value="zh_female_wanwanxiaohe_emo-am_16k">弯弯小何(情感)</option>
-
-              </select>
-
-            </div>
-
-
-
-            <div class="form-field" style="flex:2;min-width:200px" *ngIf="vgVoiceMode === 'clone'">
-
-              <label>克隆音色 ID</label>
-
-              <select [(ngModel)]="vgVoiceTimbreId">
-
-                <option value="">请选择已克隆的音色</option>
-
-                <option *ngFor="let t of vsClonedTimbreList" [value]="t.objectId">
-
-                  {{t.name || t.objectId}}
-
-                </option>
+              <button type="button" class="vs-voice-picker-trigger"
+                      [class.is-empty]="!vgCurrentVoiceSelection"
+                      [disabled]="vgSynthesizing"
+                      (click)="openVgVoicePicker()">
+                <ng-container *ngIf="vgCurrentVoiceSelection; else vgVoiceEmpty">
+                  <span class="vs-voice-picker-avatar"
+                        [class.vs-voice-picker-avatar--mine]="vgCurrentVoiceSelection.source === 'cloned'">
+                    {{vgCurrentVoiceSelection.source === 'official' ? '知' : '我'}}
+                  </span>
+                  <span class="vs-voice-picker-info">
+                    <span class="vs-voice-picker-name">{{vgCurrentVoiceSelection.name}}</span>
+                    <span class="vs-voice-picker-src">
+                      {{vgCurrentVoiceSelection.source === 'official' ? '音色库' : '我的音色'}}
+                    </span>
+                  </span>
+                </ng-container>
+                <ng-template #vgVoiceEmpty>
+                  <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
+                  <span class="vs-voice-picker-info">
+                    <span class="vs-voice-picker-name">请选择声音模型</span>
+                    <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+                  </span>
+                </ng-template>
+                <span class="vs-voice-picker-chevron">›</span>
+              </button>
 
-              </select>
+              <app-voice-picker
+                [open]="vgVoicePickerOpen"
+                [clonedList]="vsClonedTimbreList"
+                [currentSelection]="vgCurrentVoiceSelection"
+                (select)="onVgVoicePicked($event)"
+                (close)="closeVgVoicePicker()">
+              </app-voice-picker>
 
             </div>
 
@@ -4506,7 +4489,7 @@
 
               <button class="btn btn-primary" (click)="vgSynthesizeAll()"
 
-                      [disabled]="vgSynthesizing || (vgVoiceMode === 'clone' && !vgVoiceTimbreId)">
+                      [disabled]="vgSynthesizing || !vgCurrentVoiceSelection">
 
                 <span class="icon">{{vgSynthesizing ? 'hourglass_empty' : 'mic'}}</span>
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 458 - 290
src/app/app.ts


+ 82 - 0
src/app/components/app-assistant/app-assistant.component.css

@@ -388,6 +388,88 @@
   color: rgba(248, 250, 252, 0.92);
 }
 
+/* ============================================================
+ * Markdown 渲染(仅助手消息)
+ * 紧凑、高密度,跟暗色面板视觉协调
+ * ============================================================ */
+.t-asst-md {
+  white-space: normal;   /* 覆盖父级 pre-wrap */
+}
+.t-asst-md > *:first-child { margin-top: 0; }
+.t-asst-md > *:last-child  { margin-bottom: 0; }
+.t-asst-md h1,
+.t-asst-md h2,
+.t-asst-md h3,
+.t-asst-md h4 {
+  font-weight: 600;
+  margin: 14px 0 6px;
+  color: #f8fafc;
+  letter-spacing: -0.01em;
+}
+.t-asst-md h1 { font-size: 17px; }
+.t-asst-md h2 { font-size: 15.5px; }
+.t-asst-md h3 { font-size: 14.5px; }
+.t-asst-md h4 { font-size: 13.5px; color: rgba(248, 250, 252, 0.85); }
+.t-asst-md p  { margin: 6px 0; }
+.t-asst-md ul,
+.t-asst-md ol { padding-left: 22px; margin: 6px 0; }
+.t-asst-md li { margin: 2px 0; }
+.t-asst-md strong { color: #fde68a; font-weight: 600; }
+.t-asst-md em     { color: rgba(248, 250, 252, 0.92); font-style: italic; }
+.t-asst-md a {
+  color: #93c5fd;
+  text-decoration: underline;
+  text-decoration-color: rgba(147, 197, 253, 0.4);
+}
+.t-asst-md code {
+  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
+  font-size: 12.5px;
+  padding: 1px 6px;
+  border-radius: 4px;
+  background: rgba(168, 85, 247, 0.18);
+  color: #f5d0fe;
+}
+.t-asst-md pre {
+  margin: 8px 0;
+  padding: 10px 12px;
+  border-radius: 8px;
+  background: rgba(15, 23, 42, 0.7);
+  border: 1px solid rgba(168, 85, 247, 0.22);
+  overflow-x: auto;
+}
+.t-asst-md pre code {
+  display: block;
+  padding: 0;
+  background: transparent;
+  color: #e2e8f0;
+  font-size: 12.5px;
+  line-height: 1.55;
+}
+.t-asst-md blockquote {
+  margin: 6px 0;
+  padding: 4px 12px;
+  border-left: 3px solid rgba(168, 85, 247, 0.55);
+  color: rgba(248, 250, 252, 0.78);
+  background: rgba(168, 85, 247, 0.06);
+  border-radius: 0 6px 6px 0;
+}
+.t-asst-md table {
+  border-collapse: collapse;
+  margin: 8px 0;
+  font-size: 13px;
+}
+.t-asst-md th,
+.t-asst-md td {
+  padding: 6px 10px;
+  border: 1px solid rgba(248, 250, 252, 0.16);
+}
+.t-asst-md th { background: rgba(168, 85, 247, 0.12); }
+.t-asst-md hr {
+  border: 0;
+  border-top: 1px solid rgba(248, 250, 252, 0.14);
+  margin: 12px 0;
+}
+
 /* typing 占位 */
 .t-asst-msg__typing {
   display: flex;

+ 3 - 1
src/app/components/app-assistant/app-assistant.component.html

@@ -105,7 +105,9 @@
       <div *ngIf="m.pending" class="t-asst-msg__typing" aria-hidden="true">
         <span></span><span></span><span></span>
       </div>
-      <div *ngIf="!m.pending" class="t-asst-msg__content">{{ m.content }}</div>
+      <!-- 用户消息保持纯文本(避免 markdown 误解析);助手消息渲染 markdown -->
+      <div *ngIf="!m.pending && m.role === 'user'" class="t-asst-msg__content">{{ m.content }}</div>
+      <div *ngIf="!m.pending && m.role === 'assistant'" class="t-asst-msg__content t-asst-md" [innerHTML]="renderMd(m.content)"></div>
       <div *ngIf="m.suggestions?.length" class="t-asst-msg__suggests">
         <button *ngFor="let s of m.suggestions; trackBy: trackBySuggest"
                 type="button"

+ 12 - 1
src/app/components/app-assistant/app-assistant.component.ts

@@ -18,6 +18,8 @@ import {
   AssistantSuggestion,
   Conversation,
 } from '../../services/assistant.service';
+import { MarkdownService } from '../../services/markdown.service';
+import { SafeHtml } from '@angular/platform-browser';
 
 /**
  * 全局 AI 助手浮窗
@@ -52,7 +54,16 @@ export class AppAssistantComponent implements OnInit, OnDestroy, AfterViewChecke
   private subs = new Subscription();
   private shouldScroll = false;
 
-  constructor(public svc: AssistantService, private cdr: ChangeDetectorRef) {}
+  constructor(
+    public svc: AssistantService,
+    private cdr: ChangeDetectorRef,
+    private md: MarkdownService,
+  ) {}
+
+  /** 渲染助手消息为安全 HTML(用户消息保持纯文本即可) */
+  renderMd(text: string): SafeHtml {
+    return this.md.render(text);
+  }
 
   ngOnInit(): void {
     // fetch 路径下回调可能跳出 zone,所有订阅都显式 detectChanges 兜底

+ 131 - 1
src/app/components/app-sidebar/app-sidebar.component.css

@@ -245,7 +245,7 @@
   color: #f8fafc;
 }
 
-/* ===== 响应式:窄屏折叠(仅显示分区编号) ===== */
+/* ===== 响应式:平板窄栏 ===== */
 @media (max-width: 960px) {
   .t-sidebar {
     width: 64px;
@@ -289,3 +289,133 @@
     padding: 16px 12px 0;
   }
 }
+
+/* ===== 手机端:横向文字导航,保留可识别标签 ===== */
+@media (max-width: 760px) {
+  .t-sidebar {
+    width: 100%;
+    height: auto;
+    max-height: none;
+    padding: 8px 0 10px;
+    border-right: 0;
+    border-bottom: 1px solid rgba(15, 23, 42, 0.08);
+    overflow-x: auto;
+    overflow-y: hidden;
+    overscroll-behavior-x: contain;
+    scrollbar-width: none;
+  }
+
+  .t-sidebar::-webkit-scrollbar {
+    display: none;
+  }
+
+  .t-sidebar__zones {
+    width: max-content;
+    min-width: 100%;
+    flex-direction: row;
+    align-items: stretch;
+    gap: 0;
+    padding: 0 10px;
+  }
+
+  .t-zone {
+    min-width: max-content;
+    padding: 0 10px;
+    border-right: 1px solid rgba(15, 23, 42, 0.08);
+  }
+
+  .t-zone:last-child {
+    border-right: 0;
+  }
+
+  .t-zone__head {
+    padding: 0 2px 2px;
+    flex-direction: row;
+    align-items: baseline;
+    justify-content: flex-start;
+    gap: 6px;
+    margin-bottom: 4px;
+  }
+
+  .t-zone__serial {
+    font-size: 16px;
+  }
+
+  .t-zone__label {
+    display: inline;
+    font-size: 10px;
+    letter-spacing: 0.12em;
+  }
+
+  .t-zone__items {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: nowrap;
+    gap: 6px;
+  }
+
+  .t-item {
+    position: relative;
+    flex: 0 0 auto;
+    justify-content: flex-start;
+    width: auto;
+    max-width: 154px;
+    min-height: 32px;
+    padding: 7px 9px;
+    border-radius: 999px;
+    background: rgba(15, 23, 42, 0.04);
+    font-size: 12px;
+    line-height: 1.2;
+  }
+
+  .t-item__rail,
+  .t-item.is-active::after {
+    display: none;
+  }
+
+  .t-item__label {
+    display: block;
+    flex: 0 1 auto;
+  }
+
+  .t-item__badge {
+    display: none;
+  }
+
+  .t-item.is-active {
+    background: #0f172a;
+    color: #ffffff;
+  }
+
+  .t-sidebar__foot {
+    display: none;
+  }
+
+  .app-container.theme-night .t-sidebar {
+    border-bottom-color: rgba(255, 255, 255, 0.08);
+  }
+
+  .app-container.theme-night .t-zone {
+    border-right-color: rgba(255, 255, 255, 0.08);
+  }
+
+  .app-container.theme-night .t-item {
+    background: rgba(255, 255, 255, 0.06);
+  }
+
+  .app-container.theme-night .t-item.is-active {
+    background: #f8fafc;
+    color: #0b0f1a;
+  }
+}
+
+@media (max-width: 420px) {
+  .t-zone {
+    padding: 0 8px;
+  }
+
+  .t-item {
+    max-width: 140px;
+    font-size: 11.5px;
+  }
+}

+ 5 - 3
src/app/components/app-sidebar/app-sidebar.component.ts

@@ -32,6 +32,7 @@ const ZONES: SidebarZone[] = [
     items: [
       { tab: 'home', label: '首页' },
       { tab: 'pipelines', label: '生成模式中心' },
+      { tab: 'image-generation', label: '图片生成', badge: 'Beta' },
       { tab: 'topic-to-video', label: '主题生视频', badge: 'Beta' },
       { tab: 'image-to-video', label: '图生视频', badge: 'Beta' },
       { tab: 'digital-human', label: '数字人合成' },
@@ -45,10 +46,11 @@ const ZONES: SidebarZone[] = [
     serial: '02',
     label: '资产',
     items: [
-      { tab: 'tasks', label: '任务管理' },
-      { tab: 'history', label: '生成历史' },
-      { tab: 'results', label: '结果展示' },
+      { tab: 'drafts', label: '我的创作', badge: 'New' },
+      { tab: 'library', label: '素材库', badge: 'New' },
+      // 老入口保留作为兼容(后续版本可移除)
       { tab: 'videos', label: '视频管理' },
+      { tab: 'tasks', label: '任务管理' },
     ],
   },
   {

+ 96 - 11
src/app/components/digital-human-form/digital-human-form.component.ts

@@ -6,6 +6,7 @@ import {
   AudioSourceMode,
   DigitalHumanRequest
 } from '../../services/digital-human.service';
+import { VoicePickerComponent, VoiceSelection } from '../voice-picker/voice-picker.component';
 
 export interface TimbreOption {
   /** 唯一 id(即 timbre_id 或 speaker_id) */
@@ -35,7 +36,7 @@ export interface PromptPreset {
 @Component({
   selector: 'app-digital-human-form',
   standalone: true,
-  imports: [CommonModule, FormsModule, PortraitPrepareComponent],
+  imports: [CommonModule, FormsModule, PortraitPrepareComponent, VoicePickerComponent],
   template: `
     <div class="dhf-container">
 
@@ -100,14 +101,39 @@ export interface PromptPreset {
                   [(ngModel)]="ttsText"
                   placeholder="请输入数字人要朗读的口播文本"></textarea>
 
-        <label class="dhf-label" style="margin-top:10px">音色</label>
-        <select class="dhf-select" [(ngModel)]="ttsTimbreId">
-          <option value="">请选择音色</option>
-          <option *ngFor="let t of timbreOptions" [value]="t.id">{{t.name}}</option>
-        </select>
-        <div class="dhf-hint" *ngIf="timbreOptions.length === 0">
-          暂无可用音色,请先在「音色合成」模块创建。
-        </div>
+        <label class="dhf-label" style="margin-top:10px">选择声音模型</label>
+        <button type="button" class="vs-voice-picker-trigger"
+                [class.is-empty]="!currentVoiceSelection"
+                [disabled]="submitting"
+                (click)="openVoicePicker()">
+          <ng-container *ngIf="currentVoiceSelection; else dhfVoiceEmpty">
+            <span class="vs-voice-picker-avatar"
+                  [class.vs-voice-picker-avatar--mine]="currentVoiceSelection.source === 'cloned'">
+              {{currentVoiceSelection.source === 'official' ? '知' : '我'}}
+            </span>
+            <span class="vs-voice-picker-info">
+              <span class="vs-voice-picker-name">{{currentVoiceSelection.name}}</span>
+              <span class="vs-voice-picker-src">
+                {{currentVoiceSelection.source === 'official' ? '音色库' : '我的音色'}}
+              </span>
+            </span>
+          </ng-container>
+          <ng-template #dhfVoiceEmpty>
+            <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
+            <span class="vs-voice-picker-info">
+              <span class="vs-voice-picker-name">请选择声音模型</span>
+              <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+            </span>
+          </ng-template>
+          <span class="vs-voice-picker-chevron">›</span>
+        </button>
+        <app-voice-picker
+          [open]="voicePickerOpen"
+          [clonedList]="clonedTimbresForPicker"
+          [currentSelection]="currentVoiceSelection"
+          (select)="onVoicePicked($event)"
+          (close)="closeVoicePicker()">
+        </app-voice-picker>
       </div>
 
       <!-- 上传音频面板 -->
@@ -309,6 +335,10 @@ export class DigitalHumanFormComponent implements OnInit, OnDestroy {
   // tts
   ttsText: string = '';
   ttsTimbreId: string = '';
+  ttsVoiceSource: 'cloned' | 'official' = 'cloned';
+  ttsVoiceType: string = '';
+  ttsVoiceName: string = '';
+  voicePickerOpen = false;
 
   // upload
   audioFile: File | null = null;
@@ -396,6 +426,59 @@ export class DigitalHumanFormComponent implements OnInit, OnDestroy {
       : '从原视频自动提取音频,沿用原声';
   }
 
+  get clonedTimbresForPicker(): { objectId: string; name: string; speakerId: string }[] {
+    return (this.timbreOptions || []).map((item) => ({
+      objectId: item.id,
+      name: item.name || item.id,
+      speakerId: item.id,
+    }));
+  }
+
+  get currentVoiceSelection(): VoiceSelection | null {
+    if (this.ttsVoiceSource === 'official' && this.ttsVoiceType) {
+      return {
+        source: 'official',
+        name: this.ttsVoiceName || this.ttsVoiceType,
+        id: this.ttsVoiceType,
+        voiceType: this.ttsVoiceType,
+      };
+    }
+    if (this.ttsVoiceSource === 'cloned' && this.ttsTimbreId) {
+      const matched = this.timbreOptions.find((item) => item.id === this.ttsTimbreId);
+      return {
+        source: 'cloned',
+        name: this.ttsVoiceName || matched?.name || this.ttsTimbreId,
+        id: this.ttsTimbreId,
+        timbreId: this.ttsTimbreId,
+        speakerId: this.ttsTimbreId,
+      };
+    }
+    return null;
+  }
+
+  openVoicePicker(): void {
+    if (this.submitting) return;
+    this.voicePickerOpen = true;
+  }
+
+  closeVoicePicker(): void {
+    this.voicePickerOpen = false;
+  }
+
+  onVoicePicked(sel: VoiceSelection): void {
+    this.ttsVoiceSource = sel.source;
+    this.ttsVoiceName = sel.name;
+    if (sel.source === 'official') {
+      this.ttsVoiceType = sel.voiceType || sel.id;
+      this.ttsTimbreId = '';
+    } else {
+      this.ttsTimbreId = sel.timbreId || sel.id;
+      this.ttsVoiceType = '';
+    }
+    this.formError = '';
+    this.voicePickerOpen = false;
+  }
+
   onAudioSelected(event: Event): void {
     const input = event.target as HTMLInputElement;
     if (!input.files || input.files.length === 0) return;
@@ -426,7 +509,7 @@ export class DigitalHumanFormComponent implements OnInit, OnDestroy {
     if (!this.imageFile && !this.preparedPortraitUrl) return false;
 
     if (this.audioSource === 'tts') {
-      return !!this.ttsText.trim() && !!this.ttsTimbreId.trim();
+      return !!this.ttsText.trim() && !!this.currentVoiceSelection;
     }
     if (this.audioSource === 'upload') {
       return !!this.audioFile;
@@ -464,7 +547,9 @@ export class DigitalHumanFormComponent implements OnInit, OnDestroy {
         return {
           source: 'tts',
           ttsText: this.ttsText.trim(),
-          ttsTimbreId: this.ttsTimbreId.trim()
+          ttsTimbreId: this.ttsTimbreId.trim(),
+          ttsVoiceSource: this.ttsVoiceSource,
+          ttsVoiceType: this.ttsVoiceType.trim()
         };
       case 'upload':
         return { source: 'upload', audioFile: this.audioFile! };

+ 1 - 1
src/app/components/portrait-prepare/portrait-prepare.component.ts

@@ -46,7 +46,7 @@ export interface PortraitPrepareOutput {
              [class.disabled]="running"
              (click)="setMode('benchmark')">
           <div class="pp-mode-title">参考图人物替换</div>
-          <div class="pp-mode-desc">上传参考图,用您上传形象图中的人物替换参考图人物,保留参考图的姿态、动作与场景(约 0.22 元)。</div>
+          <div class="pp-mode-desc">上传参考图,用您上传形象图中的人物替换参考图人物,保留参考图的姿态、动作与场景。</div>
         </div>
       </div>
 

+ 300 - 0
src/app/components/session-bar/session-bar.component.css

@@ -0,0 +1,300 @@
+/* Pipeline 会话顶栏:复用全局 CSS 变量,深浅主题自动跟随 */
+
+.session-bar {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 10px 16px;
+  margin: 0 0 16px;
+  background: var(--surface-elevated, var(--surface-color, #fff));
+  border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
+  border-radius: var(--border-radius-md, 12px);
+  box-shadow: var(--shadow-sm, 0 4px 12px rgba(0, 0, 0, 0.06));
+  z-index: 5;
+}
+
+.session-bar__left {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex: 1 1 auto;
+  min-width: 0;
+}
+
+.session-bar__pipeline-tag {
+  flex: 0 0 auto;
+  padding: 3px 8px;
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: 0.4px;
+  color: var(--accent-color, #2997ff);
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 10%, transparent);
+  border: 1px solid color-mix(in srgb, var(--accent-color, #2997ff) 25%, transparent);
+  border-radius: 6px;
+  white-space: nowrap;
+}
+
+/* 标题:作为按钮,整块可点击 */
+.session-bar__title {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  background: transparent;
+  border: 1px solid transparent;
+  padding: 4px 8px;
+  border-radius: 6px;
+  cursor: pointer;
+  color: var(--text-primary);
+  font-size: 14px;
+  font-weight: 600;
+  max-width: 360px;
+  transition: border-color 0.15s ease, background 0.15s ease;
+}
+.session-bar__title:hover {
+  border-color: var(--border-color);
+  background: color-mix(in srgb, var(--text-primary) 4%, transparent);
+}
+.session-bar__title svg {
+  opacity: 0;
+  transition: opacity 0.15s ease;
+  flex: 0 0 auto;
+}
+.session-bar__title:hover svg { opacity: 0.6; }
+
+.session-bar__title-text {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  max-width: 320px;
+}
+
+.session-bar__title-input {
+  font-size: 14px;
+  font-weight: 600;
+  padding: 4px 8px;
+  border: 1px solid var(--accent-color, #2997ff);
+  border-radius: 6px;
+  background: var(--surface-color, #fff);
+  color: var(--text-primary);
+  outline: none;
+  min-width: 220px;
+  max-width: 360px;
+}
+
+/* 状态徽章 */
+.session-bar__status {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 3px 8px;
+  font-size: 12px;
+  font-weight: 500;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--text-primary) 6%, transparent);
+  color: var(--text-secondary);
+  white-space: nowrap;
+}
+.session-bar__status-dot {
+  width: 6px; height: 6px; border-radius: 50%;
+  background: currentColor;
+}
+.session-bar__status[data-status="running"] {
+  color: var(--warning-color, #ed8936);
+  background: color-mix(in srgb, var(--warning-color, #ed8936) 15%, transparent);
+}
+.session-bar__status[data-status="running"] .session-bar__status-dot {
+  animation: session-pulse 1.4s ease-in-out infinite;
+}
+.session-bar__status[data-status="completed"] {
+  color: var(--success-color, #48bb78);
+  background: color-mix(in srgb, var(--success-color, #48bb78) 15%, transparent);
+}
+.session-bar__status[data-status="failed"] {
+  color: var(--error-color, #f56565);
+  background: color-mix(in srgb, var(--error-color, #f56565) 15%, transparent);
+}
+
+@keyframes session-pulse {
+  0%, 100% { opacity: 1; transform: scale(1); }
+  50% { opacity: 0.4; transform: scale(1.4); }
+}
+
+.session-bar__hint {
+  font-size: 12px;
+  color: var(--text-muted);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* 右侧操作区 */
+.session-bar__right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex: 0 0 auto;
+}
+
+.session-bar__btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 12px;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-primary);
+  background: var(--surface-color, #f5f5f7);
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+  white-space: nowrap;
+}
+.session-bar__btn:hover {
+  border-color: var(--accent-color, #2997ff);
+  color: var(--accent-color, #2997ff);
+}
+.session-bar__btn.is-open {
+  border-color: var(--accent-color, #2997ff);
+  color: var(--accent-color, #2997ff);
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 8%, transparent);
+}
+.session-bar__btn--ghost { background: transparent; }
+
+.session-bar__count {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 18px;
+  height: 18px;
+  padding: 0 5px;
+  font-size: 11px;
+  border-radius: 999px;
+  background: var(--accent-color, #2997ff);
+  color: #fff;
+}
+
+.caret { transition: transform 0.2s ease; }
+.caret.is-up { transform: rotate(180deg); }
+
+/* 历史下拉 */
+.session-bar__history-wrap { position: relative; }
+
+.session-bar__dropdown {
+  position: absolute;
+  top: calc(100% + 6px);
+  right: 0;
+  width: 380px;
+  max-height: 480px;
+  overflow-y: auto;
+  padding: 6px;
+  background: var(--surface-elevated, #fff);
+  border: 1px solid var(--border-color);
+  border-radius: 12px;
+  box-shadow: var(--shadow-md, 0 18px 50px rgba(0, 0, 0, 0.12));
+  z-index: 200;
+}
+
+.session-bar__dropdown-empty {
+  padding: 24px 12px;
+  text-align: center;
+  color: var(--text-muted);
+  font-size: 13px;
+}
+
+.session-bar__dropdown-item {
+  display: grid;
+  grid-template-columns: 44px 1fr auto;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  padding: 8px;
+  border: 1px solid transparent;
+  border-radius: 8px;
+  background: transparent;
+  color: var(--text-primary);
+  text-align: left;
+  cursor: pointer;
+  transition: background 0.15s ease, border-color 0.15s ease;
+}
+.session-bar__dropdown-item + .session-bar__dropdown-item { margin-top: 2px; }
+.session-bar__dropdown-item:hover {
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 6%, transparent);
+}
+.session-bar__dropdown-item.is-active {
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 12%, transparent);
+  border-color: color-mix(in srgb, var(--accent-color, #2997ff) 35%, transparent);
+}
+
+.session-bar__dropdown-thumb {
+  width: 44px;
+  height: 44px;
+  border-radius: 6px;
+  overflow: hidden;
+  background: color-mix(in srgb, var(--text-primary) 6%, transparent);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 0 0 auto;
+}
+.session-bar__dropdown-thumb img {
+  width: 100%; height: 100%; object-fit: cover; display: block;
+}
+.session-bar__dropdown-thumb-fallback {
+  font-size: 18px;
+  color: var(--text-muted);
+}
+
+.session-bar__dropdown-meta { min-width: 0; }
+.session-bar__dropdown-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.session-bar__dropdown-sub {
+  margin-top: 3px;
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  color: var(--text-muted);
+}
+.session-bar__dropdown-status[data-status="running"] { color: var(--warning-color, #ed8936); }
+.session-bar__dropdown-status[data-status="completed"] { color: var(--success-color, #48bb78); }
+.session-bar__dropdown-status[data-status="failed"] { color: var(--error-color, #f56565); }
+
+.session-bar__dropdown-del {
+  width: 28px; height: 28px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: transparent;
+  border: 1px solid transparent;
+  border-radius: 6px;
+  cursor: pointer;
+  color: var(--text-muted);
+  flex: 0 0 auto;
+}
+.session-bar__dropdown-del:hover {
+  color: var(--error-color, #f56565);
+  background: color-mix(in srgb, var(--error-color, #f56565) 10%, transparent);
+}
+
+/* 移动端点击外侧收起:用全屏透明层 */
+.session-bar__backdrop {
+  position: fixed;
+  inset: 0;
+  z-index: 150;
+  background: transparent;
+}
+
+@media (max-width: 720px) {
+  .session-bar { flex-wrap: wrap; gap: 8px; }
+  .session-bar__hint { display: none; }
+  .session-bar__dropdown { width: 92vw; right: -8px; }
+}

+ 256 - 0
src/app/components/session-bar/session-bar.component.ts

@@ -0,0 +1,256 @@
+import {
+  ChangeDetectionStrategy,
+  Component,
+  EventEmitter,
+  Input,
+  Output,
+  computed,
+  inject,
+  signal,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { PipelineSessionService } from '../../services/pipeline-session.service';
+import { PipelineRoute } from '../../pipelines/pipeline-registry';
+import { DraftMeta } from '../../models/pipeline-draft.model';
+
+/**
+ * Pipeline 顶部「会话栏」
+ *
+ * - 左:当前 draft 标题(可点击就地改名)+ 状态徽章 + 自动保存提示
+ * - 右:[+ 新建创作] [历史 ▾](下拉显示该 Pipeline 最近 N 个 draft)
+ *
+ * 不直接管理表单状态,只做 UI 编排;交互通过事件向 Pipeline 组件冒泡:
+ *   (newSession)   用户点「新建创作」
+ *   (openSession)  用户从下拉里选了一个旧 draft
+ */
+@Component({
+  selector: 'app-session-bar',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="session-bar" [class.session-bar--running]="status() === 'running'"
+         [class.session-bar--done]="status() === 'completed'"
+         [class.session-bar--failed]="status() === 'failed'">
+
+      <!-- 左:标题 + 状态 -->
+      <div class="session-bar__left">
+        <span class="session-bar__pipeline-tag">{{ pipelineLabel || pipelineId }}</span>
+
+        <ng-container *ngIf="!editing(); else editingTpl">
+          <button type="button" class="session-bar__title" (click)="startEdit()" [title]="'点击改名'">
+            <span class="session-bar__title-text">{{ title() || '未命名创作' }}</span>
+            <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor"
+                 stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
+              <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
+            </svg>
+          </button>
+        </ng-container>
+        <ng-template #editingTpl>
+          <input class="session-bar__title-input"
+                 [(ngModel)]="editBuffer"
+                 (keydown.enter)="commitEdit()"
+                 (keydown.escape)="cancelEdit()"
+                 (blur)="commitEdit()"
+                 #titleInput
+                 maxlength="60"
+                 autofocus />
+        </ng-template>
+
+        <span class="session-bar__status" [attr.data-status]="status()">
+          <span class="session-bar__status-dot"></span>
+          {{ statusText() }}
+        </span>
+
+        <span class="session-bar__hint" *ngIf="hintText() as h">{{ h }}</span>
+      </div>
+
+      <!-- 右:操作 -->
+      <div class="session-bar__right">
+        <button type="button" class="session-bar__btn session-bar__btn--ghost"
+                (click)="onNewClick()" title="新建一个空白创作">
+          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
+               stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+            <line x1="12" y1="5" x2="12" y2="19"></line>
+            <line x1="5" y1="12" x2="19" y2="12"></line>
+          </svg>
+          新建创作
+        </button>
+
+        <div class="session-bar__history-wrap">
+          <button type="button" class="session-bar__btn"
+                  (click)="toggleHistory()" [class.is-open]="historyOpen()">
+            <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
+                 stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M3 12a9 9 0 1 0 9-9"></path>
+              <polyline points="3 4 3 9 8 9"></polyline>
+              <polyline points="12 7 12 12 15 14"></polyline>
+            </svg>
+            历史
+            <span class="session-bar__count" *ngIf="recentDrafts().length">
+              {{ recentDrafts().length }}
+            </span>
+            <svg class="caret" [class.is-up]="historyOpen()"
+                 viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor"
+                 stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <polyline points="6 9 12 15 18 9"></polyline>
+            </svg>
+          </button>
+
+          <div class="session-bar__dropdown" *ngIf="historyOpen()">
+            <div class="session-bar__dropdown-empty" *ngIf="!recentDrafts().length">
+              暂无历史创作
+            </div>
+            <button type="button" class="session-bar__dropdown-item"
+                    *ngFor="let d of recentDrafts(); trackBy: trackById"
+                    [class.is-active]="d.id === activeId()"
+                    (click)="onPickDraft(d)">
+              <div class="session-bar__dropdown-thumb">
+                <img *ngIf="d.thumbnail" [src]="d.thumbnail" alt="" />
+                <span *ngIf="!d.thumbnail" class="session-bar__dropdown-thumb-fallback">
+                  {{ statusEmoji(d.status) }}
+                </span>
+              </div>
+              <div class="session-bar__dropdown-meta">
+                <div class="session-bar__dropdown-title">{{ d.title }}</div>
+                <div class="session-bar__dropdown-sub">
+                  <span class="session-bar__dropdown-status" [attr.data-status]="d.status">
+                    {{ statusTextOf(d.status) }}
+                  </span>
+                  <span>·</span>
+                  <span>{{ formatTime(d.updatedAt) }}</span>
+                  <span *ngIf="d.artifactCount">·</span>
+                  <span *ngIf="d.artifactCount">{{ d.artifactCount }} 件产物</span>
+                </div>
+              </div>
+              <button type="button" class="session-bar__dropdown-del"
+                      (click)="onDelete($event, d)" title="删除">
+                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
+                     stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+                  <polyline points="3 6 5 6 21 6"></polyline>
+                  <path d="M19 6l-1.5 14a2 2 0 0 1-2 1.5H8.5a2 2 0 0 1-2-1.5L5 6"></path>
+                </svg>
+              </button>
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="session-bar__backdrop"
+         *ngIf="historyOpen()"
+         (click)="historyOpen.set(false)"></div>
+  `,
+  styleUrls: ['./session-bar.component.css'],
+})
+export class SessionBarComponent {
+  /** 当前 Pipeline 路由 key(决定下拉里筛哪些 draft) */
+  @Input({ required: true }) pipelineId!: PipelineRoute;
+  /** 中文显示名(来自 PIPELINES 注册表,外部传入) */
+  @Input() pipelineLabel = '';
+  /** 下拉里最多展示几条 */
+  @Input() recentLimit = 12;
+
+  @Output() newSession = new EventEmitter<void>();
+  @Output() openSession = new EventEmitter<DraftMeta>();
+
+  private readonly session = inject(PipelineSessionService);
+
+  readonly historyOpen = signal(false);
+  readonly editing = signal(false);
+  editBuffer = '';
+
+  // 派生当前 active draft 字段(响应式跟随 service.active)
+  readonly title = computed(() => this.session.active()?.title || '');
+  readonly status = computed(() => this.session.active()?.status || 'draft');
+  readonly activeId = computed(() => this.session.active()?.id || null);
+  readonly updatedAt = computed(() => this.session.active()?.updatedAt || 0);
+
+  readonly recentDrafts = computed<DraftMeta[]>(() => {
+    const all = this.session.list();
+    return all
+      .filter((m) => m.pipelineId === this.pipelineId)
+      .slice(0, this.recentLimit);
+  });
+
+  statusText = computed(() => this.statusTextOf(this.status()));
+
+  hintText = computed(() => {
+    if (!this.session.active()) return '尚未开始 · 在右侧填入参数即可创建草稿';
+    const ts = this.updatedAt();
+    if (!ts) return '';
+    return `已自动保存 · ${this.formatRelative(ts)}`;
+  });
+
+  statusTextOf(s: string): string {
+    return ({
+      draft: '草稿',
+      running: '运行中',
+      completed: '已完成',
+      failed: '失败',
+    } as Record<string, string>)[s] || s;
+  }
+
+  statusEmoji(s: string): string {
+    return ({ draft: '✎', running: '⏳', completed: '✓', failed: '!' } as Record<string, string>)[s] || '·';
+  }
+
+  startEdit(): void {
+    this.editBuffer = this.title();
+    this.editing.set(true);
+  }
+
+  cancelEdit(): void {
+    this.editing.set(false);
+  }
+
+  commitEdit(): void {
+    if (!this.editing()) return;
+    const t = this.editBuffer.trim();
+    this.editing.set(false);
+    if (t && t !== this.title()) {
+      const id = this.activeId();
+      if (id) void this.session.rename(id, t);
+    }
+  }
+
+  toggleHistory(): void {
+    this.historyOpen.update((v) => !v);
+  }
+
+  onNewClick(): void {
+    this.historyOpen.set(false);
+    this.newSession.emit();
+  }
+
+  onPickDraft(d: DraftMeta): void {
+    this.historyOpen.set(false);
+    this.openSession.emit(d);
+  }
+
+  onDelete(ev: Event, d: DraftMeta): void {
+    ev.stopPropagation();
+    if (!confirm(`确定删除"${d.title}"?此操作无法撤销。`)) return;
+    void this.session.remove(d.id);
+  }
+
+  trackById(_: number, d: DraftMeta): string { return d.id; }
+
+  formatTime(ts: number): string {
+    if (!ts) return '';
+    const d = new Date(ts);
+    const pad = (n: number) => n.toString().padStart(2, '0');
+    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+  }
+
+  formatRelative(ts: number): string {
+    const diff = Date.now() - ts;
+    if (diff < 5_000) return '刚刚';
+    if (diff < 60_000) return `${Math.floor(diff / 1000)} 秒前`;
+    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
+    if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
+    return this.formatTime(ts);
+  }
+}

+ 1 - 1
src/app/components/voice-picker/voice-picker.component.ts

@@ -99,7 +99,7 @@ const TTS_TOKEN = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
                     [class.is-active]="activeTab === 'mine'"
                     (click)="activeTab = 'mine'">
               我的音色
-              <span class="vp-tab-badge" *ngIf="clonedList?.length">{{clonedList.length}}</span>
+              <span class="vp-tab-badge" *ngIf="clonedList.length">{{clonedList.length}}</span>
             </button>
           </div>
           <button type="button" class="vp-close" (click)="emitClose()" title="关闭">✕</button>

+ 90 - 0
src/app/models/pipeline-draft.model.ts

@@ -0,0 +1,90 @@
+/**
+ * Pipeline 创作会话(Draft)模型
+ *
+ * 类比 ChatGPT 的「会话」:
+ *  - 每次进入 Pipeline 不再是临时表单,而是一个可命名、可保存、可中断、
+ *    可恢复、可枚举的创作草稿。
+ *  - snapshot 字段保存各 Pipeline 自己的完整可序列化状态(不含 File / ObjectURL)。
+ *  - artifacts 收集所有「中间产物」(每段分镜的图、图生视频片段、TTS 音频等),
+ *    解决「分镜生成的视频不入历史」问题。
+ *  - 列表页(我的创作)按 updatedAt 倒序枚举。
+ */
+
+import type { PipelineRoute } from '../pipelines/pipeline-registry';
+
+export type DraftStatus = 'draft' | 'running' | 'completed' | 'failed';
+
+export type ArtifactType = 'image' | 'video' | 'audio';
+
+export interface Artifact {
+  id: string;
+  type: ArtifactType;
+  url: string;
+  /** 中间产物的人类可读名,如「分镜 3 - 首帧」「分镜 3 - 图生视频」「段 2 TTS」 */
+  title: string;
+  /** 关联的分镜/段索引(可选) */
+  sceneIndex?: number;
+  createdAt: number;
+  /** 任意附加信息:尺寸、模式、prompt 等 */
+  extras?: Record<string, any>;
+}
+
+export interface PipelineDraft<TSnapshot = any> {
+  id: string;
+  pipelineId: PipelineRoute;
+  /** 用户可改;默认从 snapshot 推导(如主题前 30 字) */
+  title: string;
+  createdAt: number;
+  updatedAt: number;
+  status: DraftStatus;
+  /** 缩略图(首张分镜图 / 首张素材 / 成片首帧) */
+  thumbnail?: string;
+  /** 成片视频 URL(仅 status === 'completed' 时有) */
+  resultUrl?: string;
+  /** 失败原因(仅 status === 'failed' 时有) */
+  errorMessage?: string;
+  /** 中间 + 最终产物时间线 */
+  artifacts: Artifact[];
+  /** Pipeline 私有完整状态快照——回到该状态时整盘还原 */
+  snapshot: TSnapshot;
+}
+
+/**
+ * 列表索引项(轻量;列表渲染只需要这些字段,避免一次性把所有 snapshot 加载进内存)
+ */
+export interface DraftMeta {
+  id: string;
+  pipelineId: PipelineRoute;
+  title: string;
+  createdAt: number;
+  updatedAt: number;
+  status: DraftStatus;
+  thumbnail?: string;
+  resultUrl?: string;
+  /** artifacts 数量(用于列表上展示「3 张图 / 5 段视频」) */
+  artifactCount?: number;
+}
+
+export function toDraftMeta(d: PipelineDraft): DraftMeta {
+  return {
+    id: d.id,
+    pipelineId: d.pipelineId,
+    title: d.title,
+    createdAt: d.createdAt,
+    updatedAt: d.updatedAt,
+    status: d.status,
+    thumbnail: d.thumbnail,
+    resultUrl: d.resultUrl,
+    artifactCount: d.artifacts?.length || 0,
+  };
+}
+
+export function newDraftId(pipelineId: PipelineRoute): string {
+  const ts = Date.now().toString(36);
+  const rand = Math.random().toString(36).slice(2, 8);
+  return `draft_${pipelineId}_${ts}_${rand}`;
+}
+
+export function newArtifactId(): string {
+  return `art_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
+}

+ 646 - 0
src/app/pages/drafts/drafts.component.css

@@ -0,0 +1,646 @@
+/* 我的创作页 */
+
+.drafts-page { padding: 0 0 32px; }
+
+/* ========= 工具栏 ========= */
+.drafts-toolbar {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  margin: 16px 0 20px;
+}
+
+.drafts-filter-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+}
+.drafts-filter-row--secondary {
+  padding-top: 8px;
+  border-top: 1px dashed var(--border-color);
+}
+
+.drafts-pill {
+  padding: 6px 14px;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-secondary);
+  background: var(--surface-color, #f5f5f7);
+  border: 1px solid var(--border-color);
+  border-radius: 999px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+  white-space: nowrap;
+}
+.drafts-pill:hover { color: var(--text-primary); }
+.drafts-pill.is-active {
+  background: var(--accent-color, #2997ff);
+  border-color: var(--accent-color, #2997ff);
+  color: #fff;
+}
+
+.drafts-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 10px;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--text-secondary);
+  background: transparent;
+  border: 1px solid var(--border-color);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+.drafts-chip:hover { color: var(--text-primary); }
+.drafts-chip.is-active {
+  background: color-mix(in srgb, var(--text-primary) 8%, transparent);
+  border-color: var(--text-primary);
+  color: var(--text-primary);
+}
+.drafts-chip__num {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 18px;
+  height: 16px;
+  padding: 0 4px;
+  font-size: 11px;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--text-primary) 10%, transparent);
+}
+.drafts-chip--draft.is-active     { color: var(--text-primary); }
+.drafts-chip--running.is-active   { color: var(--warning-color, #ed8936); border-color: var(--warning-color, #ed8936); }
+.drafts-chip--completed.is-active { color: var(--success-color, #48bb78); border-color: var(--success-color, #48bb78); }
+.drafts-chip--failed.is-active    { color: var(--error-color, #f56565); border-color: var(--error-color, #f56565); }
+
+.drafts-search {
+  margin-left: auto;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 10px;
+  background: var(--surface-color, #fff);
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  color: var(--text-muted);
+}
+.drafts-search input {
+  border: none;
+  background: transparent;
+  font-size: 13px;
+  outline: none;
+  color: var(--text-primary);
+  min-width: 180px;
+}
+
+/* ========= 列表 + 详情布局 ========= */
+.drafts-main {
+  display: grid;
+  grid-template-columns: 380px 1fr;
+  gap: 16px;
+  align-items: stretch;
+  min-height: calc(100vh - 280px);
+}
+
+@media (max-width: 1100px) {
+  .drafts-main { grid-template-columns: 1fr; }
+}
+
+/* ========= 列表 ========= */
+.drafts-list {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  max-height: calc(100vh - 200px);
+  overflow-y: auto;
+  padding: 6px;
+  background: var(--surface-elevated, #fff);
+  border: 1px solid var(--border-color);
+  border-radius: 12px;
+}
+
+.drafts-list__empty {
+  padding: 48px 16px;
+  text-align: center;
+  color: var(--text-muted);
+}
+.drafts-list__empty-icon {
+  margin: 0 auto 12px;
+  width: 56px; height: 56px;
+  display: flex; align-items: center; justify-content: center;
+  border-radius: 14px;
+  background: color-mix(in srgb, var(--text-primary) 6%, transparent);
+}
+.drafts-list__empty h3 {
+  margin: 0 0 4px;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+.drafts-list__empty p { margin: 0; font-size: 13px; }
+
+.drafts-list__item {
+  display: grid;
+  grid-template-columns: 56px 1fr;
+  gap: 12px;
+  padding: 10px;
+  background: transparent;
+  border: 1px solid transparent;
+  border-radius: 10px;
+  cursor: pointer;
+  text-align: left;
+  color: inherit;
+  transition: background 0.15s ease, border-color 0.15s ease;
+}
+.drafts-list__item:hover {
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 6%, transparent);
+}
+.drafts-list__item.is-active {
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 12%, transparent);
+  border-color: color-mix(in srgb, var(--accent-color, #2997ff) 35%, transparent);
+}
+
+.drafts-list__thumb {
+  width: 56px; height: 56px;
+  border-radius: 10px;
+  overflow: hidden;
+  background: color-mix(in srgb, var(--text-primary) 8%, transparent);
+  display: flex; align-items: center; justify-content: center;
+}
+.drafts-list__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.drafts-list__thumb-fallback { font-size: 22px; color: var(--text-muted); }
+
+.drafts-list__meta { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
+.drafts-list__top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+.drafts-list__pipeline {
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--accent-color, #2997ff);
+  letter-spacing: 0.3px;
+}
+.drafts-list__time { font-size: 11px; color: var(--text-muted); }
+
+.drafts-list__title {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.drafts-list__bottom {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 11px;
+  color: var(--text-muted);
+}
+.drafts-list__count {
+  padding: 1px 6px;
+  background: color-mix(in srgb, var(--text-primary) 6%, transparent);
+  border-radius: 4px;
+}
+
+/* ========= 状态徽章 ========= */
+.drafts-status {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 2px 7px;
+  font-size: 11px;
+  font-weight: 500;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--text-primary) 6%, transparent);
+  color: var(--text-secondary);
+  white-space: nowrap;
+}
+.drafts-status__dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
+.drafts-status[data-status="running"] {
+  color: var(--warning-color, #ed8936);
+  background: color-mix(in srgb, var(--warning-color, #ed8936) 15%, transparent);
+}
+.drafts-status[data-status="running"] .drafts-status__dot {
+  animation: drafts-pulse 1.4s ease-in-out infinite;
+}
+.drafts-status[data-status="completed"] {
+  color: var(--success-color, #48bb78);
+  background: color-mix(in srgb, var(--success-color, #48bb78) 15%, transparent);
+}
+.drafts-status[data-status="failed"] {
+  color: var(--error-color, #f56565);
+  background: color-mix(in srgb, var(--error-color, #f56565) 15%, transparent);
+}
+@keyframes drafts-pulse {
+  0%, 100% { opacity: 1; transform: scale(1); }
+  50% { opacity: 0.4; transform: scale(1.4); }
+}
+
+/* ========= 详情区 ========= */
+.drafts-detail {
+  background: var(--surface-elevated, #fff);
+  border: 1px solid var(--border-color);
+  border-radius: 12px;
+  padding: 24px;
+  overflow-y: auto;
+  max-height: calc(100vh - 200px);
+}
+
+.drafts-detail__empty {
+  height: 100%;
+  display: flex; flex-direction: column; align-items: center; justify-content: center;
+  gap: 12px;
+  color: var(--text-muted);
+  text-align: center;
+}
+.drafts-detail__empty h3 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--text-secondary);
+}
+.drafts-detail__empty p { margin: 0; font-size: 13px; max-width: 360px; }
+.drafts-detail__loader {
+  width: 28px; height: 28px;
+  border-radius: 50%;
+  border: 3px solid var(--border-color);
+  border-top-color: var(--accent-color, #2997ff);
+  animation: drafts-spin 0.8s linear infinite;
+}
+@keyframes drafts-spin { to { transform: rotate(360deg); } }
+
+/* 标题区 */
+.drafts-detail__head { margin-bottom: 24px; }
+.drafts-detail__head-top {
+  display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
+  margin-bottom: 8px;
+}
+.drafts-detail__pipeline-tag {
+  padding: 3px 8px;
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: 0.4px;
+  color: var(--accent-color, #2997ff);
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 10%, transparent);
+  border: 1px solid color-mix(in srgb, var(--accent-color, #2997ff) 25%, transparent);
+  border-radius: 6px;
+}
+.drafts-detail__time { margin-left: auto; font-size: 12px; color: var(--text-muted); }
+
+.drafts-detail__title {
+  margin: 0 0 14px;
+  font-size: 20px;
+  font-weight: 700;
+  color: var(--text-primary);
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  border-radius: 6px;
+  padding: 4px 6px;
+  margin-left: -6px;
+}
+.drafts-detail__title:hover {
+  background: color-mix(in srgb, var(--text-primary) 4%, transparent);
+}
+.drafts-detail__title svg { opacity: 0; transition: opacity 0.15s ease; }
+.drafts-detail__title:hover svg { opacity: 0.6; }
+
+.drafts-detail__title-input {
+  margin: 0 0 14px;
+  font-size: 20px;
+  font-weight: 700;
+  padding: 4px 8px;
+  border: 1px solid var(--accent-color, #2997ff);
+  border-radius: 6px;
+  background: var(--surface-color, #fff);
+  color: var(--text-primary);
+  outline: none;
+  width: 100%;
+  max-width: 600px;
+}
+
+.drafts-detail__actions {
+  display: flex; flex-wrap: wrap; gap: 8px;
+}
+
+.drafts-btn {
+  display: inline-flex; align-items: center; gap: 5px;
+  padding: 7px 14px;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-primary);
+  background: var(--surface-color, #f5f5f7);
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+.drafts-btn:hover {
+  border-color: var(--accent-color, #2997ff);
+  color: var(--accent-color, #2997ff);
+}
+.drafts-btn--primary {
+  background: var(--accent-color, #2997ff);
+  border-color: var(--accent-color, #2997ff);
+  color: #fff;
+}
+.drafts-btn--primary:hover {
+  background: var(--accent-dark, #0077ed);
+  border-color: var(--accent-dark, #0077ed);
+  color: #fff;
+}
+.drafts-btn--danger:hover {
+  border-color: var(--error-color, #f56565);
+  color: var(--error-color, #f56565);
+}
+
+.drafts-detail__error {
+  margin-top: 14px;
+  padding: 10px 14px;
+  font-size: 13px;
+  color: var(--error-color, #f56565);
+  background: color-mix(in srgb, var(--error-color, #f56565) 10%, transparent);
+  border: 1px solid color-mix(in srgb, var(--error-color, #f56565) 25%, transparent);
+  border-radius: 8px;
+}
+
+/* 区块 */
+.drafts-detail__section { margin-top: 24px; }
+.drafts-detail__section-title {
+  display: flex; align-items: center; gap: 8px;
+  margin: 0 0 12px;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-primary);
+  padding-bottom: 8px;
+  border-bottom: 1px solid var(--border-color);
+}
+.drafts-detail__section-count {
+  padding: 1px 8px;
+  font-size: 11px;
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 12%, transparent);
+  color: var(--accent-color, #2997ff);
+  border-radius: 999px;
+}
+
+/* 成片播放器 */
+.drafts-detail__player {
+  position: relative;
+  background: #000;
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: var(--shadow-md, 0 12px 30px rgba(0, 0, 0, 0.18));
+}
+.drafts-detail__player video {
+  display: block;
+  width: 100%;
+  max-height: 540px;
+  background: #000;
+}
+.drafts-detail__player-dl {
+  position: absolute;
+  top: 12px; right: 12px;
+  display: inline-flex; align-items: center; gap: 4px;
+  padding: 6px 10px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #fff;
+  background: rgba(0, 0, 0, 0.55);
+  backdrop-filter: blur(8px);
+  border-radius: 6px;
+  text-decoration: none;
+}
+.drafts-detail__player-dl:hover { background: rgba(0, 0, 0, 0.75); }
+
+/* Artifact 时间线 */
+.drafts-detail__timeline-empty {
+  padding: 24px 16px;
+  text-align: center;
+  color: var(--text-muted);
+  font-size: 13px;
+  background: color-mix(in srgb, var(--text-primary) 3%, transparent);
+  border-radius: 8px;
+}
+
+.drafts-timeline {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  padding-left: 28px;
+}
+.drafts-timeline::before {
+  content: '';
+  position: absolute;
+  left: 11px;
+  top: 8px;
+  bottom: 8px;
+  width: 2px;
+  background: linear-gradient(to bottom,
+              color-mix(in srgb, var(--accent-color, #2997ff) 30%, transparent),
+              color-mix(in srgb, var(--accent-color, #2997ff) 5%, transparent));
+}
+
+.drafts-timeline__item {
+  position: relative;
+  display: grid;
+  grid-template-columns: 220px 1fr;
+  gap: 16px;
+  padding: 12px;
+  background: color-mix(in srgb, var(--text-primary) 3%, transparent);
+  border: 1px solid var(--border-color);
+  border-radius: 10px;
+}
+
+.drafts-timeline__bullet {
+  position: absolute;
+  left: -28px;
+  top: 18px;
+  width: 24px; height: 24px;
+  border-radius: 50%;
+  display: flex; align-items: center; justify-content: center;
+  background: var(--surface-elevated, #fff);
+  border: 2px solid var(--accent-color, #2997ff);
+  font-size: 11px;
+  z-index: 1;
+}
+
+.drafts-timeline__media {
+  border-radius: 6px;
+  overflow: hidden;
+  background: #000;
+  display: flex; align-items: center; justify-content: center;
+  max-height: 180px;
+}
+.drafts-timeline__media img,
+.drafts-timeline__media video {
+  width: 100%;
+  max-height: 180px;
+  object-fit: contain;
+  display: block;
+}
+.drafts-timeline__media audio { width: 100%; }
+
+.drafts-timeline__meta { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+.drafts-timeline__title {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+.drafts-timeline__sub {
+  font-size: 11px;
+  color: var(--text-muted);
+  display: flex; gap: 6px; flex-wrap: wrap;
+}
+.drafts-timeline__link {
+  margin-top: auto;
+  font-size: 12px;
+  color: var(--accent-color, #2997ff);
+  text-decoration: none;
+}
+.drafts-timeline__link:hover { text-decoration: underline; }
+
+@media (max-width: 720px) {
+  .drafts-page { padding-bottom: 20px; }
+
+  .drafts-toolbar {
+    margin: 12px 0 14px;
+  }
+
+  .drafts-filter-row {
+    align-items: stretch;
+    overflow-x: auto;
+    flex-wrap: nowrap;
+    padding-bottom: 2px;
+    scrollbar-width: none;
+  }
+
+  .drafts-filter-row::-webkit-scrollbar {
+    display: none;
+  }
+
+  .drafts-filter-row--secondary {
+    flex-wrap: wrap;
+    overflow-x: visible;
+  }
+
+  .drafts-search {
+    width: 100%;
+    margin-left: 0;
+    padding: 8px 10px;
+  }
+
+  .drafts-search input {
+    min-width: 0;
+    width: 100%;
+    font-size: 16px;
+  }
+
+  .drafts-main {
+    gap: 12px;
+    min-height: auto;
+  }
+
+  .drafts-list,
+  .drafts-detail {
+    max-height: none;
+    overflow: visible;
+  }
+
+  .drafts-list {
+    padding: 4px;
+  }
+
+  .drafts-list__item {
+    grid-template-columns: 48px minmax(0, 1fr);
+    gap: 10px;
+    padding: 9px;
+  }
+
+  .drafts-list__thumb {
+    width: 48px;
+    height: 48px;
+  }
+
+  .drafts-list__top,
+  .drafts-list__bottom {
+    align-items: flex-start;
+    flex-wrap: wrap;
+  }
+
+  .drafts-list__time {
+    margin-left: 0;
+  }
+
+  .drafts-detail {
+    padding: 16px;
+  }
+
+  .drafts-detail__head-top {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .drafts-detail__time {
+    margin-left: 0;
+  }
+
+  .drafts-detail__title,
+  .drafts-detail__title-input {
+    font-size: 18px;
+    line-height: 1.25;
+  }
+
+  .drafts-detail__actions {
+    flex-direction: column;
+  }
+
+  .drafts-btn {
+    width: 100%;
+    justify-content: center;
+    min-height: 40px;
+  }
+
+  .drafts-detail__player video {
+    max-height: 58vh;
+  }
+
+  .drafts-timeline {
+    padding-left: 0;
+  }
+
+  .drafts-timeline::before,
+  .drafts-timeline__bullet {
+    display: none;
+  }
+
+  .drafts-timeline__item {
+    grid-template-columns: 1fr;
+    gap: 10px;
+  }
+
+  .drafts-timeline__media,
+  .drafts-timeline__media img,
+  .drafts-timeline__media video {
+    max-height: 260px;
+  }
+}
+
+@media (max-width: 420px) {
+  .drafts-detail {
+    padding: 12px;
+  }
+
+  .drafts-pill,
+  .drafts-chip {
+    font-size: 12px;
+  }
+}

+ 258 - 0
src/app/pages/drafts/drafts.component.html

@@ -0,0 +1,258 @@
+<section class="dh-page drafts-page">
+
+  <header class="dh-page-header">
+    <div class="dh-header-icon">
+      <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
+        <polyline points="14 2 14 8 20 8"></polyline>
+        <line x1="9" y1="15" x2="15" y2="15"></line>
+        <line x1="9" y1="11" x2="15" y2="11"></line>
+      </svg>
+    </div>
+    <div class="dh-header-text">
+      <h1>我的创作</h1>
+      <p>所有 Pipeline 的创作草稿、运行任务、生成结果与中间产物。点击任意条目查看时间线或继续编辑。</p>
+    </div>
+  </header>
+
+  <!-- 筛选条 -->
+  <div class="drafts-toolbar">
+    <div class="drafts-filter-row">
+      <button type="button" class="drafts-pill"
+              [class.is-active]="pipelineFilter() === 'all'"
+              (click)="setPipelineFilter('all')">
+        全部 Pipeline
+      </button>
+      <button type="button" class="drafts-pill"
+              *ngFor="let p of pipelines; trackBy: trackById"
+              [class.is-active]="pipelineFilter() === p.route"
+              (click)="setPipelineFilter(p.route)">
+        {{ p.displayName }}
+      </button>
+    </div>
+
+    <div class="drafts-filter-row drafts-filter-row--secondary">
+      <button type="button" class="drafts-chip"
+              [class.is-active]="statusFilter() === 'all'"
+              (click)="setStatusFilter('all')">
+        全部 <span class="drafts-chip__num">{{ statusCounts().all }}</span>
+      </button>
+      <button type="button" class="drafts-chip drafts-chip--draft"
+              [class.is-active]="statusFilter() === 'draft'"
+              (click)="setStatusFilter('draft')">
+        草稿 <span class="drafts-chip__num">{{ statusCounts().draft }}</span>
+      </button>
+      <button type="button" class="drafts-chip drafts-chip--running"
+              [class.is-active]="statusFilter() === 'running'"
+              (click)="setStatusFilter('running')">
+        运行中 <span class="drafts-chip__num">{{ statusCounts().running }}</span>
+      </button>
+      <button type="button" class="drafts-chip drafts-chip--completed"
+              [class.is-active]="statusFilter() === 'completed'"
+              (click)="setStatusFilter('completed')">
+        已完成 <span class="drafts-chip__num">{{ statusCounts().completed }}</span>
+      </button>
+      <button type="button" class="drafts-chip drafts-chip--failed"
+              [class.is-active]="statusFilter() === 'failed'"
+              (click)="setStatusFilter('failed')">
+        失败 <span class="drafts-chip__num">{{ statusCounts().failed }}</span>
+      </button>
+
+      <div class="drafts-search">
+        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <circle cx="11" cy="11" r="8"></circle>
+          <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
+        </svg>
+        <input type="text" placeholder="按标题搜索..."
+               [ngModel]="searchTerm()"
+               (ngModelChange)="searchTerm.set($event)" />
+      </div>
+    </div>
+  </div>
+
+  <!-- 主体:左列表 + 右详情 -->
+  <div class="drafts-main">
+
+    <!-- 列表 -->
+    <div class="drafts-list">
+      <div class="drafts-list__empty" *ngIf="filteredList().length === 0">
+        <div class="drafts-list__empty-icon">
+          <svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+            <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
+            <polyline points="14 2 14 8 20 8"></polyline>
+          </svg>
+        </div>
+        <h3>{{ session.list().length === 0 ? '还没有任何创作' : '没有符合筛选条件的创作' }}</h3>
+        <p *ngIf="session.list().length === 0">在任意 Pipeline 页输入并开始生成即可创建第一个草稿</p>
+      </div>
+
+      <button type="button" class="drafts-list__item"
+              *ngFor="let m of filteredList(); trackBy: trackById"
+              [class.is-active]="m.id === selectedId()"
+              (click)="openDetail(m)">
+        <div class="drafts-list__thumb">
+          <img *ngIf="m.thumbnail" [src]="m.thumbnail" alt="" loading="lazy" />
+          <span *ngIf="!m.thumbnail" class="drafts-list__thumb-fallback">
+            {{ statusEmoji(m.status) }}
+          </span>
+        </div>
+
+        <div class="drafts-list__meta">
+          <div class="drafts-list__top">
+            <span class="drafts-list__pipeline">{{ pipelineLabel(m.pipelineId) }}</span>
+            <span class="drafts-list__time">{{ formatRelative(m.updatedAt) }}</span>
+          </div>
+          <div class="drafts-list__title">{{ m.title }}</div>
+          <div class="drafts-list__bottom">
+            <span class="drafts-status" [attr.data-status]="m.status">
+              <span class="drafts-status__dot"></span>
+              {{ statusText(m.status) }}
+            </span>
+            <span class="drafts-list__count" *ngIf="m.artifactCount">
+              {{ m.artifactCount }} 件产物
+            </span>
+          </div>
+        </div>
+      </button>
+    </div>
+
+    <!-- 详情 -->
+    <div class="drafts-detail" [class.has-content]="!!selectedDraft()">
+      <ng-container *ngIf="!selectedDraft() && !loadingDetail()">
+        <div class="drafts-detail__empty">
+          <svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
+            <circle cx="12" cy="12" r="10"></circle>
+            <polyline points="12 6 12 12 16 14"></polyline>
+          </svg>
+          <h3>选择左侧条目查看详情</h3>
+          <p>每个创作都包含完整的中间产物时间线、状态变迁与可继续编辑的入口</p>
+        </div>
+      </ng-container>
+
+      <ng-container *ngIf="loadingDetail()">
+        <div class="drafts-detail__empty">
+          <div class="drafts-detail__loader"></div>
+          <p>加载中…</p>
+        </div>
+      </ng-container>
+
+      <ng-container *ngIf="selectedDraft() as d">
+        <!-- 标题区 -->
+        <div class="drafts-detail__head">
+          <div class="drafts-detail__head-top">
+            <span class="drafts-detail__pipeline-tag">{{ pipelineLabel(d.pipelineId) }}</span>
+            <span class="drafts-status" [attr.data-status]="d.status">
+              <span class="drafts-status__dot"></span>
+              {{ statusText(d.status) }}
+            </span>
+            <span class="drafts-detail__time">更新于 {{ formatTime(d.updatedAt) }}</span>
+          </div>
+
+          <ng-container *ngIf="!editingTitle(); else titleEdit">
+            <h2 class="drafts-detail__title" (click)="startEditTitle()" title="点击改名">
+              {{ d.title }}
+              <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
+                <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
+              </svg>
+            </h2>
+          </ng-container>
+          <ng-template #titleEdit>
+            <input class="drafts-detail__title-input"
+                   [(ngModel)]="titleBuffer"
+                   (keydown.enter)="commitEditTitle()"
+                   (keydown.escape)="cancelEditTitle()"
+                   (blur)="commitEditTitle()"
+                   maxlength="80" autofocus />
+          </ng-template>
+
+          <div class="drafts-detail__actions">
+            <button class="drafts-btn drafts-btn--primary" type="button" (click)="continueEdit()">
+              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <polyline points="9 18 15 12 9 6"></polyline>
+              </svg>
+              继续编辑
+            </button>
+            <button class="drafts-btn" type="button" (click)="duplicate()">
+              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <rect x="9" y="9" width="13" height="13" rx="2"></rect>
+                <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+              </svg>
+              再做一版
+            </button>
+            <button class="drafts-btn" type="button" (click)="exportJson()" title="导出整份创作(含 snapshot 与 artifacts)">
+              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+                <polyline points="7 10 12 15 17 10"></polyline>
+                <line x1="12" y1="15" x2="12" y2="3"></line>
+              </svg>
+              导出
+            </button>
+            <button class="drafts-btn drafts-btn--danger" type="button" (click)="remove()">
+              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <polyline points="3 6 5 6 21 6"></polyline>
+                <path d="M19 6l-1.5 14a2 2 0 0 1-2 1.5H8.5a2 2 0 0 1-2-1.5L5 6"></path>
+              </svg>
+              删除
+            </button>
+          </div>
+
+          <div class="drafts-detail__error" *ngIf="d.errorMessage">
+            <strong>失败原因:</strong>{{ d.errorMessage }}
+          </div>
+        </div>
+
+        <!-- 成片预览 -->
+        <div class="drafts-detail__section" *ngIf="d.resultUrl">
+          <h3 class="drafts-detail__section-title">最终成片</h3>
+          <div class="drafts-detail__player">
+            <video controls [src]="d.resultUrl" preload="metadata"></video>
+            <a [href]="d.resultUrl" target="_blank" rel="noopener" class="drafts-detail__player-dl">
+              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+                <polyline points="7 10 12 15 17 10"></polyline>
+                <line x1="12" y1="15" x2="12" y2="3"></line>
+              </svg>
+              下载
+            </a>
+          </div>
+        </div>
+
+        <!-- Artifact 时间线 -->
+        <div class="drafts-detail__section">
+          <h3 class="drafts-detail__section-title">
+            产物时间线
+            <span class="drafts-detail__section-count">{{ d.artifacts.length }}</span>
+          </h3>
+
+          <div class="drafts-detail__timeline-empty" *ngIf="!d.artifacts.length">
+            <p>暂无中间产物。开始生成后,每段图片 / 视频 / 音频会按顺序记录在这里。</p>
+          </div>
+
+          <div class="drafts-timeline" *ngIf="d.artifacts.length">
+            <article class="drafts-timeline__item"
+                     *ngFor="let a of d.artifacts; trackBy: trackById"
+                     [attr.data-type]="a.type">
+              <div class="drafts-timeline__bullet">
+                <span>{{ artifactIcon(a.type) }}</span>
+              </div>
+              <div class="drafts-timeline__media">
+                <img *ngIf="a.type === 'image'" [src]="a.url" [alt]="a.title" loading="lazy" />
+                <video *ngIf="a.type === 'video'" [src]="a.url" controls preload="metadata"></video>
+                <audio *ngIf="a.type === 'audio'" [src]="a.url" controls preload="metadata"></audio>
+              </div>
+              <div class="drafts-timeline__meta">
+                <div class="drafts-timeline__title">{{ a.title }}</div>
+                <div class="drafts-timeline__sub">
+                  <span>{{ formatTime(a.createdAt) }}</span>
+                  <span *ngIf="a.sceneIndex !== undefined">· 段 {{ a.sceneIndex + 1 }}</span>
+                </div>
+                <a class="drafts-timeline__link" [href]="a.url" target="_blank" rel="noopener">原文件 ↗</a>
+              </div>
+            </article>
+          </div>
+        </div>
+      </ng-container>
+    </div>
+  </div>
+</section>

+ 215 - 0
src/app/pages/drafts/drafts.component.ts

@@ -0,0 +1,215 @@
+import {
+  ChangeDetectionStrategy,
+  Component,
+  EventEmitter,
+  Output,
+  computed,
+  inject,
+  signal,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { PipelineSessionService } from '../../services/pipeline-session.service';
+import { PipelineRoute, PIPELINES } from '../../pipelines/pipeline-registry';
+import { Artifact, DraftMeta, DraftStatus, PipelineDraft } from '../../models/pipeline-draft.model';
+
+type StatusFilter = 'all' | DraftStatus;
+
+/**
+ * 「我的创作」页 —— 跨 Pipeline 的统一创作历史与中间产物时间线。
+ *
+ * 替代原本散落的「任务管理 + 生成历史」概念:
+ *  - 列表:所有草稿(按 updatedAt 倒序),可筛 pipeline / 状态
+ *  - 详情:标题改名、继续编辑、再做一版、删除、导出 JSON
+ *         + 每条 artifact 时间线(首尾帧 / i2v 片段 / TTS 音频 / 上传素材)
+ *         + 成片播放器
+ */
+@Component({
+  selector: 'app-drafts',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './drafts.component.html',
+  styleUrls: ['./drafts.component.css'],
+})
+export class DraftsComponent {
+  /**
+   * 父组件 (app.ts) 订阅:跳转到指定 Pipeline tab。
+   * 在 emit 之前,本组件已 await session.open(id),所以目标页 ngOnInit 能直接 fromSnapshot。
+   */
+  @Output() navigateToPipeline = new EventEmitter<PipelineRoute>();
+
+  readonly session = inject(PipelineSessionService);
+
+  readonly pipelines = PIPELINES;
+
+  // 筛选
+  readonly pipelineFilter = signal<PipelineRoute | 'all'>('all');
+  readonly statusFilter = signal<StatusFilter>('all');
+  readonly searchTerm = signal('');
+
+  // 选中详情
+  readonly selectedId = signal<string | null>(null);
+  readonly selectedDraft = signal<PipelineDraft | null>(null);
+  readonly loadingDetail = signal(false);
+
+  // 改名缓冲
+  readonly editingTitle = signal(false);
+  titleBuffer = '';
+
+  // 派生
+  readonly filteredList = computed<DraftMeta[]>(() => {
+    const all = this.session.list();
+    const pf = this.pipelineFilter();
+    const sf = this.statusFilter();
+    const kw = this.searchTerm().trim().toLowerCase();
+    return all.filter((m) => {
+      if (pf !== 'all' && m.pipelineId !== pf) return false;
+      if (sf !== 'all' && m.status !== sf) return false;
+      if (kw && !(m.title || '').toLowerCase().includes(kw)) return false;
+      return true;
+    });
+  });
+
+  /** 各状态计数(基于 pipelineFilter 之后) */
+  readonly statusCounts = computed(() => {
+    const all = this.session.list();
+    const pf = this.pipelineFilter();
+    const base = pf === 'all' ? all : all.filter((m) => m.pipelineId === pf);
+    return {
+      all: base.length,
+      draft: base.filter((m) => m.status === 'draft').length,
+      running: base.filter((m) => m.status === 'running').length,
+      completed: base.filter((m) => m.status === 'completed').length,
+      failed: base.filter((m) => m.status === 'failed').length,
+    };
+  });
+
+  constructor() {
+    void this.session.bootstrap();
+  }
+
+  // ============== 筛选交互 ==============
+  setPipelineFilter(p: PipelineRoute | 'all'): void {
+    this.pipelineFilter.set(p);
+  }
+  setStatusFilter(s: StatusFilter): void {
+    this.statusFilter.set(s);
+  }
+
+  // ============== 选中详情 ==============
+  async openDetail(meta: DraftMeta): Promise<void> {
+    this.selectedId.set(meta.id);
+    this.loadingDetail.set(true);
+    this.editingTitle.set(false);
+    try {
+      // 注意:此处 session.open(id) 会把目标 draft 设为 active —— 这是有副作用的。
+      // 但由于「我的创作」页本身不属于任何 Pipeline,active 设置后只用于详情展示,
+      // 用户点「继续编辑」时跳到对应 Pipeline 自动还原。
+      const d = await this.session.open(meta.id);
+      this.selectedDraft.set(d);
+    } finally {
+      this.loadingDetail.set(false);
+    }
+  }
+
+  closeDetail(): void {
+    this.selectedId.set(null);
+    this.selectedDraft.set(null);
+    this.session.close();
+  }
+
+  // ============== 操作按钮 ==============
+  /** 跳到对应 Pipeline 页继续编辑(active 已是该 draft) */
+  continueEdit(): void {
+    const d = this.selectedDraft();
+    if (!d) return;
+    this.navigateToPipeline.emit(d.pipelineId);
+  }
+
+  /** 复制一份新草稿,立即跳到 Pipeline 页 */
+  async duplicate(): Promise<void> {
+    const d = this.selectedDraft();
+    if (!d) return;
+    const cloned = await this.session.duplicate(d.id);
+    if (cloned) {
+      // 把新副本设为 active 后跳转
+      await this.session.open(cloned.id);
+      this.navigateToPipeline.emit(cloned.pipelineId);
+    }
+  }
+
+  async remove(): Promise<void> {
+    const d = this.selectedDraft();
+    if (!d) return;
+    if (!confirm(`确定删除「${d.title}」?此操作无法撤销。`)) return;
+    await this.session.remove(d.id);
+    this.closeDetail();
+  }
+
+  startEditTitle(): void {
+    const d = this.selectedDraft();
+    if (!d) return;
+    this.titleBuffer = d.title;
+    this.editingTitle.set(true);
+  }
+  cancelEditTitle(): void {
+    this.editingTitle.set(false);
+  }
+  async commitEditTitle(): Promise<void> {
+    const d = this.selectedDraft();
+    const t = this.titleBuffer.trim();
+    this.editingTitle.set(false);
+    if (!d || !t || t === d.title) return;
+    await this.session.rename(d.id, t);
+    // 刷新 selectedDraft(rename 内部已更新 active)
+    const fresh = this.session.active();
+    if (fresh && fresh.id === d.id) this.selectedDraft.set(fresh);
+  }
+
+  exportJson(): void {
+    const d = this.selectedDraft();
+    if (!d) return;
+    const blob = new Blob([JSON.stringify(d, null, 2)], { type: 'application/json' });
+    const a = document.createElement('a');
+    a.href = URL.createObjectURL(blob);
+    a.download = `${d.title.replace(/[\\/:*?"<>|]/g, '_')}.json`;
+    a.click();
+    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+  }
+
+  // ============== 工具 ==============
+  pipelineLabel(id: PipelineRoute): string {
+    return this.pipelines.find((p) => p.route === id)?.displayName || id;
+  }
+
+  statusText(s: DraftStatus): string {
+    return ({ draft: '草稿', running: '运行中', completed: '已完成', failed: '失败' } as const)[s];
+  }
+
+  statusEmoji(s: DraftStatus): string {
+    return ({ draft: '✎', running: '⏳', completed: '✓', failed: '!' } as const)[s];
+  }
+
+  artifactIcon(t: Artifact['type']): string {
+    return t === 'image' ? '🖼️' : t === 'video' ? '🎬' : '🎵';
+  }
+
+  formatTime(ts: number): string {
+    if (!ts) return '';
+    const d = new Date(ts);
+    const pad = (n: number) => n.toString().padStart(2, '0');
+    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+  }
+
+  formatRelative(ts: number): string {
+    const diff = Date.now() - ts;
+    if (diff < 5_000) return '刚刚';
+    if (diff < 60_000) return `${Math.floor(diff / 1000)} 秒前`;
+    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
+    if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
+    return this.formatTime(ts);
+  }
+
+  trackById(_: number, x: { id: string }): string { return x.id; }
+}

+ 229 - 5
src/app/pages/home/home.component.css

@@ -57,11 +57,11 @@
 }
 
 .t-hero__title em {
-  /* 把"的视频"用 Fraunces 衬线斜体处理,与中文标形成混排反差 */
-  font-family: var(--font-serif);
-  font-style: italic;
-  font-weight: 300;
-  font-optical-sizing: auto;
+  /* 与主标题保持同一字族与字重,避免中英混排时的字体不一致感;
+     仅用浅一档颜色做层级区分。 */
+  font-family: inherit;
+  font-style: normal;
+  font-weight: inherit;
   color: #475569;
 }
 
@@ -756,6 +756,150 @@
   }
 }
 
+@media (max-width: 760px) {
+  .t-home {
+    padding: 22px 4px 56px;
+  }
+
+  .t-hero {
+    padding-top: 24px;
+  }
+
+  .t-hero__serial {
+    font-size: 26px;
+  }
+
+  .t-hero__title {
+    font-size: clamp(34px, 12vw, 48px);
+    line-height: 1.06;
+    margin-bottom: 18px;
+    letter-spacing: -0.025em;
+  }
+
+  .t-hero__sub {
+    margin-bottom: 24px;
+    font-size: 14px;
+  }
+
+  .t-input-shell {
+    flex-direction: column;
+    border-radius: 12px;
+  }
+
+  .t-input {
+    min-height: 92px;
+    padding: 16px;
+    font-size: 16px;
+  }
+
+  .t-input-shell:not(.has-value)::after {
+    left: 16px;
+    top: 27px;
+    height: 18px;
+  }
+
+  .t-input__send {
+    min-height: 46px;
+    justify-content: center;
+    padding: 0 18px;
+  }
+
+  .t-input-meta {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 4px;
+  }
+
+  .t-strip,
+  .t-recent {
+    margin-top: 48px;
+  }
+
+  .t-strip__head,
+  .t-recent__head {
+    align-items: flex-start;
+    flex-wrap: wrap;
+    gap: 8px 12px;
+  }
+
+  .t-strip__more,
+  .t-recent__more {
+    margin-left: 40px;
+  }
+
+  .t-pipe-card {
+    padding: 18px 16px 16px;
+  }
+
+  .t-chat {
+    margin: -14px;
+    height: calc(100dvh - 48px - 76px);
+    min-height: 420px;
+  }
+
+  .t-chat-main__head {
+    padding: 14px 16px 10px;
+    align-items: flex-start;
+    flex-wrap: wrap;
+  }
+
+  .t-chat-main__sub {
+    width: 100%;
+    margin-left: 0;
+  }
+
+  .t-chat-main__body {
+    padding: 18px 16px;
+    gap: 22px;
+  }
+
+  .t-chat-empty {
+    margin-top: 8px;
+    padding: 14px 0;
+  }
+
+  .t-chat-empty__hello {
+    font-size: 30px;
+  }
+
+  .t-chat-msg,
+  .t-chat-msg__suggests {
+    max-width: 100%;
+  }
+
+  .t-chat-main__foot {
+    padding: 12px 16px 16px;
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .t-chat-send {
+    width: 100%;
+  }
+
+  .t-chat-md pre,
+  .t-chat-md table {
+    max-width: 100%;
+    overflow-x: auto;
+  }
+}
+
+@media (max-width: 420px) {
+  .t-home {
+    padding-top: 16px;
+  }
+
+  .t-hero__title {
+    font-size: 32px;
+  }
+
+  .t-pipe-card__badge {
+    position: static;
+    align-self: flex-start;
+    order: -1;
+  }
+}
+
 /* ===== 左 rail ===== */
 .t-chat-rail {
   display: flex;
@@ -1028,6 +1172,86 @@
   word-break: break-word;
   letter-spacing: -0.005em;
 }
+/* ============================================================
+ * Markdown 渲染(仅助手消息),浅色主题
+ * ============================================================ */
+.t-chat-md {
+  white-space: normal;   /* 覆盖父级 pre-wrap,让 marked 输出的块级元素自由布局 */
+}
+.t-chat-md > *:first-child { margin-top: 0; }
+.t-chat-md > *:last-child  { margin-bottom: 0; }
+.t-chat-md h1,
+.t-chat-md h2,
+.t-chat-md h3,
+.t-chat-md h4 {
+  font-weight: 600;
+  margin: 16px 0 8px;
+  color: #0f172a;
+  letter-spacing: -0.01em;
+}
+.t-chat-md h1 { font-size: 19px; }
+.t-chat-md h2 { font-size: 17px; }
+.t-chat-md h3 { font-size: 15.5px; }
+.t-chat-md h4 { font-size: 14.5px; color: rgba(15, 23, 42, 0.78); }
+.t-chat-md p  { margin: 8px 0; }
+.t-chat-md ul,
+.t-chat-md ol { padding-left: 24px; margin: 8px 0; }
+.t-chat-md li { margin: 3px 0; }
+.t-chat-md strong { color: #7c2d12; font-weight: 600; }
+.t-chat-md em     { color: rgba(15, 23, 42, 0.86); font-style: italic; }
+.t-chat-md a {
+  color: #2563eb;
+  text-decoration: underline;
+  text-decoration-color: rgba(37, 99, 235, 0.35);
+}
+.t-chat-md code {
+  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
+  font-size: 13px;
+  padding: 1px 6px;
+  border-radius: 4px;
+  background: rgba(124, 58, 237, 0.1);
+  color: #6d28d9;
+}
+.t-chat-md pre {
+  margin: 10px 0;
+  padding: 12px 14px;
+  border-radius: 8px;
+  background: rgba(15, 23, 42, 0.94);
+  overflow-x: auto;
+}
+.t-chat-md pre code {
+  display: block;
+  padding: 0;
+  background: transparent;
+  color: #e2e8f0;
+  font-size: 13px;
+  line-height: 1.6;
+}
+.t-chat-md blockquote {
+  margin: 8px 0;
+  padding: 4px 14px;
+  border-left: 3px solid rgba(124, 58, 237, 0.55);
+  color: rgba(15, 23, 42, 0.78);
+  background: rgba(124, 58, 237, 0.05);
+  border-radius: 0 6px 6px 0;
+}
+.t-chat-md table {
+  border-collapse: collapse;
+  margin: 10px 0;
+  font-size: 14px;
+}
+.t-chat-md th,
+.t-chat-md td {
+  padding: 7px 12px;
+  border: 1px solid rgba(15, 23, 42, 0.12);
+}
+.t-chat-md th { background: rgba(124, 58, 237, 0.08); }
+.t-chat-md hr {
+  border: 0;
+  border-top: 1px solid rgba(15, 23, 42, 0.1);
+  margin: 14px 0;
+}
+
 .t-chat-msg.is-user .t-chat-msg__content {
   background: rgba(29, 78, 216, 0.04);
   border-left: 2px solid rgba(29, 78, 216, 0.4);

+ 2 - 1
src/app/pages/home/home.component.html

@@ -224,7 +224,8 @@
         <div *ngIf="m.pending" class="t-chat-msg__typing" aria-hidden="true">
           <span></span><span></span><span></span>
         </div>
-        <div *ngIf="!m.pending" class="t-chat-msg__content">{{ m.content }}</div>
+        <div *ngIf="!m.pending && m.role === 'user'" class="t-chat-msg__content">{{ m.content }}</div>
+        <div *ngIf="!m.pending && m.role === 'assistant'" class="t-chat-msg__content t-chat-md" [innerHTML]="renderMd(m.content)"></div>
         <div *ngIf="m.suggestions?.length" class="t-chat-msg__suggests">
           <button *ngFor="let s of m.suggestions; trackBy: trackBySuggest"
                   type="button"

+ 8 - 0
src/app/pages/home/home.component.ts

@@ -21,6 +21,8 @@ import {
   AssistantSuggestion,
   Conversation,
 } from '../../services/assistant.service';
+import { MarkdownService } from '../../services/markdown.service';
+import { SafeHtml } from '@angular/platform-browser';
 
 interface RecentResult {
   id: string;
@@ -84,8 +86,14 @@ export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
     private http: HttpClient,
     private assistant: AssistantService,
     private cdr: ChangeDetectorRef,
+    private md: MarkdownService,
   ) {}
 
+  /** 渲染助手消息为安全 HTML */
+  renderMd(text: string): SafeHtml {
+    return this.md.render(text);
+  }
+
   ngOnInit(): void {
     this.loadRecentResults();
     // 注意:所有订阅回调都显式 markForCheck + detectChanges,

+ 421 - 0
src/app/pages/library/library.component.css

@@ -0,0 +1,421 @@
+/* 素材库页 */
+
+.library-page { padding: 0 0 32px; }
+
+.library-refresh {
+  margin-left: auto;
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 6px 12px;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--text-secondary);
+  background: var(--surface-color, #fff);
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+.library-refresh:hover:not(:disabled) {
+  color: var(--accent-color, #2997ff);
+  border-color: var(--accent-color, #2997ff);
+}
+.library-refresh:disabled { opacity: 0.5; cursor: wait; }
+
+/* ========= 工具栏 ========= */
+.library-toolbar {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  margin: 16px 0 20px;
+}
+
+.library-mode {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
+}
+.library-mode__btn {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 4px;
+  padding: 12px 16px;
+  background: var(--surface-color, #f5f5f7);
+  border: 1px solid var(--border-color);
+  border-radius: 10px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+  text-align: left;
+}
+.library-mode__btn:hover {
+  border-color: color-mix(in srgb, var(--accent-color, #2997ff) 50%, transparent);
+}
+.library-mode__btn.is-active {
+  background: color-mix(in srgb, var(--accent-color, #2997ff) 8%, transparent);
+  border-color: var(--accent-color, #2997ff);
+}
+.library-mode__btn span {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+.library-mode__btn small {
+  font-size: 11px;
+  color: var(--text-muted);
+}
+.library-mode__btn.is-active span { color: var(--accent-color, #2997ff); }
+
+.library-filter-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+}
+.library-filter-row--secondary {
+  padding-top: 8px;
+  border-top: 1px dashed var(--border-color);
+}
+
+.library-pill {
+  padding: 6px 14px;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-secondary);
+  background: var(--surface-color, #f5f5f7);
+  border: 1px solid var(--border-color);
+  border-radius: 999px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+  white-space: nowrap;
+}
+.library-pill:hover { color: var(--text-primary); }
+.library-pill.is-active {
+  background: var(--accent-color, #2997ff);
+  border-color: var(--accent-color, #2997ff);
+  color: #fff;
+}
+
+.library-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 10px;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--text-secondary);
+  background: transparent;
+  border: 1px solid var(--border-color);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+.library-chip:hover { color: var(--text-primary); }
+.library-chip.is-active {
+  background: color-mix(in srgb, var(--text-primary) 8%, transparent);
+  border-color: var(--text-primary);
+  color: var(--text-primary);
+}
+.library-chip__num {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 18px;
+  height: 16px;
+  padding: 0 4px;
+  font-size: 11px;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--text-primary) 10%, transparent);
+}
+
+.library-search {
+  margin-left: auto;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 10px;
+  background: var(--surface-color, #fff);
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  color: var(--text-muted);
+}
+.library-search input {
+  border: none;
+  background: transparent;
+  font-size: 13px;
+  outline: none;
+  color: var(--text-primary);
+  min-width: 200px;
+}
+
+/* ========= 空态 ========= */
+.library-empty {
+  padding: 64px 16px;
+  text-align: center;
+  color: var(--text-muted);
+}
+.library-empty__icon {
+  margin: 0 auto 12px;
+  width: 64px; height: 64px;
+  display: flex; align-items: center; justify-content: center;
+  border-radius: 16px;
+  background: color-mix(in srgb, var(--text-primary) 6%, transparent);
+}
+.library-empty h3 {
+  margin: 0 0 4px;
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+.library-empty p { margin: 0; font-size: 13px; }
+
+/* ========= 网格 ========= */
+.library-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 16px;
+}
+
+.library-card {
+  display: flex;
+  flex-direction: column;
+  background: var(--surface-elevated, #fff);
+  border: 1px solid var(--border-color);
+  border-radius: 12px;
+  overflow: hidden;
+  cursor: pointer;
+  transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
+  position: relative;
+}
+.library-card:hover {
+  transform: translateY(-2px);
+  box-shadow: var(--shadow-md, 0 12px 30px rgba(0, 0, 0, 0.1));
+  border-color: color-mix(in srgb, var(--accent-color, #2997ff) 30%, transparent);
+}
+
+/* 媒体区 */
+.library-card__media {
+  position: relative;
+  aspect-ratio: 16 / 10;
+  background: #000;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.library-card__media video,
+.library-card__media img {
+  width: 100%; height: 100%; object-fit: cover; display: block;
+}
+.library-card__audio {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  padding: 16px;
+  background: linear-gradient(135deg, #4c1d95, #1e3a8a);
+}
+.library-card__audio-icon { font-size: 36px; }
+.library-card__audio audio { width: 100%; }
+
+.library-card__badge,
+.library-card__type-badge {
+  position: absolute;
+  top: 8px;
+  padding: 3px 8px;
+  font-size: 11px;
+  font-weight: 600;
+  color: #fff;
+  background: rgba(0, 0, 0, 0.55);
+  backdrop-filter: blur(8px);
+  border-radius: 6px;
+}
+.library-card__badge {
+  right: 8px;
+  background: var(--success-color, #48bb78);
+}
+.library-card__type-badge {
+  left: 8px;
+}
+
+/* 信息区 */
+.library-card__body {
+  padding: 12px 14px 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.library-card__pipeline {
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--accent-color, #2997ff);
+  letter-spacing: 0.3px;
+}
+.library-card__title {
+  margin: 0;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.library-card__sub {
+  font-size: 11px;
+  color: var(--text-muted);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.library-card__time {
+  font-size: 11px;
+  color: var(--text-muted);
+}
+
+/* 操作区 */
+.library-card__actions {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 14px 12px;
+  border-top: 1px solid var(--border-color);
+  margin-top: 6px;
+}
+
+.library-action {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+  padding: 6px 10px;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--text-secondary);
+  background: var(--surface-color, #f5f5f7);
+  border: 1px solid var(--border-color);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+}
+.library-action:hover {
+  border-color: var(--accent-color, #2997ff);
+  color: var(--accent-color, #2997ff);
+}
+.library-action--primary {
+  margin-left: auto;
+  background: transparent;
+  border-color: var(--accent-color, #2997ff);
+  color: var(--accent-color, #2997ff);
+}
+.library-action--primary:hover {
+  background: var(--accent-color, #2997ff);
+  color: #fff;
+}
+
+@media (max-width: 720px) {
+  .library-page { padding-bottom: 20px; }
+
+  .library-refresh {
+    width: 100%;
+    justify-content: center;
+    margin-left: 0;
+    min-height: 40px;
+  }
+
+  .library-toolbar {
+    margin: 12px 0 14px;
+    gap: 12px;
+  }
+
+  .library-mode {
+    grid-template-columns: 1fr;
+    gap: 8px;
+  }
+
+  .library-mode__btn {
+    padding: 11px 12px;
+  }
+
+  .library-filter-row {
+    align-items: stretch;
+    overflow-x: auto;
+    flex-wrap: nowrap;
+    padding-bottom: 2px;
+    scrollbar-width: none;
+  }
+
+  .library-filter-row::-webkit-scrollbar {
+    display: none;
+  }
+
+  .library-filter-row--secondary {
+    flex-wrap: wrap;
+    overflow-x: visible;
+  }
+
+  .library-search {
+    width: 100%;
+    margin-left: 0;
+    padding: 8px 10px;
+  }
+
+  .library-search input {
+    min-width: 0;
+    width: 100%;
+    font-size: 16px;
+  }
+
+  .library-grid {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 12px;
+  }
+
+  .library-card__media {
+    aspect-ratio: 16 / 11;
+  }
+
+  .library-card__title,
+  .library-card__sub {
+    white-space: normal;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  .library-card__title {
+    -webkit-line-clamp: 2;
+  }
+
+  .library-card__sub {
+    -webkit-line-clamp: 2;
+  }
+
+  .library-card__actions {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .library-action,
+  .library-action--primary {
+    width: 100%;
+    min-height: 38px;
+    margin-left: 0;
+  }
+}
+
+@media (max-width: 420px) {
+  .library-pill,
+  .library-chip {
+    font-size: 12px;
+  }
+
+  .library-card__body,
+  .library-card__actions {
+    padding-left: 12px;
+    padding-right: 12px;
+  }
+}

+ 169 - 0
src/app/pages/library/library.component.html

@@ -0,0 +1,169 @@
+<section class="dh-page library-page">
+
+  <header class="dh-page-header">
+    <div class="dh-header-icon">
+      <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <rect x="3" y="3" width="18" height="18" rx="2"></rect>
+        <polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none"></polygon>
+      </svg>
+    </div>
+    <div class="dh-header-text">
+      <h1>素材库</h1>
+      <p>所有 AI 生成的视频、图片与音频。来自「我的创作」中的成片与中间产物。</p>
+    </div>
+
+    <button type="button" class="library-refresh" (click)="refresh()" [disabled]="loading()">
+      <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <polyline points="23 4 23 10 17 10"></polyline>
+        <polyline points="1 20 1 14 7 14"></polyline>
+        <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
+      </svg>
+      {{ loading() ? '加载中…' : '刷新' }}
+    </button>
+  </header>
+
+  <!-- 筛选区 -->
+  <div class="library-toolbar">
+
+    <!-- 视图模式 -->
+    <div class="library-mode">
+      <button type="button" class="library-mode__btn"
+              [class.is-active]="viewMode() === 'final'"
+              (click)="setViewMode('final')">
+        <span>成片</span>
+        <small>仅最终生成的视频</small>
+      </button>
+      <button type="button" class="library-mode__btn"
+              [class.is-active]="viewMode() === 'all'"
+              (click)="setViewMode('all')">
+        <span>全部产物</span>
+        <small>含每段分镜、TTS 等中间产物</small>
+      </button>
+    </div>
+
+    <!-- Pipeline 筛选 -->
+    <div class="library-filter-row">
+      <button type="button" class="library-pill"
+              [class.is-active]="pipelineFilter() === 'all'"
+              (click)="setPipelineFilter('all')">
+        全部来源
+      </button>
+      <button type="button" class="library-pill"
+              *ngFor="let p of pipelines; trackBy: trackById"
+              [class.is-active]="pipelineFilter() === p.route"
+              (click)="setPipelineFilter(p.route)">
+        {{ p.displayName }}
+      </button>
+    </div>
+
+    <!-- 类型 + 搜索 -->
+    <div class="library-filter-row library-filter-row--secondary">
+      <button type="button" class="library-chip"
+              [class.is-active]="typeFilter() === 'all'"
+              (click)="setTypeFilter('all')">
+        全部 <span class="library-chip__num">{{ typeCounts().all }}</span>
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="typeFilter() === 'video'"
+              (click)="setTypeFilter('video')">
+        🎬 视频 <span class="library-chip__num">{{ typeCounts().video }}</span>
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="typeFilter() === 'image'"
+              (click)="setTypeFilter('image')">
+        🖼️ 图片 <span class="library-chip__num">{{ typeCounts().image }}</span>
+      </button>
+      <button type="button" class="library-chip"
+              [class.is-active]="typeFilter() === 'audio'"
+              (click)="setTypeFilter('audio')">
+        🎵 音频 <span class="library-chip__num">{{ typeCounts().audio }}</span>
+      </button>
+
+      <div class="library-search">
+        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <circle cx="11" cy="11" r="8"></circle>
+          <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
+        </svg>
+        <input type="text" placeholder="按标题或创作名搜索..."
+               [ngModel]="searchTerm()"
+               (ngModelChange)="searchTerm.set($event)" />
+      </div>
+    </div>
+  </div>
+
+  <!-- 主体网格 -->
+  <div class="library-empty" *ngIf="!loading() && filteredItems().length === 0">
+    <div class="library-empty__icon">
+      <svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+        <rect x="3" y="3" width="18" height="18" rx="2"></rect>
+        <polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none"></polygon>
+      </svg>
+    </div>
+    <h3>{{ items().length === 0 ? '素材库为空' : '没有符合筛选条件的素材' }}</h3>
+    <p *ngIf="items().length === 0">在任意 Pipeline 完成一次生成后,结果会自动出现在这里</p>
+  </div>
+
+  <div class="library-grid" *ngIf="filteredItems().length > 0">
+    <article class="library-card"
+             *ngFor="let it of filteredItems(); trackBy: trackByKey"
+             [attr.data-type]="it.type"
+             (click)="openDraft(it)">
+
+      <!-- 媒体预览 -->
+      <div class="library-card__media">
+        <ng-container [ngSwitch]="it.type">
+          <video *ngSwitchCase="'video'" [src]="it.url" muted preload="metadata"
+                 (mouseenter)="onHoverPlay($event)"
+                 (mouseleave)="onHoverPause($event)"></video>
+          <img *ngSwitchCase="'image'" [src]="it.url" [alt]="it.title" loading="lazy" />
+          <div *ngSwitchCase="'audio'" class="library-card__audio">
+            <span class="library-card__audio-icon">🎵</span>
+            <audio [src]="it.url" controls preload="metadata"
+                   (click)="$event.stopPropagation()"></audio>
+          </div>
+        </ng-container>
+
+        <div class="library-card__badge" *ngIf="it.isFinal">
+          <span>✓ 成片</span>
+        </div>
+        <div class="library-card__type-badge">
+          <span>{{ typeIcon(it.type) }} {{ it.type === 'video' ? '视频' : it.type === 'image' ? '图片' : '音频' }}</span>
+        </div>
+      </div>
+
+      <!-- 信息区 -->
+      <div class="library-card__body">
+        <div class="library-card__pipeline">{{ pipelineLabel(it.pipelineId) }}</div>
+        <h4 class="library-card__title" [title]="it.title">{{ it.title }}</h4>
+        <div class="library-card__sub">
+          <span>来自:{{ it.draftTitle }}</span>
+        </div>
+        <div class="library-card__time">{{ formatRelative(it.createdAt) }}</div>
+      </div>
+
+      <!-- 操作 -->
+      <div class="library-card__actions" (click)="$event.stopPropagation()">
+        <button class="library-action" type="button" (click)="openExternal(it, $event)" title="新窗口打开">
+          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+            <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
+            <polyline points="15 3 21 3 21 9"></polyline>
+            <line x1="10" y1="14" x2="21" y2="3"></line>
+          </svg>
+        </button>
+        <button class="library-action" type="button" (click)="download(it, $event)" title="下载">
+          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+            <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
+            <polyline points="7 10 12 15 17 10"></polyline>
+            <line x1="12" y1="15" x2="12" y2="3"></line>
+          </svg>
+        </button>
+        <button class="library-action library-action--primary" type="button"
+                (click)="openDraft(it); $event.stopPropagation()" title="查看所属创作">
+          打开创作 →
+        </button>
+      </div>
+
+    </article>
+  </div>
+
+</section>

+ 234 - 0
src/app/pages/library/library.component.ts

@@ -0,0 +1,234 @@
+import {
+  ChangeDetectionStrategy,
+  Component,
+  EventEmitter,
+  OnInit,
+  Output,
+  computed,
+  inject,
+  signal,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { PipelineSessionService } from '../../services/pipeline-session.service';
+import { PipelineRoute, PIPELINES } from '../../pipelines/pipeline-registry';
+import { Artifact, PipelineDraft } from '../../models/pipeline-draft.model';
+
+/** 素材库的扁平条目(来自 drafts 的成片 + artifacts) */
+interface LibraryItem {
+  /** 唯一 key:draftId + artifactId / 'final' */
+  key: string;
+  draftId: string;
+  draftTitle: string;
+  pipelineId: PipelineRoute;
+  type: 'image' | 'video' | 'audio';
+  url: string;
+  /** 显式标题:成片 = draft.title;中间产物 = artifact.title */
+  title: string;
+  /** 是否为成片(finalized 的 resultUrl) */
+  isFinal: boolean;
+  createdAt: number;
+  /** 缩略图(视频取 draft.thumbnail 或同 draft 首张图) */
+  thumbnail?: string;
+  /** artifact 原 extras 透传 */
+  extras?: Record<string, any>;
+}
+
+type LibraryFilterType = 'all' | 'video' | 'image' | 'audio';
+type LibraryViewMode = 'final' | 'all';
+
+/**
+ * 素材库 —— 跨 Pipeline 的 AI 生成成果聚合视图。
+ *
+ * 数据源:直接来自 PipelineSessionService.loadAll(),无独立后端表。
+ *  - 模式「仅成片」:只列各 draft 的 resultUrl
+ *  - 模式「全部产物」:列所有 artifacts(含中间产物:每段分镜首帧、i2v 片段、TTS 音频…)
+ *
+ * 替代原本的「结果展示」页,并比它更全(中间产物也在)。
+ */
+@Component({
+  selector: 'app-library',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './library.component.html',
+  styleUrls: ['./library.component.css'],
+})
+export class LibraryComponent implements OnInit {
+  /** 父组件订阅:跳到 draft 父详情(我的创作 -> 选中该 draft) */
+  @Output() navigateToDraft = new EventEmitter<{ draftId: string; pipelineId: PipelineRoute }>();
+
+  readonly session = inject(PipelineSessionService);
+  readonly pipelines = PIPELINES;
+
+  readonly drafts = signal<PipelineDraft[]>([]);
+  readonly loading = signal(true);
+
+  readonly viewMode = signal<LibraryViewMode>('final');
+  readonly typeFilter = signal<LibraryFilterType>('all');
+  readonly pipelineFilter = signal<PipelineRoute | 'all'>('all');
+  readonly searchTerm = signal('');
+
+  /** 扁平后的条目(响应式) */
+  readonly items = computed<LibraryItem[]>(() => {
+    const drafts = this.drafts();
+    const mode = this.viewMode();
+    const out: LibraryItem[] = [];
+
+    for (const d of drafts) {
+      // 成片入条目(仅 completed)
+      if (d.resultUrl) {
+        out.push({
+          key: `${d.id}::final`,
+          draftId: d.id,
+          draftTitle: d.title,
+          pipelineId: d.pipelineId,
+          type: 'video',
+          url: d.resultUrl,
+          title: d.title,
+          isFinal: true,
+          createdAt: d.updatedAt,
+          thumbnail: d.thumbnail,
+        });
+      }
+      if (mode === 'all') {
+        for (const a of d.artifacts || []) {
+          // 跳过已作为 finalize 落入的成片记录(避免重复)
+          if (a.title === '成片' && d.resultUrl === a.url) continue;
+          out.push({
+            key: `${d.id}::${a.id}`,
+            draftId: d.id,
+            draftTitle: d.title,
+            pipelineId: d.pipelineId,
+            type: a.type,
+            url: a.url,
+            title: a.title,
+            isFinal: false,
+            createdAt: a.createdAt,
+            thumbnail: a.type === 'image' ? a.url : d.thumbnail,
+            extras: a.extras,
+          });
+        }
+      }
+    }
+    out.sort((a, b) => b.createdAt - a.createdAt);
+    return out;
+  });
+
+  /** 筛选后的条目 */
+  readonly filteredItems = computed<LibraryItem[]>(() => {
+    const items = this.items();
+    const tf = this.typeFilter();
+    const pf = this.pipelineFilter();
+    const kw = this.searchTerm().trim().toLowerCase();
+    return items.filter((it) => {
+      if (tf !== 'all' && it.type !== tf) return false;
+      if (pf !== 'all' && it.pipelineId !== pf) return false;
+      if (kw && !(it.title.toLowerCase().includes(kw) || it.draftTitle.toLowerCase().includes(kw))) return false;
+      return true;
+    });
+  });
+
+  /** 分类计数(受 pipelineFilter / mode 影响) */
+  readonly typeCounts = computed(() => {
+    const items = this.items();
+    const pf = this.pipelineFilter();
+    const base = pf === 'all' ? items : items.filter((it) => it.pipelineId === pf);
+    return {
+      all: base.length,
+      video: base.filter((i) => i.type === 'video').length,
+      image: base.filter((i) => i.type === 'image').length,
+      audio: base.filter((i) => i.type === 'audio').length,
+    };
+  });
+
+  async ngOnInit(): Promise<void> {
+    await this.refresh();
+  }
+
+  async refresh(): Promise<void> {
+    this.loading.set(true);
+    try {
+      const arr = await this.session.loadAll();
+      this.drafts.set(arr);
+    } finally {
+      this.loading.set(false);
+    }
+  }
+
+  // ============== 筛选切换 ==============
+  setViewMode(m: LibraryViewMode): void { this.viewMode.set(m); }
+  setTypeFilter(t: LibraryFilterType): void { this.typeFilter.set(t); }
+  setPipelineFilter(p: PipelineRoute | 'all'): void { this.pipelineFilter.set(p); }
+
+  // ============== 操作 ==============
+  /** 跳到对应 draft 详情 */
+  openDraft(it: LibraryItem): void {
+    this.navigateToDraft.emit({ draftId: it.draftId, pipelineId: it.pipelineId });
+  }
+
+  /** 打开新窗口播放/查看 */
+  openExternal(it: LibraryItem, ev: Event): void {
+    ev.stopPropagation();
+    window.open(it.url, '_blank', 'noopener');
+  }
+
+  /** 鼠标悬停时静音预览播放 */
+  onHoverPlay(ev: Event): void {
+    const v = ev.target as HTMLVideoElement;
+    if (!v) return;
+    const p = v.play();
+    if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {});
+  }
+
+  onHoverPause(ev: Event): void {
+    const v = ev.target as HTMLVideoElement;
+    if (v) v.pause();
+  }
+
+  /** 直接下载 */
+  download(it: LibraryItem, ev: Event): void {
+    ev.stopPropagation();
+    const a = document.createElement('a');
+    a.href = it.url;
+    a.download = this.deriveFilename(it);
+    a.target = '_blank';
+    a.rel = 'noopener';
+    document.body.appendChild(a);
+    a.click();
+    a.remove();
+  }
+
+  // ============== 工具 ==============
+  pipelineLabel(id: PipelineRoute): string {
+    return this.pipelines.find((p) => p.route === id)?.displayName || id;
+  }
+
+  typeIcon(t: 'image' | 'video' | 'audio'): string {
+    return t === 'image' ? '🖼️' : t === 'video' ? '🎬' : '🎵';
+  }
+
+  formatTime(ts: number): string {
+    if (!ts) return '';
+    const d = new Date(ts);
+    const pad = (n: number) => n.toString().padStart(2, '0');
+    return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+  }
+
+  formatRelative(ts: number): string {
+    const diff = Date.now() - ts;
+    if (diff < 60_000) return '刚刚';
+    if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
+    if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
+    return this.formatTime(ts);
+  }
+
+  trackByKey(_: number, it: LibraryItem): string { return it.key; }
+  trackById(_: number, x: { id: string }): string { return x.id; }
+
+  private deriveFilename(it: LibraryItem): string {
+    const ext = it.type === 'video' ? '.mp4' : it.type === 'audio' ? '.mp3' : '.jpg';
+    const safe = it.title.replace(/[\\/:*?"<>|]/g, '_');
+    return `${safe}${ext}`;
+  }
+}

+ 9 - 2
src/app/pages/pipelines/action-transfer/action-transfer.component.html

@@ -1,5 +1,12 @@
 <section class="dh-page pl-page">
 
+  <app-session-bar
+    pipelineId="action-transfer"
+    pipelineLabel="动作迁移"
+    (newSession)="onNewSession()"
+    (openSession)="onOpenSession($event)">
+  </app-session-bar>
+
   <header class="dh-page-header">
     <div class="dh-header-icon">
       <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -11,7 +18,7 @@
     </div>
     <div class="dh-header-text">
       <h2>动作迁移 <span class="pl-pill">即梦 Actor v2</span></h2>
-      <p>上传一张角色图片和一段参考动作视频,让角色按视频中的动作/表情/口型动起来。计费约 0.4 元/秒。</p>
+      <p>上传一张角色图片和一段参考动作视频,让角色按视频中的动作/表情/口型动起来。</p>
     </div>
   </header>
 
@@ -96,7 +103,7 @@
 
       <div class="dh-substep" *ngIf="referenceVideo">
         <label class="dh-toggle">
-          <input type="checkbox" [(ngModel)]="cutFirstSecond" [disabled]="generating">
+          <input type="checkbox" [(ngModel)]="cutFirstSecond" (ngModelChange)="syncDraft()" [disabled]="generating">
           <span class="dh-toggle__track"><span class="dh-toggle__thumb"></span></span>
           <span class="dh-toggle__label">
             裁剪结果视频首秒

+ 153 - 64
src/app/pages/pipelines/action-transfer/action-transfer.component.ts

@@ -1,10 +1,13 @@
-import { Component, ChangeDetectorRef, NgZone, OnDestroy } from '@angular/core';
+import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { HttpClient } from '@angular/common/http';
 import { Subscription } from 'rxjs';
 import { JimengService } from '../../../services/jimeng.service';
 import { ResultsService } from '../../../services/results.service';
+import { PipelineSessionService } from '../../../services/pipeline-session.service';
+import { QiniuUploadService } from '../../../services/qiniu-upload.service';
+import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
+import { DraftMeta } from '../../../models/pipeline-draft.model';
 
 interface UploadedAsset {
   url: string;
@@ -14,22 +17,31 @@ interface UploadedAsset {
   type: 'image' | 'video';
 }
 
+/** 会话快照:上传后的七牛云 url 可永久化,本地 preview 不存 */
+interface AtSnapshot {
+  characterImage: { url: string; name: string; size: number } | null;
+  referenceVideo: { url: string; name: string; size: number } | null;
+  cutFirstSecond: boolean;
+  resultVideoUrl: string;
+  resultWorkId: string;
+}
+
 /**
  * P2: 动作迁移 Pipeline
  *
  * 调用即梦 getActorV2:
  *   image_url + video_url → 角色图按参考视频动作驱动起来。
  *
- * 上传走 /api/remix/upload-asset(支持图片/视频/音频,已扩到 200MB)。
+ * 上传走七牛云直传(云函数签发 token,支持图片/视频)。
  */
 @Component({
   selector: 'app-action-transfer',
   standalone: true,
-  imports: [CommonModule, FormsModule],
+  imports: [CommonModule, FormsModule, SessionBarComponent],
   templateUrl: './action-transfer.component.html',
   styleUrls: ['./action-transfer.component.css'],
 })
-export class ActionTransferComponent implements OnDestroy {
+export class ActionTransferComponent implements OnInit, OnDestroy {
   characterImage: UploadedAsset | null = null;
   referenceVideo: UploadedAsset | null = null;
   /** 是否裁剪结果视频的第 1 秒(即梦默认 true,可关闭) */
@@ -49,13 +61,95 @@ export class ActionTransferComponent implements OnDestroy {
   private genSub?: Subscription;
 
   constructor(
-    private http: HttpClient,
+    private qiniuUpload: QiniuUploadService,
     private jimeng: JimengService,
     private cdr: ChangeDetectorRef,
     private zone: NgZone,
     private results: ResultsService,
+    public session: PipelineSessionService,
   ) {}
 
+  ngOnInit(): void {
+    void this.session.bootstrap();
+    const cur = this.session.active();
+    if (cur && cur.pipelineId === 'action-transfer' && cur.snapshot) {
+      this.fromSnapshot(cur.snapshot as AtSnapshot);
+    }
+  }
+
+  // ====== Session ======
+  private toSnapshot(): AtSnapshot {
+    const stripPreview = (a: UploadedAsset | null) =>
+      a ? { url: a.url, name: a.name, size: a.size } : null;
+    return {
+      characterImage: stripPreview(this.characterImage),
+      referenceVideo: stripPreview(this.referenceVideo),
+      cutFirstSecond: this.cutFirstSecond,
+      resultVideoUrl: this.resultVideoUrl,
+      resultWorkId: this.resultWorkId,
+    };
+  }
+
+  private fromSnapshot(snap: AtSnapshot): void {
+    if (!snap) return;
+    this.characterImage = snap.characterImage
+      ? { ...snap.characterImage, preview: snap.characterImage.url, type: 'image' as const }
+      : null;
+    this.referenceVideo = snap.referenceVideo
+      ? { ...snap.referenceVideo, preview: snap.referenceVideo.url, type: 'video' as const }
+      : null;
+    this.cutFirstSecond = snap.cutFirstSecond ?? true;
+    this.resultVideoUrl = snap.resultVideoUrl ?? '';
+    this.resultWorkId = snap.resultWorkId ?? '';
+    this.errorMsg = '';
+    this.statusText = '';
+    this.progress = 0;
+    this.cdr.detectChanges();
+  }
+
+  private deriveTitle(): string {
+    if (this.resultWorkId) return `动作迁移-${this.resultWorkId}`;
+    if (this.characterImage?.name) return `动作迁移 · ${this.characterImage.name}`;
+    return '动作迁移 · 未命名';
+  }
+
+  syncDraft(): void {
+    this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle());
+    const cur = this.session.active();
+    const derived = this.deriveTitle();
+    const isAutoTitle = !cur || !cur.title || cur.title === '未命名创作' || cur.title === derived;
+    this.session.patch({
+      snapshot: this.toSnapshot(),
+      ...(isAutoTitle ? { title: derived } : {}),
+    });
+  }
+
+  onNewSession(): void {
+    if (this.generating) return;
+    this.cancel();
+    if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview);
+    if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview);
+    this.characterImage = null;
+    this.referenceVideo = null;
+    this.cutFirstSecond = true;
+    this.resultVideoUrl = '';
+    this.resultWorkId = '';
+    this.errorMsg = '';
+    this.statusText = '';
+    this.progress = 0;
+    this.session.close();
+    this.cdr.detectChanges();
+  }
+
+  async onOpenSession(meta: DraftMeta): Promise<void> {
+    if (this.generating) {
+      alert('当前任务运行中,请先取消或等待完成再切换');
+      return;
+    }
+    const draft = await this.session.open(meta.id);
+    if (draft?.snapshot) this.fromSnapshot(draft.snapshot as AtSnapshot);
+  }
+
   ngOnDestroy(): void {
     this.genSub?.unsubscribe();
     if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview);
@@ -90,63 +184,56 @@ export class ActionTransferComponent implements OnDestroy {
     this.uploadingSlot = slot;
     this.uploadProgress = 0;
 
-    const formData = new FormData();
-    formData.append('file', file);
-
-    // 用 XHR 拿到进度;HttpClient 在没有 reportProgress 时无法返回进度
-    const xhr = new XMLHttpRequest();
-    xhr.open('POST', '/backend/api/remix/upload-asset', true);
-    xhr.upload.onprogress = (e) => {
-      if (e.lengthComputable) {
-        this.zone.run(() => {
-          this.uploadProgress = Math.round((e.loaded / e.total) * 100);
-          this.cdr.detectChanges();
-        });
-      }
-    };
-    xhr.onload = () => {
-      this.zone.run(() => {
-        try {
-          const res = JSON.parse(xhr.responseText || '{}');
-          if (xhr.status < 200 || xhr.status >= 300 || !res?.url) {
-            this.uploadError = res?.error || `上传失败 (HTTP ${xhr.status})`;
-            this.uploadingSlot = null;
-            this.cdr.detectChanges();
-            return;
-          }
-          const previewUrl = URL.createObjectURL(file);
-          const asset: UploadedAsset = {
-            url: res.url,
-            name: file.name,
-            size: file.size,
-            preview: previewUrl,
-            type: slot === 'image' ? 'image' : 'video',
-          };
-          if (slot === 'image') {
-            if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview);
-            this.characterImage = asset;
-          } else {
-            if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview);
-            this.referenceVideo = asset;
-          }
-          this.uploadingSlot = null;
-          this.uploadProgress = 0;
+    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, slot === 'image' ? 'image' : 'video').subscribe({
+      next: (event) => {
+        if (event.state === 'progress') {
+          this.uploadProgress = event.progress;
           this.cdr.detectChanges();
-        } catch (e) {
-          this.uploadError = `响应解析失败:${(e as Error).message}`;
+          return;
+        }
+        const url = event.url || '';
+        if (!url) {
+          this.uploadError = '涓婁紶澶辫触锛氭湭杩斿洖 URL';
           this.uploadingSlot = null;
           this.cdr.detectChanges();
+          return;
+        }
+        const previewUrl = URL.createObjectURL(file);
+        const asset: UploadedAsset = {
+          url,
+          name: file.name,
+          size: file.size,
+          preview: previewUrl,
+          type: slot === 'image' ? 'image' : 'video',
+        };
+        if (slot === 'image') {
+          if (this.characterImage?.preview) URL.revokeObjectURL(this.characterImage.preview);
+          this.characterImage = asset;
+        } else {
+          if (this.referenceVideo?.preview) URL.revokeObjectURL(this.referenceVideo.preview);
+          this.referenceVideo = asset;
         }
-      });
-    };
-    xhr.onerror = () => {
-      this.zone.run(() => {
-        this.uploadError = '网络错误,上传失败';
         this.uploadingSlot = null;
+        this.uploadProgress = 0;
         this.cdr.detectChanges();
-      });
-    };
-    xhr.send(formData);
+        this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle());
+        this.session.upsertArtifact(
+          (a) => a.extras?.['slot'] === slot,
+          {
+            type: slot === 'image' ? 'image' : 'video',
+            url: asset.url,
+            title: slot === 'image' ? '角色图片(上传)' : '参考动作视频(上传)',
+            extras: { slot },
+          },
+        );
+        this.session.patch({ snapshot: this.toSnapshot() });
+      },
+      error: (err) => {
+        this.uploadError = err?.message || '上传失败';
+        this.uploadingSlot = null;
+        this.cdr.detectChanges();
+      },
+    });
   }
 
   removeAsset(slot: 'image' | 'video'): void {
@@ -172,6 +259,10 @@ export class ActionTransferComponent implements OnDestroy {
     this.resultVideoUrl = '';
     this.resultWorkId = '';
 
+    this.session.ensureActive('action-transfer', () => this.toSnapshot(), this.deriveTitle());
+    this.session.markRunning();
+    this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
+
     this.genSub = this.jimeng
       .actionTransfer(
         this.characterImage!.url,
@@ -194,18 +285,15 @@ export class ActionTransferComponent implements OnDestroy {
             this.statusText = '动作迁移完成!';
             this.generating = false;
             this.cdr.detectChanges();
+            const extras = { workId: result.workId, cutFirstSecond: this.cutFirstSecond };
+            this.session.patch({ snapshot: this.toSnapshot() });
+            this.session.finalize(result.videoUrl, extras);
             this.results.saveResult({
               type: 'video',
               url: result.videoUrl,
               title: `动作迁移-${result.workId}`,
               pipelineId: 'action_transfer',
-              extras: { workId: result.workId, cutFirstSecond: this.cutFirstSecond },
-            }).subscribe();
-            this.results.saveHistory({
-              keyword: '动作迁移生成',
-              status: 'completed',
-              resultUrl: result.videoUrl,
-              pipelineId: 'action_transfer',
+              extras,
             }).subscribe();
           });
         },
@@ -216,6 +304,7 @@ export class ActionTransferComponent implements OnDestroy {
             this.statusText = '';
             this.generating = false;
             this.cdr.detectChanges();
+            this.session.fail(this.errorMsg);
           });
         },
       });

+ 41 - 41
src/app/pages/pipelines/asset-remix/asset-remix.component.html

@@ -1,5 +1,12 @@
 <section class="dh-page pl-page ar-page">
 
+  <app-session-bar
+    pipelineId="asset-remix"
+    pipelineLabel="素材合成视频"
+    (newSession)="onNewSession()"
+    (openSession)="onOpenSession($event)">
+  </app-session-bar>
+
   <header class="dh-page-header">
     <div class="dh-header-icon">
       <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -11,7 +18,7 @@
     </div>
     <div class="dh-header-text">
       <h2>素材合成视频 <span class="pl-pill">MVP</span></h2>
-      <p>上传多张图片 → AI 生成口播文案 → 火山 TTS 配音 → ffmpeg 拼接成片。计费仅 TTS 0.005 元/千字。</p>
+      <p>上传多张图片 → AI 生成口播文案 → 火山 TTS 配音 → ffmpeg 拼接成片。</p>
     </div>
   </header>
 
@@ -83,7 +90,7 @@
         <div class="dh-field">
           <input class="dh-input" type="text" maxlength="80"
                  placeholder="例如:城市夜景 vlog / 这家店真的太香了 / 一日看尽长安花"
-                 [(ngModel)]="topic" [disabled]="generatingScripts || synthesizing || compositing">
+                 [(ngModel)]="topic" (ngModelChange)="syncDraft()" [disabled]="generatingScripts || synthesizing || compositing">
         </div>
       </div>
 
@@ -178,46 +185,39 @@
       <div class="dh-substep">
         <div class="dh-substep__head"><h4>音色</h4></div>
 
-        <!-- 系统/克隆 切换 -->
-        <div class="dh-segment" style="margin-bottom:8px;">
-          <button class="dh-segment__item"
-                  type="button"
-                  [class.is-active]="voiceMode === 'system'"
-                  [disabled]="synthesizing || compositing"
-                  (click)="voiceMode = 'system'">
-            🔊 系统音色
-          </button>
-          <button class="dh-segment__item"
-                  type="button"
-                  [class.is-active]="voiceMode === 'clone'"
-                  [disabled]="synthesizing || compositing"
-                  (click)="voiceMode = 'clone'">
-            🎤 克隆音色
-          </button>
-        </div>
-
-        <!-- 系统音色:下拉选择 -->
-        <select *ngIf="voiceMode === 'system'"
-                class="dh-input"
-                [(ngModel)]="speakerId"
-                [disabled]="synthesizing || compositing">
-          <option *ngFor="let s of speakerPresets" [value]="s.value">{{ s.label }}</option>
-        </select>
-
-        <!-- 克隆音色:手动粘贴 timbreId -->
-        <input *ngIf="voiceMode === 'clone'"
-               class="dh-input"
-               type="text"
-               [(ngModel)]="timbreId"
-               [disabled]="synthesizing || compositing"
-               placeholder="粘贴在「语音合成」页训练完成后得到的 timbre_id(Parse objectId)" />
+        <button type="button" class="vs-voice-picker-trigger"
+                [class.is-empty]="!currentVoiceSelection"
+                [disabled]="synthesizing || compositing"
+                (click)="openVoicePicker()">
+          <ng-container *ngIf="currentVoiceSelection; else voiceEmpty">
+            <span class="vs-voice-picker-avatar"
+                  [class.vs-voice-picker-avatar--mine]="currentVoiceSelection.source === 'cloned'">
+              {{currentVoiceSelection.source === 'official' ? '知' : '我'}}
+            </span>
+            <span class="vs-voice-picker-info">
+              <span class="vs-voice-picker-name">{{currentVoiceSelection.name}}</span>
+              <span class="vs-voice-picker-src">
+                {{currentVoiceSelection.source === 'official' ? '音色库' : '我的音色'}}
+              </span>
+            </span>
+          </ng-container>
+          <ng-template #voiceEmpty>
+            <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
+            <span class="vs-voice-picker-info">
+              <span class="vs-voice-picker-name">请选择声音模型</span>
+              <span class="vs-voice-picker-src">点击打开音色库 / 我的音色</span>
+            </span>
+          </ng-template>
+          <span class="vs-voice-picker-chevron">›</span>
+        </button>
 
-        <p class="ar-tip" *ngIf="voiceMode === 'system'">
-          已使用 <code>-am_16k</code> 集群官方音色({{ speakerId }}),上游网关验证可用。
-        </p>
-        <p class="ar-tip" *ngIf="voiceMode === 'clone'">
-          克隆音色需先在「语音合成」页完成训练,再把 timbre_id 复制到此处。
-        </p>
+        <app-voice-picker
+          [open]="voicePickerOpen"
+          [clonedList]="clonedTimbreList"
+          [currentSelection]="currentVoiceSelection"
+          (select)="onVoicePicked($event)"
+          (close)="closeVoicePicker()">
+        </app-voice-picker>
       </div>
     </div>
 

+ 287 - 29
src/app/pages/pipelines/asset-remix/asset-remix.component.ts

@@ -1,12 +1,20 @@
-import { Component, ChangeDetectorRef, NgZone, OnDestroy } from '@angular/core';
+import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { HttpClient } from '@angular/common/http';
 import { Subscription, from, of, throwError, timer, Observable } from 'rxjs';
 import { catchError, concatMap, finalize, map, toArray, mergeMap, retryWhen } from 'rxjs/operators';
 import { LlmService, GeminiContent, GeminiPart } from '../../../services/llm.service';
-import { composeVideoStream, CompositeHandle } from '../../../pipelines/composite-sse';
+import {
+  BrowserFfmpegService,
+  CompositeHandle,
+} from '../../../services/browser-ffmpeg.service';
 import { ResultsService } from '../../../services/results.service';
+import { PipelineSessionService } from '../../../services/pipeline-session.service';
+import { QiniuUploadService } from '../../../services/qiniu-upload.service';
+import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
+import { DraftMeta } from '../../../models/pipeline-draft.model';
+import { OFFICIAL_VOICE_LIBRARY, VoicePickerComponent, VoiceSelection } from '../../../components/voice-picker/voice-picker.component';
 
 interface UploadedImage {
   url: string;          // 七牛云公网 URL
@@ -30,6 +38,22 @@ interface RefineMessage {
   ts: number;
 }
 
+/** 会话快照:仅存可序列化字段。UploadedImage 中的 preview/base64/File 不入快照。 */
+interface ArSnapshot {
+  topic: string;
+  style: 'documentary' | 'energetic' | 'storytelling' | 'commerce';
+  voiceMode: 'system' | 'clone';
+  speakerId: string;
+  timbreId: string;
+  voiceName?: string;
+  images: Array<{
+    url: string; name: string; size: number; script: string;
+    audioUrl?: string;
+    ttsState?: 'idle' | 'pending' | 'done' | 'failed';
+  }>;
+  resultVideoUrl: string;
+}
+
 /**
  * P3: 素材合成视频 Pipeline (MVP)
  *
@@ -43,11 +67,11 @@ interface RefineMessage {
 @Component({
   selector: 'app-asset-remix',
   standalone: true,
-  imports: [CommonModule, FormsModule],
+  imports: [CommonModule, FormsModule, SessionBarComponent, VoicePickerComponent],
   templateUrl: './asset-remix.component.html',
   styleUrls: ['./asset-remix.component.css'],
 })
-export class AssetRemixComponent implements OnDestroy {
+export class AssetRemixComponent implements OnInit, OnDestroy {
   // ====== 输入 ======
   topic = '';
   /** 风格关键词:影响 LLM 生成口吻 */
@@ -61,10 +85,14 @@ export class AssetRemixComponent implements OnDestroy {
   voiceMode: 'system' | 'clone' = 'system';
 
   /** 系统音色 speaker_id */
-  speakerId = 'zh_female_shuangkuai-am_16k';
+  speakerId = '';
 
   /** 克隆音色 timbreId(来自"语音合成"页训练后的 Parse objectId) */
   timbreId = '';
+  voiceName = '';
+  voicePickerOpen = false;
+  clonedTimbreList: { objectId: string; name: string; speakerId: string }[] = [];
+  private readonly timbreListStorageKey = 'tiktok.vsClonedTimbreList';
 
   // ====== 素材 ======
   images: UploadedImage[] = [];
@@ -129,11 +157,108 @@ export class AssetRemixComponent implements OnDestroy {
   constructor(
     private http: HttpClient,
     private llm: LlmService,
+    private qiniuUpload: QiniuUploadService,
     private cdr: ChangeDetectorRef,
     private zone: NgZone,
     private results: ResultsService,
+    public session: PipelineSessionService,
+    private browserFfmpeg: BrowserFfmpegService,
   ) {}
 
+  ngOnInit(): void {
+    this.restoreClonedTimbreList();
+    void this.session.bootstrap();
+    const cur = this.session.active();
+    if (cur && cur.pipelineId === 'asset-remix' && cur.snapshot) {
+      this.fromSnapshot(cur.snapshot as ArSnapshot);
+    }
+  }
+
+  // ====== Session ======
+  private toSnapshot(): ArSnapshot {
+    return {
+      topic: this.topic,
+      style: this.style,
+      voiceMode: this.voiceMode,
+      speakerId: this.speakerId,
+      timbreId: this.timbreId,
+      voiceName: this.voiceName,
+      images: this.images.map((i) => ({
+        url: i.url, name: i.name, size: i.size, script: i.script,
+        audioUrl: i.audioUrl,
+        ttsState: i.ttsState === 'pending' ? 'idle' : i.ttsState,
+      })),
+      resultVideoUrl: this.resultVideoUrl,
+    };
+  }
+
+  private fromSnapshot(snap: ArSnapshot): void {
+    if (!snap) return;
+    this.topic = snap.topic ?? '';
+    this.style = snap.style ?? 'documentary';
+    this.voiceMode = snap.voiceMode ?? 'system';
+    this.speakerId = snap.speakerId ?? '';
+    this.timbreId = snap.timbreId ?? '';
+    this.voiceName = snap.voiceName ?? '';
+    // 恢复图片列表:使用 url 作为 preview(本地 ObjectURL 已丢失),不重新走 base64
+    this.images = (snap.images || []).map((s) => ({
+      url: s.url, name: s.name, size: s.size, preview: s.url,
+      script: s.script || '',
+      audioUrl: s.audioUrl,
+      ttsState: s.ttsState ?? 'idle',
+      base64Loading: false,
+    }));
+    this.resultVideoUrl = snap.resultVideoUrl ?? '';
+    this.errorMsg = '';
+    this.synthStatus = '';
+    this.compositeStatus = '';
+    this.synthProgress = 0;
+    this.compositeProgress = 0;
+    this.cdr.detectChanges();
+  }
+
+  private deriveTitle(): string {
+    const t = (this.topic || '').trim();
+    if (t) return t.length > 30 ? t.slice(0, 30) + '…' : t;
+    if (this.images.length) return `素材合成 · ${this.images.length} 张图`;
+    return '未命名创作';
+  }
+
+  syncDraft(): void {
+    this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
+    const cur = this.session.active();
+    const derived = this.deriveTitle();
+    const isAutoTitle = !cur || !cur.title || cur.title === '未命名创作' || cur.title === derived;
+    this.session.patch({
+      snapshot: this.toSnapshot(),
+      ...(isAutoTitle ? { title: derived } : {}),
+    });
+  }
+
+  onNewSession(): void {
+    if (this.synthesizing || this.compositing) return;
+    this.images.forEach((img) => img.preview && URL.revokeObjectURL(img.preview));
+    this.images = [];
+    this.topic = '';
+    this.resultVideoUrl = '';
+    this.errorMsg = '';
+    this.synthStatus = '';
+    this.compositeStatus = '';
+    this.synthProgress = 0;
+    this.compositeProgress = 0;
+    this.session.close();
+    this.cdr.detectChanges();
+  }
+
+  async onOpenSession(meta: DraftMeta): Promise<void> {
+    if (this.synthesizing || this.compositing) {
+      alert('当前任务运行中,请先取消或等待完成再切换');
+      return;
+    }
+    const draft = await this.session.open(meta.id);
+    if (draft?.snapshot) this.fromSnapshot(draft.snapshot as ArSnapshot);
+  }
+
   ngOnDestroy(): void {
     this.genSub?.unsubscribe();
     this.images.forEach((img) => img.preview && URL.revokeObjectURL(img.preview));
@@ -170,16 +295,19 @@ export class AssetRemixComponent implements OnDestroy {
         return;
       }
 
-      const formData = new FormData();
-      formData.append('file', file);
       const preview = URL.createObjectURL(file);
       const placeholderKey = `${file.name}-${file.size}`;
       this.uploadProgress[placeholderKey] = 0;
 
-      this.http.post<any>('/backend/api/remix/upload-asset', formData).subscribe({
-        next: (res) => {
+      this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, 'image').subscribe({
+        next: (event) => {
           this.zone.run(() => {
-            const url = res?.url;
+            if (event.state === 'progress') {
+              this.uploadProgress[placeholderKey] = event.progress;
+              this.cdr.detectChanges();
+              return;
+            }
+            const url = event.url || '';
             if (!url) {
               this.uploadError = `${file.name} 上传失败:未返回 URL`;
             } else {
@@ -194,6 +322,17 @@ export class AssetRemixComponent implements OnDestroy {
                 mimeType: file.type || 'image/jpeg',
               };
               this.images.push(img);
+              // 上传产物入会话
+              this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
+              this.session.upsertArtifact(
+                (a) => a.type === 'image' && a.extras?.['url'] === url,
+                {
+                  type: 'image', url,
+                  title: `素材 ${this.images.length} - ${file.name}`,
+                  extras: { url, source: 'upload' },
+                },
+              );
+              this.session.patch({ snapshot: this.toSnapshot() });
               // 后台异步把图片转 base64,便于后续 Gemini 视觉识别
               this.llm.fileToBase64(file)
                 .then((b64) => this.zone.run(() => {
@@ -531,6 +670,65 @@ ${text}
     return (this.voiceMode === 'system' ? this.speakerId : this.timbreId).trim();
   }
 
+  get currentVoiceSelection(): VoiceSelection | null {
+    if (this.voiceMode === 'clone' && this.timbreId) {
+      const matched = this.clonedTimbreList.find((item) => item.objectId === this.timbreId);
+      return {
+        source: 'cloned',
+        name: this.voiceName || matched?.name || this.timbreId,
+        id: this.timbreId,
+        timbreId: this.timbreId,
+        speakerId: matched?.speakerId,
+      };
+    }
+    if (this.voiceMode === 'system' && this.speakerId) {
+      return {
+        source: 'official',
+        name: this.voiceName || OFFICIAL_VOICE_LIBRARY.find((item) => item.voiceType === this.speakerId)?.name || this.speakerId,
+        id: this.speakerId,
+        voiceType: this.speakerId,
+      };
+    }
+    return null;
+  }
+
+  openVoicePicker(): void {
+    this.restoreClonedTimbreList();
+    this.voicePickerOpen = true;
+  }
+
+  closeVoicePicker(): void {
+    this.voicePickerOpen = false;
+  }
+
+  onVoicePicked(sel: VoiceSelection): void {
+    this.voiceMode = sel.source === 'official' ? 'system' : 'clone';
+    this.voiceName = sel.name;
+    if (sel.source === 'official') {
+      this.speakerId = sel.voiceType || sel.id;
+      this.timbreId = '';
+    } else {
+      this.timbreId = sel.timbreId || sel.id;
+      this.speakerId = '';
+    }
+    this.errorMsg = '';
+    this.voicePickerOpen = false;
+    this.session.patch({ snapshot: this.toSnapshot() });
+    this.cdr.detectChanges();
+  }
+
+  private restoreClonedTimbreList(): void {
+    try {
+      const saved = localStorage.getItem(this.timbreListStorageKey);
+      const parsed = saved ? JSON.parse(saved) : [];
+      this.clonedTimbreList = Array.isArray(parsed)
+        ? parsed.filter((item: any) => item && typeof item.objectId === 'string' && item.objectId)
+        : [];
+    } catch {
+      this.clonedTimbreList = [];
+    }
+  }
+
   get canSynthesize(): boolean {
     return (
       !this.synthesizing &&
@@ -546,6 +744,50 @@ ${text}
     return this.images.some((i) => i.ttsState === 'failed');
   }
 
+  private officialVoiceResourceId(voiceId: string): string {
+    return voiceId.includes('_uranus_bigtts') ? 'seed-tts-2.0' : 'seed-tts-1.0';
+  }
+
+  private readErrorText(value: any, fallback: string): string {
+    const candidates = [
+      value?.error?.error,
+      value?.error?.message,
+      value?.error?.msg,
+      value?.error,
+      value?.message,
+      value?.msg,
+      value,
+    ];
+
+    for (const candidate of candidates) {
+      const text = this.normalizeErrorText(candidate);
+      if (text) return text;
+    }
+
+    return fallback;
+  }
+
+  private normalizeErrorText(value: any, seen = new Set<any>()): string {
+    if (value == null) return '';
+    if (typeof value === 'string') return value.trim();
+    if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+    if (typeof value !== 'object') return '';
+    if (seen.has(value)) return '';
+    seen.add(value);
+
+    for (const key of ['message', 'msg', 'error', 'detail', 'reason']) {
+      const nested = this.normalizeErrorText(value?.[key], seen);
+      if (nested) return nested;
+    }
+
+    try {
+      const serialized = JSON.stringify(value);
+      return serialized && serialized !== '{}' ? serialized : '';
+    } catch {
+      return '';
+    }
+  }
+
   /**
    * 单段 TTS 调用。返回 Observable,包含 3 次指数退避重试。
    * 重试只针对网络错误 / 5xx 上游错误;4xx 通常是参数问题,不重试以免浪费配额。
@@ -560,7 +802,9 @@ ${text}
       audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: 0, loudness_rate: 0 },
     };
     if (this.voiceMode === 'system') {
-      payload.speaker_id = this.speakerId.trim();
+      const voiceId = this.speakerId.trim();
+      payload.volcengine_voice_type = voiceId;
+      payload.x_api_resource_id = this.officialVoiceResourceId(voiceId);
     } else {
       payload.timbreId = this.timbreId.trim();
     }
@@ -573,6 +817,7 @@ ${text}
             // 非 HTTP 错误但业务码非 200,也按错误抛出
             const e: any = new Error(res?.error?.message || res?.message || `第 ${idx + 1} 段合成失败`);
             e.bizCode = res?.code;
+            e.message = this.readErrorText(res, `第 ${idx + 1} 段合成失败`);
             throw e;
           }
           return url as string;
@@ -600,6 +845,17 @@ ${text}
           img.audioUrl = url;
           img.ttsState = 'done';
           img.ttsError = undefined;
+          // TTS 产物入会话
+          this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
+          this.session.upsertArtifact(
+            (a) => a.type === 'audio' && a.extras?.['imageUrl'] === img.url,
+            {
+              type: 'audio', url, sceneIndex: idx,
+              title: `素材 ${idx + 1} - TTS音频`,
+              extras: { imageUrl: img.url, voiceMode: this.voiceMode, voiceId: this.activeVoiceId },
+            },
+          );
+          this.session.patch({ snapshot: this.toSnapshot() });
           return img;
         }),
         catchError((err) => {
@@ -611,7 +867,7 @@ ${text}
             err?.message ||
             'TTS 失败';
           const status = err?.status ? `HTTP ${err.status} · ` : '';
-          img.ttsError = `${status}${detail}(已重试 ${img.ttsAttempts || 0} 次)`;
+          img.ttsError = `${status}${this.readErrorText(err, detail)}(已重试 ${img.ttsAttempts || 0} 次)`;
           console.error(`[asset-remix] TTS 段 ${idx + 1} 失败`, err);
           return of(img);
         }),
@@ -707,13 +963,17 @@ ${text}
     this.errorMsg = '';
     this.resultVideoUrl = '';
 
+    this.session.ensureActive('asset-remix', () => this.toSnapshot(), this.deriveTitle());
+    this.session.markRunning();
+    this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
+
     const segments = this.images.map((img) => ({
       id: img.url,
       imageUrl: img.url,
       audioUrl: img.audioUrl!,
     }));
 
-    this.compositeHandle = composeVideoStream({
+    this.compositeHandle = this.browserFfmpeg.composeVideo({
       segments,
       title: this.topic.trim() || '素材合成视频',
       onStage: (e) => {
@@ -735,30 +995,27 @@ ${text}
       .then((res) => {
         this.zone.run(() => {
           this.compositing = false;
-          this.resultVideoUrl = '/backend' + res.videoUrl;
+          // 浏览器端合成后七牛 CDN 返回完整 https URL,直接用
+          this.resultVideoUrl = res.videoUrl;
           this.compositeProgress = 100;
           this.compositeStatus = `合成完成(${res.segments} 段,${this.formatSize(res.size || 0)})`;
           this.cdr.detectChanges();
+          const composeExtras = {
+            segments: res.segments,
+            filename: res.filename,
+            size: res.size,
+            style: this.style,
+            voiceMode: this.voiceMode,
+            voiceId: this.activeVoiceId,
+          };
+          this.session.patch({ snapshot: this.toSnapshot() });
+          this.session.finalize(this.resultVideoUrl, composeExtras);
           this.results.saveResult({
             type: 'video',
             url: this.resultVideoUrl,
             title: this.topic.trim() || '素材合成视频',
             pipelineId: 'asset_remix',
-            extras: {
-              segments: res.segments,
-              filename: res.filename,
-              size: res.size,
-              style: this.style,
-              voiceMode: this.voiceMode,
-              voiceId: this.activeVoiceId,
-            },
-          }).subscribe();
-          this.results.saveHistory({
-            keyword: this.topic.trim() || '素材合成',
-            status: 'completed',
-            resultUrl: this.resultVideoUrl,
-            pipelineId: 'asset_remix',
-            styleName: this.style,
+            extras: composeExtras,
           }).subscribe();
         });
       })
@@ -767,6 +1024,7 @@ ${text}
           this.compositing = false;
           this.errorMsg = err?.message || '合成失败';
           this.cdr.detectChanges();
+          this.session.fail(this.errorMsg);
         });
       });
   }

+ 327 - 0
src/app/pages/pipelines/image-generation/image-generation.component.css

@@ -0,0 +1,327 @@
+.ig-page {
+  --ig-ink: #0f172a;
+  --ig-muted: #64748b;
+  --ig-line: rgba(148, 163, 184, 0.28);
+  --ig-soft: rgba(248, 250, 252, 0.86);
+}
+
+.ig-layout {
+  display: grid;
+  grid-template-columns: minmax(320px, 430px) minmax(0, 1fr);
+  gap: 18px;
+  align-items: start;
+}
+
+.ig-panel {
+  background: rgba(255, 255, 255, 0.94);
+  border: 1px solid var(--ig-line);
+  border-radius: 16px;
+  box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
+}
+
+.ig-panel--form {
+  padding: 18px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.ig-count {
+  color: var(--ig-muted);
+  font-size: 12px;
+}
+
+.ig-prompt {
+  min-height: 170px;
+  resize: vertical;
+  line-height: 1.65;
+}
+
+.ig-prompt-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 10px;
+}
+
+.ig-style-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.ig-style,
+.ig-aspect {
+  border: 1px solid var(--ig-line);
+  background: #fff;
+  color: var(--ig-ink);
+  border-radius: 10px;
+  cursor: pointer;
+  transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
+}
+
+.ig-style {
+  min-height: 42px;
+  font-weight: 700;
+}
+
+.ig-style:hover:not(:disabled),
+.ig-aspect:hover:not(:disabled) {
+  border-color: rgba(59, 130, 246, 0.55);
+  box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
+  transform: translateY(-1px);
+}
+
+.ig-style.is-active,
+.ig-aspect.is-active {
+  border-color: rgba(37, 99, 235, 0.75);
+  background: linear-gradient(135deg, rgba(37, 99, 235, 0.10), rgba(6, 182, 212, 0.10));
+}
+
+.ig-aspect-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.ig-aspect {
+  min-height: 62px;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+  padding: 10px;
+}
+
+.ig-aspect strong {
+  font-size: 14px;
+}
+
+.ig-aspect span {
+  margin-top: 3px;
+  color: var(--ig-muted);
+  font-size: 12px;
+}
+
+.ig-upload {
+  width: 100%;
+  min-height: 68px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 4px;
+  border: 1px dashed rgba(59, 130, 246, 0.45);
+  border-radius: 12px;
+  background: rgba(239, 246, 255, 0.72);
+  color: #1d4ed8;
+  cursor: pointer;
+}
+
+.ig-upload span {
+  font-weight: 800;
+}
+
+.ig-upload small {
+  color: var(--ig-muted);
+}
+
+.ig-ref-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 8px;
+  margin-top: 10px;
+}
+
+.ig-ref {
+  position: relative;
+  aspect-ratio: 1;
+  margin: 0;
+  overflow: hidden;
+  border-radius: 10px;
+  border: 1px solid var(--ig-line);
+  background: #f8fafc;
+}
+
+.ig-ref img,
+.ig-thumb img,
+.ig-stage img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.ig-ref button {
+  position: absolute;
+  right: 6px;
+  bottom: 6px;
+  border: 0;
+  border-radius: 8px;
+  padding: 4px 8px;
+  background: rgba(15, 23, 42, 0.72);
+  color: #fff;
+  font-size: 12px;
+  cursor: pointer;
+}
+
+.ig-generate {
+  min-height: 54px;
+  margin-top: 2px;
+}
+
+.ig-loading {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.ig-progress {
+  height: 6px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: rgba(148, 163, 184, 0.22);
+}
+
+.ig-progress span {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: linear-gradient(90deg, #2563eb, #06b6d4);
+  transition: width 0.2s ease;
+}
+
+.ig-preview {
+  min-height: 720px;
+  padding: 18px;
+}
+
+.ig-stage {
+  width: 100%;
+  min-height: 580px;
+  aspect-ratio: 1 / 1;
+  border-radius: 16px;
+  overflow: hidden;
+  border: 1px solid var(--ig-line);
+  background: #f8fafc;
+}
+
+.ig-stage.is-empty {
+  display: grid;
+  place-items: center;
+  background:
+    linear-gradient(135deg, rgba(15, 23, 42, 0.04), rgba(37, 99, 235, 0.06)),
+    repeating-linear-gradient(45deg, rgba(148, 163, 184, 0.12) 0 1px, transparent 1px 18px);
+}
+
+.ig-empty {
+  text-align: center;
+  color: var(--ig-muted);
+}
+
+.ig-empty span {
+  display: block;
+  margin-bottom: 8px;
+  color: rgba(15, 23, 42, 0.18);
+  font-size: 52px;
+  font-weight: 900;
+  letter-spacing: 0;
+}
+
+.ig-empty p {
+  margin: 0;
+}
+
+.ig-result-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 14px;
+}
+
+.ig-history {
+  margin-top: 20px;
+}
+
+.ig-history header {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 10px;
+}
+
+.ig-history h3 {
+  margin: 0;
+  font-size: 15px;
+}
+
+.ig-history span {
+  color: var(--ig-muted);
+  font-size: 12px;
+}
+
+.ig-history-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
+  gap: 10px;
+}
+
+.ig-thumb {
+  aspect-ratio: 1;
+  padding: 0;
+  overflow: hidden;
+  border: 2px solid transparent;
+  border-radius: 10px;
+  background: #f8fafc;
+  cursor: pointer;
+}
+
+.ig-thumb.is-active {
+  border-color: #2563eb;
+}
+
+.app-container.theme-night .ig-panel {
+  background: rgba(15, 23, 42, 0.9);
+  border-color: rgba(148, 163, 184, 0.22);
+}
+
+.app-container.theme-night .ig-style,
+.app-container.theme-night .ig-aspect {
+  background: rgba(15, 23, 42, 0.78);
+  color: #e5e7eb;
+}
+
+.app-container.theme-night .ig-stage,
+.app-container.theme-night .ig-ref,
+.app-container.theme-night .ig-thumb {
+  background: rgba(15, 23, 42, 0.7);
+}
+
+@media (max-width: 1100px) {
+  .ig-layout {
+    grid-template-columns: 1fr;
+  }
+
+  .ig-preview {
+    min-height: auto;
+  }
+
+  .ig-stage {
+    min-height: 360px;
+  }
+}
+
+@media (max-width: 640px) {
+  .ig-panel--form,
+  .ig-preview {
+    padding: 14px;
+  }
+
+  .ig-style-grid,
+  .ig-aspect-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .ig-ref-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}

+ 145 - 0
src/app/pages/pipelines/image-generation/image-generation.component.html

@@ -0,0 +1,145 @@
+<section class="dh-page pl-page ig-page">
+  <app-session-bar
+    pipelineId="image-generation"
+    pipelineLabel="图片生成"
+    (newSession)="newSession()"
+    (openSession)="openSession($event)">
+  </app-session-bar>
+
+  <header class="dh-page-header">
+    <div class="dh-header-icon">
+      <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+        <rect x="3" y="3" width="18" height="18" rx="3"></rect>
+        <circle cx="9" cy="9" r="1.5"></circle>
+        <path d="M21 15l-4.5-4.5L6 21"></path>
+      </svg>
+    </div>
+    <div class="dh-header-text">
+      <h2>图片生成 <span class="pl-pill">文生图 / 参考图创作</span></h2>
+      <p>输入画面描述,选择风格与比例,生成可直接进入素材库的图片资产。</p>
+    </div>
+  </header>
+
+  <div class="ig-layout">
+    <aside class="ig-panel ig-panel--form">
+      <div class="dh-substep">
+        <div class="dh-substep__head">
+          <h4>画面描述</h4>
+          <span class="ig-count">{{ prompt.length }} / 1200</span>
+        </div>
+        <textarea
+          class="dh-textarea ig-prompt"
+          rows="8"
+          maxlength="1200"
+          [(ngModel)]="prompt"
+          (ngModelChange)="onPromptInput()"
+          placeholder="例如:清晨的城市天台,一位穿白色衬衫的年轻女性看向远处,阳光穿过玻璃幕墙,电影感写实摄影,浅景深。">
+        </textarea>
+        <div class="ig-prompt-actions">
+          <button type="button" class="dh-chip-btn" (click)="applyPrompt('高级护肤品广告图,透明玻璃瓶置于水面,柔和晨光,干净背景,商业摄影,高级质感')">产品广告</button>
+          <button type="button" class="dh-chip-btn" (click)="applyPrompt('年轻创作者坐在工作台前,桌面有相机和笔记本电脑,窗外城市夜景,温暖灯光,真实摄影')">人物场景</button>
+          <button type="button" class="dh-chip-btn" (click)="applyPrompt('未来感 AI 工作室,透明屏幕、蓝色光线、金属材质,宽幅构图,科技感空间设计')">科技空间</button>
+        </div>
+      </div>
+
+      <div class="dh-substep">
+        <div class="dh-substep__head"><h4>风格</h4></div>
+        <div class="ig-style-grid">
+          <button
+            type="button"
+            class="ig-style"
+            *ngFor="let item of stylePresets"
+            [class.is-active]="style === item.value"
+            [disabled]="generating"
+            (click)="style = item.value; onPromptInput()">
+            <span>{{ item.label }}</span>
+          </button>
+        </div>
+      </div>
+
+      <div class="dh-substep">
+        <div class="dh-substep__head"><h4>画面比例</h4></div>
+        <div class="ig-aspect-grid">
+          <button
+            type="button"
+            class="ig-aspect"
+            *ngFor="let item of aspectOptions"
+            [class.is-active]="aspect === item.value"
+            [disabled]="generating"
+            (click)="aspect = item.value; onPromptInput()">
+            <strong>{{ item.label }}</strong>
+            <span>{{ item.hint }}</span>
+          </button>
+        </div>
+      </div>
+
+      <div class="dh-substep">
+        <div class="dh-substep__head">
+          <h4>参考图</h4>
+          <span class="ig-count">{{ referenceImages.length }} / 3</span>
+        </div>
+        <input #refInput type="file" accept="image/*" multiple hidden (change)="onReferencePicked($event)">
+        <button
+          type="button"
+          class="ig-upload"
+          [disabled]="uploading || generating || referenceImages.length >= 3"
+          (click)="triggerReferencePick(refInput)">
+          <span>{{ uploading ? '上传中...' : '上传参考图' }}</span>
+          <small>可选,用于保持主体、构图或风格</small>
+        </button>
+        <div class="ig-ref-grid" *ngIf="referenceImages.length">
+          <figure class="ig-ref" *ngFor="let img of referenceImages; let i = index">
+            <img [src]="img.preview || img.url" [alt]="img.name">
+            <button type="button" (click)="removeReference(i)" [disabled]="generating">移除</button>
+          </figure>
+        </div>
+        <div class="dh-error" *ngIf="uploadError">{{ uploadError }}</div>
+      </div>
+
+      <button type="button" class="dh-cta ig-generate" [disabled]="!canGenerate" (click)="generate()">
+        <span *ngIf="!generating">生成图片</span>
+        <span *ngIf="generating" class="ig-loading"><span class="dh-spinner"></span>{{ statusText || '生成中...' }}</span>
+      </button>
+      <div class="ig-progress" *ngIf="generating">
+        <span [style.width.%]="progress"></span>
+      </div>
+      <div class="dh-error" *ngIf="errorMsg">{{ errorMsg }}</div>
+    </aside>
+
+    <main class="ig-panel ig-preview">
+      <div class="ig-stage" [class.is-empty]="!activeImage">
+        <ng-container *ngIf="activeImage; else emptyPreview">
+          <img [src]="activeImage.url" alt="生成图片">
+        </ng-container>
+        <ng-template #emptyPreview>
+          <div class="ig-empty">
+            <span>IMAGE</span>
+            <p>生成结果会显示在这里</p>
+          </div>
+        </ng-template>
+      </div>
+
+      <div class="ig-result-actions" *ngIf="activeImage">
+        <a class="dh-link-btn" [href]="activeImage.url" target="_blank" rel="noopener">新窗口打开</a>
+        <a class="dh-link-btn dh-link-btn--ghost" [href]="activeImage.url" download>下载图片</a>
+      </div>
+
+      <section class="ig-history" *ngIf="generatedImages.length">
+        <header>
+          <h3>本次生成</h3>
+          <span>{{ generatedImages.length }} 张</span>
+        </header>
+        <div class="ig-history-grid">
+          <button
+            type="button"
+            class="ig-thumb"
+            *ngFor="let img of generatedImages"
+            [class.is-active]="activeImage?.url === img.url"
+            (click)="activeImage = img">
+            <img [src]="img.url" alt="">
+          </button>
+        </div>
+      </section>
+    </main>
+  </div>
+</section>

+ 358 - 0
src/app/pages/pipelines/image-generation/image-generation.component.ts

@@ -0,0 +1,358 @@
+import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Subscription } from 'rxjs';
+import { JimengService } from '../../../services/jimeng.service';
+import { QiniuUploadService } from '../../../services/qiniu-upload.service';
+import { ResultsService } from '../../../services/results.service';
+import { PipelineSessionService } from '../../../services/pipeline-session.service';
+import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
+import { DraftMeta } from '../../../models/pipeline-draft.model';
+
+type ImageAspect = '1:1' | '4:3' | '3:4' | '16:9' | '9:16' | '21:9';
+
+interface ReferenceImage {
+  url: string;
+  name: string;
+  size: number;
+  preview: string;
+}
+
+interface GeneratedImageItem {
+  url: string;
+  prompt: string;
+  aspect: ImageAspect;
+  createdAt: number;
+}
+
+interface ImageGenerationSnapshot {
+  prompt: string;
+  style: string;
+  aspect: ImageAspect;
+  referenceImages: Array<{ url: string; name: string; size: number }>;
+  generatedImages: GeneratedImageItem[];
+}
+
+const ASPECT_SIZE: Record<ImageAspect, { width: number; height: number }> = {
+  '1:1': { width: 2048, height: 2048 },
+  '4:3': { width: 2048, height: 1536 },
+  '3:4': { width: 1536, height: 2048 },
+  '16:9': { width: 2048, height: 1152 },
+  '9:16': { width: 1152, height: 2048 },
+  '21:9': { width: 2048, height: 878 },
+};
+
+@Component({
+  selector: 'app-image-generation',
+  standalone: true,
+  imports: [CommonModule, FormsModule, SessionBarComponent],
+  templateUrl: './image-generation.component.html',
+  styleUrls: ['./image-generation.component.css'],
+})
+export class ImageGenerationComponent implements OnInit, OnDestroy {
+  prompt = '';
+  style = 'cinematic';
+  aspect: ImageAspect = '1:1';
+  generating = false;
+  progress = 0;
+  statusText = '';
+  errorMsg = '';
+  uploadError = '';
+  uploading = false;
+
+  referenceImages: ReferenceImage[] = [];
+  generatedImages: GeneratedImageItem[] = [];
+  activeImage: GeneratedImageItem | null = null;
+
+  readonly stylePresets = [
+    { value: 'cinematic', label: '电影质感', suffix: '电影级光影,真实材质,高级色彩,细节丰富' },
+    { value: 'commercial', label: '商业摄影', suffix: '商业广告摄影,干净背景,产品级布光,高清细节' },
+    { value: 'editorial', label: '杂志大片', suffix: '时尚杂志大片,精致构图,自然姿态,高级审美' },
+    { value: 'anime', label: '动画插画', suffix: '高质量动画插画,清晰线条,丰富色彩,画面完整' },
+    { value: 'realistic', label: '写实摄影', suffix: '真实摄影风格,自然光,真实纹理,可信场景' },
+    { value: 'minimal', label: '极简设计', suffix: '极简视觉设计,留白充足,构图克制,干净现代' },
+  ];
+
+  readonly aspectOptions: { value: ImageAspect; label: string; hint: string }[] = [
+    { value: '1:1', label: '1:1', hint: '头像 / 单图' },
+    { value: '4:3', label: '4:3', hint: '横向展示' },
+    { value: '3:4', label: '3:4', hint: '小红书图' },
+    { value: '16:9', label: '16:9', hint: '视频封面' },
+    { value: '9:16', label: '9:16', hint: '短视频竖图' },
+    { value: '21:9', label: '21:9', hint: '宽幅场景' },
+  ];
+
+  private genSub?: Subscription;
+
+  constructor(
+    private jimeng: JimengService,
+    private qiniu: QiniuUploadService,
+    private results: ResultsService,
+    public session: PipelineSessionService,
+    private zone: NgZone,
+    private cdr: ChangeDetectorRef,
+  ) {}
+
+  ngOnInit(): void {
+    void this.session.bootstrap();
+    const cur = this.session.active();
+    if (cur && cur.pipelineId === 'image-generation' && cur.snapshot) {
+      this.fromSnapshot(cur.snapshot as ImageGenerationSnapshot);
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.genSub?.unsubscribe();
+    this.referenceImages.forEach((img) => {
+      if (img.preview && img.preview.startsWith('blob:')) URL.revokeObjectURL(img.preview);
+    });
+  }
+
+  get canGenerate(): boolean {
+    return !this.generating && !!this.prompt.trim();
+  }
+
+  get selectedStyleLabel(): string {
+    return this.stylePresets.find((item) => item.value === this.style)?.label || '自定义';
+  }
+
+  onPromptInput(): void {
+    this.syncDraft();
+  }
+
+  applyPrompt(text: string): void {
+    this.prompt = text;
+    this.syncDraft();
+  }
+
+  triggerReferencePick(input: HTMLInputElement): void {
+    input.value = '';
+    input.click();
+  }
+
+  onReferencePicked(event: Event): void {
+    const input = event.target as HTMLInputElement;
+    const files = Array.from(input.files || []);
+    if (!files.length) return;
+
+    const available = Math.max(0, 3 - this.referenceImages.length);
+    const selected = files.slice(0, available);
+    if (files.length > available) {
+      this.uploadError = '最多上传 3 张参考图';
+    }
+
+    selected.forEach((file) => this.uploadReference(file));
+  }
+
+  removeReference(index: number): void {
+    const [removed] = this.referenceImages.splice(index, 1);
+    if (removed?.preview?.startsWith('blob:')) URL.revokeObjectURL(removed.preview);
+    this.syncDraft();
+  }
+
+  generate(): void {
+    if (!this.canGenerate) return;
+
+    this.generating = true;
+    this.progress = 0;
+    this.statusText = '正在准备图片生成任务...';
+    this.errorMsg = '';
+    this.syncDraft('running');
+
+    const prompt = this.buildFinalPrompt();
+    const size = ASPECT_SIZE[this.aspect];
+    const refs = this.referenceImages.map((item) => item.url);
+
+    this.genSub = this.jimeng.generateImgV4(
+      prompt,
+      refs,
+      size,
+      (status, progress) => this.zone.run(() => {
+        this.statusText = status;
+        this.progress = progress;
+        this.cdr.detectChanges();
+      }),
+      { scale: refs.length ? 0.55 : 0.5, forceSingle: true },
+    ).subscribe({
+      next: (url) => this.zone.run(() => this.handleGenerated(url, prompt)),
+      error: (err) => this.zone.run(() => this.handleGenerateError(err)),
+    });
+  }
+
+  download(url: string): void {
+    window.open(url, '_blank', 'noopener');
+  }
+
+  openSession(meta: DraftMeta): void {
+    this.session.open(meta.id).then((draft) => {
+      if (draft?.snapshot) {
+        this.fromSnapshot(draft.snapshot as ImageGenerationSnapshot);
+        this.cdr.detectChanges();
+      }
+    });
+  }
+
+  newSession(): void {
+    this.prompt = '';
+    this.style = 'cinematic';
+    this.aspect = '1:1';
+    this.referenceImages.forEach((img) => {
+      if (img.preview?.startsWith('blob:')) URL.revokeObjectURL(img.preview);
+    });
+    this.referenceImages = [];
+    this.generatedImages = [];
+    this.activeImage = null;
+    this.errorMsg = '';
+    this.uploadError = '';
+    this.progress = 0;
+    this.statusText = '';
+    this.session.close();
+    this.cdr.detectChanges();
+  }
+
+  private uploadReference(file: File): void {
+    if (!file.type.startsWith('image/')) {
+      this.uploadError = '请上传图片文件';
+      return;
+    }
+    if (file.size > 10 * 1024 * 1024) {
+      this.uploadError = '单张参考图不能超过 10MB';
+      return;
+    }
+
+    this.uploading = true;
+    this.uploadError = '';
+    this.qiniu.uploadFile(file, file.name, file.type, 'image').subscribe({
+      next: (url) => {
+        const preview = URL.createObjectURL(file);
+        this.referenceImages.push({ url, name: file.name, size: file.size, preview });
+        this.uploading = false;
+        this.syncDraft();
+        this.cdr.detectChanges();
+      },
+      error: (err) => {
+        this.uploadError = err?.error?.error || err?.message || '参考图上传失败';
+        this.uploading = false;
+        this.cdr.detectChanges();
+      },
+    });
+  }
+
+  private handleGenerated(url: string, finalPrompt: string): void {
+    const item: GeneratedImageItem = {
+      url,
+      prompt: finalPrompt,
+      aspect: this.aspect,
+      createdAt: Date.now(),
+    };
+    this.generatedImages = [item, ...this.generatedImages];
+    this.activeImage = item;
+    this.generating = false;
+    this.progress = 100;
+    this.statusText = '图片生成完成';
+
+    this.session.ensureActive('image-generation', () => this.toSnapshot(), this.deriveTitle());
+    this.session.addArtifact({
+      type: 'image',
+      url,
+      title: this.deriveTitle(),
+      extras: {
+        prompt: finalPrompt,
+        aspect: this.aspect,
+        style: this.style,
+        references: this.referenceImages.map((ref) => ref.url),
+      },
+    });
+    this.session.patch({
+      title: this.deriveTitle(),
+      snapshot: this.toSnapshot(),
+      thumbnail: url,
+      status: 'completed',
+      resultUrl: url,
+    });
+
+    this.results.saveResult({
+      type: 'image',
+      url,
+      title: this.deriveTitle(),
+      quality: this.aspect,
+      pipelineId: 'image_generation',
+      extras: {
+        prompt: finalPrompt,
+        style: this.style,
+        aspect: this.aspect,
+      },
+    }).subscribe();
+    this.results.saveHistory({
+      keyword: this.prompt.trim().slice(0, 60) || '图片生成',
+      status: 'completed',
+      resultUrl: url,
+      quality: this.aspect,
+      pipelineId: 'image_generation',
+      styleName: this.selectedStyleLabel,
+    }).subscribe();
+
+    this.cdr.detectChanges();
+  }
+
+  private handleGenerateError(err: any): void {
+    this.generating = false;
+    this.progress = 0;
+    this.errorMsg = err?.error?.error?.message || err?.error?.message || err?.message || '图片生成失败';
+    this.session.fail(this.errorMsg);
+    this.cdr.detectChanges();
+  }
+
+  private buildFinalPrompt(): string {
+    const base = this.prompt.trim();
+    const style = this.stylePresets.find((item) => item.value === this.style)?.suffix || '';
+    const refInstruction = this.referenceImages.length
+      ? '参考上传图片的主体、构图或风格,但输出必须是一张完整的新图。'
+      : '';
+    return [base, style, refInstruction, '画面完整,无文字水印,无畸形肢体,高质量。']
+      .filter(Boolean)
+      .join(',');
+  }
+
+  private deriveTitle(): string {
+    const text = this.prompt.replace(/\s+/g, ' ').trim();
+    return text ? `图片生成 · ${text.slice(0, 24)}` : `图片生成 · ${this.selectedStyleLabel}`;
+  }
+
+  private syncDraft(status: 'draft' | 'running' = 'draft'): void {
+    if (!this.prompt.trim() && !this.referenceImages.length && !this.generatedImages.length) return;
+    const title = this.deriveTitle();
+    this.session.ensureActive('image-generation', () => this.toSnapshot(), title);
+    this.session.patch({
+      title,
+      snapshot: this.toSnapshot(),
+      thumbnail: this.activeImage?.url || this.referenceImages[0]?.url,
+    });
+    if (status === 'running') this.session.markRunning();
+  }
+
+  private toSnapshot(): ImageGenerationSnapshot {
+    return {
+      prompt: this.prompt,
+      style: this.style,
+      aspect: this.aspect,
+      referenceImages: this.referenceImages.map((img) => ({ url: img.url, name: img.name, size: img.size })),
+      generatedImages: this.generatedImages,
+    };
+  }
+
+  private fromSnapshot(snapshot: ImageGenerationSnapshot): void {
+    this.prompt = snapshot.prompt || '';
+    this.style = snapshot.style || 'cinematic';
+    this.aspect = snapshot.aspect || '1:1';
+    this.referenceImages.forEach((img) => {
+      if (img.preview?.startsWith('blob:')) URL.revokeObjectURL(img.preview);
+    });
+    this.referenceImages = (snapshot.referenceImages || []).map((img) => ({
+      ...img,
+      preview: img.url,
+    }));
+    this.generatedImages = snapshot.generatedImages || [];
+    this.activeImage = this.generatedImages[0] || null;
+  }
+}

+ 8 - 1
src/app/pages/pipelines/image-to-video/image-to-video.component.html

@@ -1,5 +1,12 @@
 <section class="dh-page pl-page i2v-page">
 
+  <app-session-bar
+    pipelineId="image-to-video"
+    pipelineLabel="图生视频"
+    (newSession)="onNewSession()"
+    (openSession)="onOpenSession($event)">
+  </app-session-bar>
+
   <header class="dh-page-header">
     <div class="dh-header-icon">
       <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -113,7 +120,7 @@
         <div class="dh-field">
           <textarea class="dh-textarea" rows="6"
                     placeholder="描述你希望视频如何呈现,例如:人物缓慢转头微笑,背景樱花飘落..."
-                    [(ngModel)]="prompt"
+                    [(ngModel)]="prompt" (ngModelChange)="syncDraft()"
                     [disabled]="generating"></textarea>
           <div class="dh-field__counter">{{ prompt.length }} 字</div>
         </div>

+ 129 - 23
src/app/pages/pipelines/image-to-video/image-to-video.component.ts

@@ -1,7 +1,6 @@
-import { Component, ChangeDetectorRef, NgZone, OnDestroy } from '@angular/core';
+import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { HttpClient } from '@angular/common/http';
 import { Subscription } from 'rxjs';
 import { ResultsService } from '../../../services/results.service';
 import {
@@ -11,6 +10,10 @@ import {
   JimengAspectRatio,
   JimengVideoQuality,
 } from '../../../services/jimeng.service';
+import { PipelineSessionService } from '../../../services/pipeline-session.service';
+import { QiniuUploadService } from '../../../services/qiniu-upload.service';
+import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
+import { DraftMeta } from '../../../models/pipeline-draft.model';
 
 interface CameraTemplateOption {
   id: JimengCameraTemplateId;
@@ -43,20 +46,35 @@ type I2vQuality = JimengVideoQuality;
 type I2vAspect = JimengAspectRatio;
 type I2vMode = '2' | '3' | '4'; // 2首帧 3首尾帧 4运镜
 
+/** 会话快照:只存可序列化字段(File / ObjectURL 不入快照,但七牛云 url 可保留) */
+interface I2vSnapshot {
+  prompt: string;
+  mode: I2vMode;
+  quality: I2vQuality;
+  aspect: I2vAspect;
+  frames: 121 | 241;
+  cameraTemplate: JimengCameraTemplateId;
+  cameraStrength: JimengCameraStrength;
+  firstFrame: { url: string; name: string; size: number } | null;
+  lastFrame: { url: string; name: string; size: number } | null;
+  resultVideoUrl: string;
+  resultWorkId: string;
+}
+
 /**
  * P1: 图生视频 Pipeline
  *
  * 复用 JimengService.remixVideo({ method:'2'|'3', imageUrl, quality })。
- * 上传走 /api/remix/upload-asset(七牛云)。
+ * 上传走七牛云直传(云函数签发 token)。
  */
 @Component({
   selector: 'app-image-to-video',
   standalone: true,
-  imports: [CommonModule, FormsModule],
+  imports: [CommonModule, FormsModule, SessionBarComponent],
   templateUrl: './image-to-video.component.html',
   styleUrls: ['./image-to-video.component.css'],
 })
-export class ImageToVideoComponent implements OnDestroy {
+export class ImageToVideoComponent implements OnInit, OnDestroy {
   // ---- 输入 ----
   prompt = '';
   /** '2' = 单图首帧;'3' = 首尾帧;'4' = 运镜 */
@@ -97,13 +115,105 @@ export class ImageToVideoComponent implements OnDestroy {
   private genSub?: Subscription;
 
   constructor(
-    private http: HttpClient,
+    private qiniuUpload: QiniuUploadService,
     private jimeng: JimengService,
     private cdr: ChangeDetectorRef,
     private zone: NgZone,
     private results: ResultsService,
+    public session: PipelineSessionService,
   ) {}
 
+  ngOnInit(): void {
+    void this.session.bootstrap();
+    const cur = this.session.active();
+    if (cur && cur.pipelineId === 'image-to-video' && cur.snapshot) {
+      this.fromSnapshot(cur.snapshot as I2vSnapshot);
+    }
+  }
+
+  // ====== Session ======
+  private toSnapshot(): I2vSnapshot {
+    const stripPreview = (f: I2vUploadedImage | null) =>
+      f ? { url: f.url, name: f.name, size: f.size } : null;
+    return {
+      prompt: this.prompt,
+      mode: this.mode,
+      quality: this.quality,
+      aspect: this.aspect,
+      frames: this.frames,
+      cameraTemplate: this.cameraTemplate,
+      cameraStrength: this.cameraStrength,
+      firstFrame: stripPreview(this.firstFrame),
+      lastFrame: stripPreview(this.lastFrame),
+      resultVideoUrl: this.resultVideoUrl,
+      resultWorkId: this.resultWorkId,
+    };
+  }
+
+  private fromSnapshot(snap: I2vSnapshot): void {
+    if (!snap) return;
+    this.prompt = snap.prompt ?? '';
+    this.mode = (snap.mode ?? '2') as I2vMode;
+    this.quality = (snap.quality ?? '1080p') as I2vQuality;
+    this.aspect = (snap.aspect ?? '16:9') as I2vAspect;
+    this.frames = (snap.frames ?? 121) as 121 | 241;
+    this.cameraTemplate = snap.cameraTemplate ?? 'quick_pull_back';
+    this.cameraStrength = snap.cameraStrength ?? 'medium';
+    // 恢复首/尾帧:preview 丢失(本地预览 URL),调用方仅依赖 url 即可继续生成
+    this.firstFrame = snap.firstFrame ? { ...snap.firstFrame, preview: snap.firstFrame.url } : null;
+    this.lastFrame = snap.lastFrame ? { ...snap.lastFrame, preview: snap.lastFrame.url } : null;
+    this.resultVideoUrl = snap.resultVideoUrl ?? '';
+    this.resultWorkId = snap.resultWorkId ?? '';
+    this.errorMsg = '';
+    this.statusText = '';
+    this.progress = 0;
+    this.cdr.detectChanges();
+  }
+
+  private deriveTitle(): string {
+    const t = (this.prompt || '').trim();
+    if (t) return t.length > 30 ? t.slice(0, 30) + '…' : t;
+    const modeLabel = this.mode === '2' ? '首帧' : this.mode === '3' ? '首尾帧' : '运镜';
+    return `图生视频 · ${modeLabel}`;
+  }
+
+  syncDraft(): void {
+    this.session.ensureActive('image-to-video', () => this.toSnapshot(), this.deriveTitle());
+    const cur = this.session.active();
+    const derived = this.deriveTitle();
+    const isAutoTitle = !cur || !cur.title || cur.title === '未命名创作' || cur.title === derived;
+    this.session.patch({
+      snapshot: this.toSnapshot(),
+      ...(isAutoTitle ? { title: derived } : {}),
+    });
+  }
+
+  onNewSession(): void {
+    if (this.generating) return;
+    this.cancel();
+    this.prompt = '';
+    if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
+    if (this.lastFrame?.preview) URL.revokeObjectURL(this.lastFrame.preview);
+    this.firstFrame = null;
+    this.lastFrame = null;
+    this.resultVideoUrl = '';
+    this.resultWorkId = '';
+    this.errorMsg = '';
+    this.statusText = '';
+    this.progress = 0;
+    this.session.close();
+    this.cdr.detectChanges();
+  }
+
+  async onOpenSession(meta: DraftMeta): Promise<void> {
+    if (this.generating) {
+      alert('当前任务运行中,请先取消或等待完成再切换');
+      return;
+    }
+    const draft = await this.session.open(meta.id);
+    if (draft?.snapshot) this.fromSnapshot(draft.snapshot as I2vSnapshot);
+  }
+
   ngOnDestroy(): void {
     this.genSub?.unsubscribe();
     if (this.firstFrame?.preview) URL.revokeObjectURL(this.firstFrame.preview);
@@ -131,13 +241,8 @@ export class ImageToVideoComponent implements OnDestroy {
     this.uploadError = '';
     this.uploadingSlot = slot;
 
-    const formData = new FormData();
-    formData.append('file', file);
-    formData.append('kind', 'image');
-
-    this.http.post<any>('/backend/api/remix/upload-asset', formData).subscribe({
-      next: (res) => {
-        const url: string = res?.url || res?.data?.url;
+    this.qiniuUpload.uploadFile(file, file.name, file.type, 'image').subscribe({
+      next: (url: string) => {
         if (!url) {
           this.uploadError = '上传失败:未返回 URL';
           this.uploadingSlot = null;
@@ -228,6 +333,11 @@ export class ImageToVideoComponent implements OnDestroy {
     this.resultVideoUrl = '';
     this.resultWorkId = '';
 
+    // 会话:标记运行中
+    this.session.ensureActive('image-to-video', () => this.toSnapshot(), this.deriveTitle());
+    this.session.markRunning();
+    this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
+
     const imageUrls =
       this.mode === '3'
         ? [this.firstFrame!.url, this.lastFrame!.url]
@@ -262,8 +372,11 @@ export class ImageToVideoComponent implements OnDestroy {
             this.statusText = '生成完成!';
             this.generating = false;
             this.cdr.detectChanges();
-            // 持久化到结果库 + 历史
+            // 持久化到结果库 + 会话成片
             const modeLabel = this.mode === '2' ? '首帧' : this.mode === '3' ? '首尾帧' : '运镜';
+            const extras = { workId: result.workId, mode: this.mode, aspect: this.aspect };
+            this.session.patch({ snapshot: this.toSnapshot() });
+            this.session.finalize(result.videoUrl, extras);
             this.results.saveResult({
               type: 'video',
               url: result.videoUrl,
@@ -271,15 +384,7 @@ export class ImageToVideoComponent implements OnDestroy {
               quality: this.quality,
               duration: this.frames === 241 ? '10s' : '5s',
               pipelineId: 'image_to_video',
-              extras: { workId: result.workId, mode: this.mode, aspect: this.aspect },
-            }).subscribe();
-            this.results.saveHistory({
-              keyword: `图生视频 ${modeLabel}`,
-              status: 'completed',
-              resultUrl: result.videoUrl,
-              quality: this.quality,
-              pipelineId: 'image_to_video',
-              styleName: modeLabel,
+              extras,
             }).subscribe();
           });
         },
@@ -290,6 +395,7 @@ export class ImageToVideoComponent implements OnDestroy {
             this.statusText = '';
             this.generating = false;
             this.cdr.detectChanges();
+            this.session.fail(this.errorMsg);
           });
         },
       });

+ 166 - 0
src/app/pages/pipelines/topic-to-video/topic-to-video.component.css

@@ -449,6 +449,172 @@
   color: rgba(248, 250, 252, 0.55);
 }
 
+.t2v-toggle-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+  color: rgba(15, 23, 42, 0.72);
+  margin-bottom: 10px;
+}
+.t2v-toggle-row input {
+  width: 16px;
+  height: 16px;
+  accent-color: #7c3aed;
+}
+.t2v-bible-grid,
+.t2v-continuity-fields {
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: 8px;
+}
+.t2v-bible-grid .dh-input,
+.t2v-continuity-fields .dh-input {
+  min-width: 0;
+}
+.t2v-bible-notes {
+  margin-top: 8px;
+}
+.t2v-bible-refs {
+  margin-top: 12px;
+  padding: 10px;
+  background: rgba(124, 58, 237, 0.06);
+  border: 1px solid rgba(124, 58, 237, 0.18);
+  border-radius: 8px;
+}
+.t2v-bible-refs__head {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  margin-bottom: 8px;
+  flex-wrap: wrap;
+}
+.t2v-bible-refs__title {
+  font-size: 12px;
+  font-weight: 600;
+  color: #6d28d9;
+}
+.t2v-bible-refs__grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 10px;
+}
+.t2v-bible-ref {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  min-width: 0;
+}
+.t2v-bible-ref__label {
+  font-size: 11px;
+  color: rgba(15, 23, 42, 0.7);
+  text-align: center;
+}
+.t2v-bible-ref__box {
+  position: relative;
+  aspect-ratio: 1 / 1;
+  width: 100%;
+  border-radius: 6px;
+  overflow: hidden;
+  border: 1px dashed rgba(124, 58, 237, 0.32);
+  background: rgba(124, 58, 237, 0.04);
+}
+.t2v-bible-ref__box.is-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.t2v-bible-ref__box a {
+  display: block;
+  width: 100%;
+  height: 100%;
+  text-decoration: none;
+}
+.t2v-bible-ref__box img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+  transition: transform 0.15s ease;
+}
+.t2v-bible-ref__box a:hover img {
+  transform: scale(1.04);
+}
+.t2v-bible-ref__add {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  font-size: 12px;
+  color: #6d28d9;
+  font-weight: 500;
+}
+.t2v-bible-ref__add:has(input:disabled) {
+  cursor: not-allowed;
+  opacity: 0.55;
+}
+.t2v-bible-ref__clear {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+  width: 22px;
+  height: 22px;
+  border-radius: 50%;
+  border: none;
+  background: rgba(0, 0, 0, 0.55);
+  color: #fff;
+  font-size: 14px;
+  line-height: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: background 0.15s ease;
+}
+.t2v-bible-ref__clear:hover:not(:disabled) {
+  background: rgba(220, 38, 38, 0.85);
+}
+.t2v-bible-ref__clear:disabled {
+  cursor: not-allowed;
+  opacity: 0.4;
+}
+.t2v-bible-refs__error {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #dc2626;
+}
+.t2v-continuity-fields {
+  margin-top: 8px;
+}
+.t2v-shot-controls {
+  display: grid;
+  gap: 8px;
+  margin-top: 10px;
+}
+.t2v-inherit-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  font-size: 12px;
+  color: rgba(15, 23, 42, 0.72);
+}
+.t2v-inherit-row label {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+}
+.t2v-inherit-row input {
+  accent-color: #7c3aed;
+}
+.app-container.theme-night .t2v-toggle-row {
+  color: rgba(248, 250, 252, 0.72);
+}
+.app-container.theme-night .t2v-inherit-row {
+  color: rgba(248, 250, 252, 0.72);
+}
+
 /* ============================================================
  * 视频模式 segment(带副标题)
  * ============================================================ */

+ 118 - 2
src/app/pages/pipelines/topic-to-video/topic-to-video.component.html

@@ -1,5 +1,13 @@
 <section class="dh-page pl-page t2v-page">
 
+  <!-- 创作会话顶栏:新建 / 历史 / 状态徽章 -->
+  <app-session-bar
+    pipelineId="topic-to-video"
+    pipelineLabel="主题生视频"
+    (newSession)="onNewSession()"
+    (openSession)="onOpenSession($event)">
+  </app-session-bar>
+
   <header class="dh-page-header">
     <div class="dh-header-icon">
       <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -45,7 +53,7 @@
         </div>
         <textarea class="dh-textarea" rows="3" maxlength="120"
                   placeholder="例如:探索中国西部最美高原 / 一杯美式拯救加班的下午 / 用 30 秒讲清楚什么是大语言模型"
-                  [(ngModel)]="topic" [disabled]="isRunning"></textarea>
+                  [(ngModel)]="topic" (ngModelChange)="syncDraft()" [disabled]="isRunning"></textarea>
         <div class="dh-field__counter">{{ topic.length }} / 120 字</div>
       </div>
 
@@ -58,7 +66,7 @@
           <button class="dh-segment__item" [class.is-active]="nScenes === 6" [disabled]="isRunning" (click)="nScenes = 6">6 段</button>
           <button class="dh-segment__item" [class.is-active]="nScenes === 8" [disabled]="isRunning" (click)="nScenes = 8">8 段</button>
         </div>
-        <p class="t2v-tip">{{ nScenes }} 段约 {{ nScenes * 6 }}-{{ nScenes * 8 }} 秒;图像费用约 ¥{{ (nScenes * 0.22).toFixed(2) }}。</p>
+        <p class="t2v-tip">{{ nScenes }} 段约 {{ nScenes * 6 }}-{{ nScenes * 8 }} 秒。</p>
       </div>
 
       <!-- 视觉风格:两模式共用 -->
@@ -85,6 +93,71 @@
         </div>
       </div>
 
+      <div class="dh-substep">
+        <div class="dh-substep__head">
+          <h4>全局一致性设定</h4>
+          <span class="t2v-tip">作为默认锚点;每个分镜仍可自由改动作和构图</span>
+        </div>
+        <label class="t2v-toggle-row">
+          <input type="checkbox" [(ngModel)]="continuityEnabled" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
+          <span>启用角色 / 场景 / 产品一致性增强</span>
+        </label>
+        <div class="t2v-bible-grid" *ngIf="continuityEnabled">
+          <input class="dh-input" type="text" placeholder="主体/角色:外貌、服装、标志物"
+                 [(ngModel)]="visualBible.subject" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
+          <input class="dh-input" type="text" placeholder="产品:外观、卖点、品牌感(无产品可空)"
+                 [(ngModel)]="visualBible.product" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
+          <input class="dh-input" type="text" placeholder="场景:地点、空间布局、时代"
+                 [(ngModel)]="visualBible.location" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
+          <input class="dh-input" type="text" placeholder="色调:warm amber / cool teal 等"
+                 [(ngModel)]="visualBible.palette" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
+          <input class="dh-input" type="text" placeholder="光线:golden hour / soft studio light 等"
+                 [(ngModel)]="visualBible.lighting" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
+          <input class="dh-input" type="text" placeholder="镜头基调:handheld / dolly / close-up 等"
+                 [(ngModel)]="visualBible.camera" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()">
+        </div>
+        <textarea class="dh-textarea t2v-bible-notes" rows="2" maxlength="240" *ngIf="continuityEnabled"
+                  placeholder="固定风格与禁止变化项:例如不要换衣服、不要改变产品颜色、不要切换地点"
+                  [(ngModel)]="visualBible.constraints" [disabled]="isRunning || hasInflightScene" (ngModelChange)="syncDraft()"></textarea>
+
+        <!-- 主体 / 产品 / 场景 参考图:可选上传,传给即梦做生成参考 -->
+        <div class="t2v-bible-refs" *ngIf="continuityEnabled">
+          <div class="t2v-bible-refs__head">
+            <span class="t2v-bible-refs__title">参考图(可选)</span>
+            <span class="t2v-tip">上传后即梦会作为生成参考;分镜若关闭对应继承则不传</span>
+          </div>
+          <div class="t2v-bible-refs__grid">
+            <ng-container *ngFor="let f of bibleRefSlots">
+              <div class="t2v-bible-ref">
+                <div class="t2v-bible-ref__label">{{ f.label }}</div>
+                <div class="t2v-bible-ref__box"
+                     [class.is-empty]="!bibleImageUrlFor(f.field)"
+                     [class.is-busy]="bibleImageUploading[f.field]">
+                  <ng-container *ngIf="bibleImageUrlFor(f.field); else emptySlot">
+                    <a [href]="bibleImageUrlFor(f.field)" target="_blank" rel="noopener" title="点击查看大图">
+                      <img [src]="bibleImageUrlFor(f.field)" [alt]="f.label" loading="lazy">
+                    </a>
+                    <button type="button" class="t2v-bible-ref__clear"
+                            [disabled]="isRunning || hasInflightScene || bibleImageUploading[f.field]"
+                            (click)="clearBibleImage(f.field)" title="移除参考图">×</button>
+                  </ng-container>
+                  <ng-template #emptySlot>
+                    <label class="t2v-bible-ref__add">
+                      <input type="file" accept="image/*" hidden
+                             [disabled]="isRunning || hasInflightScene || bibleImageUploading[f.field]"
+                             (change)="onBibleImagePick(f.field, $event)">
+                      <span *ngIf="!bibleImageUploading[f.field]">+ 上传</span>
+                      <span *ngIf="bibleImageUploading[f.field]">上传中…</span>
+                    </label>
+                  </ng-template>
+                </div>
+              </div>
+            </ng-container>
+          </div>
+          <div class="t2v-bible-refs__error" *ngIf="bibleImageError">{{ bibleImageError }}</div>
+        </div>
+      </div>
+
       <!-- 画面类型:仅快速模式 -->
       <div class="dh-substep" *ngIf="quickMode">
         <div class="dh-substep__head">
@@ -257,9 +330,52 @@
             <textarea class="dh-textarea t2v-scene-item__edit" rows="2" maxlength="300"
                       placeholder="中英文均可,推荐英文。例:a woman walks in dawn fog, golden hour, wide shot, slow zoom in"
                       [(ngModel)]="s.scenePrompt"
+                      (ngModelChange)="syncDraft()"
                       [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)"></textarea>
 
             <!-- 2. 视频模式选择 -->
+            <div class="t2v-shot-controls" *ngIf="continuityEnabled">
+              <label class="t2v-card-label">镜头类型</label>
+              <div class="dh-segment dh-segment--wrap">
+                <button class="dh-segment__item" type="button"
+                        *ngFor="let t of shotTypePresets"
+                        [class.is-active]="(s.shotType || 'action') === t.value"
+                        [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)"
+                        (click)="setSceneShotType(s, t.value)">
+                  {{ t.label }}
+                </button>
+              </div>
+
+              <label class="t2v-card-label">继承内容</label>
+              <div class="t2v-inherit-row">
+                <label><input type="checkbox" [(ngModel)]="s.inheritSubject" (ngModelChange)="syncDraft()"> 主体</label>
+                <label><input type="checkbox" [(ngModel)]="s.inheritLocation" (ngModelChange)="syncDraft()"> 场景</label>
+                <label><input type="checkbox" [(ngModel)]="s.inheritStyle" (ngModelChange)="syncDraft()"> 风格</label>
+              </div>
+
+              <label class="t2v-card-label">参考图</label>
+              <div class="dh-segment dh-segment--wrap">
+                <button class="dh-segment__item" type="button"
+                        *ngFor="let r of referenceModePresets"
+                        [class.is-active]="(s.referenceMode || 'auto') === r.value"
+                        [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)"
+                        (click)="s.referenceMode = r.value; syncDraft()">
+                  {{ r.label }}
+                </button>
+              </div>
+            </div>
+
+            <div class="t2v-continuity-fields" *ngIf="continuityEnabled">
+              <input class="dh-input" type="text" maxlength="180"
+                     placeholder="起始状态:承接上一镜,可留空"
+                     [(ngModel)]="s.startState" (ngModelChange)="syncDraft()"
+                     [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)">
+              <input class="dh-input" type="text" maxlength="180"
+                     placeholder="结束状态:用于尾帧和下一镜,可留空"
+                     [(ngModel)]="s.endState" (ngModelChange)="syncDraft()"
+                     [disabled]="['image-pending','tail-pending','video-pending'].includes(s.state)">
+            </div>
+
             <label class="t2v-card-label" style="margin-top:14px;">图生视频模式</label>
             <div class="dh-segment t2v-vmode-seg">
               <button class="dh-segment__item"

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 743 - 34
src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts


+ 0 - 156
src/app/pipelines/composite-sse.ts

@@ -1,156 +0,0 @@
-/**
- * 视频合成 SSE 客户端
- *
- * 调用 POST /api/video/composite/stream 并通过 fetch ReadableStream
- * 解析 Server-Sent Events,提供阶段进度回调。
- *
- * EventSource 原生不支持 POST,因此使用 fetch + 手动解析的方式实现。
- */
-
-export type CompositeStage = 'download' | 'compose' | 'concat' | 'cleanup';
-
-/**
- * 单段输入。后端按 (image/video, audio/silent) 四态分发:
- *   - imageUrl + audioUrl  → 图片 + 解说(按音频时长)
- *   - videoUrl + audioUrl  → 动态视频 + 解说(按短的截断)
- *   - videoUrl 无 audioUrl → 纯动态视频(按视频原长)
- *   - imageUrl 无 audioUrl → 静态画面(按 duration,默认 3s)
- *
- * imageUrl / videoUrl 至少要有一个;videoUrl 优先级高于 imageUrl。
- */
-export interface CompositeSegmentInput {
-  id?: string;
-  imageUrl?: string;
-  videoUrl?: string;
-  audioUrl?: string;
-  /** 仅在 imageUrl 无 audioUrl 时生效;缺省 3 秒 */
-  duration?: number;
-}
-
-export interface CompositeProgressEvent {
-  stage: CompositeStage;
-  current?: number;
-  total?: number;
-  percent?: number;
-  message?: string;
-}
-
-export interface CompositeDoneEvent {
-  success: boolean;
-  videoUrl: string;
-  filename: string;
-  size: number;
-  segments: number;
-}
-
-export interface CompositeCallbacks {
-  onStage?: (data: { stage: CompositeStage; message?: string }) => void;
-  onProgress?: (data: CompositeProgressEvent) => void;
-  onDone?: (data: CompositeDoneEvent) => void;
-  onError?: (data: { error: string }) => void;
-}
-
-export interface CompositeHandle {
-  abort: () => void;
-  promise: Promise<CompositeDoneEvent>;
-}
-
-/**
- * 启动一次合成任务并返回控制句柄。
- *
- * @example
- *   const handle = composeVideoStream({
- *     segments,
- *     title: '主题视频',
- *     onProgress: e => console.log(e),
- *   });
- *   const result = await handle.promise;
- */
-export function composeVideoStream(params: {
-  segments: CompositeSegmentInput[];
-  title: string;
-  url?: string;
-} & CompositeCallbacks): CompositeHandle {
-  const controller = new AbortController();
-  const url = params.url || '/backend/api/video/composite/stream';
-
-  const promise = (async () => {
-    const response = await fetch(url, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        segments: params.segments,
-        title: params.title,
-      }),
-      signal: controller.signal,
-    });
-    if (!response.ok || !response.body) {
-      const text = await response.text().catch(() => '');
-      throw new Error(text || `HTTP ${response.status}`);
-    }
-
-    const reader = response.body.getReader();
-    const decoder = new TextDecoder('utf-8');
-    let buffer = '';
-    let doneEvent: CompositeDoneEvent | null = null;
-    let errorEvent: { error: string } | null = null;
-
-    while (true) {
-      const { value, done } = await reader.read();
-      if (done) break;
-      buffer += decoder.decode(value, { stream: true });
-
-      // SSE 事件以双换行分隔
-      let idx: number;
-      while ((idx = buffer.indexOf('\n\n')) >= 0) {
-        const raw = buffer.slice(0, idx);
-        buffer = buffer.slice(idx + 2);
-        const parsed = parseSseEvent(raw);
-        if (!parsed) continue;
-        const { event, data } = parsed;
-
-        try {
-          const payload = data ? JSON.parse(data) : {};
-          switch (event) {
-            case 'stage':
-              params.onStage?.(payload);
-              break;
-            case 'progress':
-              params.onProgress?.(payload);
-              break;
-            case 'done':
-              doneEvent = payload;
-              params.onDone?.(payload);
-              break;
-            case 'error':
-              errorEvent = payload;
-              params.onError?.(payload);
-              break;
-          }
-        } catch (e) {
-          console.warn('[composite-sse] parse error', e, raw);
-        }
-      }
-    }
-
-    if (errorEvent) throw new Error(errorEvent.error || '合成失败');
-    if (!doneEvent) throw new Error('合成意外结束:未收到 done 事件');
-    return doneEvent;
-  })();
-
-  return {
-    abort: () => controller.abort(),
-    promise,
-  };
-}
-
-function parseSseEvent(raw: string): { event: string; data: string } | null {
-  if (!raw.trim()) return null;
-  let event = 'message';
-  let data = '';
-  for (const line of raw.split('\n')) {
-    if (line.startsWith('event:')) event = line.slice(6).trim();
-    else if (line.startsWith('data:')) data += (data ? '\n' : '') + line.slice(5).trim();
-  }
-  return { event, data };
-}

+ 12 - 0
src/app/pipelines/pipeline-registry.ts

@@ -15,6 +15,7 @@ export type PipelineStatus = 'stable' | 'beta' | 'planned';
 
 export type PipelineRoute =
   | 'digital-human'        // 数字人口播(已成熟)
+  | 'image-generation'     // 图片生成
   | 'video-generation'     // 标准 AI 重塑(旧版主入口,逐步迁移)
   | 'image-to-video'       // 图生视频(P1 计划)
   | 'action-transfer'      // 动作迁移(P2 计划)
@@ -52,6 +53,17 @@ export const PIPELINES: readonly PipelineDef[] = [
     status: 'stable',
     showInSidebar: true,
   },
+  {
+    id: 'image_generation',
+    displayName: '图片生成',
+    description: '输入提示词生成图片资产,也可上传参考图做风格或主体延展',
+    inputHint: '提示词 + 风格 + 比例',
+    iconSvg:
+      '<rect x="3" y="3" width="18" height="18" rx="3"></rect><circle cx="9" cy="9" r="1.5"></circle><path d="M21 15l-4.5-4.5L6 21"></path>',
+    route: 'image-generation',
+    status: 'beta',
+    showInSidebar: true,
+  },
   {
     id: 'video_generation',
     displayName: '标准视频生成',

+ 92 - 45
src/app/services/assistant.service.ts

@@ -1,5 +1,5 @@
 import { ApplicationRef, Injectable, NgZone } from '@angular/core';
-import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
+import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { LlmService, ChatMessage } from './llm.service';
 
@@ -33,11 +33,12 @@ const MAX_CONVS = 40;
 const MAX_MSGS_PER_CONV = 80;
 
 const SYSTEM_PROMPT = `你是「Tik-Tok 视频生成系统」的内置创作助手,专注于短视频内容创作。
-你的能力范围:
+
+## 你的能力范围
 1. 帮用户写视频脚本、镜头描述、提示词(中英文均可)
 2. 给出风格、节奏、分镜、配乐方面的建议
 3. 解答系统使用问题(系统包含以下生成模式:主题生视频 / 图生视频 / 数字人合成 / 动作迁移 / 素材合成视频 / 标准视频生成)
-4. 当用户的需求显然适合直接进入某条生成流水线时,在回答的末尾用一个独立的 JSON 代码块给出建议:
+4. 当用户的需求显然适合直接进入某条生成流水线时,在回答**最末尾**用一个独立的 JSON 代码块给出建议:
 
 \`\`\`json
 { "suggest": [{ "tab": "topic-to-video", "label": "进入主题生视频", "payload": { "topic": "盛夏海边少女回眸微笑" } }] }
@@ -45,10 +46,16 @@ const SYSTEM_PROMPT = `你是「Tik-Tok 视频生成系统」的内置创作助
 
 合法的 tab 取值:home / topic-to-video / image-to-video / digital-human / action-transfer / asset-remix / video-generation / search / monitor / tasks / history / results / videos / voice-synthesis。
 
-规则:
+## 输出格式要求(重要)
+- **使用 Markdown 排版**:合理使用 **加粗** / *斜体* / 二级标题 \`##\` / 列表 / 表格 / 代码块 提升可读性
+- 提示词、脚本、台词等可复制内容用 \`\`\`三反引号代码块\`\`\` 包裹(指明语言:text / english / json 等)
+- 多步骤回答用有序列表
+- 镜头/分镜清单用列表或表格
+- 不要客套话、不要重复用户问题、直接给可用的内容
+
+## 行为规则
 - 不要每次都强行建议;仅在明确合适时给(一次最多 2 个)
-- 提示词类回答请直接给出可复制的内容,不要客套
-- 回答用中文,简洁、具体,避免空话
+- 回答用中文,简洁、具体、避免空话
 - JSON 代码块必须放在回答最末尾,前面正文不要重复其内容`;
 
 /**
@@ -73,6 +80,8 @@ export class AssistantService {
   readonly view$ = new BehaviorSubject<'chat' | 'history'>('chat');
   /** 是否在等待 LLM */
   readonly loading$ = new BehaviorSubject<boolean>(false);
+  /** 当前流式订阅(用于切换会话 / 用户取消时中止 fetch) */
+  private activeStream: Subscription | null = null;
   /**
    * 内联模式(首屏 chatMode 全宽对话页)激活中。
    * true 时浮窗 FAB / panel 全部隐藏,避免与主区对话功能重复。
@@ -163,12 +172,14 @@ export class AssistantService {
 
   switchTo(id: string): void {
     if (!this.conversations$.value.some((c) => c.id === id)) return;
+    this.cancelActiveStream();   // 切会话前先中止上一个流,否则 chunk 会写到错的消息
     this.activeId$.next(id);
     this.view$.next('chat');
     this.persistActive();
   }
 
   deleteConversation(id: string): void {
+    if (this.activeId$.value === id) this.cancelActiveStream();
     const next = this.conversations$.value.filter((c) => c.id !== id);
     this.conversations$.next(next);
     if (this.activeId$.value === id) {
@@ -234,45 +245,81 @@ export class AssistantService {
       ...history,
     ];
 
-    // 注意:Angular 的 provideHttpClient(withFetch()) 用 fetch API,zone.js 不 patch fetch,
-    // 回调可能在 zone 外触发,导致 BehaviorSubject.next 后 CD 不跑(UI 卡在 typing)。
-    // 这里显式 zone.run 包裹,保证响应一到 CD 立即生效。
-    console.log('[assistant] LLM 请求已发出,等待响应...');
-    this.llm.chat(messages, { model: 'gpt-4o-mini', temperature: 0.7, max_tokens: 1500 }).subscribe({
-      next: (res) => {
-        console.log('[assistant] LLM 响应到达,准备更新 UI', res?.choices?.[0]?.message?.content?.slice(0, 60));
-        this.zone.run(() => {
-          const raw = res.choices?.[0]?.message?.content || '';
-          const parsed = this.parseSuggestions(raw);
-          this.patchActive((c) => ({
-            ...c,
-            messages: c.messages.map((m) =>
-              m.id === pendingMsg.id
-                ? { ...m, content: parsed.text, suggestions: parsed.suggestions, pending: false }
-                : m,
-            ),
-            updated: Date.now(),
-          }));
-          this.loading$.next(false);
-          this.forceTick();   // 显式 CD:等价于 DH 聊天的 cdr.detectChanges()
-        });
-      },
-      error: (err) => {
-        this.zone.run(() => {
-          this.patchActive((c) => ({
-            ...c,
-            messages: c.messages.map((m) =>
-              m.id === pendingMsg.id
-                ? { ...m, content: `⚠️ ${err?.message || '请求失败,请稍后重试'}`, pending: false }
-                : m,
-            ),
-            updated: Date.now(),
-          }));
-          this.loading$.next(false);
-          this.forceTick();
-        });
-      },
-    });
+    // 流式调用:每个 chunk 到达就把累计文本写回 pending 消息;done 时再做一次 suggestions 解析并去掉 pending 标记。
+    // 注意:fetch / SSE 回调可能跳出 zone,必须 zone.run + forceTick。
+    console.log('[assistant] LLM 流式请求已发出,等待响应...');
+    this.cancelActiveStream();
+    let pollTick = 0;
+    this.activeStream = this.llm
+      .chatStream(messages, { model: 'gpt-5.4', temperature: 0.7, max_tokens: 3000 })
+      .subscribe({
+        next: (ev) => {
+          if (ev.done) return; // 真正的收尾放在 complete
+          // 节流:流速很快时聚合到下一帧再 patch,避免 UI 抖
+          pollTick++;
+          this.zone.run(() => {
+            this.patchActive((c) => ({
+              ...c,
+              messages: c.messages.map((m) =>
+                m.id === pendingMsg.id
+                  // 首块到达即翻为非 pending,UI 直接进入流式渲染态
+                  ? { ...m, content: ev.full, pending: false }
+                  : m,
+              ),
+              updated: Date.now(),
+            }));
+            // 每 2 个 chunk 强制 tick 一次,平衡刷新频率与性能
+            if (pollTick % 2 === 0) this.forceTick();
+          });
+        },
+        complete: () => {
+          this.zone.run(() => {
+            // 取最终全文重新解析 suggestions
+            const finalMsg = this.conversations$.value
+              .find((c) => c.id === activeId)
+              ?.messages.find((m) => m.id === pendingMsg.id);
+            const raw = finalMsg?.content || '';
+            const parsed = this.parseSuggestions(raw);
+            this.patchActive((c) => ({
+              ...c,
+              messages: c.messages.map((m) =>
+                m.id === pendingMsg.id
+                  ? { ...m, content: parsed.text, suggestions: parsed.suggestions, pending: false }
+                  : m,
+              ),
+              updated: Date.now(),
+            }));
+            this.loading$.next(false);
+            this.activeStream = null;
+            this.forceTick();
+          });
+        },
+        error: (err) => {
+          this.zone.run(() => {
+            this.patchActive((c) => ({
+              ...c,
+              messages: c.messages.map((m) =>
+                m.id === pendingMsg.id
+                  ? { ...m, content: `⚠️ ${err?.message || '请求失败,请稍后重试'}`, pending: false }
+                  : m,
+              ),
+              updated: Date.now(),
+            }));
+            this.loading$.next(false);
+            this.activeStream = null;
+            this.forceTick();
+          });
+        },
+      });
+  }
+
+  /** 中止正在进行的流式调用(切换会话 / 删除会话时调用) */
+  cancelActiveStream(): void {
+    if (this.activeStream) {
+      this.activeStream.unsubscribe();
+      this.activeStream = null;
+      this.loading$.next(false);
+    }
   }
 
   // ============== 内部 ==============

+ 531 - 0
src/app/services/browser-ffmpeg.service.ts

@@ -0,0 +1,531 @@
+import { Injectable, inject } from '@angular/core';
+import { FFmpeg } from '@ffmpeg/ffmpeg';
+import { fetchFile, toBlobURL } from '@ffmpeg/util';
+import { QiniuUploadService } from './qiniu-upload.service';
+
+// ===================================================================
+// 合成相关公共类型(原来在 src/app/pipelines/composite-sse.ts 中,
+// 后端 SSE 客户端已废弃,类型集中到此处)
+// ===================================================================
+
+export type CompositeStage = 'download' | 'compose' | 'concat' | 'cleanup';
+
+/**
+ * 单段输入。按 (image/video, audio/silent) 四态分发:
+ *   - imageUrl + audioUrl  -> 图片 + 解说(按音频时长)
+ *   - videoUrl + audioUrl  -> 动态视频 + 解说(按音频时长)
+ *   - videoUrl 无 audioUrl -> 纯动态视频(按视频原长)
+ *   - imageUrl 无 audioUrl -> 静态画面(按 duration,默认 3s)
+ */
+export interface CompositeSegmentInput {
+  id?: string;
+  imageUrl?: string;
+  videoUrl?: string;
+  audioUrl?: string;
+  /** 仅在 imageUrl 无 audioUrl 时生效;缺省 3 秒 */
+  duration?: number;
+}
+
+export interface CompositeProgressEvent {
+  stage: CompositeStage;
+  current?: number;
+  total?: number;
+  percent?: number;
+  message?: string;
+}
+
+export interface CompositeDoneEvent {
+  success: boolean;
+  videoUrl: string;
+  filename: string;
+  size: number;
+  segments: number;
+}
+
+export interface CompositeCallbacks {
+  onStage?: (data: { stage: CompositeStage; message?: string }) => void;
+  onProgress?: (data: CompositeProgressEvent) => void;
+  onDone?: (data: CompositeDoneEvent) => void;
+  onError?: (data: { error: string }) => void;
+}
+
+export interface CompositeHandle {
+  abort: () => void;
+  promise: Promise<CompositeDoneEvent>;
+}
+
+export interface BrowserAudioExtractionResult {
+  blob: Blob;
+  base64: string;
+  mimeType: string;
+  filename: string;
+  sizeMB: number;
+}
+
+/**
+ * 浏览器端合成参数。与 composeVideoStream 的入参兼容。
+ */
+export interface BrowserComposeParams extends CompositeCallbacks {
+  segments: CompositeSegmentInput[];
+  title: string;
+  /** 输出短边像素,默认 720(1280x720)。可选 1080。 */
+  shortSide?: 720 | 1080;
+  /** 浏览器端 fetchFile 失败时是否走后端 /backend/api/video-proxy 兜底。默认 true。 */
+  useBackendProxyFallback?: boolean;
+}
+
+@Injectable({ providedIn: 'root' })
+export class BrowserFfmpegService {
+  private ffmpeg?: FFmpeg;
+  private loading?: Promise<void>;
+  private qiniu = inject(QiniuUploadService);
+
+  async extractAudioForGemini(
+    input: File | Blob | string,
+    filename = `audio-${Date.now()}.m4a`,
+  ): Promise<BrowserAudioExtractionResult> {
+    const ffmpeg = await this.getFfmpeg();
+    const inputName = this.inputName(input);
+    const outputName = filename.replace(/\.[^.]+$/, '') + '.m4a';
+
+    await ffmpeg.writeFile(inputName, await fetchFile(input));
+    const exitCode = await ffmpeg.exec([
+      '-i', inputName,
+      '-vn',
+      '-c:a', 'aac',
+      '-b:a', '64k',
+      '-ar', '16000',
+      '-ac', '1',
+      outputName,
+    ], 300000);
+
+    if (exitCode !== 0) {
+      await this.safeDelete(ffmpeg, inputName);
+      throw new Error(`浏览器音频提取失败 (exit=${exitCode})`);
+    }
+
+    const data = await ffmpeg.readFile(outputName);
+    await this.safeDelete(ffmpeg, inputName);
+    await this.safeDelete(ffmpeg, outputName);
+
+    const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
+    const copy = new Uint8Array(bytes.byteLength);
+    copy.set(bytes);
+    const blob = new Blob([copy.buffer], { type: 'audio/mp4' });
+    const base64 = await this.blobToBase64(blob);
+
+    return {
+      blob,
+      base64,
+      mimeType: 'audio/mp4',
+      filename: outputName,
+      sizeMB: blob.size / 1024 / 1024,
+    };
+  }
+
+  async extractAudioBlob(
+    input: File | Blob | string,
+    filename = `extracted-${Date.now()}.m4a`,
+  ): Promise<File> {
+    const result = await this.extractAudioForGemini(input, filename);
+    return new File([result.blob], result.filename, { type: result.mimeType });
+  }
+
+  private async getFfmpeg(): Promise<FFmpeg> {
+    if (!this.ffmpeg) {
+      this.ffmpeg = new FFmpeg();
+    }
+    if (!this.ffmpeg.loaded) {
+      this.loading ??= this.load(this.ffmpeg).finally(() => {
+        this.loading = undefined;
+      });
+      await this.loading;
+    }
+    return this.ffmpeg;
+  }
+
+  private async load(ffmpeg: FFmpeg): Promise<void> {
+    const baseUrl = new URL('assets/ffmpeg/', document.baseURI).toString();
+    const workerBaseUrl = new URL('worker/', baseUrl).toString();
+    await ffmpeg.load({
+      classWorkerURL: `${workerBaseUrl}worker.js`,
+      coreURL: await toBlobURL(`${baseUrl}ffmpeg-core.js`, 'text/javascript'),
+      wasmURL: await toBlobURL(`${baseUrl}ffmpeg-core.wasm`, 'application/wasm'),
+    });
+  }
+
+  private inputName(input: File | Blob | string): string {
+    if (typeof input === 'string') {
+      const clean = input.split('?')[0].split('#')[0];
+      const tail = clean.split('/').pop() || '';
+      return this.safeName(tail || `input-${Date.now()}.mp4`);
+    }
+    if (input instanceof File && input.name) {
+      return this.safeName(input.name);
+    }
+    return `input-${Date.now()}.mp4`;
+  }
+
+  private safeName(name: string): string {
+    const ext = (name.match(/\.[a-z0-9]+$/i)?.[0] || '.mp4').toLowerCase();
+    const base = name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_-]/g, '_') || 'input';
+    return `${base}${ext}`;
+  }
+
+  private async safeDelete(ffmpeg: FFmpeg, path: string): Promise<void> {
+    try {
+      await ffmpeg.deleteFile(path);
+    } catch {
+      // best effort cleanup
+    }
+  }
+
+  private blobToBase64(blob: Blob): Promise<string> {
+    return new Promise((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => {
+        const value = String(reader.result || '');
+        resolve(value.includes(',') ? value.split(',')[1] : value);
+      };
+      reader.onerror = () => reject(reader.error || new Error('Blob to base64 failed'));
+      reader.readAsDataURL(blob);
+    });
+  }
+
+  // ===================================================================
+  // 视频合成(浏览器端) — 替代 server.js 的 /api/video/composite/stream
+  //
+  // 与后端一致的四态分发:
+  //   A: imageUrl + audioUrl   -> 图片 + 解说,按音频时长
+  //   B: videoUrl + audioUrl   -> 视频 + 解说,按音频时长
+  //   C: videoUrl 无 audioUrl  -> 纯视频 + 静音轨,按视频原长
+  //   D: imageUrl 无 audioUrl  -> 静态画面 + 静音轨,按 duration
+  //
+  // 统一规格(保证 concat -c copy 可用):
+  //   视频 1280x720(或 1920x1080)/ 30fps / yuv420p / libx264 ultrafast
+  //   音频 AAC 128k / 44100 / stereo
+  // ===================================================================
+
+  composeVideo(params: BrowserComposeParams): CompositeHandle {
+    let aborted = false;
+    const abortController = new AbortController();
+
+    const promise: Promise<CompositeDoneEvent> = (async () => {
+      const { segments, title } = params;
+      const shortSide = params.shortSide ?? 720;
+      const useProxy = params.useBackendProxyFallback !== false;
+
+      if (!segments?.length) throw new Error('segments empty');
+
+      const ffmpeg = await this.getFfmpeg();
+      const tempFiles = new Set<string>();
+
+      // 进度权重:单段编码占 80%(按段均分),拼接 15%,上传 5%
+      const segWeight = 80 / segments.length;
+      let baseProgress = 0;
+      let currentSegProgress = 0;
+
+      const emitProgress = (
+        stage: CompositeStage,
+        message?: string,
+        current?: number,
+        total?: number,
+      ) => {
+        const percent = Math.min(100, Math.round(baseProgress + currentSegProgress));
+        params.onProgress?.({ stage, percent, current, total, message });
+      };
+      const emitStage = (stage: CompositeStage, message?: string) => {
+        params.onStage?.({ stage, message });
+      };
+      const onEncodeProgress = (ratio: number) => {
+        currentSegProgress = Math.max(0, Math.min(segWeight, segWeight * (Number(ratio) || 0)));
+        params.onProgress?.({
+          stage: 'compose',
+          percent: Math.min(100, Math.round(baseProgress + currentSegProgress)),
+        });
+      };
+
+      try {
+        emitStage('download', `prepare ${segments.length} segments`);
+
+        const segmentNames: string[] = [];
+        for (let i = 0; i < segments.length; i += 1) {
+          if (aborted) throw new Error('aborted');
+
+          currentSegProgress = 0;
+          emitStage('compose', `compose ${i + 1}/${segments.length}`);
+          emitProgress('compose', `compose ${i + 1}/${segments.length}`, i + 1, segments.length);
+
+          const segName = await this.composeOneSegment(
+            ffmpeg,
+            segments[i],
+            i,
+            shortSide,
+            tempFiles,
+            useProxy,
+            abortController.signal,
+            onEncodeProgress,
+          );
+          segmentNames.push(segName);
+          baseProgress += segWeight;
+          currentSegProgress = 0;
+        }
+
+        if (aborted) throw new Error('aborted');
+
+        emitStage('concat', `concat ${segmentNames.length} segments`);
+        emitProgress('concat', `concat ${segmentNames.length} segments`);
+
+        const listText = segmentNames.map((n) => `file '${n}'`).join('\n');
+        await ffmpeg.writeFile('list.txt', new TextEncoder().encode(listText));
+        tempFiles.add('list.txt');
+
+        const finalName = `final-${Date.now()}.mp4`;
+        const concatExit = await ffmpeg.exec([
+          '-f', 'concat',
+          '-safe', '0',
+          '-i', 'list.txt',
+          '-c', 'copy',
+          '-movflags', '+faststart',
+          finalName,
+        ]);
+        if (concatExit !== 0) throw new Error(`concat failed (exit=${concatExit})`);
+        tempFiles.add(finalName);
+
+        baseProgress = 95;
+
+        const data = await ffmpeg.readFile(finalName);
+        const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
+        const copy = new Uint8Array(bytes.byteLength);
+        copy.set(bytes);
+        const blob = new Blob([copy.buffer], { type: 'video/mp4' });
+        const size = blob.size;
+
+        emitStage('cleanup', 'upload final mp4');
+        const safeTitle = String(title || 'video')
+          .replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_')
+          .substring(0, 50) || 'video';
+        const filename = `${safeTitle}-${Date.now()}.mp4`;
+
+        const url = await new Promise<string>((resolve, reject) => {
+          this.qiniu
+            .uploadFileWithProgress(blob, filename, 'video/mp4', 'video')
+            .subscribe({
+              next: (ev) => {
+                if (ev.state === 'progress') {
+                  params.onProgress?.({
+                    stage: 'cleanup',
+                    percent: Math.min(100, 95 + Math.round((ev.progress || 0) * 0.05)),
+                    message: `uploading ${Math.round((ev.progress || 0) * 100)}%`,
+                  });
+                } else if (ev.state === 'done' && ev.url) {
+                  resolve(ev.url);
+                }
+              },
+              error: (err) => reject(err instanceof Error ? err : new Error(String(err))),
+            });
+        });
+
+        await Promise.all([...tempFiles].map((n) => this.safeDelete(ffmpeg, n)));
+
+        const done: CompositeDoneEvent = {
+          success: true,
+          videoUrl: url,
+          filename,
+          size,
+          segments: segments.length,
+        };
+        params.onProgress?.({ stage: 'cleanup', percent: 100 });
+        params.onDone?.(done);
+        return done;
+      } catch (err: any) {
+        await Promise.all([...tempFiles].map((n) => this.safeDelete(ffmpeg, n))).catch(() => {});
+        const msg = err?.message || String(err);
+        params.onError?.({ error: msg });
+        throw err instanceof Error ? err : new Error(msg);
+      }
+    })();
+
+    return {
+      abort: () => {
+        aborted = true;
+        abortController.abort();
+        try { this.ffmpeg?.terminate(); } catch { /* noop */ }
+        this.ffmpeg = undefined;
+        this.loading = undefined;
+      },
+      promise,
+    };
+  }
+
+  private async composeOneSegment(
+    ffmpeg: FFmpeg,
+    seg: CompositeSegmentInput,
+    idx: number,
+    shortSide: 720 | 1080,
+    tempFiles: Set<string>,
+    useProxy: boolean,
+    signal: AbortSignal,
+    onEncodeProgress?: (ratio: number) => void,
+  ): Promise<string> {
+    const W = shortSide === 1080 ? 1920 : 1280;
+    const H = shortSide;
+    const baseVf = `scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:black`;
+    const audioArgs = ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2'];
+    const videoArgs = ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-r', '30', '-g', '60'];
+
+    const hasImage = !!seg.imageUrl;
+    const hasVideo = !!seg.videoUrl;
+    const hasAudio = !!seg.audioUrl;
+    if (!hasImage && !hasVideo) {
+      throw new Error(`segment ${seg.id ?? idx} missing imageUrl/videoUrl`);
+    }
+
+    const segName = `seg-${idx}.mp4`;
+    let audioName = '';
+    let audioDuration = 0;
+
+    // 仅在「真正编码段视频」的 exec 期间挂载 progress 监听,避免 probeDuration 触发段内进度。
+    const runEncode = async (args: string[]): Promise<number> => {
+      const handler = (e: { progress: number }) => {
+        onEncodeProgress?.(Number(e.progress) || 0);
+      };
+      ffmpeg.on('progress', handler as any);
+      try {
+        return await ffmpeg.exec(args);
+      } finally {
+        try { ffmpeg.off('progress', handler as any); } catch { /* noop */ }
+      }
+    };
+
+    if (hasAudio) {
+      audioName = `audio-${idx}${this.guessExt(seg.audioUrl!, 'm4a')}`;
+      await ffmpeg.writeFile(audioName, await this.fetchAsset(seg.audioUrl!, useProxy, signal));
+      tempFiles.add(audioName);
+      audioDuration = await this.probeDuration(ffmpeg, audioName);
+      if (!audioDuration) throw new Error(`segment ${seg.id ?? idx} audio duration probe failed`);
+    }
+
+    let exit = 0;
+
+    if (hasVideo) {
+      const videoName = `video-${idx}.mp4`;
+      await ffmpeg.writeFile(videoName, await this.fetchAsset(seg.videoUrl!, useProxy, signal));
+      tempFiles.add(videoName);
+      const videoDuration = await this.probeDuration(ffmpeg, videoName).catch(() => 0);
+
+      if (hasAudio) {
+        // Case B: video + audio, align to audio duration
+        let vfChain = baseVf;
+        if (videoDuration > 0 && videoDuration < audioDuration - 0.05) {
+          const padSec = (audioDuration - videoDuration).toFixed(3);
+          vfChain += `,tpad=stop_mode=clone:stop_duration=${padSec}`;
+        }
+        exit = await runEncode([
+          '-i', videoName,
+          '-i', audioName,
+          '-map', '0:v:0',
+          '-map', '1:a:0',
+          ...videoArgs,
+          '-vf', vfChain,
+          ...audioArgs,
+          '-t', String(audioDuration),
+          segName,
+        ]);
+      } else {
+        // Case C: video only + silent audio
+        exit = await runEncode([
+          '-i', videoName,
+          '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
+          '-map', '0:v:0',
+          '-map', '1:a:0',
+          ...videoArgs,
+          '-vf', baseVf,
+          ...audioArgs,
+          '-shortest',
+          segName,
+        ]);
+      }
+    } else {
+      const imgName = `img-${idx}${this.guessExt(seg.imageUrl!, 'jpg')}`;
+      await ffmpeg.writeFile(imgName, await this.fetchAsset(seg.imageUrl!, useProxy, signal));
+      tempFiles.add(imgName);
+
+      if (hasAudio) {
+        // Case A: image + audio
+        exit = await runEncode([
+          '-loop', '1', '-i', imgName,
+          '-i', audioName,
+          ...videoArgs,
+          '-vf', baseVf,
+          ...audioArgs,
+          '-t', String(audioDuration),
+          '-shortest',
+          segName,
+        ]);
+      } else {
+        // Case D: image only + silent audio (use seg.duration, default 3s)
+        const dur = Math.max(0.5, Number(seg.duration) || 3);
+        exit = await runEncode([
+          '-loop', '1', '-i', imgName,
+          '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
+          ...videoArgs,
+          '-vf', baseVf,
+          ...audioArgs,
+          '-t', String(dur),
+          '-shortest',
+          segName,
+        ]);
+      }
+    }
+
+    if (exit !== 0) throw new Error(`segment ${seg.id ?? idx} encode failed (exit=${exit})`);
+    tempFiles.add(segName);
+    return segName;
+  }
+
+  /** 通过运行 ffmpeg -i <name> -f null - 解析 stderr 拿到时长(秒)。 */
+  private async probeDuration(ffmpeg: FFmpeg, name: string): Promise<number> {
+    let dur = 0;
+    const handler = ({ message }: { message: string }) => {
+      const m = message?.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
+      if (m) {
+        dur = (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]);
+      }
+    };
+    ffmpeg.on('log', handler as any);
+    try {
+      await ffmpeg.exec(['-i', name, '-f', 'null', '-']);
+    } catch {
+      // ffmpeg 用 null muxer 时偶尔非零退出,时长仍能从 stderr 拿到
+    } finally {
+      try { ffmpeg.off('log', handler as any); } catch { /* noop */ }
+    }
+    return dur;
+  }
+
+  /** 拉取远程资源到 Uint8Array。直连失败(CORS/network)时回退到 /backend/api/video-proxy。 */
+  private async fetchAsset(
+    url: string,
+    useProxy: boolean,
+    signal: AbortSignal,
+  ): Promise<Uint8Array> {
+    if (signal.aborted) throw new Error('aborted');
+    try {
+      const resp = await fetch(url, { signal });
+      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+      return new Uint8Array(await resp.arrayBuffer());
+    } catch (err) {
+      if (!useProxy) throw err;
+      const proxied = `/backend/api/video-proxy?url=${encodeURIComponent(url)}`;
+      const resp = await fetch(proxied, { signal });
+      if (!resp.ok) throw new Error(`proxy fetch failed HTTP ${resp.status}`);
+      return new Uint8Array(await resp.arrayBuffer());
+    }
+  }
+
+  private guessExt(url: string, fallback: string): string {
+    const m = url.split('?')[0].match(/\.([a-z0-9]{2,5})$/i);
+    return `.${(m ? m[1] : fallback).toLowerCase()}`;
+  }
+}

+ 124 - 0
src/app/services/cloud-api.interceptor.ts

@@ -0,0 +1,124 @@
+/**
+ * 云函数 HTTP 拦截器
+ *
+ * 拦截 `/backend/api/*` 请求,根据路径把以下接口改调云函数:
+ *   - /api/manifest[/:id]
+ *   - /api/tasks[/:id]
+ *   - /api/history[/:id]
+ *   - /api/results[/:id]
+ *   - /api/remixes[/:videoId[/:remixId]]
+ *   - /api/voice/auto-speaker-id
+ *   - /api/voice-profiles/sync
+ *   - /api/llm/chat
+ *   - /api/llm/gemini
+ *
+ * 其他路径(视频上传、ffmpeg、whisper、视频代理等)保持透传给 Express。
+ * 响应 shape 跟 server.js 保持一致,前端 call site 无需改动。
+ */
+import { HttpInterceptorFn, HttpRequest, HttpResponse, HttpEvent } from '@angular/common/http';
+import { inject } from '@angular/core';
+import { Observable, from, of } from 'rxjs';
+import { switchMap, map } from 'rxjs/operators';
+import { ParseService } from './parse.service';
+import { CLOUD_FN } from './cloud-functions';
+
+/** 解析 /backend/api/xxx 或 /api/xxx,返回 path 段数组(不含前缀) */
+function parsePath(url: string): string[] | null {
+  // 去掉 query string
+  const u = url.split('?')[0];
+  const m = u.match(/^(?:\/backend)?\/api\/(.+)$/);
+  if (!m) return null;
+  return m[1].split('/').filter(Boolean);
+}
+
+/** 构造一个 HttpResponse,复用原 req 的 url */
+function ok(body: any, req: HttpRequest<any>): HttpResponse<any> {
+  return new HttpResponse({ status: 200, body, url: req.url });
+}
+
+/** 把云函数响应({code,success,data,error})解包并校验 */
+function unwrap(res: any): any {
+  if (!res || typeof res !== 'object') throw new Error('云函数响应格式异常');
+  if (res.code !== 200 || !res.success) {
+    throw new Error(res.error || res.message || '云函数调用失败');
+  }
+  return res.data;
+}
+
+export const cloudApiInterceptor: HttpInterceptorFn = (req, next) => {
+  const segs = parsePath(req.url);
+  if (!segs || segs.length === 0) return next(req);
+
+  const parse = inject(ParseService);
+  const method = req.method.toUpperCase();
+  const body = req.body && typeof req.body === 'object' ? req.body : {};
+  const [resource, p1, p2] = segs;
+
+  // 通用 helper:调云函数并把响应转成 HttpResponse
+  const call = (fnId: string, params: Record<string, any>, shape: (data: any) => any) => {
+    if (!fnId) {
+      console.warn(`[cloudApiInterceptor] ${resource} 云函数 ID 未配置,回退到 Express`);
+      return next(req);
+    }
+    return from(parse.call(fnId, params)).pipe(
+      map(res => ok(shape(unwrap(res)), req)),
+    ) as Observable<HttpEvent<any>>;
+  };
+
+  // ==================== manifest ====================
+  if (resource === 'manifest') {
+    if (method === 'GET' && !p1)  return call(CLOUD_FN.manifest, { action: 'list' }, data => data);
+    if (method === 'POST' && !p1) return call(CLOUD_FN.manifest, { action: 'create', video: body }, data => ({ success: true, video: data }));
+    if (method === 'PUT' && p1)   return call(CLOUD_FN.manifest, { action: 'update', videoId: p1, video: body }, data => ({ success: true, video: data }));
+    if (method === 'DELETE' && p1) return call(CLOUD_FN.manifest, { action: 'delete', videoId: p1 }, data => ({ success: true, videoId: data?.videoId ?? p1 }));
+  }
+
+  // ==================== tasks ====================
+  if (resource === 'tasks') {
+    if (method === 'GET' && !p1)  return call(CLOUD_FN.task, { action: 'list' }, data => data);
+    if (method === 'POST' && !p1) return call(CLOUD_FN.task, { action: 'create', task: body }, data => ({ success: true, task: data }));
+    if (method === 'PUT' && p1)   return call(CLOUD_FN.task, { action: 'update', id: p1, task: body }, data => ({ success: true, task: data }));
+    if (method === 'DELETE' && p1) return call(CLOUD_FN.task, { action: 'delete', id: p1 }, () => ({ success: true }));
+  }
+
+  // ==================== history ====================
+  if (resource === 'history') {
+    if (method === 'GET' && !p1)    return call(CLOUD_FN.history, { action: 'list' }, data => data);
+    if (method === 'POST' && !p1)   return call(CLOUD_FN.history, { action: 'create', entry: body }, data => ({ success: true, record: data }));
+    if (method === 'DELETE' && p1)  return call(CLOUD_FN.history, { action: 'delete', id: p1 }, () => ({ success: true }));
+    if (method === 'DELETE' && !p1) return call(CLOUD_FN.history, { action: 'clearAll' }, () => ({ success: true }));
+  }
+
+  // ==================== results ====================
+  if (resource === 'results') {
+    if (method === 'GET' && !p1)  return call(CLOUD_FN.result, { action: 'list' }, data => data);
+    if (method === 'POST' && !p1) return call(CLOUD_FN.result, { action: 'create', result: body }, data => ({ success: true, result: data }));
+    if (method === 'PUT' && p1)   return call(CLOUD_FN.result, { action: 'update', id: p1, result: body }, data => ({ success: true, result: data }));
+    if (method === 'DELETE' && p1) return call(CLOUD_FN.result, { action: 'delete', id: p1 }, () => ({ success: true }));
+  }
+
+  // ==================== remixes ====================
+  if (resource === 'remixes') {
+    if (method === 'GET' && !p1)         return call(CLOUD_FN.remix, { action: 'listAll' }, data => data);
+    if (method === 'GET' && p1 && !p2)   return call(CLOUD_FN.remix, { action: 'listByVideo', videoId: p1 }, data => data);
+    if (method === 'POST' && p1 && !p2)  return call(CLOUD_FN.remix, { action: 'upsert', videoId: p1, remix: body }, () => ({ success: true }));
+    if (method === 'DELETE' && p1 && p2) return call(CLOUD_FN.remix, { action: 'delete', videoId: p1, remixId: p2 }, () => ({ success: true }));
+  }
+
+  // ==================== voice ====================
+  if (resource === 'voice' && p1 === 'auto-speaker-id' && method === 'POST') {
+    return call(CLOUD_FN.voice, { action: 'autoSpeakerId' }, data => ({ speaker_id: data?.speakerId || '' }));
+  }
+  if (resource === 'voice-profiles' && p1 === 'sync' && method === 'POST') {
+    return call(CLOUD_FN.voice, { action: 'syncProfile', profile: body }, data => ({ success: true, profile: data }));
+  }
+
+  // ==================== llm ====================
+  if (resource === 'llm') {
+    if (p1 === 'chat'   && method === 'POST') return call(CLOUD_FN.proxy, { action: 'llmChat', payload: body }, data => data);
+    if (p1 === 'gemini' && method === 'POST') return call(CLOUD_FN.proxy, { action: 'geminiChat', payload: body }, data => data);
+  }
+
+  // 其他路径透传给 Express
+  return next(req);
+};

+ 34 - 0
src/app/services/cloud-functions.ts

@@ -0,0 +1,34 @@
+/**
+ * 云函数 ID 注册表
+ *
+ * 部署流程:
+ *   1. 把 cloud-functions/*.js 上传到 fmode 云函数平台
+ *   2. 部署成功后获得函数 ID(10 位字符串)
+ *   3. 把 ID 填到下方对应字段
+ *
+ * 调用示例:
+ *   import { CLOUD_FN } from './services/cloud-functions';
+ *   await this.parse.call(CLOUD_FN.task, { action: 'list', userId });
+ */
+export const CLOUD_FN = {
+  /** 视频清单 CRUD(manifest) */
+  manifest: 'VWejNK8E5z',
+  /** 任务管理 CRUD */
+  task: 'yL6opHxC4D',
+  /** 历史记录 CRUD */
+  history: 'cegZcOyOH0',
+  /** 结果库 CRUD */
+  result: 'XPv6Sym2I6',
+  /** 视频重塑(remix)记录 */
+  remix: 'LjcaC94Kno',
+  /** 音色档案 + speaker_id 池 */
+  voice: 'LHBMMcDP4n',
+  /** 一键成片代理(暂未部署,等 Quickly 凭证更新后启用 cloud-functions/07-quicklyVideo.js) */
+  quickly: '',
+  /** LLM 代理(chat / gemini) */
+  proxy: 'wzwnEuAhwb',
+  /** 七牛云直传凭证 */
+  upload: 'VHS0noRP7Q',
+} as const;
+
+export type CloudFnKey = keyof typeof CLOUD_FN;

+ 28 - 17
src/app/services/digital-human.service.ts

@@ -4,6 +4,7 @@ import { Observable, Subject, of, throwError, from } from 'rxjs';
 import { switchMap, catchError, map } from 'rxjs/operators';
 import { JimengService } from './jimeng.service';
 import { environment } from '../../environments/environment';
+import { BrowserFfmpegService } from './browser-ffmpeg.service';
 
 // ==================== 类型定义 ====================
 
@@ -21,11 +22,15 @@ export interface DigitalHumanAudioInput {
   /** TTS 模式参数 */
   ttsText?: string;
   ttsTimbreId?: string;
+  ttsVoiceSource?: 'cloned' | 'official';
+  ttsVoiceType?: string;
   /** upload 模式参数(File 二选一 url) */
   audioFile?: File;
   audioUrl?: string;
   /** original 模式参数:从某个本地视频提取音频 */
   videoId?: string;
+  videoFile?: File;
+  videoUrl?: string;
 }
 
 export interface DigitalHumanRequest {
@@ -76,12 +81,12 @@ export type DigitalHumanEvent = DigitalHumanProgressEvent | DigitalHumanResult;
 export class DigitalHumanService {
 
   private readonly ttsEndpoint = 'https://server.fmode.cn/api/volcengine/tts/unidirectional';
-  private readonly extractAudioEndpoint = '/backend/api/remix/extract-audio';
   private readonly ttsToken = environment.jimengApi.token; // Bearer r:xxx
 
   constructor(
     private http: HttpClient,
-    private jimeng: JimengService
+    private jimeng: JimengService,
+    private browserFfmpeg: BrowserFfmpegService,
   ) {}
 
   /**
@@ -208,15 +213,16 @@ export class DigitalHumanService {
     if (!audio.ttsText || !audio.ttsText.trim()) {
       return throwError(() => new Error('请填写 TTS 文本'));
     }
-    if (!audio.ttsTimbreId || !audio.ttsTimbreId.trim()) {
+    const usingOfficial = audio.ttsVoiceSource === 'official';
+    const voiceId = usingOfficial ? String(audio.ttsVoiceType || '').trim() : String(audio.ttsTimbreId || '').trim();
+    if (!voiceId) {
       return throwError(() => new Error('请选择 TTS 音色'));
     }
     onProgress('正在合成语音...', 18);
 
-    return this.http.post<any>(this.ttsEndpoint, {
+    const body: any = {
       token: this.ttsToken,
       text: audio.ttsText.trim(),
-      timbreId: audio.ttsTimbreId.trim(),
       isStream: false,
       audio_params: {
         format: 'mp3',
@@ -224,7 +230,15 @@ export class DigitalHumanService {
         speech_rate: 0,
         loudness_rate: 0
       }
-    }).pipe(
+    };
+    if (usingOfficial) {
+      body.volcengine_voice_type = voiceId;
+      body.x_api_resource_id = voiceId.includes('_uranus_bigtts') ? 'seed-tts-2.0' : 'seed-tts-1.0';
+    } else {
+      body.timbreId = voiceId;
+    }
+
+    return this.http.post<any>(this.ttsEndpoint, body).pipe(
       map((res: any) => {
         if (res?.code === 200 && res?.data?.audioUrl) {
           onProgress('语音合成完成', 30);
@@ -259,21 +273,18 @@ export class DigitalHumanService {
     audio: DigitalHumanAudioInput,
     onProgress: (status: string, progress: number) => void
   ): Observable<string> {
-    if (!audio.videoId) {
-      return throwError(() => new Error('缺少原视频 ID,无法提取音频'));
+    const source = audio.videoFile || audio.videoUrl;
+    if (!source) {
+      return throwError(() => new Error('缺少原视频文件或 URL,无法在浏览器提取音频'));
     }
     onProgress('正在提取原视频音频...', 18);
 
-    return this.http.post(this.extractAudioEndpoint, { videoId: audio.videoId }, {
-      responseType: 'blob'
-    }).pipe(
-      switchMap((audioBlob: Blob) => {
+    return from(this.browserFfmpeg.extractAudioBlob(
+      source,
+      `extracted-${Date.now()}.m4a`,
+    )).pipe(
+      switchMap((file: File) => {
         onProgress('音频提取完成,正在上传...', 24);
-        const file = new File(
-          [audioBlob],
-          `extracted-${Date.now()}.wav`,
-          { type: audioBlob.type || 'audio/wav' }
-        );
         return this.jimeng.uploadFileToParse(file, file.name, file.type);
       }),
       map((url: string) => {

+ 117 - 0
src/app/services/draft-storage.ts

@@ -0,0 +1,117 @@
+/**
+ * Draft 持久化抽象层
+ *
+ * 默认实现:LocalStorageDraftStorage(双层 key)
+ *   - tiktok.drafts.index   → DraftMeta[]      列表渲染快
+ *   - tiktok.draft.<id>     → 完整 PipelineDraft(按需 lazy load)
+ *
+ * 后续无痛升级到 IndexedDB / 远端 /api/drafts,业务零改动。
+ */
+
+import { DraftMeta, PipelineDraft, toDraftMeta } from '../models/pipeline-draft.model';
+
+export interface DraftStorage {
+  list(): Promise<DraftMeta[]>;
+  get(id: string): Promise<PipelineDraft | null>;
+  put(draft: PipelineDraft): Promise<void>;
+  delete(id: string): Promise<void>;
+  /** 清空所有 drafts(仅调试 / 用户主动调用) */
+  clear(): Promise<void>;
+}
+
+const INDEX_KEY = 'tiktok.drafts.index';
+const DRAFT_KEY_PREFIX = 'tiktok.draft.';
+
+export class LocalStorageDraftStorage implements DraftStorage {
+  async list(): Promise<DraftMeta[]> {
+    try {
+      const raw = localStorage.getItem(INDEX_KEY);
+      if (!raw) return [];
+      const arr = JSON.parse(raw);
+      return Array.isArray(arr) ? arr : [];
+    } catch (e) {
+      console.warn('[DraftStorage] list parse failed', e);
+      return [];
+    }
+  }
+
+  async get(id: string): Promise<PipelineDraft | null> {
+    try {
+      const raw = localStorage.getItem(DRAFT_KEY_PREFIX + id);
+      if (!raw) return null;
+      return JSON.parse(raw) as PipelineDraft;
+    } catch (e) {
+      console.warn('[DraftStorage] get parse failed', id, e);
+      return null;
+    }
+  }
+
+  async put(draft: PipelineDraft): Promise<void> {
+    try {
+      localStorage.setItem(DRAFT_KEY_PREFIX + draft.id, JSON.stringify(draft));
+    } catch (e) {
+      // 配额溢出:尝试清最旧的草稿后重试一次
+      console.warn('[DraftStorage] put failed, trying to evict oldest', e);
+      await this.evictOldest();
+      try {
+        localStorage.setItem(DRAFT_KEY_PREFIX + draft.id, JSON.stringify(draft));
+      } catch (e2) {
+        console.error('[DraftStorage] put failed after evict', e2);
+        throw e2;
+      }
+    }
+    // 同步更新索引
+    const list = await this.list();
+    const idx = list.findIndex((m) => m.id === draft.id);
+    const meta = toDraftMeta(draft);
+    if (idx >= 0) list[idx] = meta;
+    else list.unshift(meta);
+    list.sort((a, b) => b.updatedAt - a.updatedAt);
+    try {
+      localStorage.setItem(INDEX_KEY, JSON.stringify(list));
+    } catch (e) {
+      console.warn('[DraftStorage] index update failed', e);
+    }
+  }
+
+  async delete(id: string): Promise<void> {
+    try {
+      localStorage.removeItem(DRAFT_KEY_PREFIX + id);
+    } catch {}
+    const list = await this.list();
+    const next = list.filter((m) => m.id !== id);
+    try {
+      localStorage.setItem(INDEX_KEY, JSON.stringify(next));
+    } catch (e) {
+      console.warn('[DraftStorage] index delete failed', e);
+    }
+  }
+
+  async clear(): Promise<void> {
+    const list = await this.list();
+    for (const m of list) {
+      try {
+        localStorage.removeItem(DRAFT_KEY_PREFIX + m.id);
+      } catch {}
+    }
+    try {
+      localStorage.removeItem(INDEX_KEY);
+    } catch {}
+  }
+
+  /** 配额溢出回收:移除最旧的非 running draft */
+  private async evictOldest(): Promise<void> {
+    const list = await this.list();
+    const evictable = list
+      .filter((m) => m.status !== 'running')
+      .sort((a, b) => a.updatedAt - b.updatedAt);
+    if (!evictable.length) return;
+    const victim = evictable[0];
+    try {
+      localStorage.removeItem(DRAFT_KEY_PREFIX + victim.id);
+      const next = list.filter((m) => m.id !== victim.id);
+      localStorage.setItem(INDEX_KEY, JSON.stringify(next));
+      console.warn('[DraftStorage] evicted oldest draft', victim.id);
+    } catch {}
+  }
+}

+ 18 - 25
src/app/services/jimeng.service.ts

@@ -3,6 +3,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
 import { Observable, Subject, timer, throwError, of } from 'rxjs';
 import { switchMap, takeWhile, tap, catchError, map } from 'rxjs/operators';
 import { environment } from '../../environments/environment';
+import { QiniuUploadService } from './qiniu-upload.service';
 
 // MD5 hash function (lightweight, for signing only)
 function md5(input: string): string {
@@ -172,7 +173,10 @@ export class JimengService {
   private readonly digitalHumanPollMaxAttempts = Math.max(environment.jimengApi.pollMaxAttempts, 120);
   private readonly digitalHumanResultCheckInterval = 3;
 
-  constructor(private http: HttpClient) {}
+  constructor(
+    private http: HttpClient,
+    private qiniuUpload: QiniuUploadService,
+  ) {}
 
   // 提交文生视频任务 (720p)
   generateVideo720p(request: JimengVideoRequest): Observable<any> {
@@ -284,7 +288,8 @@ export class JimengService {
       cameraStrength?: JimengCameraStrength;
       aspectRatio?: JimengAspectRatio;
     },
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number) => void,
+    options?: { scale?: number; forceSingle?: boolean }
   ): Observable<{ videoUrl: string; workId: string }> {
     // ---- 校验 ----
     if (params.method === '3' && params.quality === 'pro') {
@@ -422,11 +427,18 @@ export class JimengService {
     prompt: string,
     imageUrls: string[] = [],
     sizeDate: { width: number; height: number } = { width: 2048, height: 2048 },
-    onProgress?: (status: string, progress: number) => void
+    onProgress?: (status: string, progress: number) => void,
+    options?: { scale?: number; forceSingle?: boolean }
   ): Observable<string> {
     if (onProgress) onProgress('正在提交图片生成任务...', 5);
 
-    return this.submitImgV4(prompt, imageUrls, sizeDate, 0.5, true).pipe(
+    return this.submitImgV4(
+      prompt,
+      imageUrls,
+      sizeDate,
+      options?.scale ?? 0.5,
+      options?.forceSingle ?? true
+    ).pipe(
       switchMap(({ workId }) => {
         if (onProgress) onProgress('图片任务已提交,正在生成中...', 15);
         return this.pollUntilComplete(workId, 'getImgV4', (status, attempt) => {
@@ -482,27 +494,8 @@ export class JimengService {
   }
 
   uploadFileToParse(file: Blob, filename: string, contentType?: string): Observable<string> {
-    const formData = new FormData();
-    const normalizedFilename = String(filename || `asset-${Date.now()}`);
-    const typedFile = file instanceof File
-      ? file
-      : new File([file], normalizedFilename, { type: contentType || 'application/octet-stream' });
-
-    formData.append('file', typedFile, normalizedFilename);
-
-    return this.http.post<any>(
-      `/backend/api/remix/upload-asset`,
-      formData
-    ).pipe(
-      map((res: any) => {
-        const url = res?.url || '';
-        if (!url) {
-          throw new Error('上传素材失败,未返回可用 URL');
-        }
-
-        return url;
-      }),
-      tap((url: string) => console.log('📤 数字人素材上传成功:', url)),
+    return this.qiniuUpload.uploadFile(file, filename, contentType).pipe(
+      tap((url: string) => console.log('馃摛 鏁板瓧浜虹礌鏉愪笂浼犳垚鍔?', url)),
       catchError(this.handleError('uploadFileToParse'))
     );
   }

+ 121 - 0
src/app/services/llm.service.ts

@@ -3,6 +3,14 @@ import { HttpClient } from '@angular/common/http';
 import { Observable, throwError } from 'rxjs';
 import { map, catchError } from 'rxjs/operators';
 
+/** 流式事件:每个 token / chunk 到达时回调 delta;done=true 表示流结束 */
+export interface ChatStreamEvent {
+  delta: string;          // 本次 chunk 的纯文本增量
+  full: string;           // 截至目前累积的完整文本
+  done: boolean;          // 流是否结束
+  finishReason?: string;
+}
+
 // ==================== 类型定义 ====================
 
 export interface ChatMessage {
@@ -126,6 +134,119 @@ export class LlmService {
     );
   }
 
+  // ==================== 流式 ChatCompletions ====================
+
+  /**
+   * 流式 ChatCompletions:通过本地 Express SSE 端点(不走云函数;
+   * 云函数运行时不支持 chunked response)。
+   *
+   * 调用 fetch 直连 `/backend/api/llm/chat/stream`,绕过 HttpClient 的拦截器,
+   * 因为拦截器会把普通 chat 路由分流到云函数。
+   *
+   * 返回的 Observable 会逐个 emit chunk delta;流结束时 emit done=true 然后 complete。
+   * 通过返回的 subscription unsubscribe 即可中止请求(内部用 AbortController)。
+   */
+  chatStream(messages: ChatMessage[], options: ChatCompletionOptions = {}): Observable<ChatStreamEvent> {
+    return new Observable<ChatStreamEvent>((subscriber) => {
+      const controller = new AbortController();
+      const payload = {
+        model: options.model || 'gpt-4o',
+        messages,
+        temperature: options.temperature ?? 0.7,
+        max_tokens: options.max_tokens || 4096,
+      };
+
+      // 直连 fmode 云函数 dispatcher,与 ParseService.call 相同的入口;
+      // 走 fetch + ReadableStream 而不是 Parse SDK,以便消费 chunked SSE 响应。
+      const APP_ID = 'ncloudmaster';
+      const FN_ID = 'wzwnEuAhwb'; // CLOUD_FN.proxy
+      const FN_URL = 'https://server.fmode.cn/api/functions';
+
+      (async () => {
+        let full = '';
+        let finishReason: string | undefined;
+        try {
+          const resp = await fetch(FN_URL, {
+            method: 'POST',
+            headers: {
+              'Content-Type': 'application/json',
+              'X-Parse-Application-Id': APP_ID,
+            },
+            body: JSON.stringify({
+              id: FN_ID,
+              _ApplicationId: APP_ID,
+              action: 'llmChatStream',
+              payload,
+            }),
+            signal: controller.signal,
+          });
+          if (!resp.ok) {
+            let detail = '';
+            try { detail = (await resp.json())?.error || ''; } catch {}
+            throw new Error(detail || `HTTP ${resp.status}`);
+          }
+          if (!resp.body) throw new Error('streaming body missing');
+
+          // 云函数对 messages 缺失等问题会返回 JSON 而非 SSE;按 JSON 错误处理
+          const ctype = resp.headers.get('content-type') || '';
+          if (ctype.includes('application/json')) {
+            const j = await resp.json().catch(() => null);
+            const err = j?.error || j?.result?.error || 'cloud function returned JSON instead of stream';
+            throw new Error(typeof err === 'string' ? err : JSON.stringify(err));
+          }
+
+          const reader = resp.body.getReader();
+          const decoder = new TextDecoder('utf-8');
+          let buf = '';
+
+          // OpenAI SSE 格式:每行 `data: { ... }`;空行分隔事件;`data: [DONE]` 结束
+          while (true) {
+            const { value, done } = await reader.read();
+            if (done) break;
+            buf += decoder.decode(value, { stream: true });
+            let idx: number;
+            while ((idx = buf.indexOf('\n')) >= 0) {
+              const line = buf.slice(0, idx).trim();
+              buf = buf.slice(idx + 1);
+              if (!line) continue;
+              if (!line.startsWith('data:')) continue;
+              const data = line.slice(5).trim();
+              if (data === '[DONE]') {
+                subscriber.next({ delta: '', full, done: true, finishReason });
+                subscriber.complete();
+                return;
+              }
+              try {
+                const json = JSON.parse(data);
+                const choice = json?.choices?.[0];
+                const delta = choice?.delta?.content || '';
+                if (choice?.finish_reason) finishReason = choice.finish_reason;
+                if (delta) {
+                  full += delta;
+                  subscriber.next({ delta, full, done: false, finishReason });
+                }
+              } catch (e) {
+                // 上游偶有非 JSON 探活包,忽略
+              }
+            }
+          }
+          // 流自然结束但没收到 [DONE]:补一个 done
+          subscriber.next({ delta: '', full, done: true, finishReason });
+          subscriber.complete();
+        } catch (err: any) {
+          if (err?.name === 'AbortError') {
+            // 主动取消:仅 complete 不抛错
+            subscriber.complete();
+          } else {
+            subscriber.error(new Error(err?.message || 'LLM 流式请求失败'));
+          }
+        }
+      })();
+
+      return () => controller.abort();
+    });
+  }
+
   // ==================== Gemini 媒体识别 ====================
 
   /**

+ 26 - 0
src/app/services/markdown.service.ts

@@ -0,0 +1,26 @@
+import { Injectable } from '@angular/core';
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { marked } from 'marked';
+
+/**
+ * Markdown → SafeHtml 渲染器。
+ *
+ * 用于把 LLM 返回的 markdown 文本转成可绑定到 [innerHTML] 的安全 HTML。
+ * 内容来源是受信任的后端代理(fmode LLM 网关),未引入 DOMPurify 以减小包体;
+ * 若未来接入用户提供的 markdown,建议补一道 DOMPurify。
+ */
+@Injectable({ providedIn: 'root' })
+export class MarkdownService {
+  constructor(private sanitizer: DomSanitizer) {
+    marked.setOptions({
+      gfm: true,
+      breaks: true,        // 单换行视为 <br>,更贴近聊天显示
+    });
+  }
+
+  /** 渲染完整 markdown,绑定到 [innerHTML]。流式过程也可调用(marked 容忍不完整 markdown)。 */
+  render(text: string): SafeHtml {
+    const html = marked.parse(text || '', { async: false }) as string;
+    return this.sanitizer.bypassSecurityTrustHtml(html);
+  }
+}

+ 89 - 0
src/app/services/parse.service.ts

@@ -0,0 +1,89 @@
+import { Injectable } from '@angular/core';
+import { FmodeParse } from 'fmode-ng/core';
+
+export interface CloudFnResponse<T = any> {
+  code: number;
+  success: boolean;
+  data?: T;
+  error?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class ParseService {
+  private Parse: any;
+  private readonly appId = 'ncloudmaster';
+  private readonly serverURL = 'https://server.fmode.cn/parse';
+  private readonly functionsURL = 'https://server.fmode.cn/api/functions';
+
+  constructor() {
+    this.Parse = FmodeParse.initialize({
+      appId: this.appId,
+      serverURL: this.serverURL,
+    });
+  }
+
+  async call<T = any>(id: string, params: Record<string, any> = {}): Promise<CloudFnResponse<T>> {
+    try {
+      const response = await fetch(this.functionsURL, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'X-Parse-Application-Id': this.appId,
+        },
+        body: JSON.stringify({
+          id,
+          ...params,
+          _ApplicationId: this.appId,
+        }),
+      });
+
+      const text = await response.text();
+      const data = this.parseJson(text);
+
+      if (!response.ok) {
+        return {
+          code: response.status,
+          success: false,
+          error: this.readError(data, text) || `HTTP error! status: ${response.status}`,
+        };
+      }
+
+      if (data && typeof data === 'object' && 'code' in data && 'success' in data) {
+        return data as CloudFnResponse<T>;
+      }
+
+      if (data?.error) {
+        return {
+          code: 500,
+          success: false,
+          error: this.readError(data, text) || 'Cloud function error',
+        };
+      }
+
+      return { code: 200, success: true, data: (data?.result ?? data) as T };
+    } catch (e: any) {
+      console.error(`[ParseService] ${id}:`, e);
+      return { code: 500, success: false, error: e?.message || 'Cloud function call failed' };
+    }
+  }
+
+  async callOrThrow<T = any>(id: string, params: Record<string, any> = {}): Promise<T> {
+    const res = await this.call<T>(id, params);
+    if (res.code !== 200 || !res.success) {
+      throw new Error(res.error || 'Cloud function call failed');
+    }
+    return res.data as T;
+  }
+
+  private parseJson(text: string): any {
+    try {
+      return text ? JSON.parse(text) : null;
+    } catch {
+      return null;
+    }
+  }
+
+  private readError(data: any, fallback: string): string {
+    return data?.error?.message || data?.error || data?.msg || fallback || '';
+  }
+}

+ 357 - 0
src/app/services/pipeline-session.service.ts

@@ -0,0 +1,357 @@
+/**
+ * Pipeline 会话服务
+ *
+ * 用法(Pipeline 组件侧):
+ *
+ *   constructor(public session: PipelineSessionService) {}
+ *
+ *   ngOnInit() {
+ *     this.session.bootstrap();           // 应用启动期一次
+ *     const id = this.route.snapshot.queryParamMap.get('draftId');
+ *     if (id) this.session.open(id).then(d => d && this.fromSnapshot(d.snapshot));
+ *   }
+ *
+ *   onTopicChange() {
+ *     this.session.ensureActive('topic-to-video', () => this.toSnapshot(), this.deriveTitle());
+ *     this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
+ *   }
+ *
+ *   onClipDone(scene, url) {
+ *     this.session.addArtifact({ type:'video', url, title:`分镜 ${i} - i2v`, sceneIndex: i });
+ *   }
+ *
+ *   onComposeDone(url) { this.session.finalize(url); }
+ *   onComposeFail(err) { this.session.fail(err.message); }
+ */
+
+import { Injectable, computed, signal } from '@angular/core';
+import { PipelineRoute } from '../pipelines/pipeline-registry';
+import {
+  Artifact,
+  ArtifactType,
+  DraftMeta,
+  DraftStatus,
+  PipelineDraft,
+  newArtifactId,
+  newDraftId,
+  toDraftMeta,
+} from '../models/pipeline-draft.model';
+import { DraftStorage, LocalStorageDraftStorage } from './draft-storage';
+
+interface PatchOpts {
+  /** 静默更新,不触发 updatedAt 变化(一般不用) */
+  silent?: boolean;
+}
+
+@Injectable({ providedIn: 'root' })
+export class PipelineSessionService {
+  private storage: DraftStorage = new LocalStorageDraftStorage();
+
+  /** 列表(DraftMeta 轻量索引) */
+  readonly list = signal<DraftMeta[]>([]);
+
+  /** 当前激活的 draft —— Pipeline 组件通过 effect / 手动读 */
+  readonly active = signal<PipelineDraft | null>(null);
+
+  /** 派生:按 pipelineId 分组返回 list */
+  readonly byPipeline = computed(() => {
+    const map = new Map<PipelineRoute, DraftMeta[]>();
+    for (const m of this.list()) {
+      const arr = map.get(m.pipelineId) || [];
+      arr.push(m);
+      map.set(m.pipelineId, arr);
+    }
+    return map;
+  });
+
+  private bootstrapped = false;
+  private saveTimer: any = null;
+  private readonly SAVE_DEBOUNCE = 500;
+
+  /** 注入自定义 storage(测试 / IndexedDB 升级用) */
+  setStorage(s: DraftStorage): void {
+    this.storage = s;
+  }
+
+  /** 应用启动期一次:把索引拉进 signal */
+  async bootstrap(): Promise<void> {
+    if (this.bootstrapped) return;
+    this.bootstrapped = true;
+    const arr = await this.storage.list();
+    arr.sort((a, b) => b.updatedAt - a.updatedAt);
+    this.list.set(arr);
+  }
+
+  // ==================== 基础 CRUD ====================
+
+  /**
+   * 新建一个 draft 并设为 active。
+   * 注意:不立刻落盘,等 patch 触发首次 save——避免「点进页面就产生空 draft」。
+   */
+  newDraft<T>(pipelineId: PipelineRoute, snapshot: T, title?: string): PipelineDraft<T> {
+    const now = Date.now();
+    const draft: PipelineDraft<T> = {
+      id: newDraftId(pipelineId),
+      pipelineId,
+      title: title?.trim() || '未命名创作',
+      createdAt: now,
+      updatedAt: now,
+      status: 'draft',
+      artifacts: [],
+      snapshot,
+    };
+    this.active.set(draft as PipelineDraft);
+    return draft;
+  }
+
+  /** 打开一个已有 draft */
+  async open(id: string): Promise<PipelineDraft | null> {
+    const d = await this.storage.get(id);
+    if (d) {
+      // 如果之前卡在 running,进来时降级为 failed(避免幽灵任务)
+      if (d.status === 'running') {
+        d.status = 'failed';
+        d.errorMessage = d.errorMessage || '任务已中断(页面被关闭或刷新)';
+        d.updatedAt = Date.now();
+        await this.storage.put(d);
+        await this.refreshList();
+      }
+      this.active.set(d);
+    }
+    return d;
+  }
+
+  /** 关闭当前 active(不删数据,仅取消选中) */
+  close(): void {
+    this.active.set(null);
+  }
+
+  /**
+   * 确保有 active draft;如果没有,立刻新建一个。
+   * 用于"用户开始输入时"延迟创建 draft 的场景。
+   */
+  ensureActive<T>(pipelineId: PipelineRoute, makeSnapshot: () => T, title?: string): PipelineDraft<T> {
+    const cur = this.active();
+    if (cur && cur.pipelineId === pipelineId) return cur as PipelineDraft<T>;
+    return this.newDraft(pipelineId, makeSnapshot(), title);
+  }
+
+  /**
+   * 给当前 active draft 打 patch,并 debounce 落盘。
+   * 任何字段都可以传,title/snapshot/thumbnail/status/resultUrl/errorMessage...
+   */
+  patch(partial: Partial<PipelineDraft>, opts: PatchOpts = {}): void {
+    const cur = this.active();
+    if (!cur) return;
+    const next: PipelineDraft = {
+      ...cur,
+      ...partial,
+      // artifacts 如果没传,保留原值
+      artifacts: partial.artifacts ?? cur.artifacts,
+      updatedAt: opts.silent ? cur.updatedAt : Date.now(),
+    };
+    this.active.set(next);
+    this.scheduleSave(next);
+  }
+
+  /**
+   * 追加中间产物(图 / 视频片段 / 音频)。
+   * 自动设置 thumbnail(首张图)并把 status 推到 running(如果还在 draft)。
+   */
+  addArtifact(input: Omit<Artifact, 'id' | 'createdAt'> & { id?: string; createdAt?: number }): void {
+    const cur = this.active();
+    if (!cur) return;
+    const artifact: Artifact = {
+      id: input.id || newArtifactId(),
+      createdAt: input.createdAt || Date.now(),
+      type: input.type,
+      url: input.url,
+      title: input.title,
+      sceneIndex: input.sceneIndex,
+      extras: input.extras,
+    };
+    const artifacts = [...cur.artifacts, artifact];
+
+    let thumbnail = cur.thumbnail;
+    if (!thumbnail && artifact.type === 'image') thumbnail = artifact.url;
+
+    const status: DraftStatus = cur.status === 'completed' ? 'completed'
+                              : cur.status === 'failed' ? 'failed'
+                              : 'running';
+
+    this.patch({ artifacts, thumbnail, status });
+  }
+
+  /** 把 artifacts 中某条按 sceneIndex+type 替换或追加(重生成同一段时用) */
+  upsertArtifact(predicate: (a: Artifact) => boolean,
+                 input: Omit<Artifact, 'id' | 'createdAt'> & { id?: string; createdAt?: number }): void {
+    const cur = this.active();
+    if (!cur) return;
+    const idx = cur.artifacts.findIndex(predicate);
+    const artifact: Artifact = {
+      id: input.id || (idx >= 0 ? cur.artifacts[idx].id : newArtifactId()),
+      createdAt: input.createdAt || Date.now(),
+      type: input.type,
+      url: input.url,
+      title: input.title,
+      sceneIndex: input.sceneIndex,
+      extras: input.extras,
+    };
+    const artifacts = [...cur.artifacts];
+    if (idx >= 0) artifacts[idx] = artifact;
+    else artifacts.push(artifact);
+
+    let thumbnail = cur.thumbnail;
+    if (!thumbnail && artifact.type === 'image') thumbnail = artifact.url;
+
+    const status: DraftStatus = cur.status === 'completed' ? 'completed'
+                              : cur.status === 'failed' ? 'failed'
+                              : 'running';
+    this.patch({ artifacts, thumbnail, status });
+  }
+
+  /** 标记开始运行(成片任务等长流程入口处调一次) */
+  markRunning(): void {
+    const cur = this.active();
+    if (!cur) return;
+    if (cur.status !== 'running') {
+      this.patch({ status: 'running', errorMessage: undefined });
+    }
+  }
+
+  /** 成片成功:写 resultUrl + status='completed' */
+  finalize(resultUrl: string, extras?: Record<string, any>): void {
+    const cur = this.active();
+    if (!cur) return;
+    // 同时把成片自身作为一条 artifact 收录,便于历史展示
+    const artifact: Artifact = {
+      id: newArtifactId(),
+      type: 'video',
+      url: resultUrl,
+      title: '成片',
+      createdAt: Date.now(),
+      extras,
+    };
+    const artifacts = [...cur.artifacts, artifact];
+    this.patch({
+      status: 'completed',
+      resultUrl,
+      artifacts,
+      thumbnail: cur.thumbnail || (cur.artifacts.find((a) => a.type === 'image')?.url),
+      errorMessage: undefined,
+    });
+    this.flushSave();
+  }
+
+  fail(reason: string): void {
+    const cur = this.active();
+    if (!cur) return;
+    this.patch({ status: 'failed', errorMessage: reason });
+    this.flushSave();
+  }
+
+  // ==================== 列表操作 ====================
+
+  async refreshList(): Promise<void> {
+    const arr = await this.storage.list();
+    arr.sort((a, b) => b.updatedAt - a.updatedAt);
+    this.list.set(arr);
+  }
+
+  /**
+   * 一次性加载所有 draft 的完整数据(含 artifacts 与 snapshot)。
+   * 供「素材库」聚合视图用。本地存储下成本可接受;
+   * 后续切换到 IndexedDB / 远端时再加分页与缓存。
+   */
+  async loadAll(): Promise<PipelineDraft[]> {
+    const metas = await this.storage.list();
+    const out: PipelineDraft[] = [];
+    for (const m of metas) {
+      const d = await this.storage.get(m.id);
+      if (d) out.push(d);
+    }
+    out.sort((a, b) => b.updatedAt - a.updatedAt);
+    return out;
+  }
+
+  async rename(id: string, title: string): Promise<void> {
+    const t = title.trim() || '未命名创作';
+    const cur = this.active();
+    if (cur && cur.id === id) {
+      this.patch({ title: t });
+      this.flushSave();
+    } else {
+      const d = await this.storage.get(id);
+      if (!d) return;
+      d.title = t;
+      d.updatedAt = Date.now();
+      await this.storage.put(d);
+      await this.refreshList();
+    }
+  }
+
+  async remove(id: string): Promise<void> {
+    await this.storage.delete(id);
+    if (this.active()?.id === id) this.active.set(null);
+    await this.refreshList();
+  }
+
+  /** 复制一份作为新 draft(草稿态,不复制 artifacts/resultUrl) */
+  async duplicate(id: string): Promise<PipelineDraft | null> {
+    const d = await this.storage.get(id);
+    if (!d) return null;
+    const now = Date.now();
+    const cloned: PipelineDraft = {
+      ...d,
+      id: newDraftId(d.pipelineId),
+      title: `${d.title}(副本)`,
+      createdAt: now,
+      updatedAt: now,
+      status: 'draft',
+      artifacts: [],
+      thumbnail: undefined,
+      resultUrl: undefined,
+      errorMessage: undefined,
+    };
+    await this.storage.put(cloned);
+    await this.refreshList();
+    return cloned;
+  }
+
+  // ==================== 持久化 ====================
+
+  private scheduleSave(draft: PipelineDraft): void {
+    if (this.saveTimer) clearTimeout(this.saveTimer);
+    this.saveTimer = setTimeout(() => {
+      this.saveTimer = null;
+      void this.doSave(draft);
+    }, this.SAVE_DEBOUNCE);
+  }
+
+  /** 立即落盘(finalize / fail 时调) */
+  flushSave(): void {
+    if (this.saveTimer) {
+      clearTimeout(this.saveTimer);
+      this.saveTimer = null;
+    }
+    const cur = this.active();
+    if (cur) void this.doSave(cur);
+  }
+
+  private async doSave(draft: PipelineDraft): Promise<void> {
+    try {
+      await this.storage.put(draft);
+      // 同步更新 list signal(不重新拉一次,避免 round-trip)
+      const list = this.list();
+      const meta = toDraftMeta(draft);
+      const idx = list.findIndex((m) => m.id === draft.id);
+      const next = idx >= 0
+        ? list.map((m, i) => (i === idx ? meta : m))
+        : [meta, ...list];
+      next.sort((a, b) => b.updatedAt - a.updatedAt);
+      this.list.set(next);
+    } catch (e) {
+      console.error('[PipelineSession] save failed', e);
+    }
+  }
+}

+ 129 - 0
src/app/services/qiniu-upload.service.ts

@@ -0,0 +1,129 @@
+import { Injectable, NgZone } from '@angular/core';
+import { Observable, from } from 'rxjs';
+import { filter, map, switchMap } from 'rxjs/operators';
+import { CLOUD_FN } from './cloud-functions';
+import { ParseService } from './parse.service';
+
+export interface QiniuUploadToken {
+  uploadUrl: string;
+  token: string;
+  key: string;
+  url: string;
+  mimeType: string;
+  kind: 'image' | 'video' | 'audio';
+  expiresAt?: number;
+}
+
+export interface QiniuUploadProgress {
+  state: 'progress' | 'done';
+  progress: number;
+  url?: string;
+  key?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class QiniuUploadService {
+  constructor(
+    private parse: ParseService,
+    private zone: NgZone,
+  ) {}
+
+  uploadFile(file: Blob, filename: string, contentType?: string, kind?: 'image' | 'video' | 'audio'): Observable<string> {
+    return this.uploadFileWithProgress(file, filename, contentType, kind).pipe(
+      filter((event) => event.state === 'done' && !!event.url),
+      map((event) => event.url as string),
+    );
+  }
+
+  uploadFileWithProgress(
+    file: Blob,
+    filename: string,
+    contentType?: string,
+    kind?: 'image' | 'video' | 'audio',
+  ): Observable<QiniuUploadProgress> {
+    const normalizedFilename = String(filename || `asset-${Date.now()}`);
+    const mimeType = contentType || file.type || 'application/octet-stream';
+    const typedFile = file instanceof File
+      ? file
+      : new File([file], normalizedFilename, { type: mimeType });
+
+    return from(this.createUploadToken(normalizedFilename, mimeType, typedFile.size, kind)).pipe(
+      switchMap((token) => this.uploadToQiniu(typedFile, token)),
+    );
+  }
+
+  private async createUploadToken(
+    filename: string,
+    mimeType: string,
+    size: number,
+    kind?: 'image' | 'video' | 'audio',
+  ): Promise<QiniuUploadToken> {
+    if (!CLOUD_FN.upload) {
+      throw new Error('上传云函数 ID 未配置');
+    }
+
+    const res = await this.parse.call<QiniuUploadToken>(CLOUD_FN.upload, {
+      action: 'createUploadToken',
+      filename,
+      mimeType,
+      size,
+      kind,
+    });
+
+    if (res.code !== 200 || !res.success || !res.data?.token || !res.data?.url) {
+      throw new Error(res.error || '获取七牛上传凭证失败');
+    }
+
+    return res.data;
+  }
+
+  private uploadToQiniu(file: File, token: QiniuUploadToken): Observable<QiniuUploadProgress> {
+    return new Observable<QiniuUploadProgress>((observer) => {
+      const formData = new FormData();
+      formData.append('token', token.token);
+      formData.append('key', token.key);
+      formData.append('file', file, file.name);
+
+      const xhr = new XMLHttpRequest();
+      xhr.open('POST', token.uploadUrl, true);
+
+      xhr.upload.onprogress = (event) => {
+        if (!event.lengthComputable) return;
+        const progress = Math.max(0, Math.min(99, Math.round((event.loaded / event.total) * 100)));
+        this.zone.run(() => observer.next({ state: 'progress', progress }));
+      };
+
+      xhr.onload = () => {
+        this.zone.run(() => {
+          if (xhr.status < 200 || xhr.status >= 300) {
+            observer.error(new Error(this.readQiniuError(xhr)));
+            return;
+          }
+          observer.next({ state: 'done', progress: 100, url: token.url, key: token.key });
+          observer.complete();
+        });
+      };
+
+      xhr.onerror = () => {
+        this.zone.run(() => observer.error(new Error('七牛上传网络错误')));
+      };
+
+      xhr.send(formData);
+
+      return () => {
+        if (xhr.readyState !== XMLHttpRequest.DONE) {
+          xhr.abort();
+        }
+      };
+    });
+  }
+
+  private readQiniuError(xhr: XMLHttpRequest): string {
+    try {
+      const payload = JSON.parse(xhr.responseText || '{}');
+      return payload?.error || payload?.message || `七牛上传失败 (HTTP ${xhr.status})`;
+    } catch {
+      return xhr.responseText || `七牛上传失败 (HTTP ${xhr.status})`;
+    }
+  }
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů