Pārlūkot izejas kodu

commit 拆分server.js

Yi Jiarui 3 mēneši atpakaļ
vecāks
revīzija
720c64df26
44 mainītis faili ar 4314 papildinājumiem un 432 dzēšanām
  1. 2 0
      .gitignore
  2. 78 6
      cloud-functions/11-jimengManager.js
  3. 107 15
      cloud-functions/12-douyinManager.js
  4. 418 2
      cloud-functions/13-douyinInsightManager.js
  5. 3 3
      cloud-functions/DEPLOY.md
  6. 652 1
      server.js
  7. 210 0
      src/app/app.css
  8. 62 7
      src/app/app.html
  9. 254 12
      src/app/app.ts
  10. 1 2
      src/app/components/app-assistant/app-assistant.component.ts
  11. 1 1
      src/app/components/app-sidebar/app-sidebar.component.ts
  12. 47 0
      src/app/models/creation-brief.model.ts
  13. 51 0
      src/app/models/douyin-insight.model.ts
  14. 30 0
      src/app/models/video-generation-capability.model.ts
  15. 7 1
      src/app/pages/analysis-history/analysis-history.component.html
  16. 1 2
      src/app/pages/home/home.component.ts
  17. 8 4
      src/app/pages/pipelines/image-to-video/image-to-video.component.html
  18. 19 6
      src/app/pages/pipelines/image-to-video/image-to-video.component.ts
  19. 103 0
      src/app/pages/pipelines/text-to-video/text-to-video.component.css
  20. 162 0
      src/app/pages/pipelines/text-to-video/text-to-video.component.html
  21. 340 0
      src/app/pages/pipelines/text-to-video/text-to-video.component.ts
  22. 32 0
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.css
  23. 34 16
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.html
  24. 182 32
      src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts
  25. 459 15
      src/app/pages/topic-pool/topic-pool.component.css
  26. 147 62
      src/app/pages/topic-pool/topic-pool.component.html
  27. 157 1
      src/app/pages/topic-pool/topic-pool.component.ts
  28. 5 5
      src/app/pipelines/pipeline-registry.ts
  29. 1 1
      src/app/services/assistant.service.ts
  30. 3 2
      src/app/services/cost-estimator.service.ts
  31. 130 0
      src/app/services/creation-brief.service.ts
  32. 29 15
      src/app/services/douyin-api.service.ts
  33. 231 0
      src/app/services/douyin-evidence-analysis.service.ts
  34. 14 2
      src/app/services/douyin-insight.service.ts
  35. 83 0
      src/app/services/douyin-transcript.service.ts
  36. 20 11
      src/app/services/douyin.service.ts
  37. 52 14
      src/app/services/jimeng.service.ts
  38. 6 7
      src/app/services/markdown.service.ts
  39. 8 1
      src/app/services/parse.service.ts
  40. 2 0
      src/app/services/template.service.ts
  41. 5 1
      src/app/services/topic-to-video-batch-runner.service.ts
  42. 30 0
      src/app/services/video-duration.service.ts
  43. 58 0
      src/app/services/video-generation-capability.service.ts
  44. 70 185
      src/app/services/viral-analysis.service.ts

+ 2 - 0
.gitignore

@@ -42,6 +42,8 @@ testem.log
 /typings
 __screenshots__/
 .gstack/
+memory/
+session-checkpoints/
 
 # System files
 .DS_Store

+ 78 - 6
cloud-functions/11-jimengManager.js

@@ -8,6 +8,8 @@ const JIMENG_TOKEN = readEnv('JIMENG_TOKEN') || 'Bearer r:f0333969e312a40e4703e8
 const JIMENG_BASE_URL = readEnv('JIMENG_BASE_URL') || 'https://server.fmode.cn/api/volcengine/jimeng';
 const PARSE_BASE_URL = readEnv('PARSE_BASE_URL') || 'https://server.fmode.cn/parse';
 const PARSE_APP_ID = readEnv('PARSE_APP_ID') || 'ncloudmaster';
+const REQUEST_TIMEOUT_MS = Number(readEnv('JIMENG_REQUEST_TIMEOUT_MS') || 60000);
+const MAX_ATTEMPTS = Math.max(1, Number(readEnv('JIMENG_MAX_ATTEMPTS') || 3));
 
 const ALLOWED_ENDPOINTS = new Set([
   'getVideoV3_720p',
@@ -86,31 +88,101 @@ function stripEmpty(value) {
 }
 
 async function postJson(url, body) {
-  const r = await fetch(url, {
+  return requestJson(url, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify(body),
   });
-  return readResponse(r);
 }
 
 async function getJson(url, headers) {
-  const r = await fetch(url, { method: 'GET', headers });
-  const data = await readResponse(r);
+  const data = await requestJson(url, { method: 'GET', headers });
   if (data && data.success === false) throw new Error(data.error || '查询结果失败');
   return data;
 }
 
-async function readResponse(r) {
+async function requestJson(url, options) {
+  let lastError = null;
+  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+    try {
+      const r = await fetchWithTimeout(url, options, REQUEST_TIMEOUT_MS);
+      const data = await readResponse(r, url, attempt);
+      if (data && data.success === false && isRetryableStatus(data.code) && attempt < MAX_ATTEMPTS) {
+        await sleep(backoffMs(attempt));
+        continue;
+      }
+      return data;
+    } catch (error) {
+      lastError = error;
+      if (!isRetryableNetworkError(error) || attempt >= MAX_ATTEMPTS) break;
+      await sleep(backoffMs(attempt));
+    }
+  }
+
+  const message = lastError && lastError.message ? lastError.message : 'fetch failed';
+  throw new Error(`即梦上游网络请求失败:${message};url=${maskUrl(url)};attempts=${MAX_ATTEMPTS}`);
+}
+
+async function fetchWithTimeout(url, options, timeoutMs) {
+  if (typeof AbortController === 'undefined') {
+    return fetch(url, options);
+  }
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), timeoutMs);
+  try {
+    return await fetch(url, { ...options, signal: controller.signal });
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+async function readResponse(r, url, attempt) {
   const text = await r.text();
   let data = null;
   try { data = text ? JSON.parse(text) : null; } catch {}
   if (!r.ok) {
-    return { code: r.status, success: false, error: readError(data, text) || `HTTP ${r.status}` };
+    return {
+      code: r.status,
+      success: false,
+      error: readError(data, text) || `HTTP ${r.status}`,
+      upstream: {
+        status: r.status,
+        url: maskUrl(url),
+        attempt,
+        maxAttempts: MAX_ATTEMPTS,
+      },
+    };
   }
   return data || { code: 500, success: false, error: '服务返回异常' };
 }
 
+function isRetryableStatus(status) {
+  const code = Number(status || 0);
+  return code === 408 || code === 429 || code >= 500;
+}
+
+function isRetryableNetworkError(error) {
+  const message = error && error.message ? error.message : String(error || '');
+  return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|network|abort|timeout/i.test(message);
+}
+
+function backoffMs(attempt) {
+  return Math.min(12000, 1200 * attempt * attempt);
+}
+
+function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function maskUrl(url) {
+  try {
+    const u = new URL(url);
+    return `${u.origin}${u.pathname}`;
+  } catch {
+    return String(url || '').split('?')[0];
+  }
+}
+
 function readError(data, fallback) {
   return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
 }

+ 107 - 15
cloud-functions/12-douyinManager.js

@@ -4,23 +4,31 @@
  *
  * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 douyin。
  */
-const TIKHUB_BASE_URL = readEnv('TIKHUB_BASE_URL') || 'https://api.tikhub.io';
+const DOUYIN_API_BASE_URL = (readEnv('DOUYIN_API_BASE_URL') || readEnv('VOC_SOCIAL_BASE_URL') || 'https://server.fmode.cn/api/voc-social').replace(/\/+$/, '');
+const IS_TIKHUB_DIRECT = /api\.tikhub\.io/i.test(DOUYIN_API_BASE_URL);
+const LOCAL_VOC_TOKEN_FALLBACK = 'r:33c57d404c8fffc9b19199a4da0bb663';
+const VOC_SOCIAL_TOKEN = readEnv('DOUYIN_API_TOKEN') || readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK;
 const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
+const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN;
 
 const ROUTES = {
-  searchVideos: { method: 'POST', path: '/api/v1/douyin/search/fetch_general_search_v2' },
-  challengeSearch: { method: 'POST', path: '/api/v1/douyin/search/fetch_challenge_search_v2' },
-  videoDetail: { method: 'GET', path: '/api/v1/douyin/app/v3/fetch_one_video_v3' },
-  userProfileWeb: { method: 'GET', path: '/api/v1/douyin/web/handler_user_profile_v2' },
-  userProfileApp: { method: 'GET', path: '/api/v1/douyin/app/v3/handler_user_profile' },
-  userPosts: { method: 'GET', path: '/api/v1/douyin/web/fetch_user_post_videos' },
-  comments: { method: 'GET', path: '/api/v1/douyin/app/v3/fetch_video_comments' },
-  replies: { method: 'POST', path: '/api/v1/douyin/comment/reply/list' },
+  searchVideos: { method: 'POST', path: '/douyin/search/fetch_general_search_v2' },
+  challengeSearch: { method: 'POST', path: '/douyin/search/fetch_challenge_search_v2' },
+  videoDetail: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_v3' },
+  userProfileWeb: { method: 'GET', path: '/douyin/web/handler_user_profile_v2' },
+  userProfileApp: { method: 'GET', path: '/douyin/app/v3/handler_user_profile' },
+  userPosts: { method: 'GET', path: '/douyin/app/v3/fetch_user_post_videos' },
+  comments: { method: 'GET', path: '/douyin/app/v3/fetch_video_comments' },
+  replies: { method: 'GET', path: '/douyin/app/v3/fetch_video_comment_replies' },
 };
 
 async function handler(request, response) {
   try {
     const action = pickParam(request, 'action') || 'call';
+    if (action === 'diagnose') {
+      return diagnose(request, response);
+    }
+
     if (action !== 'call') {
       return response.json({ code: 400, success: false, error: `未知 action: ${action}` });
     }
@@ -34,7 +42,7 @@ async function handler(request, response) {
     const params = pickParam(request, 'params') || {};
     const body = pickParam(request, 'payload', 'data') || {};
     const data = route.method === 'GET'
-      ? await requestJson('GET', route.path, params)
+      ? await requestJson('GET', route.path, { ...body, ...params })
       : await requestJson('POST', route.path, body);
 
     response.json(data);
@@ -45,13 +53,21 @@ async function handler(request, response) {
 }
 
 async function requestJson(method, path, payload) {
-  const url = new URL(`${TIKHUB_BASE_URL}${path}`);
+  if (!DOUYIN_API_TOKEN) {
+    return {
+      code: 400,
+      success: false,
+      error: '抖音数据网关未配置有效 token:FMode voc-social 请配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN;TikHub 直连才使用 TIKHUB_TOKEN。'
+    };
+  }
+
+  const url = new URL(`${DOUYIN_API_BASE_URL}${normalizeDouyinPath(path)}`);
   const init = {
     method,
     headers: {
       'Content-Type': 'application/json',
       'Accept': 'application/json',
-      'Authorization': `Bearer ${TIKHUB_TOKEN}`,
+      'Authorization': bearerAuth(DOUYIN_API_TOKEN),
     },
   };
 
@@ -65,16 +81,59 @@ async function requestJson(method, path, payload) {
     init.body = JSON.stringify(stripEmpty(payload || {}));
   }
 
-  const r = await fetch(url.toString(), init);
+  let r;
+  try {
+    r = await fetch(url.toString(), init);
+  } catch (error) {
+    return {
+      code: 502,
+      success: false,
+      error: `抖音数据网关网络请求失败:${formatFetchError(error)}。base=${maskBaseUrl(DOUYIN_API_BASE_URL)} route=${path}`
+    };
+  }
   const text = await r.text();
   let data = null;
   try { data = text ? JSON.parse(text) : null; } catch {}
   if (!r.ok) {
-    return { code: r.status, success: false, error: readError(data, text) || `HTTP ${r.status}` };
+    return { code: r.status, success: false, error: readError(data, text) || `抖音数据接口请求失败 HTTP ${r.status}` };
   }
   return data || { code: 500, success: false, error: '服务返回异常' };
 }
 
+async function diagnose(request, response) {
+  const routeName = String(pickParam(request, 'route') || 'videoDetail').trim();
+  const route = ROUTES[routeName] || ROUTES.videoDetail;
+  const awemeId = String(pickParam(request, 'awemeId') || '7592116912205630761').trim();
+  const payload = routeName === 'videoDetail' ? { aweme_id: awemeId } : {};
+  const result = {
+    code: 200,
+    success: true,
+    data: {
+      baseUrl: maskBaseUrl(DOUYIN_API_BASE_URL),
+      isTikhubDirect: IS_TIKHUB_DIRECT,
+      tokenConfigured: !!DOUYIN_API_TOKEN,
+      tokenSource: tokenSource(),
+      routeName,
+      routePath: route.path,
+      normalizedPath: normalizeDouyinPath(route.path),
+      probe: null,
+    }
+  };
+
+  if (String(pickParam(request, 'probe') || '') === '1') {
+    result.data.probe = await requestJson(route.method, route.path, payload);
+  }
+
+  return response.json(result);
+}
+
+function normalizeDouyinPath(path) {
+  if (IS_TIKHUB_DIRECT && !path.startsWith('/api/v1/')) {
+    return `/api/v1${path}`;
+  }
+  return path;
+}
+
 function pickParam(request, ...names) {
   const sources = [request.params, request.body, request];
   for (const src of sources) {
@@ -103,7 +162,40 @@ function stripEmpty(value) {
 }
 
 function readError(data, fallback) {
-  return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
+  const detail = data?.detail;
+  if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social';
+  const message = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || fallback || '';
+  if (/company或用户信息不存在/.test(String(message))) {
+    return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。';
+  }
+  return message;
+}
+
+function bearerAuth(token) {
+  const value = String(token || '').trim();
+  return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
+}
+
+function tokenSource() {
+  if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN';
+  if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
+  if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
+  if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
+  if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
+  if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
+  if (IS_TIKHUB_DIRECT && readEnv('TIKHUB_TOKEN')) return 'TIKHUB_TOKEN';
+  if (!IS_TIKHUB_DIRECT && LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
+  return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : '';
+}
+
+function formatFetchError(error) {
+  const message = error && error.message ? error.message : String(error || 'fetch failed');
+  const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : '';
+  return `${message}${cause}`;
+}
+
+function maskBaseUrl(value) {
+  return String(value || '').replace(/(token=)[^&]+/ig, '$1***');
 }
 
 function readEnv(name) {

+ 418 - 2
cloud-functions/13-douyinInsightManager.js

@@ -4,17 +4,30 @@
  *   analysisCreate | analysisList | analysisGet | analysisUpdate
  *   topicCreate | topicList | topicUpdate | topicArchive
  *   dailyReportCreate | dailyReportList | dailyReportGet
+ *   transcriptStart | transcriptGet
  *
  * 说明:本函数只负责业务资产的持久化和账号隔离。抖音原始数据抓取仍由 12-douyinManager 负责。
  */
 const PARSE_API_HOST = readEnv('PARSE_API_HOST') || 'https://server.fmode.cn';
 const PARSE_APP_ID = readEnv('PARSE_APP_ID') || 'ncloudmaster';
+const DOUYIN_API_BASE_URL = (readEnv('DOUYIN_API_BASE_URL') || readEnv('VOC_SOCIAL_BASE_URL') || 'https://server.fmode.cn/api/voc-social').replace(/\/+$/, '');
+const IS_TIKHUB_DIRECT = /api\.tikhub\.io/i.test(DOUYIN_API_BASE_URL);
+const LOCAL_VOC_TOKEN_FALLBACK = 'r:33c57d404c8fffc9b19199a4da0bb663';
+const VOC_SOCIAL_TOKEN = readEnv('DOUYIN_API_TOKEN') || readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK;
+const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
+const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN;
+const TRANSCRIPTION_GATEWAY = (readEnv('IFLYTEK_GATEWAY_BASE_URL') || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, '');
+const VOC_TOKEN = readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK;
 
 async function handler(request, response) {
   try {
+    const action = pickParam(request, 'action') || '';
+    if (action === 'diagnose') {
+      return diagnose(request, response);
+    }
+
     await ensureTables();
 
-    const action = pickParam(request, 'action') || '';
     const session = await requireSession(request);
     const requestedUserId = pickParam(request, 'userId') || '';
     if (requestedUserId && requestedUserId !== session.userId) {
@@ -36,6 +49,9 @@ async function handler(request, response) {
     if (action === 'dailyReportList') return listRows(response, 'VideoflowDailyReport', userId, pickParam(request, 'limit') || 100);
     if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'id'));
 
+    if (action === 'transcriptStart') return startTranscript(request, response, userId);
+    if (action === 'transcriptGet') return getTranscript(request, response, userId);
+
     response.json({ code: 400, success: false, error: `未知 action: ${action}` });
   } catch (error) {
     console.error('douyinInsightManager failed:', error.message);
@@ -44,7 +60,7 @@ async function handler(request, response) {
 }
 
 async function ensureTables() {
-  for (const table of ['VideoflowViralAnalysis', 'VideoflowTopicIdea', 'VideoflowDailyReport']) {
+  for (const table of ['VideoflowViralAnalysis', 'VideoflowTopicIdea', 'VideoflowDailyReport', 'VideoflowTranscriptJob']) {
     await Psql.query(`
       CREATE TABLE IF NOT EXISTS "${table}" (
         "objectId"  VARCHAR(50) PRIMARY KEY,
@@ -129,11 +145,411 @@ async function updateRow(response, table, userId, id, patch) {
   response.json({ code: 200, success: true, data: merged });
 }
 
+async function startTranscript(request, response, userId) {
+  const awemeId = clean(pickParam(request, 'awemeId'));
+  const analysisId = clean(pickParam(request, 'analysisId'));
+  if (!awemeId) return response.json({ code: 400, success: false, error: '缺少 awemeId' });
+
+  const now = new Date().toISOString();
+  const job = {
+    id: `transcript_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+    awemeId,
+    analysisId,
+    provider: 'iflytek-gateway',
+    status: 'pending',
+    warnings: [],
+    createdAt: now,
+    updatedAt: now,
+  };
+
+  if (!VOC_TOKEN) {
+    job.status = 'needs_provider_config';
+    job.warnings.push('云函数未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。');
+    await upsertTranscriptJob(userId, job);
+    return response.json({ code: 200, success: true, data: job });
+  }
+
+  try {
+    const detail = await fetchDouyinDetail(awemeId);
+    const media = selectAudioCandidate(detail);
+    if (!media.url) {
+      job.status = 'needs_media';
+      job.warnings.push('视频详情中未找到可直接提交转写的音频地址。');
+      await upsertTranscriptJob(userId, job);
+      return response.json({ code: 200, success: true, data: job });
+    }
+
+    const durationMs = media.durationMs || extractDurationMs(detail);
+    if (!durationMs) {
+      job.status = 'needs_media';
+      job.warnings.push('未能确认音频时长,转写网关需要 durationMs。');
+      await upsertTranscriptJob(userId, job);
+      return response.json({ code: 200, success: true, data: job });
+    }
+
+    const uploaded = await uploadGatewayAudio(media.url, durationMs);
+    job.orderId = uploaded.orderId;
+    job.estimateTime = uploaded.estimateTime || 0;
+    job.mediaUrl = media.url;
+    job.durationMs = durationMs;
+    job.warnings.push(`已提交转写任务:${uploaded.orderId}`);
+    await upsertTranscriptJob(userId, job);
+    response.json({ code: 200, success: true, data: job });
+  } catch (error) {
+    job.status = 'failed';
+    job.errorMessage = error.message || '提交逐字稿任务失败';
+    job.warnings.push(job.errorMessage);
+    await upsertTranscriptJob(userId, job);
+    response.json({ code: 200, success: true, data: job });
+  }
+}
+
+async function getTranscript(request, response, userId) {
+  const id = clean(pickParam(request, 'id'));
+  if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+  const rows = await Psql.query(
+    `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
+    [id, userId]
+  );
+  if (!rows.length) return response.json({ code: 404, success: false, error: '未找到逐字稿任务' });
+
+  const job = rowToObj(rows[0]);
+  if (!job.orderId || job.status === 'completed' || job.status === 'failed') {
+    return response.json({ code: 200, success: true, data: job });
+  }
+
+  if (!VOC_TOKEN) {
+    job.status = 'needs_provider_config';
+    job.warnings = [...(job.warnings || []), '云函数未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'];
+    await upsertTranscriptJob(userId, job);
+    return response.json({ code: 200, success: true, data: job });
+  }
+
+  try {
+    const data = await queryGateway(job.orderId);
+    const status = String(gatewayValue(data, 'status') || '').toLowerCase();
+    const text = clean(gatewayValue(data, 'text'));
+    const segments = normalizeGatewaySegments(gatewayValue(data, 'segments'));
+    if (status === 'completed' || text) {
+      job.status = 'completed';
+      job.text = text || segments.map(s => s.text).join('\n');
+      job.segments = segments;
+    } else if (status === 'failed' || status === 'error') {
+      job.status = 'failed';
+      job.errorMessage = gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写失败';
+      job.warnings = [...(job.warnings || []), job.errorMessage];
+    } else {
+      job.status = 'pending';
+      job.warnings = [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])];
+    }
+    job.updatedAt = new Date().toISOString();
+    await upsertTranscriptJob(userId, job);
+    response.json({ code: 200, success: true, data: job });
+  } catch (error) {
+    job.status = 'failed';
+    job.errorMessage = error.message || '查询逐字稿任务失败';
+    job.warnings = [...(job.warnings || []), job.errorMessage];
+    job.updatedAt = new Date().toISOString();
+    await upsertTranscriptJob(userId, job);
+    response.json({ code: 200, success: true, data: job });
+  }
+}
+
+async function upsertTranscriptJob(userId, job) {
+  const existing = await Psql.query(
+    `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
+    [job.id, userId]
+  );
+  if (existing.length) {
+    await Psql.query(
+      `UPDATE "VideoflowTranscriptJob" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
+      [JSON.stringify(job), job.status || '', job.id, userId]
+    );
+    return;
+  }
+  await Psql.query(
+    `INSERT INTO "VideoflowTranscriptJob" ("objectId","bizId","userId","data","status")
+     VALUES ($1,$2,$3,$4,$5)`,
+    [generateId(), job.id, userId, JSON.stringify(job), job.status || '']
+  );
+}
+
 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 };
 }
 
+async function fetchDouyinDetail(awemeId) {
+  if (!DOUYIN_API_TOKEN) {
+    throw new Error('抖音数据网关未配置有效 token:FMode voc-social 请配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN;TikHub 直连才使用 TIKHUB_TOKEN。');
+  }
+
+  const url = new URL(`${DOUYIN_API_BASE_URL}${normalizeDouyinPath('/douyin/app/v3/fetch_one_video_v3')}`);
+  url.searchParams.set('aweme_id', awemeId);
+  let resp;
+  try {
+    resp = await fetch(url.toString(), {
+      method: 'GET',
+      headers: {
+        'Accept': 'application/json',
+        'Authorization': bearerAuth(DOUYIN_API_TOKEN),
+      },
+    });
+  } catch (error) {
+    throw new Error(`抖音数据网关网络请求失败:${formatFetchError(error)}。base=${maskBaseUrl(DOUYIN_API_BASE_URL)} route=/douyin/app/v3/fetch_one_video_v3`);
+  }
+  const data = await resp.json().catch(() => ({}));
+  if (!resp.ok || data.success === false) throw new Error(readGatewayError(data) || `抖音详情获取失败 HTTP ${resp.status}`);
+  return data;
+}
+
+async function diagnose(request, response) {
+  const awemeId = String(pickParam(request, 'awemeId') || '7592116912205630761').trim();
+  const result = {
+    code: 200,
+    success: true,
+    data: {
+      parseApiHost: PARSE_API_HOST,
+      douyinBaseUrl: maskBaseUrl(DOUYIN_API_BASE_URL),
+      isTikhubDirect: IS_TIKHUB_DIRECT,
+      douyinTokenConfigured: !!DOUYIN_API_TOKEN,
+      douyinTokenSource: douyinTokenSource(),
+      transcriptTokenConfigured: !!VOC_TOKEN,
+      transcriptTokenSource: transcriptTokenSource(),
+      transcriptionGateway: maskBaseUrl(TRANSCRIPTION_GATEWAY),
+      probe: null,
+    }
+  };
+
+  if (String(pickParam(request, 'probe') || '') === '1') {
+    try {
+      const detail = await fetchDouyinDetail(awemeId);
+      result.data.probe = {
+        code: detail?.code,
+        success: detail?.success !== false,
+        hasData: !!detail?.data,
+        keys: detail && typeof detail === 'object' ? Object.keys(detail).slice(0, 10) : [],
+      };
+    } catch (error) {
+      result.data.probe = {
+        code: 502,
+        success: false,
+        error: error && error.message ? error.message : String(error || 'probe failed'),
+      };
+    }
+  }
+
+  if (String(pickParam(request, 'probeDb') || '') === '1') {
+    try {
+      await ensureTables();
+      result.data.database = { success: true };
+    } catch (error) {
+      result.data.database = {
+        success: false,
+        error: error && error.message ? error.message : String(error || 'database probe failed'),
+      };
+    }
+  }
+
+  const sessionToken = clean(pickParam(request, 'sessionToken'));
+  if (sessionToken) {
+    try {
+      const user = await verifyParseSession(sessionToken);
+      result.data.session = {
+        success: !!user?.objectId,
+        userId: user?.objectId || '',
+      };
+    } catch (error) {
+      result.data.session = {
+        success: false,
+        error: error && error.message ? error.message : String(error || 'session probe failed'),
+      };
+    }
+  }
+
+  return response.json(result);
+}
+
+function normalizeDouyinPath(path) {
+  if (IS_TIKHUB_DIRECT && !path.startsWith('/api/v1/')) {
+    return `/api/v1${path}`;
+  }
+  return path;
+}
+
+function selectAudioCandidate(detail) {
+  const candidates = [];
+  collectMediaUrls(detail, candidates, []);
+  const audio = candidates.find(item => item.kind === 'audio') || candidates.find(item => /audio|mp4a|music|sound/i.test(item.url));
+  return audio || { url: '', durationMs: extractDurationMs(detail) };
+}
+
+function collectMediaUrls(value, out, path) {
+  if (!value) return;
+  if (Array.isArray(value)) {
+    value.forEach((item, index) => collectMediaUrls(item, out, [...path, String(index)]));
+    return;
+  }
+  if (typeof value !== 'object') return;
+  for (const [key, raw] of Object.entries(value)) {
+    const keyPath = [...path, key].join('.');
+    if (typeof raw === 'string' && /^https?:\/\//i.test(raw)) {
+      const joined = keyPath.toLowerCase();
+      const kind = /audio|music|sound|mp4a/.test(joined) ? 'audio' : /video|play|download|media/.test(joined) ? 'video' : '';
+      if (kind) out.push({ url: raw, kind, keyPath, durationMs: extractDurationMs(value) });
+    } else if (raw && typeof raw === 'object') {
+      collectMediaUrls(raw, out, [...path, key]);
+    }
+  }
+}
+
+function extractDurationMs(value) {
+  const found = findFirstNumber(value, ['duration', 'duration_ms', 'durationMs', 'video_duration']);
+  if (!found) return 0;
+  return found > 1000 ? Math.round(found) : Math.round(found * 1000);
+}
+
+function findFirstNumber(value, keys, depth = 0) {
+  if (!value || depth > 5) return 0;
+  if (Array.isArray(value)) {
+    for (const item of value) {
+      const hit = findFirstNumber(item, keys, depth + 1);
+      if (hit) return hit;
+    }
+    return 0;
+  }
+  if (typeof value !== 'object') return 0;
+  for (const [key, raw] of Object.entries(value)) {
+    if (keys.includes(key)) {
+      const number = Number(raw);
+      if (Number.isFinite(number) && number > 0) return number;
+    }
+    const hit = findFirstNumber(raw, keys, depth + 1);
+    if (hit) return hit;
+  }
+  return 0;
+}
+
+async function uploadGatewayAudio(mediaUrl, durationMs) {
+  let mediaResp;
+  try {
+    mediaResp = await fetch(mediaUrl);
+  } catch (error) {
+    throw new Error(`音频下载网络失败:${formatFetchError(error)}`);
+  }
+  if (!mediaResp.ok) throw new Error(`音频下载失败 HTTP ${mediaResp.status}`);
+  const blob = await mediaResp.blob();
+  const form = new FormData();
+  form.append('audio', blob, `douyin-audio-${Date.now()}.m4a`);
+  form.append('durationMs', String(durationMs));
+  form.append('roleType', '1');
+  form.append('roleNum', '0');
+  let resp;
+  try {
+    resp = await fetch(`${TRANSCRIPTION_GATEWAY}/upload`, {
+      method: 'POST',
+      headers: {
+        Authorization: bearerAuth(VOC_TOKEN),
+        Accept: 'application/json',
+      },
+      body: form,
+    });
+  } catch (error) {
+    throw new Error(`转写网关上传网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)}`);
+  }
+  const data = await resp.json().catch(() => ({}));
+  if (!resp.ok || data.success === false) throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${resp.status}`);
+  const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
+  if (!orderId) throw new Error('转写网关未返回 orderId');
+  return { orderId, estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0) };
+}
+
+async function queryGateway(orderId) {
+  let resp;
+  try {
+    resp = await fetch(`${TRANSCRIPTION_GATEWAY}/result`, {
+      method: 'POST',
+      headers: {
+        Authorization: bearerAuth(VOC_TOKEN),
+        Accept: 'application/json',
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({ orderId }),
+    });
+  } catch (error) {
+    throw new Error(`转写网关查询网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)}`);
+  }
+  const data = await resp.json().catch(() => ({}));
+  if (!resp.ok) throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${resp.status}`);
+  return data;
+}
+
+function gatewayValue(data, key) {
+  return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key];
+}
+
+function readGatewayError(data) {
+  const detail = data?.detail;
+  if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social';
+  const message = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || '';
+  if (/company或用户信息不存在/.test(String(message))) {
+    return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。';
+  }
+  return message;
+}
+
+function normalizeGatewaySegments(segments) {
+  const arr = Array.isArray(segments) ? segments : [];
+  return arr.map(segment => ({
+    start: normalizeTime(segment.start ?? segment.begin ?? segment.bg),
+    end: normalizeTime(segment.end ?? segment.ed),
+    text: clean(segment.text || segment.onebest || segment.content),
+  })).filter(segment => segment.text);
+}
+
+function normalizeTime(value) {
+  const number = Number(value);
+  if (!Number.isFinite(number)) return null;
+  return number > 1000 ? number / 1000 : number;
+}
+
+function bearerAuth(token) {
+  const value = clean(token);
+  return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
+}
+
+function douyinTokenSource() {
+  if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN';
+  if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
+  if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
+  if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
+  if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
+  if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
+  if (IS_TIKHUB_DIRECT && readEnv('TIKHUB_TOKEN')) return 'TIKHUB_TOKEN';
+  if (!IS_TIKHUB_DIRECT && LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
+  return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : '';
+}
+
+function transcriptTokenSource() {
+  if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
+  if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
+  if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
+  if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
+  if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
+  if (LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
+  return '';
+}
+
+function formatFetchError(error) {
+  const message = error && error.message ? error.message : String(error || 'fetch failed');
+  const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : '';
+  return `${message}${cause}`;
+}
+
+function maskBaseUrl(value) {
+  return String(value || '').replace(/(token=)[^&]+/ig, '$1***');
+}
+
 function pickParam(request, ...names) {
   const sources = [request.params, request.body, request];
   for (const src of sources) {

+ 3 - 3
cloud-functions/DEPLOY.md

@@ -18,7 +18,7 @@
 | `authCredit` | `10-authCreditManager.js` | 待部署/暂未启用 | register / login / me / reserve / commit / refund / ledger |
 | `jimeng` | `11-jimengManager.js` | 已配置 | 即梦图片、视频、数字人、动作迁移代理 |
 | `douyin` | `12-douyinManager.js` | 已配置 | TikHub 抖音搜索、详情、评论、博主数据代理 |
-| `douyinInsight` | `13-douyinInsightManager.js` | 已配置 | 爆款分析、选题池、日报资产持久化 |
+| `douyinInsight` | `13-douyinInsightManager.js` | 已配置 | 爆款分析、选题池、日报资产持久化、逐字稿任务 |
 
 ## 部署步骤
 
@@ -41,8 +41,8 @@
 | `09-uploadManager.js` | `QINIU_AK`, `QINIU_SK`, `QINIU_BUCKET`, `QINIU_CDN_DOMAIN`, `QINIU_CDN_PREFIX`, `QINIU_UPLOAD_URL` | 七牛直传 token。 |
 | `10-authCreditManager.js` | Parse session 可用即可;无第三方密钥 | 账号、积分、充值框架。商业化恢复前再启用。 |
 | `11-jimengManager.js` | `JIMENG_TOKEN`, `JIMENG_BASE_URL`, `PARSE_BASE_URL`, `PARSE_APP_ID` | 即梦真实生成代理。 |
-| `12-douyinManager.js` | `TIKHUB_BASE_URL`, `TIKHUB_TOKEN` | 抖音真实平台数据抓取。 |
-| `13-douyinInsightManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID` | 业务资产持久化和用户隔离。 |
+| `12-douyinManager.js` | `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`(可复用 `VOC_TOKEN` / `VOICE_TOKEN`) | 抖音真实平台数据抓取。默认走 `https://server.fmode.cn/api/voc-social`。 |
+| `13-douyinInsightManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID`, `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`, `VOC_TOKEN`(或 `TRANSCRIPTION_VOC_TOKEN` / `VOICE_TOKEN`), `IFLYTEK_GATEWAY_BASE_URL` | 业务资产持久化和用户隔离;逐字稿任务会通过抖音数据网关获取视频详情并调用讯飞网关转写。 |
 
 以上变量均已在云函数源码中改为“环境变量优先、当前兼容值兜底”。正式部署时应在 fmode 云函数平台配置环境变量,并在确认新变量生效后轮换旧凭证。
 

+ 652 - 1
server.js

@@ -46,6 +46,21 @@ const MANIFEST_PATH = path.join(DATA_DIR, 'manifest.json');
 const LEGACY_MANIFEST_PATH = path.join(LEGACY_VIDEO_DIR, 'manifest.json');
 const WHISPER_DIR = path.join(PROJECT_ROOT, 'Whisper');
 const downloadTasks = new Map();
+const transcriptTasks = new Map();
+const TRANSCRIPT_TEMP_DIR = path.join(DATA_DIR, 'douyin-transcripts');
+const DOUYIN_API_BASE_URL = (process.env.DOUYIN_API_BASE_URL || 'https://server.fmode.cn/api/voc-social').replace(/\/+$/, '');
+const TRANSCRIPTION_GATEWAY_BASE_URL = (process.env.IFLYTEK_GATEWAY_BASE_URL || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, '');
+const DOUYIN_GATEWAY_MAX_ATTEMPTS = Math.max(1, Number(process.env.DOUYIN_GATEWAY_MAX_ATTEMPTS || 4));
+const DOUYIN_API_ROUTES = {
+  searchVideos: { method: 'POST', path: '/douyin/search/fetch_general_search_v2' },
+  challengeSearch: { method: 'POST', path: '/douyin/search/fetch_challenge_search_v2' },
+  videoDetail: { method: 'GET', path: '/douyin/app/v3/fetch_one_video_v3' },
+  userProfileWeb: { method: 'GET', path: '/douyin/web/handler_user_profile_v2' },
+  userProfileApp: { method: 'GET', path: '/douyin/app/v3/handler_user_profile' },
+  userPosts: { method: 'GET', path: '/douyin/app/v3/fetch_user_post_videos' },
+  comments: { method: 'GET', path: '/douyin/app/v3/fetch_video_comments' },
+  replies: { method: 'GET', path: '/douyin/app/v3/fetch_video_comment_replies' },
+};
 const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
 const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
 const QINIU_BUCKET = 'nova-repos';
@@ -2764,6 +2779,633 @@ app.post('/api/llm/gemini', async (req, res) => {
 });
 MIGRATED-TO-CLOUD: proxyHub — END */
 
+// ==================== Douyin transcript worker ====================
+
+function cleanText(value) {
+  return String(value || '').trim();
+}
+
+function getVocToken() {
+  return cleanText(
+    process.env.DOUYIN_API_TOKEN
+    || process.env.VOC_TOKEN
+    || process.env.TRANSCRIPTION_VOC_TOKEN
+    || process.env.VOICE_TOKEN
+    || process.env.OPENCLAW_VOC_TOKEN
+    || process.env.VOC_SOCIAL_TOKEN
+  );
+}
+
+function bearerAuth(token) {
+  const value = cleanText(token);
+  return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
+}
+
+function stripEmpty(value) {
+  if (!value || typeof value !== 'object') return value;
+  const out = Array.isArray(value) ? [] : {};
+  for (const [key, val] of Object.entries(value)) {
+    if (val === undefined || val === null || val === '') continue;
+    if (val && typeof val === 'object' && !Array.isArray(val)) {
+      const nested = stripEmpty(val);
+      if (Object.keys(nested).length) out[key] = nested;
+    } else {
+      out[key] = val;
+    }
+  }
+  return out;
+}
+
+function readGatewayError(data, fallback) {
+  return data?.error?.message || data?.error || data?.mess || data?.message || data?.msg || data?.detail || fallback || '';
+}
+
+function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function formatNetworkError(error) {
+  const cause = error?.cause?.code || error?.cause?.message || '';
+  return `${error?.message || 'fetch failed'}${cause ? `;cause=${cause}` : ''}`;
+}
+
+async function fetchDouyinGatewayWithRetry(url, init, routePath) {
+  let lastError = null;
+  for (let attempt = 1; attempt <= DOUYIN_GATEWAY_MAX_ATTEMPTS; attempt += 1) {
+    try {
+      return await fetch(url, init);
+    } catch (error) {
+      lastError = error;
+      const reason = formatNetworkError(error);
+      console.warn(`[douyin-gateway] ${routePath} attempt ${attempt}/${DOUYIN_GATEWAY_MAX_ATTEMPTS} failed: ${reason}`);
+      if (attempt < DOUYIN_GATEWAY_MAX_ATTEMPTS) {
+        await sleep(Math.min(300 * attempt, 1200));
+      }
+    }
+  }
+  throw lastError || new Error('fetch failed');
+}
+
+async function requestDouyinGateway(routeName, params = {}, payload = {}) {
+  const route = DOUYIN_API_ROUTES[routeName];
+  if (!route) {
+    throw Object.assign(new Error('不支持的抖音接口'), { statusCode: 400 });
+  }
+
+  const token = getVocToken();
+  if (!token) {
+    throw Object.assign(new Error('本地抖音数据网关未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN。'), { statusCode: 400 });
+  }
+
+  const url = new URL(`${DOUYIN_API_BASE_URL}${route.path}`);
+  const init = {
+    method: route.method,
+    headers: {
+      'Content-Type': 'application/json',
+      Accept: 'application/json',
+      Authorization: bearerAuth(token),
+    },
+  };
+
+  if (route.method === 'GET') {
+    const query = { ...(payload || {}), ...(params || {}) };
+    for (const [key, value] of Object.entries(query)) {
+      if (value !== undefined && value !== null && value !== '') {
+        url.searchParams.set(key, String(value));
+      }
+    }
+  } else {
+    init.body = JSON.stringify(stripEmpty(payload || {}));
+  }
+
+  let response;
+  try {
+    response = await fetchDouyinGatewayWithRetry(url.toString(), init, route.path);
+  } catch (error) {
+    throw Object.assign(new Error(`本地抖音数据网关网络请求失败:${formatNetworkError(error)};attempts=${DOUYIN_GATEWAY_MAX_ATTEMPTS};base=${DOUYIN_API_BASE_URL} route=${route.path}`), {
+      statusCode: 502,
+    });
+  }
+
+  const text = await response.text();
+  let data = null;
+  try { data = text ? JSON.parse(text) : null; } catch {}
+  if (!response.ok || data?.success === false) {
+    throw Object.assign(new Error(readGatewayError(data, text) || `抖音数据接口请求失败 HTTP ${response.status}`), {
+      statusCode: response.status || 500,
+    });
+  }
+
+  return data || { code: 500, success: false, error: '服务返回异常' };
+}
+
+app.post('/api/douyin/call', async (req, res) => {
+  try {
+    const { route, params = {}, payload = {}, optional = false } = req.body || {};
+    const data = await requestDouyinGateway(route, params, payload);
+    res.json({ success: true, data });
+  } catch (error) {
+    if (req.body?.optional) {
+      return res.json({
+        success: false,
+        optional: true,
+        error: error.message || '本地抖音数据网关调用失败',
+      });
+    }
+    res.status(error.statusCode || 500).json({
+      success: false,
+      error: error.message || '本地抖音数据网关调用失败',
+    });
+  }
+});
+
+app.get('/api/douyin/diagnose', async (req, res) => {
+  const routeName = cleanText(req.query.route || 'videoDetail');
+  const route = DOUYIN_API_ROUTES[routeName] || DOUYIN_API_ROUTES.videoDetail;
+  const result = {
+    success: true,
+    data: {
+      baseUrl: DOUYIN_API_BASE_URL,
+      routeName,
+      routePath: route.path,
+      tokenConfigured: !!getVocToken(),
+      probe: null,
+    },
+  };
+  if (String(req.query.probe || '') === '1') {
+    try {
+      result.data.probe = await requestDouyinGateway(routeName, { aweme_id: cleanText(req.query.awemeId) || '7592116912205630761' });
+    } catch (error) {
+      result.data.probe = { success: false, error: error.message || '探测失败' };
+    }
+  }
+  res.json(result);
+});
+
+function createTranscriptJob(input) {
+  const now = new Date().toISOString();
+  return {
+    id: `transcript_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
+    awemeId: cleanText(input.awemeId),
+    analysisId: cleanText(input.analysisId),
+    provider: cleanText(input.provider) || 'iflytek-gateway',
+    status: 'queued',
+    stageLabel: '已创建转写任务',
+    progress: 0,
+    warnings: [],
+    createdAt: now,
+    updatedAt: now
+  };
+}
+
+function updateTranscriptJob(job, patch) {
+  Object.assign(job, patch, { updatedAt: new Date().toISOString() });
+  transcriptTasks.set(job.id, job);
+  return job;
+}
+
+function findAwemeDetail(node, depth = 0) {
+  if (!node || depth > 8) return null;
+  if (Array.isArray(node)) {
+    for (const item of node) {
+      const found = findAwemeDetail(item, depth + 1);
+      if (found) return found;
+    }
+    return null;
+  }
+  if (typeof node !== 'object') return null;
+  if (node.aweme_id && node.video) return node;
+  if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail;
+  if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info;
+  for (const value of Object.values(node)) {
+    const found = findAwemeDetail(value, depth + 1);
+    if (found) return found;
+  }
+  return null;
+}
+
+function extractAwemeId(value) {
+  const text = cleanText(value);
+  const patterns = [/aweme_id=(\d+)/, /modal_id=(\d+)/, /douyin\.com\/video\/(\d+)/, /douyin\.com\/share\/video\/(\d+)/, /\b(\d{15,25})\b/];
+  for (const pattern of patterns) {
+    const match = text.match(pattern);
+    if (match?.[1]) return match[1];
+  }
+  return /^\d{15,25}$/.test(text) ? text : '';
+}
+
+function decodeMaybeBase64Url(value) {
+  const text = cleanText(value);
+  if (/^https?:\/\//i.test(text)) return text;
+  if (!/^[A-Za-z0-9+/=_-]{20,}$/.test(text)) return '';
+  try {
+    const decoded = Buffer.from(text, 'base64').toString('utf8');
+    return /^https?:\/\//i.test(decoded) ? decoded : '';
+  } catch {
+    return '';
+  }
+}
+
+function isLikelyMediaUrl(url) {
+  return /^https?:\/\//i.test(url) && !/\.(?:jpg|jpeg|png|webp|gif)(?:\?|$)/i.test(url);
+}
+
+function inferMediaKind(pathParts, url) {
+  const joined = pathParts.join('.').toLowerCase();
+  if (/audio|mp4a|music|sound/.test(joined) || /audio|mp4a/i.test(url)) return 'audio';
+  if (/play_addr|download_addr|bit_rate|video|media/.test(joined) || isLikelyMediaUrl(url)) return 'video';
+  return 'unknown';
+}
+
+function collectMediaCandidates(node, pathParts = [], out = []) {
+  if (!node) return out;
+  if (Array.isArray(node)) {
+    node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out));
+    return out;
+  }
+  if (typeof node !== 'object') return out;
+
+  for (const [key, value] of Object.entries(node)) {
+    const nextPath = [...pathParts, key];
+    if (key === 'url_list' && Array.isArray(value)) {
+      value.forEach((item, index) => {
+        const url = decodeMaybeBase64Url(item);
+        const kind = inferMediaKind(nextPath, url);
+        if (url && ['audio', 'video'].includes(kind)) {
+          out.push({
+            url,
+            kind,
+            keyPath: nextPath.join('.'),
+            index,
+            dataSize: Number(node.data_size || node.size || 0),
+            bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0)
+          });
+        }
+      });
+    } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') {
+      const url = decodeMaybeBase64Url(value);
+      const kind = inferMediaKind(nextPath, url);
+      if (url && ['audio', 'video'].includes(kind)) {
+        out.push({
+          url,
+          kind,
+          keyPath: nextPath.join('.'),
+          dataSize: Number(node.data_size || node.size || 0),
+          bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0)
+        });
+      }
+    }
+    collectMediaCandidates(value, nextPath, out);
+  }
+  return out;
+}
+
+function selectMediaCandidate(detail) {
+  const seen = new Set();
+  const candidates = collectMediaCandidates(detail)
+    .filter(item => isLikelyMediaUrl(item.url))
+    .filter(item => {
+      if (seen.has(item.url)) return false;
+      seen.add(item.url);
+      return true;
+    });
+
+  candidates.sort((a, b) => {
+    const aPreferred = a.kind === 'audio' ? 0 : 1;
+    const bPreferred = b.kind === 'audio' ? 0 : 1;
+    if (aPreferred !== bPreferred) return aPreferred - bPreferred;
+    const rank = item => {
+      const keyPath = String(item.keyPath || '').toLowerCase();
+      if (item.kind === 'audio' && keyPath.includes('video.dynamic_audio_list')) return 0;
+      if (item.kind === 'audio' && keyPath.includes('video.bit_rate_audio')) return 1;
+      if (item.kind === 'audio' && keyPath.includes('music.')) return 3;
+      return 2;
+    };
+    const aRank = rank(a);
+    const bRank = rank(b);
+    if (aRank !== bRank) return aRank - bRank;
+    return (a.dataSize || Number.MAX_SAFE_INTEGER) - (b.dataSize || Number.MAX_SAFE_INTEGER);
+  });
+
+  return candidates[0] || null;
+}
+
+function durationFromDetail(detail) {
+  const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs;
+  const numeric = Number(raw || 0);
+  if (!Number.isFinite(numeric) || numeric <= 0) return 0;
+  return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
+}
+
+function extensionFromUrl(url, fallback) {
+  try {
+    const parsed = new URL(url);
+    const ext = path.extname(parsed.pathname).toLowerCase();
+    if (/^\.(mp4|m4a|mp3|wav|aac|mov|webm)$/i.test(ext)) return ext;
+  } catch {}
+  return fallback;
+}
+
+async function fetchDouyinDetailForTranscript(input) {
+  const supplied = findAwemeDetail(input.detail);
+  if (supplied?.aweme_id && supplied.video) return supplied;
+
+  const token = getVocToken();
+  if (!token) {
+    throw Object.assign(new Error('未配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN,无法获取抖音视频详情。'), {
+      status: 'needs_provider_config'
+    });
+  }
+
+  const awemeId = cleanText(input.awemeId) || extractAwemeId(input.sourceUrl || input.url);
+  if (!awemeId) {
+    throw Object.assign(new Error('缺少 awemeId,无法获取视频详情。'), { status: 'needs_media' });
+  }
+
+  const url = new URL(`${DOUYIN_API_BASE_URL}/douyin/app/v3/fetch_one_video_v3`);
+  url.searchParams.set('aweme_id', awemeId);
+  const response = await fetch(url.toString(), {
+    method: 'GET',
+    headers: {
+      Accept: 'application/json',
+      Authorization: bearerAuth(token)
+    }
+  });
+  const data = await response.json().catch(() => ({}));
+  if (!response.ok || data.success === false) {
+    throw new Error(data.error?.message || data.error || data.message || data.mess || `抖音详情获取失败 HTTP ${response.status}`);
+  }
+  const detail = findAwemeDetail(data);
+  if (!detail?.aweme_id) {
+    throw Object.assign(new Error('抖音详情响应中未找到 aweme_detail。'), { status: 'needs_media' });
+  }
+  return detail;
+}
+
+async function downloadTranscriptMedia(candidate, job) {
+  fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true });
+  const ext = extensionFromUrl(candidate.url, candidate.kind === 'audio' ? '.m4a' : '.mp4');
+  const filePath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}-source${ext}`);
+  const { response } = await fetchRemoteVideoResponse([candidate.url], {
+    Accept: '*/*',
+    'User-Agent': 'Mozilla/5.0',
+    Referer: 'https://www.douyin.com/',
+    Origin: 'https://www.douyin.com'
+  });
+  await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath));
+  return filePath;
+}
+
+function extractAudioForTranscript(inputPath, job) {
+  return new Promise((resolve, reject) => {
+    fs.mkdirSync(TRANSCRIPT_TEMP_DIR, { recursive: true });
+    const outputPath = path.join(TRANSCRIPT_TEMP_DIR, `${job.id}.m4a`);
+    const args = ['-y', '-i', inputPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath];
+    const proc = spawn('ffmpeg', args, { cwd: PROJECT_ROOT });
+    let stderr = '';
+    proc.stderr.on('data', data => { stderr += data.toString(); });
+    proc.on('error', err => {
+      reject(Object.assign(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并加入 PATH。' : err.message), {
+        status: 'needs_media_processing'
+      }));
+    });
+    proc.on('close', code => {
+      if (code !== 0 || !fs.existsSync(outputPath)) {
+        const lastLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
+        reject(Object.assign(new Error(`音频提取失败${lastLine ? `:${lastLine}` : ''}`), {
+          status: 'needs_media_processing'
+        }));
+        return;
+      }
+      resolve(outputPath);
+    });
+  });
+}
+
+async function uploadTranscriptGateway(filePath, durationMs) {
+  const token = getVocToken();
+  if (!token) {
+    throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。'), {
+      status: 'needs_provider_config'
+    });
+  }
+  if (!durationMs) {
+    throw Object.assign(new Error('缺少音频时长 durationMs,无法提交转写网关。'), { status: 'needs_media' });
+  }
+
+  const buffer = fs.readFileSync(filePath);
+  const form = new FormData();
+  form.append('audio', new Blob([buffer], { type: 'audio/mp4' }), path.basename(filePath));
+  form.append('durationMs', String(durationMs));
+  form.append('roleType', '1');
+  form.append('roleNum', '0');
+
+  const response = await fetch(`${TRANSCRIPTION_GATEWAY_BASE_URL}/upload`, {
+    method: 'POST',
+    headers: {
+      Authorization: bearerAuth(token),
+      Accept: 'application/json'
+    },
+    body: form
+  });
+  const data = await response.json().catch(() => ({}));
+  if (!response.ok || data.success === false) {
+    throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${response.status}`);
+  }
+  const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
+  if (!orderId) throw new Error('转写网关未返回 orderId。');
+  return {
+    orderId,
+    estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0)
+  };
+}
+
+async function queryTranscriptGateway(orderId) {
+  const token = getVocToken();
+  if (!token) {
+    throw Object.assign(new Error('未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'), {
+      status: 'needs_provider_config'
+    });
+  }
+  const response = await fetch(`${TRANSCRIPTION_GATEWAY_BASE_URL}/result`, {
+    method: 'POST',
+    headers: {
+      Authorization: bearerAuth(token),
+      Accept: 'application/json',
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify({ orderId })
+  });
+  const data = await response.json().catch(() => ({}));
+  if (!response.ok) {
+    throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${response.status}`);
+  }
+  return data;
+}
+
+function gatewayValue(data, key) {
+  return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key];
+}
+
+function normalizeSegmentTime(value) {
+  const number = Number(value);
+  if (!Number.isFinite(number)) return null;
+  return number > 1000 ? number / 1000 : number;
+}
+
+function normalizeTranscriptSegments(segments) {
+  return (Array.isArray(segments) ? segments : []).map(segment => ({
+    start: normalizeSegmentTime(segment.start ?? segment.begin ?? segment.bg),
+    end: normalizeSegmentTime(segment.end ?? segment.ed),
+    text: cleanText(segment.text || segment.onebest || segment.content)
+  })).filter(segment => segment.text);
+}
+
+async function pollTranscriptJob(job) {
+  if (!job.orderId || job.status === 'completed' || job.status === 'failed') return job;
+  const data = await queryTranscriptGateway(job.orderId);
+  const status = cleanText(gatewayValue(data, 'status')).toLowerCase();
+  const text = cleanText(gatewayValue(data, 'text'));
+  const segments = normalizeTranscriptSegments(gatewayValue(data, 'segments'));
+  if (status === 'completed' || text || segments.length) {
+    return updateTranscriptJob(job, {
+      status: 'completed',
+      stageLabel: '转写完成',
+      progress: 100,
+      text: text || segments.map(segment => segment.text).join('\n'),
+      segments
+    });
+  }
+  if (status === 'failed' || status === 'error') {
+    return updateTranscriptJob(job, {
+      status: 'failed',
+      stageLabel: '转写失败',
+      errorMessage: gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写网关返回失败'
+    });
+  }
+  return updateTranscriptJob(job, {
+    status: 'polling_provider',
+    stageLabel: '转写处理中',
+    progress: Math.max(Number(job.progress || 0), 85),
+    warnings: [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])]
+  });
+}
+
+async function runTranscriptJob(job, input) {
+  try {
+    updateTranscriptJob(job, { status: 'resolving_detail', stageLabel: '正在获取视频详情', progress: 10 });
+    const detail = await fetchDouyinDetailForTranscript(input);
+    const awemeId = cleanText(detail.aweme_id || job.awemeId);
+    if (awemeId && awemeId !== job.awemeId) updateTranscriptJob(job, { awemeId });
+
+    updateTranscriptJob(job, { status: 'selecting_media', stageLabel: '正在选择可转写媒体', progress: 25 });
+    const suppliedMediaUrl = cleanText(input.mediaUrl);
+    const candidate = suppliedMediaUrl
+      ? { url: suppliedMediaUrl, kind: /audio|m4a|mp3|aac/i.test(suppliedMediaUrl) ? 'audio' : 'video', keyPath: 'input.mediaUrl' }
+      : selectMediaCandidate(detail);
+    if (!candidate?.url) {
+      updateTranscriptJob(job, {
+        status: 'needs_media',
+        stageLabel: '未找到可转写媒体',
+        progress: 25,
+        warnings: [...(job.warnings || []), '视频详情中未找到音频或视频下载地址。']
+      });
+      return;
+    }
+
+    updateTranscriptJob(job, {
+      status: 'downloading_media',
+      stageLabel: '正在下载媒体',
+      progress: 45,
+      mediaUrl: candidate.url,
+      sourceKind: candidate.kind === 'audio' ? 'douyin_audio' : 'douyin_video'
+    });
+    const sourcePath = await downloadTranscriptMedia(candidate, job);
+    updateTranscriptJob(job, { localVideoPath: sourcePath });
+
+    const durationMs = Number(input.durationMs || durationFromDetail(detail) || 0);
+    let audioPath = sourcePath;
+    if (candidate.kind !== 'audio') {
+      updateTranscriptJob(job, { status: 'extracting_audio', stageLabel: '正在提取音频', progress: 65 });
+      audioPath = await extractAudioForTranscript(sourcePath, job);
+    }
+
+    updateTranscriptJob(job, {
+      status: 'submitting_provider',
+      stageLabel: '正在提交转写网关',
+      progress: 80,
+      localAudioPath: audioPath,
+      durationMs
+    });
+    const uploaded = await uploadTranscriptGateway(audioPath, durationMs);
+    updateTranscriptJob(job, {
+      status: 'polling_provider',
+      stageLabel: '转写任务已提交',
+      progress: 85,
+      orderId: uploaded.orderId,
+      estimateTime: uploaded.estimateTime,
+      warnings: [...(job.warnings || []), `已提交转写任务:${uploaded.orderId}`]
+    });
+  } catch (error) {
+    updateTranscriptJob(job, {
+      status: error.status || 'failed',
+      stageLabel: '转写任务失败',
+      errorMessage: error.message || '转写任务失败',
+      warnings: [...(job.warnings || []), error.message || '转写任务失败']
+    });
+  }
+}
+
+app.post('/api/douyin/transcript/start', (req, res) => {
+  const input = req.body || {};
+  if (!cleanText(input.awemeId) && !cleanText(input.sourceUrl) && !cleanText(input.mediaUrl)) {
+    return res.status(400).json({ success: false, error: '缺少 awemeId、sourceUrl 或 mediaUrl,无法创建转写任务。' });
+  }
+  const job = createTranscriptJob(input);
+  transcriptTasks.set(job.id, job);
+  res.json({ success: true, job: { ...job } });
+  setImmediate(() => runTranscriptJob(job, input));
+});
+
+app.get('/api/douyin/transcript/:jobId', async (req, res) => {
+  const job = transcriptTasks.get(req.params.jobId);
+  if (!job) {
+    return res.status(404).json({ success: false, error: '未找到逐字稿任务。' });
+  }
+  try {
+    if (job.status === 'polling_provider' && job.orderId) {
+      await pollTranscriptJob(job);
+    }
+    res.json({ success: true, job: { ...job } });
+  } catch (error) {
+    updateTranscriptJob(job, {
+      status: error.status || 'failed',
+      stageLabel: '查询转写结果失败',
+      errorMessage: error.message || '查询转写结果失败',
+      warnings: [...(job.warnings || []), error.message || '查询转写结果失败']
+    });
+    res.json({ success: true, job: { ...job } });
+  }
+});
+
+app.post('/api/douyin/transcript/:jobId/retry', (req, res) => {
+  const previous = transcriptTasks.get(req.params.jobId);
+  if (!previous) {
+    return res.status(404).json({ success: false, error: '未找到逐字稿任务。' });
+  }
+  const body = req.body || {};
+  const input = {
+    ...body,
+    awemeId: body.awemeId || previous.awemeId,
+    analysisId: body.analysisId || previous.analysisId,
+    mediaUrl: body.mediaUrl || previous.mediaUrl
+  };
+  const job = createTranscriptJob(input);
+  transcriptTasks.set(job.id, job);
+  res.json({ success: true, job: { ...job } });
+  setImmediate(() => runTranscriptJob(job, input));
+});
+
 // ==================== 健康检查 ====================
 
 app.get('/api/health', (req, res) => {
@@ -2774,7 +3416,16 @@ app.get('/api/health', (req, res) => {
     services: {
       manifest: fs.existsSync(MANIFEST_PATH),
       whisperDir: fs.existsSync(WHISPER_DIR),
-      videoDir: fs.existsSync(DATA_VIDEO_DIR) || fs.existsSync(LEGACY_VIDEO_DIR)
+      videoDir: fs.existsSync(DATA_VIDEO_DIR) || fs.existsSync(LEGACY_VIDEO_DIR),
+      douyinTranscriptWorker: {
+        enabled: true,
+        tempDir: TRANSCRIPT_TEMP_DIR,
+        taskCount: transcriptTasks.size,
+        douyinGateway: DOUYIN_API_BASE_URL,
+        transcriptionGateway: TRANSCRIPTION_GATEWAY_BASE_URL,
+        douyinGatewayMaxAttempts: DOUYIN_GATEWAY_MAX_ATTEMPTS,
+        hasVocToken: !!getVocToken()
+      }
     }
   });
 });

+ 210 - 0
src/app/app.css

@@ -8843,3 +8843,213 @@ code {
     animation-iteration-count: 1 !important;
   }
 }
+
+/* 爆款分析弹窗:生产体验优化 */
+.viral-analysis-overlay {
+  background: rgba(15, 23, 42, 0.62) !important;
+  backdrop-filter: blur(8px);
+}
+
+.viral-analysis-modal {
+  width: min(980px, calc(100vw - 32px)) !important;
+  max-height: min(860px, calc(100vh - 48px));
+  overflow: hidden;
+  border: 1px solid rgba(148, 163, 184, 0.35) !important;
+  border-radius: 16px !important;
+  background: #f8fafc !important;
+  box-shadow: 0 24px 72px rgba(15, 23, 42, 0.28) !important;
+}
+
+.viral-analysis-modal .modal-header {
+  padding: 16px 20px !important;
+  background: #ffffff !important;
+  border-bottom: 1px solid #e2e8f0 !important;
+}
+
+.viral-analysis-modal .modal-header h3 {
+  margin: 0;
+  color: #0f172a !important;
+  font-size: 18px;
+  font-weight: 700;
+}
+
+.viral-analysis-modal .icon-btn {
+  width: 36px;
+  height: 36px;
+  border-radius: 999px;
+  background: #f1f5f9;
+  color: #334155;
+}
+
+.viral-analysis-body {
+  gap: 12px !important;
+  padding: 16px 20px 20px !important;
+  max-height: calc(min(860px, calc(100vh - 48px)) - 132px) !important;
+  background: #f8fafc;
+}
+
+.viral-analysis-hero,
+.viral-analysis-section {
+  border: 1px solid #e2e8f0 !important;
+  border-radius: 10px !important;
+  background: #ffffff !important;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+}
+
+.viral-analysis-hero {
+  padding: 18px !important;
+}
+
+.viral-analysis-section {
+  padding: 16px !important;
+}
+
+.viral-analysis-hero h4,
+.viral-analysis-section h4 {
+  color: #0f172a !important;
+  font-size: 16px;
+  font-weight: 700;
+}
+
+.viral-analysis-hero p,
+.viral-analysis-section p {
+  color: #334155 !important;
+  font-size: 14px;
+  line-height: 1.72;
+}
+
+.viral-analysis-kicker {
+  color: #64748b !important;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.viral-analysis-grid {
+  gap: 12px !important;
+}
+
+.viral-transcript-alert {
+  margin-top: 10px;
+  padding: 12px 14px;
+  border: 1px solid #fed7aa;
+  border-radius: 10px;
+  background: #fff7ed;
+  color: #9a3412;
+}
+
+.viral-transcript-alert strong {
+  display: block;
+  margin-bottom: 4px;
+  font-size: 13px;
+}
+
+.viral-transcript-alert p {
+  margin: 4px 0 0 !important;
+  color: #9a3412 !important;
+  font-size: 13px !important;
+}
+
+.viral-transcript-progress {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-top: 10px;
+  color: #475569;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.viral-transcript-progress__bar {
+  flex: 1;
+  height: 7px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: #e2e8f0;
+}
+
+.viral-transcript-progress__fill {
+  height: 100%;
+  min-width: 4px;
+  border-radius: inherit;
+  background: linear-gradient(90deg, #2563eb, #db2777);
+  transition: width 180ms ease;
+}
+
+.viral-transcript-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 12px;
+}
+
+.viral-transcript-actions .btn {
+  min-height: 40px;
+  padding: 0 14px;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  background: #ffffff;
+  color: #0f172a;
+  font-weight: 600;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
+}
+
+.viral-transcript-actions .btn:hover:not(:disabled) {
+  border-color: #2563eb;
+  color: #1d4ed8;
+  background: #eff6ff;
+}
+
+.viral-transcript-actions .btn:disabled {
+  opacity: 0.78 !important;
+  border-color: #cbd5e1 !important;
+  background: #e2e8f0 !important;
+  color: #64748b !important;
+  cursor: not-allowed;
+  box-shadow: none;
+}
+
+.viral-analysis-footer {
+  padding: 12px 20px !important;
+  background: #ffffff !important;
+  border-top: 1px solid #e2e8f0 !important;
+}
+
+.viral-analysis-footer .btn {
+  min-height: 40px;
+  border-radius: 8px;
+  font-weight: 600;
+}
+
+.viral-analysis-footer .btn-primary {
+  color: #ffffff !important;
+  background: linear-gradient(135deg, #2563eb, #db2777) !important;
+  border: 0 !important;
+}
+
+@media (max-width: 760px) {
+  .viral-analysis-modal {
+    width: calc(100vw - 20px) !important;
+    max-height: calc(100vh - 20px);
+    border-radius: 12px !important;
+  }
+
+  .viral-analysis-hero,
+  .viral-analysis-grid {
+    grid-template-columns: 1fr !important;
+  }
+
+  .viral-analysis-hero {
+    flex-direction: column;
+  }
+
+  .viral-analysis-footer,
+  .viral-transcript-actions {
+    align-items: stretch;
+  }
+
+  .viral-analysis-footer .btn,
+  .viral-transcript-actions .btn {
+    width: 100%;
+    justify-content: center;
+  }
+}

+ 62 - 7
src/app/app.html

@@ -101,6 +101,8 @@
       <!-- 图生视频 Pipeline (P1) -->
       <app-image-generation *ngIf="currentTab === 'image-generation'"></app-image-generation>
 
+      <app-text-to-video *ngIf="currentTab === 'video-generation'"></app-text-to-video>
+
       <app-batch-production *ngIf="currentTab === 'batch-production'"></app-batch-production>
 
       <app-retrospective *ngIf="currentTab === 'retrospective'"></app-retrospective>
@@ -3767,7 +3769,7 @@
 
       <!-- 视频生成工作流模块 -->
 
-      <section class="dh-page vg-page" *ngIf="currentTab === 'video-generation'">
+      <section class="dh-page vg-page" *ngIf="false && currentTab === 'video-generation'">
 
         <header class="dh-page-header">
 
@@ -6220,28 +6222,76 @@
             <p>{{insight.analysis.summary}}</p>
           </section>
 
+          <section class="viral-analysis-section">
+            <h4>证据状态</h4>
+            <p>
+              互动:播放 {{insight.videoSnapshot.playCount || 0}} / 点赞 {{insight.videoSnapshot.diggCount || 0}} / 评论 {{insight.videoSnapshot.commentCount || 0}} / 分享 {{insight.videoSnapshot.shareCount || 0}}
+            </p>
+            <p>
+              样本:评论 {{insight.commentsSnapshot.length || 0}} 条
+              <ng-container *ngIf="insight.repliesSnapshot?.length"> / 回复 {{insight.repliesSnapshot?.length}} 条</ng-container>
+              / 逐字稿:{{insight.transcript ? '已使用' : '未提供,按结构推断'}}
+            </p>
+            <p *ngIf="insight.transcriptJob">
+              转写任务:{{viralTranscriptStatusLabel(insight.transcriptJob)}}
+              <ng-container *ngIf="insight.transcriptJob.stageLabel"> / {{insight.transcriptJob.stageLabel}}</ng-container>
+              <ng-container *ngIf="insight.transcriptJob.orderId"> / {{insight.transcriptJob.orderId}}</ng-container>
+            </p>
+            <div class="viral-transcript-progress" *ngIf="insight.transcriptJob && viralTranscriptIsProcessing(insight.transcriptJob)">
+              <div class="viral-transcript-progress__bar">
+                <div class="viral-transcript-progress__fill" [style.width]="(insight.transcriptJob.progress || 0) + '%'"></div>
+              </div>
+              <span>{{insight.transcriptJob.progress || 0}}%</span>
+            </div>
+            <div class="viral-transcript-alert" *ngIf="viralTranscriptShouldWarn(insight.transcriptJob)">
+              <strong>{{viralTranscriptPrimaryMessage(insight.transcriptJob)}}</strong>
+              <p *ngFor="let warning of (insight.transcriptJob?.warnings || [])">{{warning}}</p>
+              <p *ngIf="insight.transcriptJob?.errorMessage">{{insight.transcriptJob?.errorMessage}}</p>
+            </div>
+            <div class="viral-transcript-actions" *ngIf="!insight.transcript">
+              <button class="btn" type="button" *ngIf="canStartViralTranscript(insight)"
+                      (click)="startViralTranscript()" [disabled]="viralTranscriptBusy">
+                {{viralTranscriptBusy ? '提交中...' : (insight.transcriptJob ? '重新提交转写' : '补逐字稿')}}
+              </button>
+              <button class="btn" type="button" *ngIf="insight.transcriptJob && insight.transcriptJob.status !== 'completed'"
+                      (click)="refreshViralTranscript()" [disabled]="viralTranscriptBusy">
+                {{viralTranscriptBusy ? '查询中...' : '刷新转写结果'}}
+              </button>
+            </div>
+          </section>
+
           <div class="viral-analysis-grid">
+            <section class="viral-analysis-section">
+              <h4>钩子类型</h4>
+              <p>{{insight.analysis.hookType || '待判断'}}</p>
+            </section>
+
             <section class="viral-analysis-section">
               <h4>开头方式</h4>
-              <p>{{insight.analysis.openingPattern || insight.analysis.hookType}}</p>
+              <p>{{insight.analysis.openingPattern || '待判断'}}</p>
             </section>
 
             <section class="viral-analysis-section">
-              <h4>内容节奏</h4>
-              <p>{{insight.analysis.contentRhythm}}</p>
+              <h4>核心冲突</h4>
+              <p>{{insight.analysis.conflict || '待判断'}}</p>
             </section>
 
             <section class="viral-analysis-section">
               <h4>用户情绪</h4>
-              <p>{{insight.analysis.audienceEmotion}}</p>
+              <p>{{insight.analysis.audienceEmotion || '待判断'}}</p>
             </section>
 
             <section class="viral-analysis-section">
               <h4>评论触发点</h4>
-              <p>{{insight.analysis.commentTrigger}}</p>
+              <p>{{insight.analysis.commentTrigger || '暂无可用评论触发点'}}</p>
             </section>
           </div>
 
+          <section class="viral-analysis-section">
+            <h4>内容节奏</h4>
+            <p>{{insight.analysis.contentRhythm}}</p>
+          </section>
+
           <section class="viral-analysis-section">
             <h4>可复用脚本框架</h4>
             <p>{{insight.analysis.reusableFrame}}</p>
@@ -6259,9 +6309,14 @@
             </div>
           </section>
 
+          <section class="viral-analysis-section" *ngIf="insight.analysis.evidenceRefs.length">
+            <h4>证据引用</h4>
+            <p *ngFor="let ref of insight.analysis.evidenceRefs">{{ref}}</p>
+          </section>
+
           <section class="viral-analysis-section" *ngIf="insight.analysis.riskNotes.length || insight.confidence !== 'high'">
             <h4>注意事项</h4>
-            <p *ngIf="insight.confidence !== 'high'">当前未接入完整字幕,结果基于标题、互动数据、评论及可获取的评论回复推断。</p>
+            <p *ngIf="insight.confidence !== 'high'">当前证据不足以判定为高置信,建议补充逐字稿或更多匹配评论后再作为正式脚本。</p>
             <p *ngFor="let note of insight.analysis.riskNotes">{{note}}</p>
           </section>
         </ng-container>

+ 254 - 12
src/app/app.ts

@@ -42,6 +42,7 @@ import { AssetRemixComponent } from './pages/pipelines/asset-remix/asset-remix.c
 
 import { TopicToVideoComponent } from './pages/pipelines/topic-to-video/topic-to-video.component';
 import { ImageGenerationComponent } from './pages/pipelines/image-generation/image-generation.component';
+import { TextToVideoComponent } from './pages/pipelines/text-to-video/text-to-video.component';
 import { TopicPoolComponent } from './pages/topic-pool/topic-pool.component';
 import { AnalysisHistoryComponent } from './pages/analysis-history/analysis-history.component';
 import { DailyReportHistoryComponent } from './pages/daily-report-history/daily-report-history.component';
@@ -79,8 +80,11 @@ import { TaskRecoveryService } from './services/task-recovery.service';
 import { userFailureSuggestion, userFriendlyError } from './services/user-message.util';
 import { ViralAnalysisService } from './services/viral-analysis.service';
 import { TopicPoolService } from './services/topic-pool.service';
-import { DailyReport, TopicIdea, ViralAnalysis } from './models/douyin-insight.model';
+import { DailyReport, DouyinTranscriptJob, TopicIdea, ViralAnalysis } from './models/douyin-insight.model';
 import { DailyReportService } from './services/daily-report.service';
+import { CreationBriefService } from './services/creation-brief.service';
+import { CreationBrief } from './models/creation-brief.model';
+import { DouyinTranscriptService } from './services/douyin-transcript.service';
 
 
 
@@ -737,6 +741,7 @@ interface MonitorAuthor {
 
     TopicToVideoComponent,
     ImageGenerationComponent,
+    TextToVideoComponent,
     TopicPoolComponent,
     AnalysisHistoryComponent,
     DailyReportHistoryComponent,
@@ -791,6 +796,8 @@ export class App {
   private readonly viralAnalysis = inject(ViralAnalysisService);
   private readonly topicPool = inject(TopicPoolService);
   private readonly dailyReports = inject(DailyReportService);
+  private readonly creationBriefs = inject(CreationBriefService);
+  private readonly douyinTranscripts = inject(DouyinTranscriptService);
 
 
   protected readonly title = signal('抖音AI视频生成系统');
@@ -855,6 +862,7 @@ export class App {
   currentViralAnalysis: ViralAnalysis | null = null;
 
   viralAnalysisSourceTitle: string = '';
+  viralTranscriptBusy: boolean = false;
 
   monitorUniqueIdInput: string = '';
 
@@ -2392,10 +2400,10 @@ export class App {
 
   /** 首屏组件输出:可能带 payload(如预填的主题) */
   onTopicPoolNavigate(ev: { tab: 'topic-to-video' | 'digital-human'; topic: TopicIdea }): void {
-    if (ev.tab === 'topic-to-video') {
-      try {
-        sessionStorage.setItem('t2v.prefillTopic', ev.topic.title || ev.topic.angle);
-      } catch {}
+    const brief = this.creationBriefs.fromTopicIdea(ev.topic);
+    this.creationBriefs.savePendingBrief(ev.tab, brief);
+    if (ev.tab === 'digital-human') {
+      this.prefillDigitalHumanFromBrief(brief);
     }
     this.setCurrentTab(ev.tab);
     this.showToast(`已带入选题:${ev.topic.title}`, 'success', 3000);
@@ -5947,11 +5955,13 @@ export class App {
 
         : [analysis.analysis.summary || analysis.videoSnapshot.desc || '爆款拆解选题'];
 
-      const saved = angles.slice(0, 3).map((angle) => this.topicPool.saveTopic({
+      const candidates = angles.slice(0, 5).map((angle, index, list) => this.buildViralTopicCandidate(analysis, angle, index, list.length));
+
+      const saved = candidates.map((candidate) => this.topicPool.saveTopic({
 
-        title: angle,
+        title: candidate.title,
 
-        angle,
+        angle: candidate.angle,
 
         sourceType: 'viral_analysis',
 
@@ -5959,13 +5969,29 @@ export class App {
 
         sourceAuthorIds: analysis.videoSnapshot.authorId ? [analysis.videoSnapshot.authorId] : [],
 
-        tags: ['爆款分析'],
+        tags: candidate.tags,
+
+        audience: candidate.audience,
+
+        hook: candidate.hook,
+
+        outline: candidate.fullOutline,
+
+        shortOutline: candidate.shortOutline,
+
+        fullOutline: candidate.fullOutline,
+
+        sourceTitle: analysis.videoSnapshot.desc || analysis.awemeId,
 
-        audience: '',
+        sourceSummary: analysis.analysis.summary,
 
-        hook: analysis.analysis.openingPattern,
+        sourceEvidence: candidate.sourceEvidence,
 
-        outline: analysis.analysis.reusableFrame,
+        variantIndex: candidate.variantIndex,
+
+        variantTotal: candidates.length,
+
+        isRecommended: candidate.variantIndex === 1,
 
         status: 'idea',
 
@@ -5987,6 +6013,77 @@ export class App {
 
   }
 
+  private buildViralTopicCandidate(analysis: ViralAnalysis, rawAngle: string, index: number, total: number): {
+    title: string;
+    angle: string;
+    hook: string;
+    audience: string;
+    shortOutline: string;
+    fullOutline: string;
+    sourceEvidence: string[];
+    tags: string[];
+    variantIndex: number;
+  } {
+    const angle = this.cleanTopicText(rawAngle) || analysis.videoSnapshot.desc || '爆款拆解选题';
+    const title = this.buildTopicTitle(angle, index, total);
+    const hook = this.buildTopicHook(analysis, angle);
+    const conflict = this.cleanTopicText(analysis.analysis.conflict) || '用户已有认知和真实结果之间的反差';
+    const commentTrigger = this.cleanTopicText(analysis.analysis.commentTrigger) || '你遇到过类似问题吗?';
+    const proof = this.cleanTopicText(analysis.analysis.proofPoint) || '来源视频互动和评论反馈';
+    const audience = this.cleanTopicText(analysis.analysis.audienceEmotion) || '对该问题有明确关注的用户';
+    const shortOutline = [
+      `1. 开头:${hook}`,
+      `2. 冲突:${conflict}`,
+      `3. 论证:围绕「${angle}」拆 2-3 个判断依据`,
+      `4. 互动:用「${commentTrigger}」引导评论`,
+    ].join('\n');
+    const fullOutline = [
+      `选题标题:${title}`,
+      `核心角度:${angle}`,
+      `开头钩子:${hook}`,
+      '',
+      '脚本框架:',
+      `1. 先用一句具体判断切入:${hook}`,
+      `2. 放大用户正在犹豫的点:${conflict}`,
+      `3. 给出你的核心判断:这条内容真正要讲的是「${angle}」。`,
+      `4. 拆 2-3 个理由:每个理由都配一个场景、案例、数字或前后对比。`,
+      `5. 补一个可信依据:${proof}。`,
+      `6. 结尾抛出评论问题:${commentTrigger}`,
+    ].join('\n');
+    return {
+      title,
+      angle,
+      hook,
+      audience,
+      shortOutline,
+      fullOutline,
+      sourceEvidence: [
+        ...(analysis.analysis.evidenceRefs || []),
+        ...(analysis.analysis.riskNotes || []).map((item) => `风险提醒:${item}`),
+      ].filter(Boolean).slice(0, 8),
+      tags: ['爆款分析', `方向${index + 1}/${total}`, analysis.analysis.hookType].filter(Boolean),
+      variantIndex: index + 1,
+    };
+  }
+
+  private buildTopicTitle(angle: string, index: number, total: number): string {
+    const cleaned = this.cleanTopicText(angle).replace(/^把「?(.+?)」?改成你的账号口播版本$/, '$1 的账号口播改写');
+    if (total <= 1) return cleaned;
+    return `方向 ${index + 1}/${total}:${cleaned}`;
+  }
+
+  private buildTopicHook(analysis: ViralAnalysis, angle: string): string {
+    const opening = this.cleanTopicText(analysis.analysis.openingPattern);
+    if (opening && opening.length <= 54) return opening;
+    if (/评论|问题|追问/.test(angle)) return `从评论里的真实问题切入:${angle}`;
+    if (/避坑|误区|别|不要|踩雷/.test(angle)) return `先指出一个常见误区,再给出你的判断:${angle}`;
+    return `先抛出一个具体判断:${angle}`;
+  }
+
+  private cleanTopicText(value: unknown): string {
+    return String(value || '').replace(/\s+/g, ' ').trim();
+  }
+
 
 
   useViralAnalysisForTopicVideo(): void {
@@ -5997,12 +6094,144 @@ export class App {
 
     const angle = analysis.analysis.reusableAngles[0] || analysis.analysis.summary || analysis.videoSnapshot.desc;
 
+    const brief: CreationBrief = {
+      id: `brief_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+      source: 'viral_analysis',
+      title: angle,
+      angle,
+      audience: analysis.analysis.audienceEmotion || '',
+      hook: analysis.analysis.openingPattern || analysis.analysis.hookType || '',
+      outline: [
+        analysis.analysis.reusableFrame,
+        analysis.analysis.conflict ? `核心冲突:${analysis.analysis.conflict}` : '',
+        analysis.analysis.commentTrigger ? `评论触发点:${analysis.analysis.commentTrigger}` : '',
+      ].filter(Boolean).join('\n'),
+      tags: ['爆款分析', analysis.analysis.hookType].filter(Boolean),
+      sourceVideoIds: [analysis.awemeId],
+      sourceAuthorIds: analysis.videoSnapshot.authorId ? [analysis.videoSnapshot.authorId] : [],
+      confidence: analysis.confidence,
+      evidence: [
+        { type: 'video', id: analysis.awemeId, text: analysis.videoSnapshot.desc, confidence: analysis.confidence },
+        ...(analysis.analysis.evidenceRefs || []).map((text) => ({ type: 'analysis' as const, text, confidence: analysis.confidence })),
+      ],
+      createdAt: new Date().toISOString(),
+    };
+    this.creationBriefs.savePendingBrief('topic-to-video', brief);
+
     this.showToast(`已整理选题:${angle}`, 'success');
 
     this.setCurrentTab('topic-to-video');
 
   }
 
+  async startViralTranscript(): Promise<void> {
+    const analysis = this.currentViralAnalysis;
+    if (!analysis || !this.canStartViralTranscript(analysis)) return;
+    this.viralTranscriptBusy = true;
+    try {
+      const job = await this.douyinTranscripts.startForAnalysis(analysis);
+      this.currentViralAnalysis = {
+        ...analysis,
+        transcriptJob: job,
+        updatedAt: new Date().toISOString(),
+      };
+      this.showToast(
+        this.getViralTranscriptMessage(job),
+        this.viralTranscriptIsProcessing(job) || job.status === 'completed' ? 'success' : 'warn',
+        5000
+      );
+    } catch (err: any) {
+      this.showToast(this.getErrorText(err, '逐字稿任务提交失败'), 'error');
+    } finally {
+      this.viralTranscriptBusy = false;
+      this.refreshView();
+    }
+  }
+
+  async refreshViralTranscript(): Promise<void> {
+    const analysis = this.currentViralAnalysis;
+    if (!analysis?.transcriptJob || this.viralTranscriptBusy) return;
+    this.viralTranscriptBusy = true;
+    try {
+      const job = await this.douyinTranscripts.refreshForAnalysis(analysis);
+      const updated = this.viralAnalysis.getLocalAnalysis(analysis.id);
+      this.currentViralAnalysis = updated || {
+        ...analysis,
+        transcriptJob: job,
+        transcript: job.status === 'completed' && job.text ? job.text : analysis.transcript,
+        transcriptSource: job.status === 'completed' && job.text ? 'asr' : analysis.transcriptSource,
+        confidence: job.status === 'completed' && job.text ? 'high' : analysis.confidence,
+        updatedAt: new Date().toISOString(),
+      };
+      this.showToast(job.status === 'completed' ? '逐字稿已回填' : '逐字稿仍在处理中', job.status === 'completed' ? 'success' : 'info', 4000);
+    } catch (err: any) {
+      this.showToast(this.getErrorText(err, '逐字稿查询失败'), 'error');
+    } finally {
+      this.viralTranscriptBusy = false;
+      this.refreshView();
+    }
+  }
+
+  canStartViralTranscript(analysis: ViralAnalysis | null | undefined): boolean {
+    if (!analysis || this.viralTranscriptBusy || !!analysis.transcript) return false;
+    const status = analysis.transcriptJob?.status;
+    return !status || status === 'failed' || status === 'needs_media' || status === 'needs_media_processing' || status === 'needs_provider_config';
+  }
+
+  viralTranscriptStatusLabel(job: DouyinTranscriptJob | undefined): string {
+    if (!job) return '未提交';
+    const labels: Record<string, string> = {
+      queued: '排队中',
+      resolving_detail: '获取视频详情',
+      selecting_media: '选择媒体',
+      downloading_media: '下载媒体',
+      extracting_audio: '提取音频',
+      submitting_provider: '提交转写',
+      polling_provider: '等待结果',
+      pending: '处理中',
+      completed: '已完成',
+      failed: '失败',
+      needs_media: '缺少可转写媒体',
+      needs_media_processing: '需要媒体处理环境',
+      needs_provider_config: '服务未配置',
+    };
+    return labels[job.status] || job.stageLabel || '未知状态';
+  }
+
+  viralTranscriptPrimaryMessage(job: DouyinTranscriptJob | undefined): string {
+    if (!job) return '';
+    return this.getViralTranscriptMessage(job);
+  }
+
+  viralTranscriptIsProcessing(job: DouyinTranscriptJob | undefined): boolean {
+    return !!job && [
+      'queued',
+      'resolving_detail',
+      'selecting_media',
+      'downloading_media',
+      'extracting_audio',
+      'submitting_provider',
+      'polling_provider',
+      'pending',
+    ].includes(job.status);
+  }
+
+  viralTranscriptShouldWarn(job: DouyinTranscriptJob | undefined): boolean {
+    return !!job && job.status !== 'completed' && !this.viralTranscriptIsProcessing(job);
+  }
+
+  private getViralTranscriptMessage(job: DouyinTranscriptJob): string {
+    if (job.stageLabel && this.viralTranscriptIsProcessing(job)) return job.stageLabel;
+    if (job.status === 'pending' || job.status === 'queued') return '逐字稿任务已提交,稍后可刷新结果';
+    if (job.status === 'completed') return '逐字稿已回填';
+    if (job.status === 'needs_media') return '未找到可转写媒体,请确认视频详情能返回音频或视频地址';
+    if (job.status === 'needs_media_processing') return job.errorMessage || '需要可用的媒体处理环境,请确认本地 ffmpeg 可用';
+    if (job.status === 'needs_provider_config') {
+      return job.warnings?.[0] || '未读取到转写服务配置,请检查 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN';
+    }
+    return job.errorMessage || job.warnings?.[0] || '逐字稿任务失败,可修复配置后重新提交';
+  }
+
 
 
   private startViralAnalysis(input: Parameters<ViralAnalysisService['analyzeVideo']>[0], sourceTitle: string): void {
@@ -13901,6 +14130,19 @@ ${taskOne}
     if (status === 'running') this.pipelineSession.markRunning();
   }
 
+  private prefillDigitalHumanFromBrief(brief: CreationBrief): void {
+    const prefill = this.creationBriefs.toDigitalHumanPrefill(brief);
+    this.dhAudioSource = 'tts';
+    this.dhTtsText = prefill.ttsText.slice(0, 600);
+    this.dhPrompt = prefill.prompt.slice(0, 200);
+    this.dhStep = 'form';
+    this.dhError = '';
+    this.dhStatusText = '';
+    this.dhProgress = 0;
+    this.dhResultVideoUrl = '';
+    this.syncDigitalHumanDraft('draft');
+  }
+
   private completeDigitalHumanDraft(videoUrl: string, imageUrl?: string, audioUrl?: string): void {
     this.syncDigitalHumanDraft('running');
 

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

@@ -19,7 +19,6 @@ import {
   Conversation,
 } from '../../services/assistant.service';
 import { MarkdownService } from '../../services/markdown.service';
-import { SafeHtml } from '@angular/platform-browser';
 
 /**
  * 全局 AI 助手浮窗
@@ -61,7 +60,7 @@ export class AppAssistantComponent implements OnInit, OnDestroy, AfterViewChecke
   ) {}
 
   /** 渲染助手消息为安全 HTML(用户消息保持纯文本即可) */
-  renderMd(text: string): SafeHtml {
+  renderMd(text: string): string {
     return this.md.render(text);
   }
 

+ 1 - 1
src/app/components/app-sidebar/app-sidebar.component.ts

@@ -40,7 +40,7 @@ const ZONES: SidebarZone[] = [
       { tab: 'digital-human', label: '数字人合成' },
       { tab: 'action-transfer', label: '动作迁移' },
       { tab: 'asset-remix', label: '素材合成视频' },
-      { tab: 'video-generation', label: '标准视频生成' },
+      { tab: 'video-generation', label: '文生视频' },
     ],
   },
   {

+ 47 - 0
src/app/models/creation-brief.model.ts

@@ -0,0 +1,47 @@
+import { InsightConfidence } from './douyin-insight.model';
+
+export type CreationBriefSource = 'topic_pool' | 'viral_analysis' | 'daily_report' | 'assistant' | 'manual';
+
+export interface CreationBriefEvidence {
+  type: 'video' | 'comment' | 'transcript' | 'analysis' | 'daily_report';
+  id?: string;
+  text?: string;
+  url?: string;
+  confidence?: InsightConfidence;
+}
+
+export interface CreationBrief {
+  id: string;
+  source: CreationBriefSource;
+  topicId?: string;
+  title: string;
+  angle: string;
+  audience?: string;
+  hook?: string;
+  outline?: string;
+  tags: string[];
+  sourceVideoIds: string[];
+  sourceAuthorIds: string[];
+  confidence?: InsightConfidence;
+  evidence: CreationBriefEvidence[];
+  createdAt: string;
+}
+
+export interface TopicToVideoPrefill {
+  topic: string;
+  angle?: string;
+  hook?: string;
+  outline?: string;
+  context?: string;
+  topicId?: string;
+  source: CreationBriefSource;
+  brief: CreationBrief;
+}
+
+export interface DigitalHumanPrefill {
+  ttsText: string;
+  prompt: string;
+  topicId?: string;
+  source: CreationBriefSource;
+  brief: CreationBrief;
+}

+ 51 - 0
src/app/models/douyin-insight.model.ts

@@ -52,6 +52,7 @@ export interface ViralAnalysis {
   repliesSnapshot?: ViralReplySnapshot[];
   transcript?: string;
   transcriptSource?: 'detail' | 'asr' | 'manual';
+  transcriptJob?: DouyinTranscriptJob;
   confidence: InsightConfidence;
   analysis: ViralAnalysisContent;
   savedTopicIds: string[];
@@ -59,6 +60,48 @@ export interface ViralAnalysis {
   updatedAt: string;
 }
 
+export type DouyinTranscriptStatus =
+  | 'queued'
+  | 'resolving_detail'
+  | 'selecting_media'
+  | 'downloading_media'
+  | 'extracting_audio'
+  | 'submitting_provider'
+  | 'polling_provider'
+  | 'pending'
+  | 'completed'
+  | 'failed'
+  | 'needs_media'
+  | 'needs_media_processing'
+  | 'needs_provider_config';
+
+export type DouyinTranscriptProvider = 'iflytek-gateway' | 'whisper' | 'manual';
+
+export interface DouyinTranscriptJob {
+  id: string;
+  awemeId: string;
+  analysisId?: string;
+  provider: DouyinTranscriptProvider;
+  status: DouyinTranscriptStatus;
+  stageLabel?: string;
+  progress?: number;
+  sourceUrl?: string;
+  mediaUrl?: string;
+  sourceKind?: 'douyin_audio' | 'douyin_video' | 'direct_media_url' | 'local_file' | 'gateway_order';
+  localVideoId?: string;
+  localVideoPath?: string;
+  localAudioPath?: string;
+  durationMs?: number;
+  orderId?: string;
+  estimateTime?: number;
+  text?: string;
+  segments?: Array<{ start?: number | null; end?: number | null; text: string }>;
+  warnings: string[];
+  errorMessage?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
 export interface TopicIdea {
   id: string;
   userId: string;
@@ -71,6 +114,14 @@ export interface TopicIdea {
   audience?: string;
   hook?: string;
   outline?: string;
+  shortOutline?: string;
+  fullOutline?: string;
+  sourceTitle?: string;
+  sourceSummary?: string;
+  sourceEvidence?: string[];
+  variantIndex?: number;
+  variantTotal?: number;
+  isRecommended?: boolean;
   status: 'idea' | 'script_ready' | 'generating' | 'completed' | 'archived';
   pipelineTarget?: 'topic_video' | 'digital_human' | 'image_generation' | 'asset_remix';
   confidence?: InsightConfidence;

+ 30 - 0
src/app/models/video-generation-capability.model.ts

@@ -0,0 +1,30 @@
+export type VideoGenerationMode =
+  | 'text-to-video'
+  | 'image-to-video-first-frame'
+  | 'image-to-video-first-last'
+  | 'image-to-video-camera'
+  | 'topic-to-video'
+  | 'batch-topic-to-video';
+
+export type VideoGenerationQuality = '720p' | '1080p' | 'pro';
+
+export interface VideoDurationOption {
+  seconds: number;
+  label: string;
+  recommended?: boolean;
+}
+
+export interface VideoGenerationCapability {
+  mode: VideoGenerationMode;
+  qualities: VideoGenerationQuality[];
+  defaultQuality: VideoGenerationQuality;
+  minSeconds: number;
+  maxSeconds: number;
+  defaultSeconds: number;
+  recommendedDurations: VideoDurationOption[];
+  allowCustomDuration: boolean;
+  unsupportedCombinations: Array<{
+    quality?: VideoGenerationQuality;
+    reason: string;
+  }>;
+}

+ 7 - 1
src/app/pages/analysis-history/analysis-history.component.html

@@ -72,10 +72,16 @@
           <span>{{item.videoSnapshot.authorName || '未知作者'}}</span>
           <span>评论 {{item.commentsSnapshot.length || 0}} 条</span>
           <span *ngIf="item.repliesSnapshot?.length">回复 {{item.repliesSnapshot?.length}} 条</span>
-          <span *ngIf="item.transcript">已使用字幕</span>
+          <span>{{item.transcript ? '已使用字幕' : '结构推断'}}</span>
+          <span *ngIf="item.analysis.riskNotes.length">风险 {{item.analysis.riskNotes.length }} 条</span>
           <span>更新时间 {{formatTime(item.updatedAt)}}</span>
         </div>
 
+        <div class="analysis-meta" *ngIf="item.analysis.hookType || item.analysis.conflict">
+          <span *ngIf="item.analysis.hookType">钩子:{{item.analysis.hookType}}</span>
+          <span *ngIf="item.analysis.conflict">冲突:{{item.analysis.conflict}}</span>
+        </div>
+
         <div class="analysis-angles" *ngIf="item.analysis.reusableAngles.length">
           <span *ngFor="let angle of item.analysis.reusableAngles.slice(0, 3)">{{angle}}</span>
         </div>

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

@@ -28,7 +28,6 @@ import { ContentAssetService } from '../../services/content-asset.service';
 import { CreatorDashboardService } from '../../services/creator-dashboard.service';
 import { GenerationTaskService } from '../../services/generation-task.service';
 import { MarkdownService } from '../../services/markdown.service';
-import { SafeHtml } from '@angular/platform-browser';
 
 interface RecentResult {
   id: string;
@@ -111,7 +110,7 @@ export class HomeComponent implements OnInit, OnDestroy, AfterViewChecked {
   ) {}
 
   /** 渲染助手消息为安全 HTML */
-  renderMd(text: string): SafeHtml {
+  renderMd(text: string): string {
     return this.md.render(text);
   }
 

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

@@ -139,11 +139,15 @@
       <div class="dh-substep">
         <div class="dh-substep__head"><h4>视频时长</h4></div>
         <div class="dh-segment">
-          <button class="dh-segment__item" [class.is-active]="frames === 121"
-                  [disabled]="generating" (click)="frames = 121">5 秒</button>
-          <button class="dh-segment__item" [class.is-active]="frames === 241"
-                  [disabled]="generating" (click)="frames = 241">10 秒</button>
+          <button class="dh-segment__item" *ngFor="let sec of durationOptions"
+                  [class.is-active]="durationSeconds === sec"
+                  [disabled]="generating" (click)="setDurationSeconds(sec)">{{sec}} 秒</button>
         </div>
+        <input class="dh-input" type="number" min="3" max="15" step="1"
+               [ngModel]="durationSeconds"
+               [disabled]="generating"
+               (ngModelChange)="setDurationSeconds($event)"
+               placeholder="自定义秒数">
       </div>
 
       <div class="dh-substep">

+ 19 - 6
src/app/pages/pipelines/image-to-video/image-to-video.component.ts

@@ -17,6 +17,7 @@ import { DraftMeta } from '../../../models/pipeline-draft.model';
 import { userFriendlyError } from '../../../services/user-message.util';
 import { CostEstimatorService } from '../../../services/cost-estimator.service';
 import { GenerationTaskService } from '../../../services/generation-task.service';
+import { VideoDurationService } from '../../../services/video-duration.service';
 
 interface CameraTemplateOption {
   id: JimengCameraTemplateId;
@@ -55,7 +56,8 @@ interface I2vSnapshot {
   mode: I2vMode;
   quality: I2vQuality;
   aspect: I2vAspect;
-  frames: 121 | 241;
+  frames: number;
+  durationSeconds?: number;
   cameraTemplate: JimengCameraTemplateId;
   cameraStrength: JimengCameraStrength;
   firstFrame: { url: string; name: string; size: number } | null;
@@ -84,8 +86,10 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
   mode: I2vMode = '2';
   quality: I2vQuality = '1080p';
   aspect: I2vAspect = '16:9';
-  /** 121 ≈ 5s,241 ≈ 10s */
-  frames: 121 | 241 = 121;
+  /** 用户看秒数,提交时转换为 frames */
+  durationSeconds = 5;
+  frames = 121;
+  readonly durationOptions = [5, 8, 10, 15];
 
   // 运镜参数
   readonly cameraTemplates = CAMERA_TEMPLATES;
@@ -127,6 +131,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     public session: PipelineSessionService,
     private generationTasks: GenerationTaskService,
     private costEstimator: CostEstimatorService,
+    private videoDuration: VideoDurationService,
   ) {}
 
   ngOnInit(): void {
@@ -147,6 +152,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
       quality: this.quality,
       aspect: this.aspect,
       frames: this.frames,
+      durationSeconds: this.durationSeconds,
       cameraTemplate: this.cameraTemplate,
       cameraStrength: this.cameraStrength,
       firstFrame: stripPreview(this.firstFrame),
@@ -162,7 +168,8 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     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.durationSeconds = Math.max(3, Math.min(15, Math.round(Number(snap.durationSeconds ?? this.videoDuration.framesToSeconds(snap.frames ?? 121)) || 5)));
+    this.frames = this.videoDuration.secondsToFrames(this.durationSeconds);
     this.cameraTemplate = snap.cameraTemplate ?? 'quick_pull_back';
     this.cameraStrength = snap.cameraStrength ?? 'medium';
     // 恢复首/尾帧:preview 丢失(本地预览 URL),调用方仅依赖 url 即可继续生成
@@ -319,6 +326,12 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.quality = q;
   }
 
+  setDurationSeconds(seconds: number): void {
+    this.durationSeconds = Math.max(3, Math.min(15, Math.round(Number(seconds) || 5)));
+    this.frames = this.videoDuration.secondsToFrames(this.durationSeconds);
+    this.syncDraft();
+  }
+
   get canGenerate(): boolean {
     if (this.generating) return false;
     // 运镜模式提示词可选;其它模式必填
@@ -338,7 +351,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.errorMsg = '';
     this.resultVideoUrl = '';
     this.resultWorkId = '';
-    const estimate = this.costEstimator.estimateImageToVideo(this.quality, this.costEstimator.framesToSeconds(this.frames));
+    const estimate = this.costEstimator.estimateImageToVideo(this.quality, this.durationSeconds);
     const task = this.generationTasks.create({
       title: this.deriveTitle(),
       pipelineId: 'image-to-video',
@@ -417,7 +430,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
               url: result.videoUrl,
               title: `图生视频-${modeLabel}-${this.quality.toUpperCase()}`,
               quality: this.quality,
-              duration: this.frames === 241 ? '10s' : '5s',
+              duration: `${this.durationSeconds}s`,
               pipelineId: 'image_to_video',
               extras,
             }).subscribe();

+ 103 - 0
src/app/pages/pipelines/text-to-video/text-to-video.component.css

@@ -0,0 +1,103 @@
+.t2video-page .dh-textarea {
+  min-height: 240px;
+  resize: vertical;
+}
+
+.t2video-preset-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.t2video-card-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.t2video-option-card {
+  min-height: 52px;
+  padding: 10px 12px;
+  border: 1px solid rgba(37, 99, 235, 0.16);
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.58);
+  color: #0f172a;
+  text-align: left;
+  cursor: pointer;
+  transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
+}
+
+.t2video-option-card span {
+  display: block;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.t2video-option-card small {
+  display: block;
+  margin-top: 4px;
+  font-size: 11px;
+  color: #64748b;
+}
+
+.t2video-option-card:hover:not(:disabled) {
+  border-color: rgba(37, 99, 235, 0.42);
+  background: rgba(37, 99, 235, 0.06);
+}
+
+.t2video-option-card.is-active {
+  border-color: #2563eb;
+  background: rgba(37, 99, 235, 0.1);
+  box-shadow: 0 4px 12px rgba(37, 99, 235, 0.16);
+}
+
+.t2video-option-card:disabled {
+  cursor: not-allowed;
+  opacity: 0.62;
+}
+
+.t2video-tip {
+  margin: 8px 0 0;
+  font-size: 12px;
+  color: #64748b;
+  line-height: 1.55;
+}
+
+.t2video-cancel {
+  width: 100%;
+  justify-content: center;
+  margin-top: 10px;
+}
+
+.t2video-result-meta {
+  font-size: 12px;
+  color: #64748b;
+}
+
+.app-container.theme-night .t2video-option-card {
+  background: rgba(255, 255, 255, 0.04);
+  border-color: rgba(96, 165, 250, 0.18);
+  color: #f8fafc;
+}
+
+.app-container.theme-night .t2video-option-card:hover:not(:disabled) {
+  border-color: rgba(96, 165, 250, 0.5);
+  background: rgba(96, 165, 250, 0.08);
+}
+
+.app-container.theme-night .t2video-option-card.is-active {
+  border-color: #60a5fa;
+  background: rgba(96, 165, 250, 0.15);
+}
+
+.app-container.theme-night .t2video-option-card small,
+.app-container.theme-night .t2video-tip,
+.app-container.theme-night .t2video-result-meta {
+  color: #94a3b8;
+}
+
+@media (max-width: 720px) {
+  .t2video-card-grid {
+    grid-template-columns: 1fr;
+  }
+}

+ 162 - 0
src/app/pages/pipelines/text-to-video/text-to-video.component.html

@@ -0,0 +1,162 @@
+<section class="dh-page pl-page t2video-page">
+  <app-session-bar
+    pipelineId="video-generation"
+    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">
+        <path d="M4 5h10"></path>
+        <path d="M4 9h7"></path>
+        <rect x="3" y="13" width="12" height="7" rx="2"></rect>
+        <path d="M17 15l4-2v7l-4-2z"></path>
+      </svg>
+    </div>
+    <div class="dh-header-text">
+      <h2>文生视频</h2>
+      <p>输入一段画面描述,直接生成单段 AI 视频。</p>
+    </div>
+  </header>
+
+  <div class="pl-grid">
+    <div class="dh-step-col">
+      <div class="dh-step-col__head">
+        <span class="dh-step-col__step">Step 1</span>
+        <span class="dh-step-col__title">提示词</span>
+      </div>
+
+      <div class="dh-substep">
+        <div class="dh-substep__head">
+          <h4>视频描述 <em>*</em></h4>
+        </div>
+        <div class="dh-field">
+          <textarea class="dh-textarea" rows="10"
+                    placeholder="例如:清晨的咖啡馆里,一杯热拿铁放在木桌上,阳光穿过窗户,镜头缓慢推进,咖啡蒸汽轻轻上升。"
+                    [(ngModel)]="prompt"
+                    (ngModelChange)="onPromptInput()"
+                    [disabled]="generating"></textarea>
+          <div class="dh-field__counter">{{ prompt.length }} 字</div>
+        </div>
+      </div>
+
+      <div class="dh-substep">
+        <div class="dh-substep__head"><h4>快捷补充</h4></div>
+        <div class="t2video-preset-list">
+          <button class="dh-chip-btn" type="button"
+                  *ngFor="let item of promptPresets"
+                  [disabled]="generating"
+                  (click)="applyPreset(item)">
+            {{ item }}
+          </button>
+        </div>
+      </div>
+    </div>
+
+    <div class="dh-step-col">
+      <div class="dh-step-col__head">
+        <span class="dh-step-col__step">Step 2</span>
+        <span class="dh-step-col__title">生成参数</span>
+      </div>
+
+      <div class="dh-substep">
+        <div class="dh-substep__head"><h4>风格</h4></div>
+        <div class="t2video-card-grid">
+          <button class="t2video-option-card" type="button"
+                  *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="t2video-card-grid">
+          <button class="t2video-option-card" type="button"
+                  *ngFor="let item of aspectOptions"
+                  [class.is-active]="aspect === item.value"
+                  [disabled]="generating"
+                  (click)="setAspect(item.value)">
+            <span>{{ item.label }}</span>
+            <small>{{ item.hint }}</small>
+          </button>
+        </div>
+      </div>
+
+      <div class="dh-substep">
+        <div class="dh-substep__head"><h4>视频时长</h4></div>
+        <div class="dh-segment">
+          <button class="dh-segment__item"
+                  *ngFor="let sec of durationOptions"
+                  [class.is-active]="durationSeconds === sec"
+                  [disabled]="generating"
+                  (click)="setDurationSeconds(sec)">
+            {{ sec }} 秒
+          </button>
+        </div>
+        <p class="t2video-tip">{{ durationHint }}</p>
+      </div>
+
+      <div class="dh-substep">
+        <div class="dh-substep__head"><h4>分辨率</h4></div>
+        <div class="dh-segment">
+          <button class="dh-segment__item"
+                  [class.is-active]="quality === '720p'"
+                  [disabled]="generating"
+                  (click)="setQuality('720p')">720P</button>
+          <button class="dh-segment__item"
+                  [class.is-active]="quality === '1080p'"
+                  [disabled]="generating"
+                  (click)="setQuality('1080p')">1080P</button>
+          <button class="dh-segment__item"
+                  [class.is-active]="quality === 'pro'"
+                  [disabled]="generating"
+                  (click)="setQuality('pro')">Pro</button>
+        </div>
+      </div>
+    </div>
+
+    <div class="dh-step-col">
+      <div class="dh-step-col__head">
+        <span class="dh-step-col__step">Step 3</span>
+        <span class="dh-step-col__title">生成与预览</span>
+      </div>
+
+      <button class="pl-cta-primary"
+              type="button"
+              [disabled]="!canGenerate"
+              (click)="generate()">
+        {{ generating ? '生成中...' : '生成文生视频' }}
+      </button>
+      <button class="dh-chip-btn t2video-cancel"
+              type="button"
+              *ngIf="generating"
+              (click)="cancel()">取消</button>
+
+      <div class="pl-progress-card" *ngIf="generating || statusText">
+        <div class="pl-progress-card__top">
+          <span>{{ statusText || '准备中...' }}</span>
+          <strong>{{ progress }}%</strong>
+        </div>
+        <div class="pl-progress">
+          <span [style.width.%]="progress"></span>
+        </div>
+      </div>
+
+      <div class="pl-error-card" *ngIf="errorMsg">{{ errorMsg }}</div>
+
+      <div class="pl-result-card" *ngIf="resultVideoUrl">
+        <video [src]="resultVideoUrl" controls playsinline></video>
+        <div class="pl-result-card__actions">
+          <button class="dh-chip-btn" type="button" (click)="download(resultVideoUrl)">打开视频</button>
+          <span class="t2video-result-meta">{{ quality }} · {{ durationSeconds }} 秒 · {{ aspect }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</section>

+ 340 - 0
src/app/pages/pipelines/text-to-video/text-to-video.component.ts

@@ -0,0 +1,340 @@
+import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Subscription } from 'rxjs';
+import { SessionBarComponent } from '../../../components/session-bar/session-bar.component';
+import { DraftMeta } from '../../../models/pipeline-draft.model';
+import { CostEstimatorService } from '../../../services/cost-estimator.service';
+import { GenerationTaskService } from '../../../services/generation-task.service';
+import { JimengAspectRatio, JimengService, JimengVideoQuality } from '../../../services/jimeng.service';
+import { PipelineSessionService } from '../../../services/pipeline-session.service';
+import { ResultsService } from '../../../services/results.service';
+import { userFriendlyError } from '../../../services/user-message.util';
+import { VideoDurationService } from '../../../services/video-duration.service';
+
+interface TextToVideoSnapshot {
+  prompt: string;
+  style: string;
+  aspect: JimengAspectRatio;
+  quality: JimengVideoQuality;
+  durationSeconds: number;
+  frames: number;
+  resultVideoUrl: string;
+  resultWorkId: string;
+}
+
+@Component({
+  selector: 'app-text-to-video',
+  standalone: true,
+  imports: [CommonModule, FormsModule, SessionBarComponent],
+  templateUrl: './text-to-video.component.html',
+  styleUrls: ['./text-to-video.component.css'],
+})
+export class TextToVideoComponent implements OnInit, OnDestroy {
+  prompt = '';
+  style = 'cinematic';
+  aspect: JimengAspectRatio = '9:16';
+  quality: JimengVideoQuality = '720p';
+  durationSeconds = 5;
+  frames = 121;
+  resultVideoUrl = '';
+  resultWorkId = '';
+
+  generating = false;
+  progress = 0;
+  statusText = '';
+  errorMsg = '';
+
+  readonly durationOptions = [5, 10];
+  readonly durationHint = '即梦文生视频当前稳定支持 5 秒或 10 秒。';
+
+  readonly stylePresets = [
+    { value: 'cinematic', label: '电影感', suffix: '电影级光影,镜头运动自然,画面真实,高质量短视频' },
+    { value: 'commercial', label: '商业广告', suffix: '商业广告质感,主体清晰,节奏干净,产品展示自然' },
+    { value: 'documentary', label: '纪实', suffix: '纪实摄影风格,自然光线,真实场景,轻微手持镜头' },
+    { value: 'anime', label: '动画', suffix: '高质量动画风格,色彩鲜明,动作流畅,画面完整' },
+    { value: 'minimal', label: '极简', suffix: '极简构图,干净背景,柔和运动,现代视觉设计' },
+  ];
+
+  readonly aspectOptions: { value: JimengAspectRatio; label: string; hint: string }[] = [
+    { value: '9:16', label: '9:16', hint: '抖音竖屏' },
+    { value: '16:9', label: '16:9', hint: '横屏视频' },
+    { value: '1:1', label: '1:1', hint: '方形内容' },
+    { value: '4:3', label: '4:3', hint: '传统横幅' },
+    { value: '3:4', label: '3:4', hint: '竖版图文' },
+  ];
+
+  readonly promptPresets = [
+    '镜头缓慢推进,主体动作自然,背景有轻微动态',
+    '从近景到中景,光影缓慢变化,节奏高级',
+    '人物或主体自然运动,画面有真实生活感',
+    '产品在干净背景中旋转展示,细节清晰',
+    '城市夜景中霓虹光流动,镜头平稳移动',
+  ];
+
+  private genSub?: Subscription;
+  private generationTaskId = '';
+
+  constructor(
+    private jimeng: JimengService,
+    private results: ResultsService,
+    public session: PipelineSessionService,
+    private generationTasks: GenerationTaskService,
+    private costEstimator: CostEstimatorService,
+    private videoDuration: VideoDurationService,
+    private zone: NgZone,
+    private cdr: ChangeDetectorRef,
+  ) {}
+
+  ngOnInit(): void {
+    void this.session.bootstrap();
+    const cur = this.session.active();
+    if (cur && cur.pipelineId === 'video-generation' && cur.snapshot) {
+      this.fromSnapshot(cur.snapshot as TextToVideoSnapshot);
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.genSub?.unsubscribe();
+  }
+
+  get canGenerate(): boolean {
+    return !this.generating && this.prompt.trim().length >= 4;
+  }
+
+  get selectedStyleLabel(): string {
+    return this.stylePresets.find((item) => item.value === this.style)?.label || '自定义';
+  }
+
+  onPromptInput(): void {
+    this.syncDraft();
+  }
+
+  applyPreset(text: string): void {
+    this.prompt = this.prompt
+      ? `${this.prompt.replace(/\s+$/, '')},${text}`
+      : text;
+    this.syncDraft();
+  }
+
+  setDurationSeconds(seconds: number): void {
+    const raw = Math.round(Number(seconds) || 5);
+    this.durationSeconds = raw <= 7 ? 5 : 10;
+    this.frames = this.videoDuration.secondsToFrames(this.durationSeconds);
+    this.syncDraft();
+  }
+
+  setQuality(quality: JimengVideoQuality): void {
+    this.quality = quality;
+    this.syncDraft();
+  }
+
+  setAspect(aspect: JimengAspectRatio): void {
+    this.aspect = aspect;
+    this.syncDraft();
+  }
+
+  generate(): void {
+    if (!this.canGenerate) return;
+
+    this.generating = true;
+    this.progress = 0;
+    this.statusText = '正在提交文生视频任务...';
+    this.errorMsg = '';
+    this.resultVideoUrl = '';
+    this.resultWorkId = '';
+
+    const finalPrompt = this.buildFinalPrompt();
+    const estimate = this.costEstimator.estimateVideoGeneration(this.quality, this.durationSeconds, '文生视频');
+    const task = this.generationTasks.create({
+      title: this.deriveTitle(),
+      pipelineId: 'video-generation',
+      operation: estimate.operation,
+      estimatedCredits: estimate.totalCredits,
+      costLines: estimate.lines,
+      snapshot: this.toSnapshot(),
+      steps: [
+        { id: 'submit', label: '提交任务' },
+        { id: 'poll', label: '等待生成' },
+        { id: 'archive', label: '归档结果' },
+      ],
+    });
+    this.generationTaskId = task.id;
+    this.generationTasks.markRunning(task.id, 'submit', 5);
+
+    this.session.ensureActive('video-generation', () => this.toSnapshot(), this.deriveTitle());
+    this.session.markRunning();
+    this.session.patch({ title: this.deriveTitle(), snapshot: this.toSnapshot() });
+
+    this.genSub = this.jimeng.remixVideo(
+      finalPrompt,
+      {
+        method: '1',
+        frames: this.frames,
+        aspectRatio: this.aspect,
+        quality: this.quality,
+      },
+      (status, progress, meta) => {
+        this.zone.run(() => {
+          if (meta?.['workId']) {
+            this.generationTasks.markWaitingExternal(
+              this.generationTaskId,
+              { workId: String(meta['workId']), routerName: String(meta['routerName'] || '') },
+              'poll',
+              Math.round(progress),
+            );
+          } else {
+            this.generationTasks.markRunning(this.generationTaskId, progress >= 15 ? 'poll' : 'submit', Math.round(progress));
+          }
+          this.statusText = status;
+          this.progress = Math.round(progress);
+          this.cdr.detectChanges();
+        });
+      },
+    ).subscribe({
+      next: (result) => this.zone.run(() => this.handleGenerated(result.videoUrl, result.workId, finalPrompt)),
+      error: (err) => this.zone.run(() => this.handleGenerateError(err)),
+    });
+  }
+
+  cancel(): void {
+    this.genSub?.unsubscribe();
+    this.generating = false;
+    this.statusText = '已取消';
+    this.progress = 0;
+    this.cdr.detectChanges();
+  }
+
+  onNewSession(): void {
+    if (this.generating) return;
+    this.prompt = '';
+    this.style = 'cinematic';
+    this.aspect = '9:16';
+    this.quality = '720p';
+    this.durationSeconds = 5;
+    this.frames = 121;
+    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 TextToVideoSnapshot);
+  }
+
+  download(url: string): void {
+    window.open(url, '_blank', 'noopener');
+  }
+
+  private handleGenerated(videoUrl: string, workId: string, finalPrompt: string): void {
+    this.resultVideoUrl = videoUrl;
+    this.resultWorkId = workId;
+    this.generating = false;
+    this.progress = 100;
+    this.statusText = '文生视频生成完成';
+
+    const extras = {
+      workId,
+      prompt: finalPrompt,
+      aspect: this.aspect,
+      style: this.style,
+      durationSeconds: this.durationSeconds,
+      quality: this.quality,
+    };
+    this.session.patch({ title: this.deriveTitle(), snapshot: this.toSnapshot() });
+    this.session.finalize(videoUrl, extras);
+    this.generationTasks.markStepCompleted(this.generationTaskId, 'archive', 100);
+    this.generationTasks.markCompleted(this.generationTaskId, videoUrl);
+
+    this.results.saveResult({
+      type: 'video',
+      url: videoUrl,
+      title: this.deriveTitle(),
+      quality: this.quality,
+      duration: `${this.durationSeconds}s`,
+      pipelineId: 'text_to_video',
+      extras,
+    }).subscribe();
+    this.results.saveHistory({
+      keyword: this.prompt.trim().slice(0, 60) || '文生视频',
+      status: 'completed',
+      resultUrl: videoUrl,
+      quality: this.quality,
+      pipelineId: 'text_to_video',
+      styleName: this.selectedStyleLabel,
+    }).subscribe();
+    this.cdr.detectChanges();
+  }
+
+  private handleGenerateError(err: any): void {
+    this.generating = false;
+    this.progress = 0;
+    this.statusText = '';
+    this.errorMsg = userFriendlyError(err, '文生视频生成失败,请稍后重试');
+    this.session.fail(this.errorMsg);
+    this.generationTasks.markFailed(this.generationTaskId, err, { retryable: true, recoverable: true });
+    this.cdr.detectChanges();
+  }
+
+  private buildFinalPrompt(): string {
+    const style = this.stylePresets.find((item) => item.value === this.style)?.suffix || '';
+    return [
+      this.prompt.trim(),
+      style,
+      '画面连贯,主体稳定,无文字水印,无畸形结构,短视频成片质感。',
+    ].filter(Boolean).join(',');
+  }
+
+  private deriveTitle(): string {
+    const text = this.prompt.replace(/\s+/g, ' ').trim();
+    return text ? `文生视频 · ${text.slice(0, 24)}` : `文生视频 · ${this.selectedStyleLabel}`;
+  }
+
+  private syncDraft(): void {
+    if (!this.prompt.trim() && !this.resultVideoUrl) return;
+    const title = this.deriveTitle();
+    this.session.ensureActive('video-generation', () => this.toSnapshot(), title);
+    this.session.patch({
+      title,
+      snapshot: this.toSnapshot(),
+      thumbnail: this.resultVideoUrl || undefined,
+    });
+  }
+
+  private toSnapshot(): TextToVideoSnapshot {
+    return {
+      prompt: this.prompt,
+      style: this.style,
+      aspect: this.aspect,
+      quality: this.quality,
+      durationSeconds: this.durationSeconds,
+      frames: this.frames,
+      resultVideoUrl: this.resultVideoUrl,
+      resultWorkId: this.resultWorkId,
+    };
+  }
+
+  private fromSnapshot(snap: TextToVideoSnapshot): void {
+    this.prompt = snap.prompt || '';
+    this.style = snap.style || 'cinematic';
+    this.aspect = (snap.aspect || '9:16') as JimengAspectRatio;
+    this.quality = (snap.quality || '720p') as JimengVideoQuality;
+    const seconds = snap.durationSeconds ?? this.videoDuration.framesToSeconds(snap.frames || 121);
+    this.setDurationSeconds(seconds);
+    this.resultVideoUrl = snap.resultVideoUrl || '';
+    this.resultWorkId = snap.resultWorkId || '';
+    this.errorMsg = '';
+    this.statusText = '';
+    this.progress = 0;
+    this.cdr.detectChanges();
+  }
+}

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

@@ -882,3 +882,35 @@
     flex-direction: column;
   }
 }
+
+.t2v-brief-card {
+  margin-top: 10px;
+  padding: 10px 12px;
+  border: 1px solid rgba(37, 99, 235, 0.14);
+  border-radius: 8px;
+  background: rgba(37, 99, 235, 0.05);
+  color: #334155;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.t2v-brief-card__title {
+  margin-bottom: 4px;
+  font-size: 12px;
+  font-weight: 700;
+  color: #2563eb;
+}
+
+.t2v-brief-card p {
+  margin: 2px 0;
+}
+
+.app-container.theme-night .t2v-brief-card {
+  border-color: rgba(96, 165, 250, 0.22);
+  background: rgba(96, 165, 250, 0.08);
+  color: rgba(226, 232, 240, 0.9);
+}
+
+.app-container.theme-night .t2v-brief-card__title {
+  color: #93c5fd;
+}

+ 34 - 16
src/app/pages/pipelines/topic-to-video/topic-to-video.component.html

@@ -80,6 +80,12 @@
                   placeholder="例如:探索中国西部最美高原 / 一杯美式拯救加班的下午 / 用 30 秒讲清楚什么是大语言模型"
                   [(ngModel)]="topic" (ngModelChange)="syncDraft()" [disabled]="isRunning"></textarea>
         <div class="dh-field__counter">{{ topic.length }} / 120 字</div>
+        <div class="t2v-brief-card" *ngIf="sourceBrief">
+          <div class="t2v-brief-card__title">已带入选题资料</div>
+          <p *ngIf="topicAngle"><strong>角度:</strong>{{ topicAngle }}</p>
+          <p *ngIf="topicHook"><strong>钩子:</strong>{{ topicHook }}</p>
+          <p *ngIf="topicOutline"><strong>脚本框架:</strong>{{ topicOutline | slice:0:160 }}{{ topicOutline.length > 160 ? '...' : '' }}</p>
+        </div>
       </div>
 
       <!-- 分镜数量:仅快速模式 -->
@@ -201,22 +207,28 @@
         <ng-container *ngIf="mediaMode === 'video'">
           <span class="t2v-sublabel">单段时长</span>
           <div class="dh-segment">
-            <button class="dh-segment__item"
-                    [class.is-active]="clipFrames === 121"
-                    [disabled]="isRunning" (click)="clipFrames = 121">5 秒</button>
-            <button class="dh-segment__item"
-                    [class.is-active]="clipFrames === 241"
-                    [disabled]="isRunning" (click)="clipFrames = 241">10 秒</button>
+            <button class="dh-segment__item" *ngFor="let sec of clipDurationOptions"
+                    [class.is-active]="clipDurationSeconds === sec"
+                    [disabled]="isRunning" (click)="setClipDurationSeconds(sec)">{{sec}} 秒</button>
           </div>
+          <input class="dh-input" type="number" min="5" max="10" step="1"
+                 [ngModel]="clipDurationSeconds"
+                 [disabled]="isRunning"
+                 (ngModelChange)="setClipDurationSeconds($event)"
+                 placeholder="自定义秒数">
+          <p class="t2v-tip">{{ clipDurationHint }}</p>
 
           <span class="t2v-sublabel">视频质量</span>
           <div class="dh-segment">
             <button class="dh-segment__item"
                     [class.is-active]="clipQuality === '720p'"
-                    [disabled]="isRunning" (click)="clipQuality = '720p'">720p</button>
+                    [disabled]="isRunning" (click)="setClipQuality('720p')">720p</button>
             <button class="dh-segment__item"
                     [class.is-active]="clipQuality === '1080p'"
-                    [disabled]="isRunning" (click)="clipQuality = '1080p'">1080p</button>
+                    [disabled]="isRunning" (click)="setClipQuality('1080p')">1080p</button>
+            <button class="dh-segment__item"
+                    [class.is-active]="clipQuality === 'pro'"
+                    [disabled]="isRunning" (click)="setClipQuality('pro')">Pro</button>
           </div>
         </ng-container>
       </div>
@@ -229,22 +241,28 @@
         </div>
         <span class="t2v-sublabel">单段时长</span>
         <div class="dh-segment">
-          <button class="dh-segment__item"
-                  [class.is-active]="clipFrames === 121"
-                  [disabled]="isRunning || hasInflightScene" (click)="clipFrames = 121">5 秒</button>
-          <button class="dh-segment__item"
-                  [class.is-active]="clipFrames === 241"
-                  [disabled]="isRunning || hasInflightScene" (click)="clipFrames = 241">10 秒</button>
+          <button class="dh-segment__item" *ngFor="let sec of clipDurationOptions"
+                  [class.is-active]="clipDurationSeconds === sec"
+                  [disabled]="isRunning || hasInflightScene" (click)="setClipDurationSeconds(sec)">{{sec}} 秒</button>
         </div>
+        <input class="dh-input" type="number" min="5" max="10" step="1"
+               [ngModel]="clipDurationSeconds"
+               [disabled]="isRunning || hasInflightScene"
+               (ngModelChange)="setClipDurationSeconds($event)"
+               placeholder="自定义秒数">
+        <p class="t2v-tip">{{ clipDurationHint }}</p>
 
         <span class="t2v-sublabel">视频质量</span>
         <div class="dh-segment">
           <button class="dh-segment__item"
                   [class.is-active]="clipQuality === '720p'"
-                  [disabled]="isRunning || hasInflightScene" (click)="clipQuality = '720p'">720p</button>
+                  [disabled]="isRunning || hasInflightScene" (click)="setClipQuality('720p')">720p</button>
           <button class="dh-segment__item"
                   [class.is-active]="clipQuality === '1080p'"
-                  [disabled]="isRunning || hasInflightScene" (click)="clipQuality = '1080p'">1080p</button>
+                  [disabled]="isRunning || hasInflightScene" (click)="setClipQuality('1080p')">1080p</button>
+          <button class="dh-segment__item"
+                  [class.is-active]="clipQuality === 'pro'"
+                  [disabled]="isRunning || hasInflightScene" (click)="setClipQuality('pro')">Pro</button>
         </div>
       </div>
 

+ 182 - 32
src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts

@@ -1,8 +1,8 @@
 import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { Subscription, from, of } from 'rxjs';
-import { catchError, concatMap, map, toArray } from 'rxjs/operators';
+import { Subscription, from, of, timer } from 'rxjs';
+import { catchError, concatMap, map, switchMap, toArray } from 'rxjs/operators';
 import { LlmService } from '../../../services/llm.service';
 import {
   JimengService,
@@ -22,6 +22,9 @@ import { DraftMeta } from '../../../models/pipeline-draft.model';
 import { userFriendlyError } from '../../../services/user-message.util';
 import { TemplateService } from '../../../services/template.service';
 import { GenerationTemplate } from '../../../models/template.model';
+import { CreationBriefService } from '../../../services/creation-brief.service';
+import { CreationBrief } from '../../../models/creation-brief.model';
+import { VideoDurationService } from '../../../services/video-duration.service';
 
 interface SceneSegment {
   /** 画面描述(中文提示词);快速模式由 LLM 生成,手动模式由用户填入 */
@@ -100,13 +103,19 @@ type T2vMediaMode = 'image' | 'video';
  */
 interface T2vSnapshot {
   topic: string;
+  sourceBrief?: CreationBrief | null;
+  topicAngle?: string;
+  topicHook?: string;
+  topicOutline?: string;
+  topicContext?: string;
   nScenes: 3 | 4 | 5 | 6 | 8;
   style: T2vStyle;
   aspect: T2vAspect;
   quickMode: boolean;
   mediaMode: T2vMediaMode;
-  clipFrames: 121 | 241;
-  clipQuality: Extract<JimengVideoQuality, '720p' | '1080p'>;
+  clipFrames: number;
+  clipDurationSeconds?: number;
+  clipQuality: JimengVideoQuality;
   continuityEnabled: boolean;
   visualBible: T2vVisualBible;
   scenes: SceneSegment[];
@@ -121,8 +130,9 @@ interface T2vTemplateConfig {
   aspect?: T2vAspect;
   quickMode?: boolean;
   mediaMode?: T2vMediaMode;
-  clipFrames?: 121 | 241;
-  clipQuality?: Extract<JimengVideoQuality, '720p' | '1080p'>;
+  clipFrames?: number;
+  clipDurationSeconds?: number;
+  clipQuality?: JimengVideoQuality;
   continuityEnabled?: boolean;
   visualBible?: T2vVisualBible;
   scriptFrame?: string;
@@ -147,6 +157,11 @@ interface T2vTemplateConfig {
 export class TopicToVideoComponent implements OnInit, OnDestroy {
   // ====== 输入 ======
   topic = '';
+  sourceBrief: CreationBrief | null = null;
+  topicAngle = '';
+  topicHook = '';
+  topicOutline = '';
+  topicContext = '';
   nScenes: 3 | 4 | 5 | 6 | 8 = 5;
   style: T2vStyle = 'cinematic';
   aspect: T2vAspect = 'landscape';
@@ -161,10 +176,13 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
   // ===== 画面类型(仅快速模式生效)=====
   /** 静态图 = 仅文生图;动态视频 = 文生图 + 图生视频 */
   mediaMode: T2vMediaMode = 'image';
-  /** 动态视频每段时长:121 ⁈5s,241 ⁈10s */
-  clipFrames: 121 | 241 = 121;
-  /** 动态视频质量:720p / 1080p */
-  clipQuality: Extract<JimengVideoQuality, '720p' | '1080p'> = '720p';
+  /** 动态视频每段时长:用户看秒数,提交时转换为 frames */
+  clipDurationSeconds = 5;
+  clipFrames = 121;
+  clipDurationHint = '即梦当前稳定支持 5 秒或 10 秒,其他输入会自动就近匹配。';
+  /** 动态视频质量:720p / 1080p / Pro */
+  clipQuality: JimengVideoQuality = '720p';
+  readonly clipDurationOptions = [5, 10];
 
   /** 连续性增强:不锁死分镜,只把全局角色/场景/风格作为默认锚点注入生成 */
   continuityEnabled = true;
@@ -233,6 +251,8 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     private browserFfmpeg: BrowserFfmpegService,
     private qiniu: QiniuUploadService,
     private templates: TemplateService,
+    private creationBriefs: CreationBriefService,
+    private videoDuration: VideoDurationService,
   ) {}
 
   /** 三个 bible 参考图各自的上传中状态,防重复点击、控制禁用 UI */
@@ -320,6 +340,14 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     // 拉取草稿索引,让顶栏「历史」下拉立刻可用
     void this.session.bootstrap();
 
+    const brief = this.creationBriefs.consumePendingBrief('topic-to-video');
+    if (brief) {
+      this.applyCreationBrief(brief);
+      this.session.newDraft('topic-to-video', this.toSnapshot(), this.deriveTitle());
+      this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
+      return;
+    }
+
     // 从「我的创作」页跳转过来:session 已预先 open 了本 Pipeline 的某个 draft,
     // 这里识别并还原。
     const cur = this.session.active();
@@ -352,6 +380,35 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     } catch {}
   }
 
+  private applyCreationBrief(brief: CreationBrief): void {
+    const prefill = this.creationBriefs.toTopicToVideoPrefill(brief);
+    this.sourceBrief = brief;
+    this.topic = prefill.topic || brief.title || brief.angle || '';
+    this.topicAngle = prefill.angle || '';
+    this.topicHook = prefill.hook || '';
+    this.topicOutline = prefill.outline || '';
+    this.topicContext = prefill.context || '';
+    this.templateMessage = `已带入选题池结构化内容:${brief.title}`;
+  }
+
+  private creationBriefPromptContext(): string {
+    const evidenceText = (this.sourceBrief?.evidence || [])
+      .map((item) => item.text || item.id || '')
+      .filter(Boolean)
+      .slice(0, 4)
+      .join('\n');
+    const lines = [
+      this.topicAngle && this.topicAngle !== this.topic ? `选题角度:${this.topicAngle}` : '',
+      this.topicHook ? `开头钩子:${this.topicHook}` : '',
+      this.topicOutline ? `脚本框架:\n${this.topicOutline}` : '',
+      evidenceText ? `证据摘要:\n${evidenceText}` : '',
+      this.sourceBrief?.tags?.length ? `来源标签:${this.sourceBrief.tags.join('、')}` : '',
+      this.sourceBrief?.confidence ? `证据置信度:${this.sourceBrief.confidence}` : '',
+      this.sourceBrief?.sourceVideoIds?.length ? `来源视频:${this.sourceBrief.sourceVideoIds.join('、')}` : '',
+    ].filter(Boolean);
+    return lines.join('\n');
+  }
+
   get topicTemplates(): GenerationTemplate[] {
     return this.templates.listByPipeline('topic-to-video');
   }
@@ -360,12 +417,18 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
   private toSnapshot(): T2vSnapshot {
     return {
       topic: this.topic,
+      sourceBrief: this.sourceBrief,
+      topicAngle: this.topicAngle,
+      topicHook: this.topicHook,
+      topicOutline: this.topicOutline,
+      topicContext: this.topicContext,
       nScenes: this.nScenes,
       style: this.style,
       aspect: this.aspect,
       quickMode: this.quickMode,
       mediaMode: this.mediaMode,
       clipFrames: this.clipFrames,
+      clipDurationSeconds: this.clipDurationSeconds,
       clipQuality: this.clipQuality,
       continuityEnabled: this.continuityEnabled,
       visualBible: { ...this.visualBible },
@@ -406,12 +469,20 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
   private fromSnapshot(snap: T2vSnapshot): void {
     if (!snap) return;
     this.topic = snap.topic ?? '';
+    this.sourceBrief = snap.sourceBrief || null;
+    this.topicAngle = snap.topicAngle || '';
+    this.topicHook = snap.topicHook || '';
+    this.topicOutline = snap.topicOutline || '';
+    this.topicContext = snap.topicContext || '';
     this.nScenes = (snap.nScenes ?? 5) as any;
     this.style = (snap.style ?? 'cinematic') as any;
     this.aspect = (snap.aspect ?? 'landscape') as any;
     this.quickMode = !!snap.quickMode;
     this.mediaMode = (snap.mediaMode ?? 'image') as any;
-    this.clipFrames = (snap.clipFrames ?? 121) as any;
+    this.clipDurationSeconds = this.normalizeClipDurationSeconds(
+      snap.clipDurationSeconds ?? this.videoDuration.framesToSeconds(snap.clipFrames ?? 121),
+    );
+    this.clipFrames = this.videoDuration.secondsToFrames(this.clipDurationSeconds);
     this.clipQuality = (snap.clipQuality ?? '720p') as any;
     this.continuityEnabled = snap.continuityEnabled !== false;
     this.visualBible = { ...this.createEmptyVisualBible(), ...(snap.visualBible || {}) };
@@ -515,6 +586,7 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
       quickMode: this.quickMode,
       mediaMode: this.mediaMode,
       clipFrames: this.clipFrames,
+      clipDurationSeconds: this.clipDurationSeconds,
       clipQuality: this.clipQuality,
       continuityEnabled: this.continuityEnabled,
       visualBible: this.sanitizeTemplateBible(this.visualBible),
@@ -531,8 +603,10 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     if (this.isAllowedAspect(config.aspect)) this.aspect = config.aspect;
     if (typeof config.quickMode === 'boolean') this.quickMode = config.quickMode;
     if (config.mediaMode === 'image' || config.mediaMode === 'video') this.mediaMode = config.mediaMode;
-    if (config.clipFrames === 121 || config.clipFrames === 241) this.clipFrames = config.clipFrames;
-    if (config.clipQuality === '720p' || config.clipQuality === '1080p') this.clipQuality = config.clipQuality;
+    if (config.clipDurationSeconds || config.clipFrames) {
+      this.setClipDurationSeconds(config.clipDurationSeconds ?? this.videoDuration.framesToSeconds(config.clipFrames || 121), false);
+    }
+    if (config.clipQuality === '720p' || config.clipQuality === '1080p' || config.clipQuality === 'pro') this.clipQuality = config.clipQuality;
     if (typeof config.continuityEnabled === 'boolean') this.continuityEnabled = config.continuityEnabled;
     this.visualBible = {
       ...this.createEmptyVisualBible(),
@@ -555,6 +629,22 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     return /^https?:\/\//i.test(value) ? value : '';
   }
 
+  setClipDurationSeconds(seconds: number, sync = true): void {
+    this.clipDurationSeconds = this.normalizeClipDurationSeconds(seconds);
+    this.clipFrames = this.videoDuration.secondsToFrames(this.clipDurationSeconds);
+    if (sync) this.syncDraft();
+  }
+
+  private normalizeClipDurationSeconds(seconds: number): number {
+    const raw = Math.round(Number(seconds) || 5);
+    return raw <= 7 ? 5 : 10;
+  }
+
+  setClipQuality(quality: JimengVideoQuality): void {
+    this.clipQuality = quality;
+    this.syncDraft();
+  }
+
   private isAllowedSceneCount(value: unknown): value is 3 | 4 | 5 | 6 | 8 {
     return value === 3 || value === 4 || value === 5 || value === 6 || value === 8;
   }
@@ -584,6 +674,11 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     if (this.isRunning) return;
     // 重置内存状态
     this.topic = '';
+    this.sourceBrief = null;
+    this.topicAngle = '';
+    this.topicHook = '';
+    this.topicOutline = '';
+    this.topicContext = '';
     this.scenes = [];
     this.visualBible = this.createEmptyVisualBible();
     this.stage = 'idle';
@@ -865,7 +960,7 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     // 避免 LLM 输出过长影响分镜质量。
     let targetSecLow: number, targetSecHigh: number;
     if (this.mediaMode === 'video') {
-      const clipSec = this.clipFrames === 121 ? 5 : 10;
+      const clipSec = this.clipDurationSeconds;
       targetSecLow = Math.max(1, clipSec - 1.5);
       targetSecHigh = clipSec;
     } else {
@@ -942,7 +1037,7 @@ ${arc}
   private async generateStoryboardV2(styleHint: string): Promise<SceneSegment[]> {
     let targetSecLow: number, targetSecHigh: number;
     if (this.mediaMode === 'video') {
-      const clipSec = this.clipFrames === 121 ? 5 : 10;
+      const clipSec = this.clipDurationSeconds;
       targetSecLow = Math.max(1, clipSec - 1.5);
       targetSecHigh = clipSec;
     } else {
@@ -953,12 +1048,16 @@ ${arc}
     const charHigh = Math.round(targetSecHigh * 4.5);
     const arc = this.buildNarrativeArc(this.nScenes);
     const existingBible = this.visualBibleText();
+    const briefContext = this.creationBriefPromptContext();
 
     const prompt = `你是一名短视频导演兼分镜师。请根据主题输出 ${this.nScenes} 段连续分镜,适用于真人口播/剧情短片/产品广告。
 
 主题:
 ${this.topic.trim()}
 
+结构化创作资料(用于约束分镜,不要把它当作画面标题逐字复述):
+${briefContext || '无'}
+
 已有全局设定(为空则你补全):
 ${existingBible || '无'}
 
@@ -1249,9 +1348,55 @@ ${arc}
   }
 
   // ============== Step 2.5: 即梦图生视频(仅 mediaMode='video')==============
+  /**
+   * 自动模式的动态视频优先准备尾帧,然后采用首尾帧模式(method=3)。
+   * Pro 接口不支持首尾帧时会保留首帧模式(method=2)。
+   */
+  private prepareTailFrameForClip(scene: SceneSegment, idx: number) {
+    if (this.clipQuality === 'pro' || scene.tailImageUrl || !scene.imageUrl) return of(scene);
+    const { w, h } = this.currentAspect();
+    const styleHint = this.stylePresets.find((s) => s.value === this.style)?.promptHint || '';
+    const refs = [scene.imageUrl];
+    const prompt = `${this.buildScenePrompt(scene, 'tail', idx)}, ${styleHint}`;
+    this.zone.run(() => {
+      this.stageStatus = `正在准备第 ${idx + 1} 段尾帧...`;
+      this.cdr.detectChanges();
+    });
+    return this.waitForGeneratedImageToSettle(scene, idx, 'tail-reference').pipe(
+      switchMap(() => this.jimeng.generateImgV4(
+        prompt,
+        refs,
+        { width: w, height: h },
+        undefined,
+        this.imageReferenceOptions('tail', refs)
+      )),
+      map((url) => {
+        scene.tailImageUrl = url;
+        scene.tailImageSource = 'ai';
+        return scene;
+      }),
+      catchError((err) => {
+        console.warn('[t2v] 自动尾帧生成失败,回退首帧图生视频:', err);
+        return of(scene);
+      }),
+    );
+  }
+
+  private waitForGeneratedImageToSettle(scene: SceneSegment, idx: number, target: 'tail-reference' | 'video-reference') {
+    if (!scene.imageUrl || !/^https?:\/\//i.test(scene.imageUrl)) return of(scene);
+    const waitMs = idx < 2 ? 2500 : 900;
+    this.zone.run(() => {
+      this.stageStatus = target === 'tail-reference'
+        ? `正在等待第 ${idx + 1} 段首帧素材稳定...`
+        : `正在准备第 ${idx + 1} 段动态视频素材...`;
+      this.cdr.detectChanges();
+    });
+    return timer(waitMs).pipe(map(() => scene));
+  }
+
   /**
    * 为每一帧已生成的图片调用即梦 i2v,得到一段动态视频。
-   * 采用「首帧」模式(method=2),带场景 prompt 驱动动态。
+   * 优先采用「首尾帧」模式(method=3),带场景 prompt 驱动动态。
    * 任何一段失败 → 该段降级为静态图(保留 imageUrl,不设 videoUrl),
    * 不中断整个流程。
    */
@@ -1275,17 +1420,20 @@ ${arc}
             scene.state = 'video-pending';
             this.zone.run(() => this.cdr.detectChanges());
 
-            return this.jimeng
-              .generateImageToVideo({
-                imageUrls: [scene.imageUrl!],
-                prompt: this.buildScenePrompt(scene, 'video', idx),
-                method: '2', // 首帧
-                quality: this.clipQuality,
-                frames: this.clipFrames,
-                aspectRatio,
-              })
-              .pipe(
-                map((result) => {
+            return this.prepareTailFrameForClip(scene, idx).pipe(
+              switchMap(() => this.waitForGeneratedImageToSettle(scene, idx, 'video-reference')),
+              concatMap(() => {
+                const useFirstLast = this.clipQuality !== 'pro' && !!scene.tailImageUrl;
+                const method: '2' | '3' = useFirstLast ? '3' : '2';
+                return this.jimeng.generateImageToVideo({
+                  imageUrls: useFirstLast ? [scene.imageUrl!, scene.tailImageUrl!] : [scene.imageUrl!],
+                  prompt: this.buildScenePrompt(scene, 'video', idx),
+                  method,
+                  quality: this.clipQuality,
+                  frames: this.clipFrames,
+                  aspectRatio,
+                }).pipe(
+                  map((result) => {
                   scene.videoUrl = result.videoUrl;
                   scene.state = 'video-done';
                   done += 1;
@@ -1300,13 +1448,13 @@ ${arc}
                       {
                         type: 'video', url: result.videoUrl, sceneIndex: sceneIdx,
                         title: `分镜 ${sceneIdx + 1} - 图生视频【快速】`,
-                        extras: { kind: 'i2v', method: '2', frames: this.clipFrames, quality: this.clipQuality },
+                        extras: { kind: 'i2v', method, frames: this.clipFrames, quality: this.clipQuality },
                       },
                     );
                   });
                   return scene;
-                }),
-                catchError((err) => {
+                  }),
+                  catchError((err) => {
                   // 降级:该段仍用静态图,不报错
                   scene.state = 'video-failed';
                   scene.error = userFriendlyError(err, '动态片段生成失败,请稍后重试');
@@ -1317,8 +1465,10 @@ ${arc}
                     this.cdr.detectChanges();
                   });
                   return of(scene);
-                }),
-              );
+                  }),
+                );
+              }),
+            );
           }),
           toArray(),
         )

+ 459 - 15
src/app/pages/topic-pool/topic-pool.component.css

@@ -90,7 +90,7 @@
 
 .topic-pool-toolbar {
   display: grid;
-  grid-template-columns: 1fr 180px 180px;
+  grid-template-columns: minmax(260px, 1fr) 160px 160px minmax(220px, 300px);
   gap: 12px;
   margin-bottom: 16px;
 }
@@ -125,7 +125,8 @@
 
 .topic-list {
   display: grid;
-  gap: 14px;
+  grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
+  gap: 12px;
 }
 
 .topic-card {
@@ -138,14 +139,44 @@
   padding: 18px;
 }
 
+.topic-source-card {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-secondary);
+  padding: 14px;
+  cursor: pointer;
+  transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease;
+}
+
+.topic-source-card:hover {
+  border-color: rgba(37, 99, 235, 0.45);
+  box-shadow: 0 12px 26px rgba(15, 23, 42, 0.08);
+  transform: translateY(-1px);
+}
+
+.topic-source-main {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  min-width: 0;
+}
+
 .topic-card-head {
   display: flex;
   justify-content: space-between;
   align-items: flex-start;
-  gap: 16px;
+  gap: 12px;
+}
+
+.compact-head {
+  min-height: 72px;
 }
 
 .topic-source,
+.topic-variant,
 .topic-status,
 .topic-tag {
   display: inline-flex;
@@ -161,19 +192,191 @@
   font-weight: 700;
 }
 
+.topic-variant {
+  background: rgba(20, 184, 166, 0.12);
+  color: #0f766e;
+  font-weight: 700;
+  padding: 6px 8px;
+}
+
+.topic-recommended {
+  display: inline-flex;
+  align-items: center;
+  max-width: 360px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: rgba(245, 158, 11, 0.13);
+  color: #92400e;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1;
+  padding: 6px 8px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .topic-status {
   background: rgba(37, 99, 235, 0.1);
   color: #1d4ed8;
   padding: 7px 9px;
 }
 
-.topic-card h3 {
+.topic-status-pill {
+  display: inline-flex;
+  align-items: center;
+  border-radius: 999px;
+  background: var(--bg-tertiary);
+  color: var(--text-secondary);
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1;
+  padding: 6px 8px;
+  white-space: nowrap;
+}
+
+.topic-card h3,
+.topic-source-card h3 {
   margin: 0;
-  font-size: 18px;
+  display: -webkit-box;
+  overflow: hidden;
+  color: var(--text-primary);
+  font-size: 15px;
+  line-height: 1.45;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
 }
 
 .topic-angle {
-  margin: 10px 0 0;
+  margin: 8px 0 0;
+  color: var(--text-secondary);
+  line-height: 1.6;
+}
+
+.compact-summary {
+  display: -webkit-box;
+  min-height: 40px;
+  overflow: hidden;
+  font-size: 13px;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.topic-hook {
+  margin-top: 12px;
+  border: 1px solid rgba(37, 99, 235, 0.18);
+  border-radius: 8px;
+  background: rgba(37, 99, 235, 0.05);
+  padding: 10px 12px;
+}
+
+.topic-hook strong,
+.topic-production-detail strong {
+  display: block;
+  margin-bottom: 6px;
+  color: var(--text-primary);
+  font-size: 13px;
+}
+
+.topic-hook p {
+  margin: 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+}
+
+.topic-primary-direction {
+  margin-top: 14px;
+  border: 1px solid rgba(37, 99, 235, 0.18);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 14px;
+}
+
+.topic-compact-primary {
+  flex: 0 0 auto;
+  margin-top: 10px;
+  border-top: 1px solid var(--border-color);
+  padding-top: 10px;
+}
+
+.compact-primary-label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+
+.compact-primary-label span {
+  flex: 0 0 auto;
+  border-radius: 999px;
+  background: rgba(245, 158, 11, 0.13);
+  color: #92400e;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1;
+  padding: 5px 7px;
+}
+
+.compact-primary-label strong {
+  overflow: hidden;
+  color: var(--text-primary);
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.topic-compact-primary p {
+  display: -webkit-box;
+  margin: 7px 0 0;
+  min-height: 36px;
+  overflow: hidden;
+  color: var(--text-secondary);
+  font-size: 13px;
+  line-height: 1.45;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.topic-compact-primary ul {
+  margin: 8px 0 0;
+  min-height: 36px;
+  padding-left: 16px;
+  color: var(--text-secondary);
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.topic-compact-primary li {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.topic-primary-head,
+.topic-direction-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.topic-primary-head strong {
+  color: var(--primary-color);
+  font-size: 13px;
+}
+
+.topic-primary-head span {
+  color: var(--text-muted);
+  font-size: 12px;
+}
+
+.topic-primary-direction h4,
+.topic-direction-card h4 {
+  margin: 8px 0 0;
+  font-size: 16px;
+}
+
+.topic-primary-direction > p {
+  margin: 8px 0 0;
   color: var(--text-secondary);
   line-height: 1.7;
 }
@@ -192,6 +395,85 @@
   margin: 6px 0 0;
   color: var(--text-secondary);
   line-height: 1.7;
+  white-space: pre-line;
+}
+
+.topic-production-detail {
+  margin-top: 12px;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  overflow: hidden;
+}
+
+.topic-direction-list {
+  display: grid;
+  gap: 12px;
+  margin-top: 14px;
+}
+
+.topic-direction-card {
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+  background: var(--bg-primary);
+  padding: 14px;
+}
+
+.topic-direction-card.is-primary {
+  border-color: rgba(245, 158, 11, 0.55);
+  box-shadow: 0 10px 22px rgba(245, 158, 11, 0.1);
+}
+
+.topic-direction-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.topic-direction-actions .btn,
+.topic-direction-actions select {
+  width: auto;
+  min-height: 36px;
+}
+
+.btn.btn-mini {
+  min-height: 30px;
+  padding: 0 10px;
+  font-size: 12px;
+}
+
+.btn.btn-mini:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+}
+
+.topic-production-detail summary {
+  cursor: pointer;
+  padding: 11px 12px;
+  color: var(--text-primary);
+  font-weight: 700;
+}
+
+.topic-full-outline,
+.topic-source-evidence {
+  border-top: 1px solid var(--border-color);
+  padding: 12px;
+}
+
+.topic-full-outline p,
+.topic-source-evidence p {
+  margin: 0;
+  color: var(--text-secondary);
+  line-height: 1.7;
+  white-space: pre-line;
+}
+
+.topic-source-evidence ul {
+  margin: 8px 0 0;
+  padding-left: 18px;
+  color: var(--text-secondary);
+  line-height: 1.7;
 }
 
 .topic-edit-form {
@@ -239,34 +521,53 @@
 .topic-tags {
   display: flex;
   flex-wrap: wrap;
-  gap: 8px;
-  margin-top: 12px;
+  gap: 6px;
+  margin-top: 10px;
 }
 
 .topic-tag {
   background: var(--bg-tertiary);
   color: var(--text-secondary);
-  padding: 6px 9px;
+  padding: 5px 7px;
+}
+
+.compact-tags .topic-tag {
+  max-width: 112px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .topic-meta {
   display: flex;
   flex-wrap: wrap;
-  gap: 12px;
-  margin-top: 12px;
+  gap: 8px;
+  margin-top: auto;
+  padding-top: 10px;
   color: var(--text-muted);
   font-size: 12px;
 }
 
 .topic-card-actions {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
+  display: grid;
+  flex: 0 0 auto;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 8px;
+  margin-top: 0;
 }
 
 .topic-card-actions .btn,
 .topic-card-actions select {
   width: 100%;
+  height: 38px;
+  min-height: 38px;
+  padding: 0 8px;
+  font-size: 13px;
+  line-height: 1;
+}
+
+.topic-card-actions select {
+  grid-column: 1 / -1;
 }
 
 .topic-empty {
@@ -295,6 +596,122 @@
   margin: 0;
 }
 
+.topic-modal-backdrop {
+  position: fixed;
+  inset: 0;
+  z-index: 5000;
+  display: grid;
+  place-items: center;
+  background: rgba(15, 23, 42, 0.72);
+  padding: 28px;
+  backdrop-filter: blur(4px);
+}
+
+.topic-modal {
+  position: relative;
+  z-index: 5001;
+  display: flex;
+  flex-direction: column;
+  width: min(1080px, calc(100vw - 56px));
+  max-height: min(860px, calc(100vh - 56px));
+  overflow: hidden;
+  border: 1px solid #d7e2f5;
+  border-radius: 8px;
+  background: #ffffff;
+  color: #0f172a;
+  box-shadow: 0 28px 80px rgba(15, 23, 42, 0.26);
+}
+
+.topic-modal-header {
+  flex: 0 0 auto;
+  display: flex;
+  justify-content: space-between;
+  gap: 18px;
+  border-bottom: 1px solid #dbe7f8;
+  background: #ffffff;
+  padding: 18px 20px;
+}
+
+.topic-modal-header h2 {
+  margin: 8px 0 0;
+  color: #0f172a;
+  font-size: 20px;
+  line-height: 1.45;
+}
+
+.topic-modal-header p {
+  margin: 8px 0 0;
+  color: #475569;
+  line-height: 1.6;
+}
+
+.modal-close-btn {
+  flex: 0 0 auto;
+  width: 36px;
+  height: 36px;
+  border: 1px solid #d7e2f5;
+  border-radius: 50%;
+  background: #f8fafc;
+  color: #0f172a;
+  cursor: pointer;
+  font-size: 22px;
+  line-height: 1;
+}
+
+.topic-modal-actions {
+  flex: 0 0 auto;
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+  border-bottom: 1px solid #dbe7f8;
+  background: #ffffff;
+  padding: 12px 20px;
+}
+
+.topic-modal-actions .btn {
+  width: 100%;
+  height: 40px;
+}
+
+.is-modal-list {
+  overflow: auto;
+  margin: 0;
+  background: #f6f9ff;
+  padding: 16px 20px 20px;
+}
+
+.topic-modal .topic-direction-card,
+.topic-modal .topic-hook,
+.topic-modal .topic-production-detail,
+.topic-modal .topic-edit-form {
+  background: #ffffff;
+  color: #0f172a;
+}
+
+.topic-modal .topic-direction-card {
+  border-color: #d7e2f5;
+}
+
+.topic-modal .topic-angle,
+.topic-modal .topic-hook p,
+.topic-modal .topic-outline p,
+.topic-modal .topic-full-outline p,
+.topic-modal .topic-source-evidence p,
+.topic-modal .topic-source-evidence ul {
+  color: #475569;
+}
+
+.topic-modal .topic-outline,
+.topic-modal .topic-full-outline,
+.topic-modal .topic-source-evidence {
+  background: #ffffff;
+}
+
+.topic-modal .topic-production-detail summary {
+  background: #ffffff;
+  color: #0f172a;
+}
+
 @media (max-width: 900px) {
   .topic-pool-page {
     padding: 20px 16px 36px;
@@ -310,7 +727,34 @@
   }
 
   .topic-pool-toolbar,
-  .topic-card {
+  .topic-card,
+  .topic-source-card {
+    grid-template-columns: 1fr;
+  }
+
+  .topic-list {
     grid-template-columns: 1fr;
   }
+
+  .topic-direction-actions .btn,
+  .topic-direction-actions select {
+    width: 100%;
+  }
+
+  .topic-modal-backdrop {
+    padding: 12px;
+  }
+
+  .topic-modal {
+    width: calc(100vw - 24px);
+    max-height: calc(100vh - 24px);
+  }
+
+  .topic-modal-actions {
+    grid-template-columns: 1fr;
+  }
+
+  .topic-modal-header {
+    flex-direction: row;
+  }
 }

+ 147 - 62
src/app/pages/topic-pool/topic-pool.component.html

@@ -74,91 +74,176 @@
     </select>
   </div>
 
-  <div class="topic-list" *ngIf="filteredTopics().length > 0; else emptyState">
-    <article class="topic-card" *ngFor="let topic of filteredTopics(); trackBy: trackByTopic">
-      <div class="topic-card-main">
-        <div class="topic-card-head">
+  <div class="topic-list" *ngIf="topicGroups().length > 0; else emptyState">
+    <article class="topic-source-card" *ngFor="let group of topicGroups(); trackBy: trackByGroup" (click)="openGroupDetail(group)">
+      <div class="topic-source-main">
+        <div class="topic-card-head compact-head">
           <div>
-            <label class="topic-select-row">
-              <input type="checkbox" [ngModel]="isSelected(topic)" (ngModelChange)="toggleTopicSelection(topic, $event)">
-              <span class="topic-source">{{sourceLabel(topic.sourceType)}}</span>
-            </label>
-            <h3>{{topic.title}}</h3>
+            <div class="topic-select-row">
+              <span class="topic-status-pill">{{groupStatusLabel(group)}}</span>
+              <span class="topic-variant">{{group.directionCount}} 个方向</span>
+              <span class="topic-source">{{groupSourceLabel(group)}}</span>
+            </div>
+            <h3>{{group.sourceTitle}}</h3>
           </div>
-          <span class="topic-status">{{statusLabel(topic.status)}}</span>
+          <span class="topic-status">{{group.primary.status === 'completed' ? '已完成' : '未完'}}</span>
         </div>
 
-        <p class="topic-angle">{{topic.angle}}</p>
-
-        <div class="topic-edit-form" *ngIf="editingTopicId() === topic.id">
-          <label>
-            标题
-            <input type="text" [ngModel]="editDraft().title" (ngModelChange)="updateEditDraft({ title: $event })">
-          </label>
-          <label>
-            角度
-            <textarea rows="3" [ngModel]="editDraft().angle" (ngModelChange)="updateEditDraft({ angle: $event })"></textarea>
-          </label>
-          <label>
-            标签
-            <input type="text" placeholder="用逗号分隔" [ngModel]="editDraft().tagsText" (ngModelChange)="updateEditDraft({ tagsText: $event })">
-          </label>
-          <label>
-            开头方式
-            <input type="text" [ngModel]="editDraft().hook" (ngModelChange)="updateEditDraft({ hook: $event })">
-          </label>
-          <label>
-            脚本框架
-            <textarea rows="4" [ngModel]="editDraft().outline" (ngModelChange)="updateEditDraft({ outline: $event })"></textarea>
-          </label>
-          <div class="topic-edit-actions">
-            <button class="btn btn-primary" type="button" (click)="saveEdit(topic)">保存修改</button>
-            <button class="btn" type="button" (click)="cancelEdit()">取消</button>
-          </div>
-        </div>
+        <p class="topic-angle compact-summary">{{group.sourceSummary}}</p>
 
-        <div class="topic-outline" *ngIf="topic.outline">
-          <strong>脚本框架</strong>
-          <p>{{topic.outline}}</p>
+        <div class="topic-compact-primary">
+          <div class="compact-primary-label">
+            <span>主推</span>
+            <strong>{{group.primary.title}}</strong>
+          </div>
+          <p>{{group.primary.angle}}</p>
+          <ul *ngIf="compactOutline(group.primary).length">
+            <li *ngFor="let line of compactOutline(group.primary)">{{line}}</li>
+          </ul>
         </div>
 
-        <div class="topic-tags" *ngIf="topic.tags.length || topic.confidence">
-          <span class="topic-tag" *ngFor="let tag of topic.tags">{{tag}}</span>
-          <span class="topic-tag">{{confidenceLabel(topic.confidence)}}</span>
+        <div class="topic-tags compact-tags" *ngIf="compactTags(group).length || group.primary.confidence">
+          <span class="topic-tag" *ngFor="let tag of compactTags(group)">{{tag}}</span>
+          <span class="topic-tag">{{confidenceLabel(group.primary.confidence)}}</span>
         </div>
 
         <div class="topic-meta">
-          <span>更新时间 {{formatTime(topic.updatedAt)}}</span>
-          <span *ngIf="topic.sourceVideoIds.length">来源视频 {{topic.sourceVideoIds.length}} 条</span>
+          <span>更新时间 {{formatTime(group.updatedAt)}}</span>
+          <span *ngIf="group.sourceVideoId">视频 {{group.sourceVideoId}}</span>
         </div>
+
       </div>
 
-      <div class="topic-card-actions">
-        <button class="btn btn-primary" type="button" (click)="useForTopicVideo(topic)">
-          进入主题生视频
-        </button>
-        <button class="btn" type="button" (click)="useForDigitalHuman(topic)">
-          进入数字人
+      <div class="topic-card-actions" (click)="$event.stopPropagation()">
+        <button class="btn btn-primary" type="button" (click)="useForTopicVideo(group.primary)">
+          生成
         </button>
-        <button class="btn" type="button" (click)="openFirstSourceVideo(topic)" [disabled]="!topic.sourceVideoIds.length">
-          查看来源视频
+        <button class="btn" type="button" (click)="openGroupDetail(group)">
+          查看
         </button>
-        <button class="btn" type="button" (click)="startEdit(topic)">
-          编辑选题
+        <button class="btn" type="button" (click)="openGroupSourceVideo(group)" [disabled]="!group.sourceVideoId">
+          来源
         </button>
-        <select [ngModel]="topic.status" (ngModelChange)="updateStatus(topic, $event)">
-          <option value="idea">待写脚本</option>
-          <option value="script_ready">脚本就绪</option>
+        <select [ngModel]="group.primary.status" (ngModelChange)="updateStatus(group.primary, $event)">
+          <option value="idea">待筛选</option>
+          <option value="script_ready">脚本就绪</option>
           <option value="generating">生成中</option>
           <option value="completed">已完成</option>
         </select>
-        <button class="btn btn-warn" type="button" (click)="archive(topic)">
-          归档
-        </button>
       </div>
     </article>
   </div>
 
+  <div class="topic-modal-backdrop" *ngIf="activeGroup() as group" (click)="closeGroupDetail()">
+    <section class="topic-modal" (click)="$event.stopPropagation()">
+      <header class="topic-modal-header">
+        <div>
+          <div class="topic-select-row">
+            <span class="topic-status-pill">{{groupStatusLabel(group)}}</span>
+            <span class="topic-variant">{{group.directionCount}} 个方向</span>
+            <span class="topic-source">{{groupSourceLabel(group)}}</span>
+          </div>
+          <h2>{{group.sourceTitle}}</h2>
+          <p>{{group.sourceSummary}}</p>
+        </div>
+        <button class="modal-close-btn" type="button" (click)="closeGroupDetail()">×</button>
+      </header>
+
+      <div class="topic-modal-actions">
+        <button class="btn btn-primary" type="button" (click)="useForTopicVideo(group.primary)">用主推生成视频</button>
+        <button class="btn" type="button" (click)="useForDigitalHuman(group.primary)">主推进数字人</button>
+        <button class="btn" type="button" (click)="openGroupSourceVideo(group)" [disabled]="!group.sourceVideoId">查看来源视频</button>
+      </div>
+
+      <div class="topic-direction-list is-modal-list">
+        <article class="topic-direction-card" *ngFor="let topic of group.topics; trackBy: trackByTopic" [class.is-primary]="topic.id === group.primary.id">
+          <div class="topic-direction-head">
+            <label class="topic-select-row">
+              <input type="checkbox" [ngModel]="isSelected(topic)" (ngModelChange)="toggleTopicSelection(topic, $event)">
+              <span class="topic-variant" *ngIf="topicVariantLabel(topic)">{{topicVariantLabel(topic)}}</span>
+              <span class="topic-recommended" *ngIf="recommendedLabel(topic, group)">主推</span>
+            </label>
+            <button class="btn btn-mini" type="button" (click)="setRecommended(topic)" [disabled]="topic.id === group.primary.id">
+              设为主推
+            </button>
+          </div>
+
+          <h4>{{topic.title}}</h4>
+          <p class="topic-angle">{{topic.angle}}</p>
+
+          <div class="topic-hook" *ngIf="topic.hook">
+            <strong>开头钩子</strong>
+            <p>{{topic.hook}}</p>
+          </div>
+
+          <div class="topic-edit-form" *ngIf="editingTopicId() === topic.id">
+            <label>
+              标题
+              <input type="text" [ngModel]="editDraft().title" (ngModelChange)="updateEditDraft({ title: $event })">
+            </label>
+            <label>
+              角度
+              <textarea rows="3" [ngModel]="editDraft().angle" (ngModelChange)="updateEditDraft({ angle: $event })"></textarea>
+            </label>
+            <label>
+              标签
+              <input type="text" placeholder="用逗号分隔" [ngModel]="editDraft().tagsText" (ngModelChange)="updateEditDraft({ tagsText: $event })">
+            </label>
+            <label>
+              开头方式
+              <input type="text" [ngModel]="editDraft().hook" (ngModelChange)="updateEditDraft({ hook: $event })">
+            </label>
+            <label>
+              短脚本框架
+              <textarea rows="4" [ngModel]="editDraft().shortOutline" (ngModelChange)="updateEditDraft({ shortOutline: $event })"></textarea>
+            </label>
+            <label>
+              完整脚本框架
+              <textarea rows="5" [ngModel]="editDraft().outline" (ngModelChange)="updateEditDraft({ outline: $event })"></textarea>
+            </label>
+            <div class="topic-edit-actions">
+              <button class="btn btn-primary" type="button" (click)="saveEdit(topic)">保存修改</button>
+              <button class="btn" type="button" (click)="cancelEdit()">取消</button>
+            </div>
+          </div>
+
+          <div class="topic-outline" *ngIf="shortOutline(topic)">
+            <strong>短脚本框架</strong>
+            <p>{{shortOutline(topic)}}</p>
+          </div>
+
+          <details class="topic-production-detail" *ngIf="hasProductionDetail(topic)">
+            <summary>完整脚本与来源证据</summary>
+            <div class="topic-full-outline" *ngIf="fullOutline(topic)">
+              <strong>完整脚本框架</strong>
+              <p>{{fullOutline(topic)}}</p>
+            </div>
+            <div class="topic-source-evidence" *ngIf="topic.sourceSummary || topic.sourceEvidence?.length">
+              <strong>来源证据</strong>
+              <p *ngIf="topic.sourceSummary">{{topic.sourceSummary}}</p>
+              <ul *ngIf="topic.sourceEvidence?.length">
+                <li *ngFor="let evidence of topic.sourceEvidence">{{evidence}}</li>
+              </ul>
+            </div>
+          </details>
+
+          <div class="topic-direction-actions">
+            <button class="btn btn-primary" type="button" (click)="useForTopicVideo(topic)">用此方向生成</button>
+            <button class="btn" type="button" (click)="useForDigitalHuman(topic)">进入数字人</button>
+            <button class="btn" type="button" (click)="startEdit(topic)">编辑</button>
+            <select [ngModel]="topic.status" (ngModelChange)="updateStatus(topic, $event)">
+              <option value="idea">待筛选</option>
+              <option value="script_ready">脚本就绪</option>
+              <option value="generating">生成中</option>
+              <option value="completed">已完成</option>
+            </select>
+            <button class="btn btn-warn" type="button" (click)="archive(topic)">归档</button>
+          </div>
+        </article>
+      </div>
+    </section>
+  </div>
+
   <ng-template #emptyState>
     <div class="topic-empty">
       <span class="icon">inventory_2</span>

+ 157 - 1
src/app/pages/topic-pool/topic-pool.component.ts

@@ -13,6 +13,20 @@ interface TopicEditDraft {
   tagsText: string;
   hook: string;
   outline: string;
+  shortOutline: string;
+}
+
+interface TopicGroup {
+  key: string;
+  sourceType: TopicIdea['sourceType'];
+  sourceVideoId: string;
+  sourceTitle: string;
+  sourceSummary: string;
+  topics: TopicIdea[];
+  primary: TopicIdea;
+  directionCount: number;
+  updatedAt: string;
+  tags: string[];
 }
 
 @Component({
@@ -38,12 +52,14 @@ export class TopicPoolComponent implements OnInit {
   readonly editingTopicId = signal<string>('');
   readonly selectedTopicIds = signal<string[]>([]);
   readonly selectedTemplateId = signal<string>('');
+  readonly activeGroupKey = signal<string>('');
   readonly editDraft = signal<TopicEditDraft>({
     title: '',
     angle: '',
     tagsText: '',
     hook: '',
     outline: '',
+    shortOutline: '',
   });
 
   readonly stats = computed(() => {
@@ -75,12 +91,19 @@ export class TopicPoolComponent implements OnInit {
         topic.angle,
         topic.hook || '',
         topic.outline || '',
+        topic.sourceTitle || '',
+        topic.sourceSummary || '',
+        ...(topic.sourceEvidence || []),
+        ...(topic.sourceVideoIds || []),
         ...(topic.tags || []),
       ].join(' ').toLowerCase();
       return haystack.includes(keyword);
     });
   });
 
+  readonly topicGroups = computed<TopicGroup[]>(() => this.groupTopics(this.filteredTopics()));
+  readonly activeGroup = computed<TopicGroup | null>(() => this.topicGroups().find((group) => group.key === this.activeGroupKey()) || null);
+
   ngOnInit(): void {
     this.refresh();
     this.refreshFromCloud();
@@ -116,6 +139,15 @@ export class TopicPoolComponent implements OnInit {
     this.refresh();
   }
 
+  setRecommended(topic: TopicIdea): void {
+    const key = this.topicGroupKey(topic);
+    const groupTopics = this.topics().filter((item) => this.topicGroupKey(item) === key);
+    for (const item of groupTopics) {
+      this.topicPool.updateTopic(item.id, { isRecommended: item.id === topic.id });
+    }
+    this.refresh();
+  }
+
   toggleTopicSelection(topic: TopicIdea, checked: boolean): void {
     const ids = this.selectedTopicIds();
     this.selectedTopicIds.set(checked
@@ -127,6 +159,15 @@ export class TopicPoolComponent implements OnInit {
     return this.selectedTopicIds().includes(topic.id);
   }
 
+  openGroupDetail(group: TopicGroup): void {
+    this.activeGroupKey.set(group.key);
+  }
+
+  closeGroupDetail(): void {
+    this.activeGroupKey.set('');
+    this.cancelEdit();
+  }
+
   sendSelectedToBatch(): void {
     const selected = this.topics().filter((topic) => this.selectedTopicIds().includes(topic.id));
     if (!selected.length) return;
@@ -135,7 +176,7 @@ export class TopicPoolComponent implements OnInit {
       title: topic.title,
       angle: topic.angle,
       hook: topic.hook || '',
-      outline: topic.outline || '',
+      outline: topic.fullOutline || topic.outline || '',
       tags: topic.tags || [],
     }));
     localStorage.setItem('videoWorkflow.batchProduction.importTopics', JSON.stringify(payload));
@@ -150,6 +191,7 @@ export class TopicPoolComponent implements OnInit {
       tagsText: (topic.tags || []).join(','),
       hook: topic.hook || '',
       outline: topic.outline || '',
+      shortOutline: topic.shortOutline || '',
     });
   }
 
@@ -165,6 +207,8 @@ export class TopicPoolComponent implements OnInit {
       tags: this.parseTags(draft.tagsText),
       hook: draft.hook.trim(),
       outline: draft.outline.trim(),
+      fullOutline: draft.outline.trim(),
+      shortOutline: draft.shortOutline.trim(),
     });
     this.editingTopicId.set('');
     this.refresh();
@@ -177,19 +221,39 @@ export class TopicPoolComponent implements OnInit {
   openFirstSourceVideo(topic: TopicIdea): void {
     const awemeId = topic.sourceVideoIds?.[0] || '';
     if (!awemeId) return;
+    this.closeGroupDetail();
     this.sourceVideoOpen.emit({ awemeId, topic });
   }
 
+  openGroupSourceVideo(group: TopicGroup): void {
+    this.openFirstSourceVideo(group.primary);
+  }
+
   useForTopicVideo(topic: TopicIdea): void {
     this.persistSelectedTopic(topic);
+    this.closeGroupDetail();
     this.navigateToPipeline.emit({ tab: 'topic-to-video', topic });
   }
 
   useForDigitalHuman(topic: TopicIdea): void {
     this.persistSelectedTopic(topic);
+    this.closeGroupDetail();
     this.navigateToPipeline.emit({ tab: 'digital-human', topic });
   }
 
+  groupSourceLabel(group: TopicGroup): string {
+    return group.sourceType === 'viral_analysis' ? '爆款来源视频' : this.sourceLabel(group.sourceType);
+  }
+
+  groupStatusLabel(group: TopicGroup): string {
+    const statuses = group.topics.map((topic) => topic.status);
+    if (statuses.includes('generating')) return '生成中';
+    if (statuses.includes('completed')) return '有成片';
+    if (statuses.includes('script_ready')) return '脚本就绪';
+    if (statuses.includes('idea')) return '待筛选';
+    return this.statusLabel(group.primary.status);
+  }
+
   sourceLabel(source: TopicIdea['sourceType']): string {
     const map: Record<TopicIdea['sourceType'], string> = {
       viral_analysis: '爆款分析',
@@ -218,6 +282,39 @@ export class TopicPoolComponent implements OnInit {
     return '未标记';
   }
 
+  topicVariantLabel(topic: TopicIdea): string {
+    if (!topic.variantIndex || !topic.variantTotal || topic.variantTotal <= 1) return '';
+    return `同源方向 ${topic.variantIndex}/${topic.variantTotal}`;
+  }
+
+  recommendedLabel(topic: TopicIdea, group: TopicGroup): string {
+    return topic.id === group.primary.id ? '主推' : '';
+  }
+
+  shortOutline(topic: TopicIdea): string {
+    return topic.shortOutline || this.firstLines(topic.outline || topic.fullOutline || '', 4);
+  }
+
+  compactOutline(topic: TopicIdea): string[] {
+    return this.shortOutline(topic)
+      .split(/\r?\n/)
+      .map((line) => line.trim())
+      .filter(Boolean)
+      .slice(0, 2);
+  }
+
+  compactTags(group: TopicGroup): string[] {
+    return group.tags.filter((tag) => !/^方向\d+\/\d+$/.test(tag)).slice(0, 4);
+  }
+
+  fullOutline(topic: TopicIdea): string {
+    return topic.fullOutline || topic.outline || '';
+  }
+
+  hasProductionDetail(topic: TopicIdea): boolean {
+    return !!(this.fullOutline(topic) || topic.sourceEvidence?.length || topic.sourceSummary);
+  }
+
   formatTime(value: string): string {
     if (!value) return '';
     const date = new Date(value);
@@ -230,6 +327,10 @@ export class TopicPoolComponent implements OnInit {
     return topic.id;
   }
 
+  trackByGroup(_: number, group: TopicGroup): string {
+    return group.key;
+  }
+
   private persistSelectedTopic(topic: TopicIdea): void {
     localStorage.setItem('videoWorkflow.topicPool.selectedTopic', JSON.stringify(topic));
     const templateId = this.selectedTemplateId();
@@ -247,4 +348,59 @@ export class TopicPoolComponent implements OnInit {
       .filter((item, index, list) => !!item && list.indexOf(item) === index)
       .slice(0, 12);
   }
+
+  private firstLines(value: string, count: number): string {
+    return String(value || '')
+      .split(/\r?\n/)
+      .map((line) => line.trim())
+      .filter(Boolean)
+      .slice(0, count)
+      .join('\n');
+  }
+
+  private groupTopics(topics: TopicIdea[]): TopicGroup[] {
+    const map = new Map<string, TopicIdea[]>();
+    for (const topic of topics) {
+      const key = this.topicGroupKey(topic);
+      map.set(key, [...(map.get(key) || []), topic]);
+    }
+
+    return Array.from(map.entries())
+      .map(([key, items]) => {
+        const sorted = [...items].sort((a, b) => {
+          if (!!b.isRecommended !== !!a.isRecommended) return Number(!!b.isRecommended) - Number(!!a.isRecommended);
+          return Number(a.variantIndex || 999) - Number(b.variantIndex || 999)
+            || Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
+        });
+        const primary = sorted.find((item) => item.isRecommended) || sorted[0];
+        return {
+          key,
+          sourceType: primary.sourceType,
+          sourceVideoId: primary.sourceVideoIds?.[0] || '',
+          sourceTitle: this.groupTitle(sorted, primary),
+          sourceSummary: primary.sourceSummary || primary.angle || primary.title,
+          topics: sorted,
+          primary,
+          directionCount: sorted.length,
+          updatedAt: sorted.reduce((latest, item) => Date.parse(item.updatedAt) > Date.parse(latest) ? item.updatedAt : latest, primary.updatedAt),
+          tags: Array.from(new Set(sorted.flatMap((item) => item.tags || []))).slice(0, 8),
+        };
+      })
+      .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
+  }
+
+  private topicGroupKey(topic: TopicIdea): string {
+    if (topic.sourceType === 'viral_analysis' && topic.sourceVideoIds?.[0]) {
+      return `viral:${topic.sourceVideoIds[0]}`;
+    }
+    return `${topic.sourceType}:${topic.sourceVideoIds?.[0] || topic.id}`;
+  }
+
+  private groupTitle(topics: TopicIdea[], primary: TopicIdea): string {
+    const sourceTitle = topics.map((item) => item.sourceTitle).find(Boolean);
+    if (sourceTitle) return sourceTitle;
+    const videoId = primary.sourceVideoIds?.[0];
+    if (videoId && primary.sourceType === 'viral_analysis') return `来源视频 ${videoId}`;
+    return primary.title;
+  }
 }

+ 5 - 5
src/app/pipelines/pipeline-registry.ts

@@ -16,7 +16,7 @@ export type PipelineStatus = 'stable' | 'beta' | 'planned';
 export type PipelineRoute =
   | 'digital-human'        // 数字人口播(已成熟)
   | 'image-generation'     // 图片生成
-  | 'video-generation'     // 标准 AI 重塑(旧版主入口,逐步迁移)
+  | 'video-generation'     // 文生视频
   | 'image-to-video'       // 图生视频(P1 计划)
   | 'action-transfer'      // 动作迁移(P2 计划)
   | 'asset-remix'          // 素材→视频(P3 计划)
@@ -66,11 +66,11 @@ export const PIPELINES: readonly PipelineDef[] = [
   },
   {
     id: 'video_generation',
-    displayName: '标准视频生成',
-    description: '上传原视频,重新整理画面、文案和配音,生成新的成片',
-    inputHint: '原始视频 + 改造指令',
+    displayName: '文生视频',
+    description: '输入一段画面描述,直接生成单段 AI 视频',
+    inputHint: '提示词 + 比例 + 时长',
     iconSvg:
-      '<polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>',
+      '<path d="M4 5h10"></path><path d="M4 9h7"></path><rect x="3" y="13" width="12" height="7" rx="2"></rect><path d="M17 15l4-2v7l-4-2z"></path>',
     route: 'video-generation',
     status: 'beta',
     showInSidebar: true,

+ 1 - 1
src/app/services/assistant.service.ts

@@ -37,7 +37,7 @@ const SYSTEM_PROMPT = `你是「Tik-Tok 视频生成系统」的内置创作助
 ## 你的能力范围
 1. 帮用户写视频脚本、镜头描述、中文提示词
 2. 给出风格、节奏、分镜、配乐方面的建议
-3. 解答系统使用问题(系统包含以下生成模式:主题生视频 / 图生视频 / 数字人合成 / 动作迁移 / 素材合成视频 / 标准视频生成
+3. 解答系统使用问题(系统包含以下生成模式:主题生视频 / 文生视频 / 图生视频 / 数字人合成 / 动作迁移 / 素材合成视频)
 4. 当用户的需求显然适合直接进入某条生成流水线时,在回答**最末尾**用一个独立的 JSON 代码块给出建议:
 
 \`\`\`json

+ 3 - 2
src/app/services/cost-estimator.service.ts

@@ -27,7 +27,7 @@ export class CostEstimatorService {
   }
 
   estimateVideoGeneration(quality: EstimateQuality = '1080p', seconds = 5, title = '视频生成'): CostEstimate {
-    const normalizedSeconds = Number(seconds || 5) >= 10 ? 10 : 5;
+    const normalizedSeconds = Math.max(1, Math.round(Number(seconds || 5)));
     const cnyPerSecond = quality === 'pro' ? 1 : quality === '1080p' ? 0.63 : 0.28;
     return this.build('jimeng.video', `${title} ${quality} ${normalizedSeconds} 秒`, [
       {
@@ -108,7 +108,8 @@ export class CostEstimatorService {
   }
 
   framesToSeconds(frames: number): number {
-    return Number(frames || 121) >= 241 ? 10 : 5;
+    const normalizedFrames = Math.max(1, Number(frames || 121));
+    return Math.max(1, Math.round((normalizedFrames - 1) / 24));
   }
 
   private build(operation: string, title: string, lines: CostEstimateLine[]): CostEstimate {

+ 130 - 0
src/app/services/creation-brief.service.ts

@@ -0,0 +1,130 @@
+import { Injectable } from '@angular/core';
+import { TopicIdea } from '../models/douyin-insight.model';
+import {
+  CreationBrief,
+  CreationBriefEvidence,
+  DigitalHumanPrefill,
+  TopicToVideoPrefill,
+} from '../models/creation-brief.model';
+
+@Injectable({ providedIn: 'root' })
+export class CreationBriefService {
+  private readonly storagePrefix = 'videoWorkflow.creationBrief.pending.';
+
+  fromTopicIdea(topic: TopicIdea): CreationBrief {
+    const title = this.clean(topic.title) || this.clean(topic.angle) || '未命名选题';
+    const angle = this.clean(topic.angle) || title;
+    const evidence: CreationBriefEvidence[] = (topic.sourceVideoIds || []).map((id) => ({
+      type: 'video',
+      id,
+      confidence: topic.confidence,
+    }));
+
+    return {
+      id: `brief_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+      source: this.sourceFromTopic(topic),
+      topicId: topic.id,
+      title,
+      angle,
+      audience: this.clean(topic.audience),
+      hook: this.clean(topic.hook),
+      outline: this.clean(topic.fullOutline || topic.outline || topic.shortOutline),
+      tags: Array.from(new Set((topic.tags || []).map((tag) => this.clean(tag)).filter(Boolean))),
+      sourceVideoIds: topic.sourceVideoIds || [],
+      sourceAuthorIds: topic.sourceAuthorIds || [],
+      confidence: topic.confidence,
+      evidence,
+      createdAt: new Date().toISOString(),
+    };
+  }
+
+  toTopicToVideoPrefill(brief: CreationBrief): TopicToVideoPrefill {
+    return {
+      topic: this.clean(brief.title) || this.clean(brief.angle),
+      angle: this.clean(brief.angle),
+      hook: this.clean(brief.hook),
+      outline: this.normalizeOutline(brief.outline || ''),
+      context: this.topicContext(brief),
+      topicId: brief.topicId,
+      source: brief.source,
+      brief,
+    };
+  }
+
+  toDigitalHumanPrefill(brief: CreationBrief): DigitalHumanPrefill {
+    return {
+      ttsText: this.digitalHumanScript(brief),
+      prompt: this.digitalHumanPrompt(brief),
+      topicId: brief.topicId,
+      source: brief.source,
+      brief,
+    };
+  }
+
+  savePendingBrief(targetPipeline: 'topic-to-video' | 'digital-human', brief: CreationBrief): void {
+    try {
+      sessionStorage.setItem(`${this.storagePrefix}${targetPipeline}`, JSON.stringify(brief));
+    } catch {}
+  }
+
+  consumePendingBrief(targetPipeline: 'topic-to-video' | 'digital-human'): CreationBrief | null {
+    const key = `${this.storagePrefix}${targetPipeline}`;
+    try {
+      const raw = sessionStorage.getItem(key);
+      if (!raw) return null;
+      sessionStorage.removeItem(key);
+      return JSON.parse(raw) as CreationBrief;
+    } catch {
+      return null;
+    }
+  }
+
+  private topicContext(brief: CreationBrief): string {
+    return [
+      `选题:${brief.title}`,
+      brief.angle && brief.angle !== brief.title ? `角度:${brief.angle}` : '',
+      brief.audience ? `目标人群:${brief.audience}` : '',
+      brief.hook ? `开头钩子:${brief.hook}` : '',
+      brief.outline ? `脚本框架:\n${brief.outline}` : '',
+      brief.tags.length ? `来源标签:${brief.tags.join('、')}` : '',
+    ].filter(Boolean).join('\n');
+  }
+
+  private digitalHumanScript(brief: CreationBrief): string {
+    const hook = brief.hook || brief.angle || brief.title;
+    const outline = brief.outline ? this.normalizeOutline(brief.outline) : '';
+    if (outline.length >= 80) return outline.slice(0, 600);
+
+    const audience = brief.audience ? `如果你也是${brief.audience},` : '如果你也在关注这个问题,';
+    return [
+      `${audience}先记住一句话:${hook}`,
+      `这个选题真正值得讲的不是表面的「${brief.title}」,而是背后的「${brief.angle}」。`,
+      '我建议你先从三个点判断:第一,看它解决的是不是一个真实高频问题;第二,看评论区有没有持续追问;第三,看这个表达能不能落到自己的场景里。',
+      '如果你也想把这个方向做成一条内容,可以先从一个具体案例切入,再给出判断标准,最后把问题抛回评论区。',
+    ].join('\n');
+  }
+
+  private digitalHumanPrompt(brief: CreationBrief): string {
+    const tags = brief.tags.length ? `,贴合${brief.tags.slice(0, 3).join('、')}内容` : '';
+    return `自然口播,镜头稳定,表情可信,语速适中,重点句轻微点头强调${tags}。`;
+  }
+
+  private sourceFromTopic(topic: TopicIdea): CreationBrief['source'] {
+    if (topic.sourceType === 'viral_analysis') return 'viral_analysis';
+    if (topic.sourceType === 'daily_report') return 'daily_report';
+    if (topic.sourceType === 'assistant') return 'assistant';
+    return 'manual';
+  }
+
+  private normalizeOutline(outline: string): string {
+    return outline
+      .split(/\r?\n/)
+      .map((line) => this.clean(line))
+      .filter(Boolean)
+      .join('\n');
+  }
+
+  private clean(value: unknown): string {
+    return String(value || '').replace(/\s+/g, ' ').trim();
+  }
+}

+ 29 - 15
src/app/services/douyin-api.service.ts

@@ -1,9 +1,8 @@
 import { Injectable } from '@angular/core';
-import { Observable, from, throwError } from 'rxjs';
+import { HttpClient } from '@angular/common/http';
+import { Observable, firstValueFrom, from, throwError } from 'rxjs';
 import { catchError, retry } from 'rxjs/operators';
 import { environment } from '../../environments/environment';
-import { ParseService } from './parse.service';
-import { CLOUD_FN } from './cloud-functions';
 
 // 抖音API接口定义
 interface DouyinSearchParams {
@@ -32,7 +31,7 @@ interface DouyinCommentsParams {
 export class DouyinApiService {
   private readonly MAX_RETRIES = environment.douyinApi.maxRetries;
 
-  constructor(private parse: ParseService) {}
+  constructor(private http: HttpClient) {}
 
   /**
    * 抖音综合搜索
@@ -104,15 +103,15 @@ export class DouyinApiService {
    * @param cursor 游标
    * @returns 回复列表
    */
-  getCommentReplies(item_id: string, comment_id: string, cursor: number = 0): Observable<any> {
+  getCommentReplies(item_id: string, comment_id: string, cursor: number = 0, options: { optional?: boolean } = {}): Observable<any> {
     return this.callDouyin('replies', { payload: {
       item_id,
       comment_id,
       cursor,
       count: 20
-    } }).pipe(
+    }, optional: options.optional }).pipe(
       retry(this.MAX_RETRIES),
-      catchError(this.handleError)
+      catchError(options.optional ? this.handleOptionalError : this.handleError)
     );
   }
 
@@ -128,15 +127,30 @@ export class DouyinApiService {
     );
   }
 
-  private callDouyin(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): Observable<any> {
-    if (!CLOUD_FN.douyin) {
-      return throwError(() => new Error('抖音服务暂未配置,请先部署抖音云函数。'));
+  private callDouyin(route: string, params: { payload?: Record<string, any>; params?: Record<string, any>; optional?: boolean }): Observable<any> {
+    return from(this.callDouyinLocalFirst(route, params));
+  }
+
+  private async callDouyinLocalFirst(route: string, params: { payload?: Record<string, any>; params?: Record<string, any>; optional?: boolean }): Promise<any> {
+    try {
+      const local = await firstValueFrom(this.http.post<{ success?: boolean; data?: any; error?: string }>('/backend/api/douyin/call', {
+        route,
+        ...params,
+      }));
+      if (local?.success) return local.data;
+      throw new Error(local?.error || '本地抖音数据代理返回失败');
+    } catch (localError) {
+      const message = this.readLocalProxyError(localError);
+      throw new Error(`本地抖音数据代理调用失败,请确认已启动 npm run server:${message}`);
     }
-    return from(this.parse.callOrThrow<any>(CLOUD_FN.douyin, {
-      action: 'call',
-      route,
-      ...params,
-    }));
+  }
+
+  private readLocalProxyError(error: any): string {
+    return error?.error?.error || error?.error?.message || error?.message || '本地抖音数据代理不可用';
+  }
+
+  private handleOptionalError(error: any): Observable<never> {
+    return throwError(() => error);
   }
 
   /**

+ 231 - 0
src/app/services/douyin-evidence-analysis.service.ts

@@ -0,0 +1,231 @@
+import { Injectable } from '@angular/core';
+import {
+  ViralAnalysisContent,
+  ViralCommentSnapshot,
+  ViralReplySnapshot,
+  ViralVideoSnapshot,
+} from '../models/douyin-insight.model';
+
+interface RankedComment {
+  text: string;
+  score: number;
+  kind: 'question' | 'high_interaction' | 'emotion';
+}
+
+@Injectable({ providedIn: 'root' })
+export class DouyinEvidenceAnalysisService {
+  buildContent(
+    video: ViralVideoSnapshot,
+    comments: ViralCommentSnapshot[],
+    replies: ViralReplySnapshot[] = [],
+    transcript = '',
+  ): ViralAnalysisContent {
+    const sourceText = this.clean(transcript || video.desc);
+    const commentPool = this.rankComments(comments, replies);
+    const questions = commentPool.filter((item) => item.kind === 'question');
+    const topComments = commentPool.slice(0, 4);
+    const hookType = this.inferHookType(sourceText);
+    const conflict = this.inferConflict(sourceText, comments, replies);
+    const opening = this.firstSentence(sourceText) || video.desc || '从具体场景或观点切入';
+    const emotion = this.inferEmotion(comments, replies);
+    const proof = this.evidenceFromStats(video);
+    const interactionScore = this.interactionScore(video);
+    const frame = this.reusableFrame(video, hookType, conflict, questions, topComments);
+    const riskNotes = this.riskNotes(video, comments, replies, transcript);
+
+    return {
+      summary: this.summary(video, hookType, conflict, interactionScore, comments.length, !!transcript),
+      hookType,
+      openingPattern: opening,
+      contentRhythm: this.contentRhythm(sourceText, transcript),
+      conflict,
+      proofPoint: proof,
+      audienceEmotion: emotion,
+      commentTrigger: questions[0]?.text || topComments[0]?.text || '围绕用户最想追问的问题引导评论',
+      reusableFrame: frame.join(' -> '),
+      reusableAngles: this.reusableAngles(video, conflict, questions, topComments),
+      riskNotes,
+      evidenceRefs: this.evidenceRefs(video, comments, replies, transcript, interactionScore),
+    };
+  }
+
+  private summary(
+    video: ViralVideoSnapshot,
+    hookType: string,
+    conflict: string,
+    score: number,
+    commentCount: number,
+    hasTranscript: boolean,
+  ): string {
+    const proof = this.evidenceFromStats(video);
+    const basis = hasTranscript ? '逐字稿、互动数据和评论' : '标题/描述、互动数据和评论';
+    return `本地证据拆解:该视频主要依靠「${hookType}」切入,用「${conflict}」制造观看理由;${proof},评论样本 ${commentCount} 条,互动分 ${Math.round(score)}。分析依据为${basis}。`;
+  }
+
+  private interactionScore(video: ViralVideoSnapshot): number {
+    return Number(video.diggCount || 0)
+      + Number(video.commentCount || 0) * 4
+      + Number(video.shareCount || 0) * 6
+      + Number(video.playCount || 0) * 0.003;
+  }
+
+  private rankComments(comments: ViralCommentSnapshot[], replies: ViralReplySnapshot[]): RankedComment[] {
+    return [...comments, ...replies]
+      .map((item) => {
+        const text = this.clean(item.text);
+        const like = Number(item.likeCount || 0);
+        const reply = Number((item as ViralCommentSnapshot).replyCount || 0);
+        const score = like + reply * 2 + (this.isQuestion(text) ? 12 : 0) + (this.hasEmotionSignal(text) ? 6 : 0);
+        const kind: RankedComment['kind'] = this.isQuestion(text)
+          ? 'question'
+          : this.hasEmotionSignal(text)
+            ? 'emotion'
+            : 'high_interaction';
+        return { text, score, kind };
+      })
+      .filter((item) => item.text)
+      .sort((a, b) => b.score - a.score);
+  }
+
+  private inferHookType(text: string): string {
+    const t = this.clean(text);
+    if (/[??]|为什么|怎么|如何|到底|有没有/.test(t)) return '问题钩子';
+    if (/别再|不要|千万|避坑|踩雷|错了|误区|真相/.test(t)) return '反常识/避坑钩子';
+    if (/我发现|亲测|真实|讲个|经历|以前|后来|普通人/.test(t)) return '个人经历钩子';
+    if (/\d|一[个-龥]?招|三[个-龥]?点|5个|10个|清单|步骤/.test(t)) return '清单/数字钩子';
+    if (/但是|其实|反而|不是.*而是|看起来|没想到/.test(t)) return '反转钩子';
+    if (/爆|火|涨粉|成交|转化|收入|结果|翻倍/.test(t)) return '结果承诺钩子';
+    return '场景共鸣钩子';
+  }
+
+  private inferConflict(text: string, comments: ViralCommentSnapshot[], replies: ViralReplySnapshot[]): string {
+    const t = `${this.clean(text)} ${[...comments, ...replies].map((item) => item.text).join(' ')}`;
+    if (/避坑|踩雷|别买|别再|不要|后悔/.test(t)) return '用户害怕踩坑,需要一个清晰判断标准';
+    if (/焦虑|担心|纠结|不敢|怕|压力/.test(t)) return '用户在风险和收益之间犹豫';
+    if (/贵|便宜|性价比|价格|值不值/.test(t)) return '用户在价格和价值感之间犹豫';
+    if (/没用|无效|失败|翻车|白费/.test(t)) return '用户担心结果不可控,需要证明边界';
+    if (/不会|不懂|小白|怎么做|流程/.test(t)) return '用户缺少操作路径,需要拆成步骤';
+    if (/但是|其实|反而|不是|没想到/.test(t)) return '适合用反转打破原有认知';
+    if (/为什么|怎么|如何/.test(t)) return '适合用问题驱动展开,降低理解门槛';
+    return '围绕用户已有认知和真实结果制造轻冲突';
+  }
+
+  private inferEmotion(comments: ViralCommentSnapshot[], replies: ViralReplySnapshot[]): string {
+    const text = [...comments.map((item) => item.text), ...replies.map((item) => item.text)].join(' ');
+    if (/贵|便宜|值|性价比|价格/.test(text)) return '价值感犹豫';
+    if (/怕|担心|焦虑|纠结|不敢|踩雷/.test(text)) return '风险规避';
+    if (/哪里|怎么买|链接|求|想要|试试|报名/.test(text)) return '行动意愿';
+    if (/哈哈|笑|真实|太对|共鸣|扎心/.test(text)) return '共鸣互动';
+    if (/收藏|学到了|有用|mark|码住/.test(text)) return '学习收藏';
+    return comments.length || replies.length ? '好奇追问' : '评论证据不足';
+  }
+
+  private contentRhythm(text: string, transcript: string): string {
+    const source = this.clean(transcript || text);
+    const sentences = source.split(/[。!?!?;;\n]/).map((item) => item.trim()).filter(Boolean);
+    if (sentences.length >= 5) return '开头快速定调,中段连续抛出多个论点或案例,结尾用问题承接评论';
+    if (/\d|第一|第二|第三|首先|然后|最后/.test(source)) return '数字/步骤式节奏,适合拆成 3 段口播结构';
+    if (/但是|其实|反而|没想到/.test(source)) return '反转式节奏,前半制造预期,后半给出新判断';
+    return '短观点节奏,先给判断,再补 2-3 个理由,最后抛评论问题';
+  }
+
+  private reusableFrame(
+    video: ViralVideoSnapshot,
+    hookType: string,
+    conflict: string,
+    questions: RankedComment[],
+    topComments: RankedComment[],
+  ): string[] {
+    const first = this.truncate(this.firstSentence(video.desc), 42) || '一个具体场景/判断';
+    const question = questions[0]?.text || topComments[0]?.text || '你最想解决哪一步?';
+    return [
+      `开头:用「${first}」做${hookType},先给出一个具体判断`,
+      `冲突:指出「${conflict}」,让用户知道为什么要继续看`,
+      '论证:拆 2-3 个可验证原因,最好配案例、数字、前后对比或亲身观察',
+      `互动:结尾抛出「${this.truncate(question, 42)}」这类问题,引导用户留言`,
+    ];
+  }
+
+  private reusableAngles(
+    video: ViralVideoSnapshot,
+    conflict: string,
+    questions: RankedComment[],
+    topComments: RankedComment[],
+  ): string[] {
+    const base = this.truncate(video.desc || '该主题', 30);
+    const candidates = [
+      base ? `把「${base}」改成你的账号口播版本` : '',
+      conflict ? `围绕「${conflict}」做一条观点型内容` : '',
+      questions[0]?.text ? `从评论问题「${this.truncate(questions[0].text, 28)}」反推一条选题` : '',
+      topComments[0]?.text && topComments[0]?.text !== questions[0]?.text ? `围绕热评「${this.truncate(topComments[0].text, 28)}」做一条用户共鸣内容` : '',
+      /避坑|踩雷|别买|别再|不要|误区/.test(`${video.desc} ${conflict}`) ? `把来源视频改写成一条避坑清单口播` : '',
+      /怎么|如何|流程|步骤|不会|不懂/.test(`${video.desc} ${questions.map((item) => item.text).join(' ')}`) ? `把用户最关心的问题拆成步骤型教程` : '',
+      /贵|便宜|性价比|价格|值不值/.test(`${video.desc} ${conflict} ${topComments.map((item) => item.text).join(' ')}`) ? `围绕价格和价值感做一条判断标准内容` : '',
+    ];
+    const unique = candidates
+      .map((item) => this.clean(item))
+      .filter((item, index, list) => !!item && list.indexOf(item) === index)
+      .slice(0, 5);
+    return unique.length ? unique : [`围绕「${base || '该主题'}」做一条观点型内容`];
+  }
+
+  private riskNotes(
+    video: ViralVideoSnapshot,
+    comments: ViralCommentSnapshot[],
+    replies: ViralReplySnapshot[],
+    transcript: string,
+  ): string[] {
+    return [
+      ...(transcript ? [] : ['未提供完整逐字稿,本条为基于视频描述、互动数据和评论的结构推断。']),
+      ...(comments.length >= 3 ? [] : ['评论样本少于 3 条,评论触发点需谨慎使用。']),
+      ...(!comments.length && !replies.length ? ['缺少评论/回复样本,用户情绪和互动点可信度偏低。'] : []),
+      ...(!video.diggCount && !video.commentCount && !video.shareCount ? ['缺少互动数据,无法判断真实爆款强度。'] : []),
+    ];
+  }
+
+  private evidenceRefs(
+    video: ViralVideoSnapshot,
+    comments: ViralCommentSnapshot[],
+    replies: ViralReplySnapshot[],
+    transcript: string,
+    score: number,
+  ): string[] {
+    const ranked = this.rankComments(comments, replies);
+    return [
+      `来源视频:${video.awemeId || video.desc || '未知视频'} / ${this.evidenceFromStats(video)} / 互动分 ${Math.round(score)}`,
+      transcript ? `逐字稿片段:${this.truncate(transcript, 140)}` : '逐字稿:未提供,当前为结构推断',
+      ...ranked.slice(0, 3).map((item) => `评论证据:${item.text}`),
+    ].slice(0, 6);
+  }
+
+  private evidenceFromStats(video: ViralVideoSnapshot): string {
+    const parts = [
+      video.playCount ? `播放 ${video.playCount}` : '',
+      video.diggCount ? `点赞 ${video.diggCount}` : '',
+      video.commentCount ? `评论 ${video.commentCount}` : '',
+      video.shareCount ? `分享 ${video.shareCount}` : '',
+    ].filter(Boolean);
+    return parts.join(' / ') || '互动数据未提供';
+  }
+
+  private firstSentence(text: string): string {
+    return this.clean(text).split(/[。!?!?;;\n]/).map((item) => item.trim()).filter(Boolean)[0] || '';
+  }
+
+  private isQuestion(text: string): boolean {
+    return /[??]|怎么|哪里|多少|能不能|有没有|适合|为什么|求|链接|可以吗/.test(text);
+  }
+
+  private hasEmotionSignal(text: string): boolean {
+    return /真实|太对|扎心|焦虑|怕|贵|便宜|收藏|学到了|哈哈|想要|试试|求/.test(text);
+  }
+
+  private truncate(text: string, length: number): string {
+    const clean = this.clean(text);
+    return clean.length > length ? `${clean.slice(0, length)}...` : clean;
+  }
+
+  private clean(value: unknown): string {
+    return String(value || '').replace(/\s+/g, ' ').trim();
+  }
+}

+ 14 - 2
src/app/services/douyin-insight.service.ts

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
 import { AuthCreditService } from './auth-credit.service';
 import { CLOUD_FN } from './cloud-functions';
 import { ParseService } from './parse.service';
-import { DailyReport, TopicIdea, ViralAnalysis } from '../models/douyin-insight.model';
+import { DailyReport, DouyinTranscriptJob, TopicIdea, ViralAnalysis } from '../models/douyin-insight.model';
 
 type InsightAction =
   | 'analysisCreate'
@@ -15,7 +15,9 @@ type InsightAction =
   | 'topicArchive'
   | 'dailyReportCreate'
   | 'dailyReportList'
-  | 'dailyReportGet';
+  | 'dailyReportGet'
+  | 'transcriptStart'
+  | 'transcriptGet';
 
 @Injectable({ providedIn: 'root' })
 export class DouyinInsightService {
@@ -73,6 +75,16 @@ export class DouyinInsightService {
     return this.call<DailyReport[]>('dailyReportList', { limit });
   }
 
+  async startTranscript(awemeId: string, analysisId?: string): Promise<DouyinTranscriptJob | null> {
+    if (!this.available) return null;
+    return this.call<DouyinTranscriptJob>('transcriptStart', { awemeId, analysisId });
+  }
+
+  async getTranscript(jobId: string): Promise<DouyinTranscriptJob | null> {
+    if (!this.available) return null;
+    return this.call<DouyinTranscriptJob>('transcriptGet', { id: jobId });
+  }
+
   private async call<T>(action: InsightAction, params: Record<string, any>): Promise<T> {
     const session = this.auth.session;
     if (!session) throw new Error('请先登录');

+ 83 - 0
src/app/services/douyin-transcript.service.ts

@@ -0,0 +1,83 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+import { DouyinTranscriptJob, ViralAnalysis } from '../models/douyin-insight.model';
+import { DouyinInsightService } from './douyin-insight.service';
+import { ViralAnalysisService } from './viral-analysis.service';
+import { DouyinEvidenceAnalysisService } from './douyin-evidence-analysis.service';
+
+@Injectable({ providedIn: 'root' })
+export class DouyinTranscriptService {
+  constructor(
+    private http: HttpClient,
+    private insight: DouyinInsightService,
+    private viralAnalysis: ViralAnalysisService,
+    private evidenceAnalysis: DouyinEvidenceAnalysisService,
+  ) {}
+
+  async startForAnalysis(analysis: ViralAnalysis): Promise<DouyinTranscriptJob> {
+    const job = await this.startLocalTranscript(analysis).catch(() => this.insight.startTranscript(analysis.awemeId, analysis.id));
+    if (!job) throw new Error('逐字稿服务暂不可用');
+    this.viralAnalysis.updateLocalAnalysis(analysis.id, {
+      transcriptJob: job,
+      updatedAt: new Date().toISOString(),
+    });
+    return job;
+  }
+
+  async refreshForAnalysis(analysis: ViralAnalysis): Promise<DouyinTranscriptJob> {
+    const jobId = analysis.transcriptJob?.id;
+    if (!jobId) throw new Error('缺少逐字稿任务 ID');
+    const job = await this.getLocalTranscript(jobId).catch(() => this.insight.getTranscript(jobId));
+    if (!job) throw new Error('逐字稿任务不存在');
+    const patch: Partial<ViralAnalysis> = {
+      transcriptJob: job,
+      updatedAt: new Date().toISOString(),
+    };
+    if (job.status === 'completed' && job.text) {
+      patch.transcript = job.text;
+      patch.transcriptSource = 'asr';
+      patch.confidence = 'high';
+      patch.analysis = this.mergeAnalysis(
+        analysis,
+        this.evidenceAnalysis.buildContent(
+          analysis.videoSnapshot,
+          analysis.commentsSnapshot,
+          analysis.repliesSnapshot || [],
+          job.text,
+        ),
+      );
+    }
+    this.viralAnalysis.updateLocalAnalysis(analysis.id, patch);
+    return job;
+  }
+
+  private async startLocalTranscript(analysis: ViralAnalysis): Promise<DouyinTranscriptJob | null> {
+    const response = await firstValueFrom(this.http.post<{ success?: boolean; job?: DouyinTranscriptJob; data?: DouyinTranscriptJob }>(
+      '/backend/api/douyin/transcript/start',
+      {
+        awemeId: analysis.awemeId,
+        analysisId: analysis.id,
+        detail: analysis.videoSnapshot,
+      },
+    ));
+    return response?.job || response?.data || null;
+  }
+
+  private async getLocalTranscript(jobId: string): Promise<DouyinTranscriptJob | null> {
+    const response = await firstValueFrom(this.http.get<{ success?: boolean; job?: DouyinTranscriptJob; data?: DouyinTranscriptJob }>(
+      `/backend/api/douyin/transcript/${encodeURIComponent(jobId)}`,
+    ));
+    return response?.job || response?.data || null;
+  }
+
+  private mergeAnalysis(analysis: ViralAnalysis, next: ViralAnalysis['analysis']): ViralAnalysis['analysis'] {
+    return {
+      ...next,
+      reusableAngles: analysis.analysis.reusableAngles?.length ? analysis.analysis.reusableAngles : next.reusableAngles,
+      riskNotes: Array.from(new Set([...(next.riskNotes || []), ...(analysis.analysis.riskNotes || [])]))
+        .filter((note) => !/未提供完整逐字稿|当前未接入完整字幕/.test(note))
+        .slice(0, 5),
+    };
+  }
+}

+ 20 - 11
src/app/services/douyin.service.ts

@@ -1,18 +1,16 @@
 import { Injectable } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
-import { from, Observable, throwError, timer } from 'rxjs';
+import { firstValueFrom, from, Observable, throwError, timer } from 'rxjs';
 import { catchError, map, retry, switchMap } from 'rxjs/operators';
 import { DouyinVideo, VideoDownloadTask } from '../models/types';
 import { environment } from '../../environments/environment';
 import { AuthCreditService } from './auth-credit.service';
-import { ParseService } from './parse.service';
-import { CLOUD_FN } from './cloud-functions';
 
 @Injectable({ providedIn: 'root' })
 export class DouyinService {
   private readonly MAX_RETRIES = environment.douyinApi.maxRetries;
 
-  constructor(private http: HttpClient, private authCredit: AuthCreditService, private parse: ParseService) {}
+  constructor(private http: HttpClient, private authCredit: AuthCreditService) {}
 
   // 抖音话题搜索 V2
   searchVideos(
@@ -390,14 +388,25 @@ export class DouyinService {
   }
 
   private callDouyin(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): Observable<any> {
-    if (!CLOUD_FN.douyin) {
-      return throwError(() => new Error('抖音服务暂未配置,请先部署抖音云函数。'));
+    return from(this.callDouyinLocalFirst(route, params));
+  }
+
+  private async callDouyinLocalFirst(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): Promise<any> {
+    try {
+      const local = await firstValueFrom(this.http.post<{ success?: boolean; data?: any; error?: string }>('/backend/api/douyin/call', {
+        route,
+        ...params,
+      }));
+      if (local?.success) return local.data;
+      throw new Error(local?.error || '本地抖音数据代理返回失败');
+    } catch (localError) {
+      const message = this.readLocalProxyError(localError);
+      throw new Error(`本地抖音数据代理调用失败,请确认已启动 npm run server:${message}`);
     }
-    return from(this.parse.callOrThrow<any>(CLOUD_FN.douyin, {
-      action: 'call',
-      route,
-      ...params,
-    }));
+  }
+
+  private readLocalProxyError(error: any): string {
+    return error?.error?.error || error?.error?.message || error?.message || '本地抖音数据代理不可用';
   }
 
   private withCreditGate<T>(

+ 52 - 14
src/app/services/jimeng.service.ts

@@ -205,7 +205,7 @@ export class JimengService {
     const body = {
       prompt: request.prompt,
       method: request.method,
-      frames: request.frames || 121,
+      frames: this.normalizeVideoFrames(request.frames),
       config: request.config || { aspect_ratio: '16:9' }
     };
 
@@ -222,7 +222,7 @@ export class JimengService {
     const body = {
       prompt: request.prompt,
       method: request.method,
-      frames: request.frames || 121,
+      frames: this.normalizeVideoFrames(request.frames),
       config: request.config || { aspect_ratio: '16:9' }
     };
 
@@ -258,7 +258,7 @@ export class JimengService {
 
     const body: any = {
       prompt: request.prompt,
-      frames: request.frames || 121,
+      frames: this.normalizeVideoFrames(request.frames),
       aspect_ratio: aspectCode,
     };
     if (request.method === '2' && imageUrl) {
@@ -304,13 +304,14 @@ export class JimengService {
     onProgress?: (status: string, progress: number, meta?: Record<string, any>) => void,
     options?: { scale?: number; forceSingle?: boolean }
   ): Observable<{ videoUrl: string; workId: string }> {
-    const seconds = this.framesToSeconds(params.frames || 121);
+    const frames = this.normalizeVideoFrames(params.frames);
+    const seconds = this.framesToSeconds(frames);
     const cost = this.videoCreditCost(params.quality, seconds);
     return this.withCreditGate(
       'jimeng.imageToVideo',
       cost,
       `图生视频 ${params.quality} ${seconds} 秒`,
-      () => this.generateImageToVideoUnlocked(params, onProgress, options)
+      () => this.generateImageToVideoUnlocked({ ...params, frames }, onProgress, options)
     );
   }
 
@@ -357,7 +358,7 @@ export class JimengService {
     const request: JimengVideoRequest = {
       prompt: params.prompt,
       method: params.method,
-      frames: params.frames || 121,
+      frames: this.normalizeVideoFrames(params.frames),
       config,
     };
 
@@ -974,14 +975,15 @@ export class JimengService {
   ): Observable<{ videoUrl: string; workId: string }> {
     const method = options.method || '1';
     const quality = options.quality || '1080p';
-    const seconds = this.framesToSeconds(options.frames || 121);
+    const frames = this.normalizeVideoFrames(options.frames);
+    const seconds = this.framesToSeconds(frames);
     const cost = this.videoCreditCost(quality, seconds);
 
     return this.withCreditGate(
       'jimeng.video',
       cost,
       `视频生成 ${quality} ${seconds} 秒`,
-      () => this.remixVideoUnlocked(prompt, options, onProgress)
+      () => this.remixVideoUnlocked(prompt, { ...options, frames }, onProgress)
     );
   }
 
@@ -1002,7 +1004,7 @@ export class JimengService {
     const request: JimengVideoRequest = {
       prompt,
       method,
-      frames: options.frames || 121,
+      frames: this.normalizeVideoFrames(options.frames),
       config: method === '1'
         ? { aspect_ratio: options.aspectRatio || '16:9' }
         : { image_urls: options.imageUrl ? [options.imageUrl] : [] }
@@ -1024,9 +1026,9 @@ export class JimengService {
 
         const workId = res.data.workId;
         console.log(`✅ 任务已提交, workId=${workId}`);
-        if (onProgress) onProgress('任务已提交,正在生成视频...', 15);
-
         const routerName = quality === 'pro' ? 'getVideoV3_Pro' : quality === '720p' ? 'getVideoV3_720p' : 'getVideoV3_1080p';
+        if (onProgress) onProgress('external-task-id', 15, { workId, routerName });
+        if (onProgress) onProgress('任务已提交,正在生成视频...', 15);
 
         return this.pollUntilComplete(workId, routerName, (status, attempt) => {
           const progressPercent = Math.min(15 + (attempt / this.pollMaxAttempts) * 75, 90);
@@ -1280,6 +1282,12 @@ export class JimengService {
     return Number(frames || 121) >= 241 ? 10 : 5;
   }
 
+  private normalizeVideoFrames(frames?: number): 121 | 241 {
+    const raw = Number(frames || 121);
+    if (!Number.isFinite(raw) || raw <= 0) return 121;
+    return raw <= 181 ? 121 : 241;
+  }
+
   private videoCreditCost(quality: JimengVideoQuality, seconds: number): number {
     const cnyPerSecond = quality === 'pro' ? 1 : quality === '1080p' ? 0.63 : 0.28;
     return Math.ceil(cnyPerSecond * seconds * 10);
@@ -1289,7 +1297,8 @@ export class JimengService {
     return (error: any): Observable<never> => {
       console.error(`即梦API [${operation}] 错误:`, error);
 
-      const rawDetail = error?.error;
+      const responseDetail = error?.response || error?.detail || null;
+      const rawDetail = error?.error || responseDetail?.raw || responseDetail;
       let detail = rawDetail;
 
       if (typeof rawDetail === 'string') {
@@ -1300,17 +1309,18 @@ export class JimengService {
         }
       }
 
+      const upstream = error?.upstream || responseDetail?.upstream || (typeof detail === 'object' ? detail?.upstream : null);
       const nestedErrmsg = typeof detail === 'object'
         ? detail?.message?.errmsg || detail?.errmsg || detail?.error?.errmsg || null
         : null;
 
       const requestIdValue = nestedErrmsg?.request_id || (typeof detail === 'object' ? detail?.request_id : '');
       const requestId = requestIdValue ? ` (request_id: ${requestIdValue})` : '';
-      const message = typeof detail === 'object'
+      const message = this.actionableJimengMessage(operation, upstream, error, detail) || (typeof detail === 'object'
         ? nestedErrmsg?.message || detail?.msg || detail?.message?.tip || detail?.message || detail?.error || error?.message
         : typeof detail === 'string' && detail.trim()
           ? detail
-          : error?.message || `即梦API ${operation} 调用失败`;
+          : error?.message || `即梦API ${operation} 调用失败`);
 
       const fallbackDetail = (() => {
         if (!rawDetail) return '';
@@ -1328,8 +1338,36 @@ export class JimengService {
 
       const normalizedError = new Error(finalMessage);
       (normalizedError as any).raw = error;
+      (normalizedError as any).response = responseDetail;
+      (normalizedError as any).upstream = upstream;
 
       return throwError(() => normalizedError);
     };
   }
+
+  private actionableJimengMessage(operation: string, upstream: any, error: any, detail: any): string {
+    const status = Number(upstream?.status || detail?.code || error?.response?.code || 0);
+    const attempts = Number(upstream?.maxAttempts || upstream?.attempt || 0);
+    const retryText = attempts > 1 ? `,已自动重试 ${attempts} 次` : '';
+    const raw = [
+      error?.message,
+      typeof detail === 'string' ? detail : '',
+      typeof detail === 'object' ? detail?.error || detail?.message || detail?.msg : '',
+    ].filter(Boolean).join(' ');
+    const lower = raw.toLowerCase();
+
+    if (status === 429 || /rate|limit|频繁|限流|too many/.test(lower)) {
+      return `即梦上游当前限流${retryText},请等待 1-3 分钟后重试当前步骤。`;
+    }
+    if (status === 408 || /abort|timeout|timed out|超时/.test(lower)) {
+      return `即梦上游响应超时${retryText},任务可能仍在生成中,请稍后重试或到任务记录中恢复查询。`;
+    }
+    if (status >= 500 || /fetch failed|network|econnreset|etimedout|enotfound|eai_again|连接|网络/.test(lower)) {
+      return `即梦上游服务暂时不可达${retryText},请稍后重试;如果连续失败,先降低清晰度或缩短时长。`;
+    }
+    if (/workid|objectid|未返回|result|任务/.test(lower) && operation === 'getWorkResult') {
+      return '即梦任务结果暂未返回,请保留任务记录,稍后恢复查询。';
+    }
+    return '';
+  }
 }

+ 6 - 7
src/app/services/markdown.service.ts

@@ -1,13 +1,12 @@
-import { Injectable } from '@angular/core';
-import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { Injectable, SecurityContext } from '@angular/core';
+import { DomSanitizer } from '@angular/platform-browser';
 import { marked } from 'marked';
 
 /**
  * Markdown → SafeHtml 渲染器。
  *
- * 用于把 LLM 返回的 markdown 文本转成可绑定到 [innerHTML] 的安全 HTML。
- * 内容来源是受信任的后端代理(fmode LLM 网关),未引入 DOMPurify 以减小包体;
- * 若未来接入用户提供的 markdown,建议补一道 DOMPurify。
+ * 用于把 LLM 返回的 markdown 文本转成可绑定到 [innerHTML] 的 HTML。
+ * marked 负责 markdown 解析,Angular sanitizer 负责移除不安全 HTML。
  */
 @Injectable({ providedIn: 'root' })
 export class MarkdownService {
@@ -19,8 +18,8 @@ export class MarkdownService {
   }
 
   /** 渲染完整 markdown,绑定到 [innerHTML]。流式过程也可调用(marked 容忍不完整 markdown)。 */
-  render(text: string): SafeHtml {
+  render(text: string): string {
     const html = marked.parse(text || '', { async: false }) as string;
-    return this.sanitizer.bypassSecurityTrustHtml(html);
+    return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
   }
 }

+ 8 - 1
src/app/services/parse.service.ts

@@ -6,6 +6,7 @@ export interface CloudFnResponse<T = any> {
   success: boolean;
   data?: T;
   error?: string;
+  [key: string]: any;
 }
 
 @Injectable({ providedIn: 'root' })
@@ -45,6 +46,8 @@ export class ParseService {
           code: response.status,
           success: false,
           error: this.readError(data, text) || `HTTP error! status: ${response.status}`,
+          raw: data,
+          upstream: data?.upstream,
         };
       }
 
@@ -70,7 +73,11 @@ export class ParseService {
   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');
+      const error = new Error(res.error || 'Cloud function call failed');
+      (error as any).response = res;
+      (error as any).detail = res;
+      (error as any).upstream = res['upstream'] || res['raw']?.upstream;
+      throw error;
     }
     return res.data as T;
   }

+ 2 - 0
src/app/services/template.service.ts

@@ -150,6 +150,7 @@ export class TemplateService {
           quickMode: true,
           mediaMode: 'image',
           clipFrames: 121,
+          clipDurationSeconds: 5,
           clipQuality: '720p',
           continuityEnabled: true,
           visualBible: {
@@ -183,6 +184,7 @@ export class TemplateService {
           quickMode: true,
           mediaMode: 'video',
           clipFrames: 121,
+          clipDurationSeconds: 5,
           clipQuality: '720p',
           continuityEnabled: true,
           visualBible: {

+ 5 - 1
src/app/services/topic-to-video-batch-runner.service.ts

@@ -5,6 +5,7 @@ import { BatchItemRunner } from './batch-job.service';
 import { GenerationTaskService } from './generation-task.service';
 import { JimengService, JimengVideoQuality } from './jimeng.service';
 import { PipelineSessionService } from './pipeline-session.service';
+import { VideoDurationService } from './video-duration.service';
 
 @Injectable({ providedIn: 'root' })
 export class TopicToVideoBatchRunnerService {
@@ -12,6 +13,7 @@ export class TopicToVideoBatchRunnerService {
     private generationTasks: GenerationTaskService,
     private jimeng: JimengService,
     private sessions: PipelineSessionService,
+    private videoDuration: VideoDurationService,
   ) {}
 
   async run(item: BatchJobItem, job: BatchJob, context?: Parameters<BatchItemRunner>[2]): Promise<BatchJobRunResult> {
@@ -19,7 +21,8 @@ export class TopicToVideoBatchRunnerService {
     const templateConfig = this.safeObject(input['templateConfig']);
     const topic = String(input['topic'] || item.title || '').trim();
     const quality = this.normalizeQuality(templateConfig['clipQuality']);
-    const frames = Number(templateConfig['clipFrames'] || 121) >= 241 ? 241 : 121;
+    const durationSeconds = Math.max(3, Math.min(15, Math.round(Number(templateConfig['clipDurationSeconds']) || this.videoDuration.framesToSeconds(Number(templateConfig['clipFrames'] || 121)))));
+    const frames = this.videoDuration.secondsToFrames(durationSeconds);
     const aspectRatio = this.normalizeAspect(templateConfig['aspect']);
 
     const task = this.generationTasks.create({
@@ -78,6 +81,7 @@ export class TopicToVideoBatchRunnerService {
         workId: result.workId,
         quality,
         frames,
+        durationSeconds,
       },
       snapshot: {
         source: 'batch-production',

+ 30 - 0
src/app/services/video-duration.service.ts

@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import { VideoGenerationCapability } from '../models/video-generation-capability.model';
+
+@Injectable({ providedIn: 'root' })
+export class VideoDurationService {
+  private readonly fps = 24;
+
+  secondsToFrames(seconds: number): number {
+    const normalized = Math.max(1, Number(seconds) || 5);
+    return Math.max(1, Math.round(normalized * this.fps) + 1);
+  }
+
+  framesToSeconds(frames: number): number {
+    const normalized = Math.max(1, Number(frames) || 121);
+    return Math.max(1, Math.round((normalized - 1) / this.fps));
+  }
+
+  normalizeSeconds(seconds: number, capability: VideoGenerationCapability): number {
+    const raw = Number(seconds);
+    const fallback = capability.defaultSeconds || 5;
+    const value = Number.isFinite(raw) && raw > 0 ? raw : fallback;
+    return Math.max(capability.minSeconds, Math.min(capability.maxSeconds, Math.round(value)));
+  }
+
+  validateDuration(seconds: number, capability: VideoGenerationCapability): string {
+    if (seconds < capability.minSeconds) return `最短支持 ${capability.minSeconds} 秒`;
+    if (seconds > capability.maxSeconds) return `最长支持 ${capability.maxSeconds} 秒`;
+    return '';
+  }
+}

+ 58 - 0
src/app/services/video-generation-capability.service.ts

@@ -0,0 +1,58 @@
+import { Injectable } from '@angular/core';
+import {
+  VideoGenerationCapability,
+  VideoGenerationMode,
+  VideoGenerationQuality,
+} from '../models/video-generation-capability.model';
+
+@Injectable({ providedIn: 'root' })
+export class VideoGenerationCapabilityService {
+  capabilityFor(mode: VideoGenerationMode): VideoGenerationCapability {
+    if (mode === 'image-to-video-camera') {
+      return this.build(mode, ['720p'], '720p', [
+        { quality: '1080p', reason: '运镜模式当前仅支持 720p' },
+        { quality: 'pro', reason: '运镜模式当前不支持 Pro' },
+      ]);
+    }
+    if (mode === 'image-to-video-first-last') {
+      return this.build(mode, ['720p', '1080p'], '1080p', [
+        { quality: 'pro', reason: '首尾帧模式当前不支持 Pro' },
+      ]);
+    }
+    return this.build(mode, ['720p', '1080p', 'pro'], mode === 'topic-to-video' ? '720p' : '1080p', []);
+  }
+
+  isQualitySupported(mode: VideoGenerationMode, quality: VideoGenerationQuality): boolean {
+    return this.capabilityFor(mode).qualities.includes(quality);
+  }
+
+  unsupportedReason(mode: VideoGenerationMode, quality: VideoGenerationQuality): string {
+    const capability = this.capabilityFor(mode);
+    if (capability.qualities.includes(quality)) return '';
+    return capability.unsupportedCombinations.find((item) => item.quality === quality)?.reason || '当前模式不支持该画质';
+  }
+
+  private build(
+    mode: VideoGenerationMode,
+    qualities: VideoGenerationQuality[],
+    defaultQuality: VideoGenerationQuality,
+    unsupportedCombinations: VideoGenerationCapability['unsupportedCombinations'],
+  ): VideoGenerationCapability {
+    return {
+      mode,
+      qualities,
+      defaultQuality,
+      minSeconds: 3,
+      maxSeconds: 15,
+      defaultSeconds: 5,
+      recommendedDurations: [
+        { seconds: 5, label: '5 秒', recommended: true },
+        { seconds: 8, label: '8 秒' },
+        { seconds: 10, label: '10 秒' },
+        { seconds: 15, label: '15 秒' },
+      ],
+      allowCustomDuration: true,
+      unsupportedCombinations,
+    };
+  }
+}

+ 70 - 185
src/app/services/viral-analysis.service.ts

@@ -1,18 +1,16 @@
 import { Injectable } from '@angular/core';
-import { forkJoin, from, Observable, of, throwError } from 'rxjs';
+import { forkJoin, Observable, of, throwError } from 'rxjs';
 import { catchError, map, switchMap } from 'rxjs/operators';
 import { AuthCreditService } from './auth-credit.service';
-import { DouyinInsightService } from './douyin-insight.service';
 import { DouyinApiService } from './douyin-api.service';
-import { LlmService } from './llm.service';
 import {
   AnalyzeVideoInput,
   ViralAnalysis,
-  ViralAnalysisContent,
   ViralCommentSnapshot,
   ViralReplySnapshot,
   ViralVideoSnapshot,
 } from '../models/douyin-insight.model';
+import { DouyinEvidenceAnalysisService } from './douyin-evidence-analysis.service';
 
 const VIRAL_ANALYSIS_STORAGE_KEY = 'videoWorkflow.viralAnalyses.items';
 
@@ -20,87 +18,64 @@ const VIRAL_ANALYSIS_STORAGE_KEY = 'videoWorkflow.viralAnalyses.items';
 export class ViralAnalysisService {
   constructor(
     private auth: AuthCreditService,
-    private insight: DouyinInsightService,
     private douyinApi: DouyinApiService,
-    private llm: LlmService,
+    private evidenceAnalysis: DouyinEvidenceAnalysisService,
   ) {}
 
   analyzeVideo(input: AnalyzeVideoInput): Observable<ViralAnalysis> {
-    const userId = this.auth.currentUser?.objectId;
-    if (!userId) {
-      this.auth.requestLogin('爆款分析');
-      return throwError(() => new Error('请先登录后使用爆款分析'));
-    }
+    const userId = this.currentAnalysisUserId();
 
     const awemeId = String(input.awemeId || '').trim();
     if (!awemeId) return throwError(() => new Error('缺少视频 ID,无法分析'));
 
-    let reservationId = '';
-    return from(this.auth.reserveCredit('douyin.viralAnalysis', 2, '爆款分析', { awemeId })).pipe(
-      switchMap((reserved) => {
-        reservationId = reserved.reservationId;
-        return forkJoin({
-          detail: this.douyinApi.getVideoDetail(awemeId).pipe(catchError(() => of(null))),
-          comments: this.douyinApi.getVideoComments({ aweme_id: awemeId, count: 20 }).pipe(catchError(() => of(null))),
-        }).pipe(
-          switchMap(({ detail, comments }) => {
-            const detailObject = this.extractDetail(detail);
-            const videoSnapshot = this.buildVideoSnapshot(awemeId, input.video || {}, detailObject);
-            const transcript = this.extractTranscript(detailObject);
-            const commentsSnapshot = this.extractComments(comments).slice(0, 20);
-            return this.fetchReplySamples(awemeId, commentsSnapshot).pipe(
-              switchMap((repliesSnapshot) => this.generateAnalysis(videoSnapshot, commentsSnapshot, repliesSnapshot, transcript).pipe(
-                map((analysisContent) => this.persistAnalysis({
-                  id: this.createId(),
-                  userId,
-                  awemeId,
-                  source: input.source,
-                  videoSnapshot,
-                  commentsSnapshot,
-                  repliesSnapshot,
-                  transcript: transcript || undefined,
-                  transcriptSource: transcript ? 'detail' : undefined,
-                  confidence: transcript ? 'high' : (commentsSnapshot.length || repliesSnapshot.length ? 'medium' : 'low'),
-                  analysis: analysisContent,
-                  savedTopicIds: [],
-                  createdAt: new Date().toISOString(),
-                  updatedAt: new Date().toISOString(),
-                })),
-              )),
-            );
+    return forkJoin({
+      detail: this.douyinApi.getVideoDetail(awemeId).pipe(catchError(() => of(null))),
+      comments: this.douyinApi.getVideoComments({ aweme_id: awemeId, count: 50 }).pipe(catchError(() => of(null))),
+    }).pipe(
+      switchMap(({ detail, comments }) => {
+        const detailObject = this.extractDetail(detail);
+        const videoSnapshot = this.buildVideoSnapshot(awemeId, input.video || {}, detailObject);
+        const transcript = this.extractTranscript(detailObject);
+        const commentsSnapshot = this.extractComments(comments).slice(0, 50);
+        return this.fetchReplySamples(awemeId, commentsSnapshot).pipe(
+          map((repliesSnapshot) => {
+            const analysisContent = this.evidenceAnalysis.buildContent(videoSnapshot, commentsSnapshot, repliesSnapshot, transcript);
+            const now = new Date().toISOString();
+            return this.persistAnalysis({
+              id: this.createId(),
+              userId,
+              awemeId,
+              source: input.source,
+              videoSnapshot,
+              commentsSnapshot,
+              repliesSnapshot,
+              transcript: transcript || undefined,
+              transcriptSource: transcript ? 'detail' : undefined,
+              confidence: this.localConfidence(videoSnapshot, commentsSnapshot, repliesSnapshot, transcript),
+              analysis: analysisContent,
+              savedTopicIds: [],
+              createdAt: now,
+              updatedAt: now,
+            });
           }),
-          switchMap((analysis) => from(this.auth.commitReservation(reservationId, { awemeId, title: analysis.videoSnapshot.desc })).pipe(
-            map(() => analysis),
-          )),
-          catchError((err) => from(this.auth.refundReservation(reservationId, err?.message || '爆款分析失败')).pipe(
-            switchMap(() => throwError(() => err)),
-          )),
         );
       }),
     );
   }
 
   listAnalyses(): ViralAnalysis[] {
-    const userId = this.auth.currentUser?.objectId;
-    if (!userId) return [];
+    const userId = this.currentAnalysisUserId();
     return this.readAll()
       .filter((item) => item.userId === userId)
       .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
   }
 
   async refreshAnalysesFromCloud(): Promise<ViralAnalysis[]> {
-    const userId = this.auth.currentUser?.objectId;
-    if (!userId) return [];
-    const cloudItems = await this.insight.listAnalyses();
-    if (!cloudItems.length) return this.listAnalyses();
-    const others = this.readAll().filter((item) => item.userId !== userId);
-    this.writeAll([...others, ...cloudItems]);
-    return cloudItems.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
+    return this.listAnalyses();
   }
 
   markTopicsSaved(analysisId: string, topicIds: string[]): ViralAnalysis {
-    const userId = this.auth.currentUser?.objectId;
-    if (!userId) throw new Error('请先登录');
+    const userId = this.currentAnalysisUserId();
     const items = this.readAll();
     const index = items.findIndex((item) => item.id === analysisId && item.userId === userId);
     if (index < 0) throw new Error('未找到该分析结果');
@@ -108,12 +83,25 @@ export class ViralAnalysisService {
     const updated = { ...items[index], savedTopicIds: nextIds, updatedAt: new Date().toISOString() };
     items[index] = updated;
     this.writeAll(items);
-    this.insight.updateAnalysis(analysisId, { savedTopicIds: nextIds, updatedAt: updated.updatedAt }).catch((err) => {
-      console.warn('[ViralAnalysisService] 云端更新分析失败,已保留本地数据:', err?.message || err);
-    });
     return updated;
   }
 
+  updateLocalAnalysis(analysisId: string, patch: Partial<ViralAnalysis>): ViralAnalysis {
+    const userId = this.currentAnalysisUserId();
+    const items = this.readAll();
+    const index = items.findIndex((item) => item.id === analysisId && item.userId === userId);
+    if (index < 0) throw new Error('未找到该分析结果');
+    const updated = { ...items[index], ...patch, id: analysisId, userId, updatedAt: new Date().toISOString() };
+    items[index] = updated;
+    this.writeAll(items);
+    return updated;
+  }
+
+  getLocalAnalysis(analysisId: string): ViralAnalysis | null {
+    const userId = this.currentAnalysisUserId();
+    return this.readAll().find((item) => item.id === analysisId && item.userId === userId) || null;
+  }
+
   private fetchReplySamples(awemeId: string, comments: ViralCommentSnapshot[]): Observable<ViralReplySnapshot[]> {
     const targetComments = comments
       .filter((item) => !!item.id && Number(item.replyCount || 0) > 0)
@@ -122,7 +110,7 @@ export class ViralAnalysisService {
 
     if (!targetComments.length) return of([]);
 
-    return forkJoin(targetComments.map((comment) => this.douyinApi.getCommentReplies(awemeId, comment.id || '').pipe(
+    return forkJoin(targetComments.map((comment) => this.douyinApi.getCommentReplies(awemeId, comment.id || '', 0, { optional: true }).pipe(
       map((payload) => this.extractReplies(payload, comment.id || '').slice(0, 5)),
       catchError(() => of([] as ViralReplySnapshot[])),
     ))).pipe(
@@ -130,60 +118,23 @@ export class ViralAnalysisService {
     );
   }
 
-  private generateAnalysis(
+  private currentAnalysisUserId(): string {
+    return this.auth.currentUser?.objectId || 'local-user';
+  }
+
+  private localConfidence(
     video: ViralVideoSnapshot,
     comments: ViralCommentSnapshot[],
-    replies: ViralReplySnapshot[] = [],
-    transcript = '',
-  ): Observable<ViralAnalysisContent> {
-    const systemPrompt = [
-      '你是短视频爆款内容分析师,专注抖音口播、图文和短视频内容拆解。',
-      '请只输出 JSON,不要输出 Markdown。',
-      '如果没有完整字幕,必须基于标题、互动数据和评论做结构推断,不要伪装成完整逐字稿分析。',
-    ].join('\n');
-    const userPrompt = [
-      '请分析这个抖音视频,并输出以下 JSON 字段:',
-      '{',
-      '  "summary": "一句话说明这个视频为什么可能有效",',
-      '  "hookType": "开头钩子类型",',
-      '  "openingPattern": "开头表达方式",',
-      '  "contentRhythm": "内容节奏",',
-      '  "conflict": "核心冲突或反差",',
-      '  "proofPoint": "让用户相信的证据点",',
-      '  "audienceEmotion": "主要触发的用户情绪",',
-      '  "commentTrigger": "容易引发评论的点",',
-      '  "reusableFrame": "可复用脚本框架",',
-      '  "reusableAngles": ["同款选题方向1", "同款选题方向2", "同款选题方向3"],',
-      '  "riskNotes": ["风险提醒"],',
-      '  "evidenceRefs": ["依据1", "依据2"]',
-      '}',
-      '',
-      `视频标题/描述:${video.desc || '无'}`,
-      `作者:${video.authorName || '未知'}`,
-      `互动:点赞 ${video.diggCount || 0},评论 ${video.commentCount || 0},分享 ${video.shareCount || 0},播放 ${video.playCount || 0}`,
-      '',
-      '视频字幕/口播文本:',
-      transcript || '暂无完整字幕/口播文本',
-      '',
-      '评论样本:',
-      comments.length
-        ? comments.map((item, index) => `${index + 1}. ${item.text}`).join('\n')
-        : '暂无评论样本',
-      '',
-      '评论回复样本:',
-      replies.length
-        ? replies.map((item, index) => `${index + 1}. ${item.text}`).join('\n')
-        : '暂无评论回复样本',
-    ].join('\n');
-
-    return this.llm.askWithSystem(systemPrompt, userPrompt, {
-      model: 'gpt-4o-mini',
-      temperature: 0.35,
-      max_tokens: 1800,
-    }).pipe(
-      map((raw) => this.normalizeAnalysisContent(raw)),
-      catchError(() => of(this.fallbackAnalysis(video, comments, transcript))),
-    );
+    replies: ViralReplySnapshot[],
+    transcript: string,
+  ): ViralAnalysis['confidence'] {
+    let score = 0;
+    if (video.diggCount || video.commentCount || video.shareCount || video.playCount) score += 1;
+    if (comments.length >= 3) score += 2;
+    else if (comments.length || replies.length) score += 1;
+    if (transcript) score += 2;
+    if (comments.some((item) => Number(item.replyCount || 0) > 0) || replies.length) score += 1;
+    return score >= 5 ? 'high' : score >= 3 ? 'medium' : 'low';
   }
 
   private buildVideoSnapshot(awemeId: string, fallback: Partial<ViralVideoSnapshot>, detailPayload: unknown): ViralVideoSnapshot {
@@ -325,53 +276,10 @@ export class ViralAnalysisService {
     }
   }
 
-  private normalizeAnalysisContent(raw: string): ViralAnalysisContent {
-    const json = this.parseJsonFromText(raw);
-    return {
-      summary: this.toText(json?.summary),
-      hookType: this.toText(json?.hookType),
-      openingPattern: this.toText(json?.openingPattern),
-      contentRhythm: this.toText(json?.contentRhythm),
-      conflict: this.toText(json?.conflict),
-      proofPoint: this.toText(json?.proofPoint),
-      audienceEmotion: this.toText(json?.audienceEmotion),
-      commentTrigger: this.toText(json?.commentTrigger),
-      reusableFrame: this.toText(json?.reusableFrame),
-      reusableAngles: this.toStringArray(json?.reusableAngles).slice(0, 5),
-      riskNotes: this.toStringArray(json?.riskNotes).slice(0, 5),
-      evidenceRefs: this.toStringArray(json?.evidenceRefs).slice(0, 6),
-    };
-  }
-
-  private fallbackAnalysis(video: ViralVideoSnapshot, comments: ViralCommentSnapshot[], transcript = ''): ViralAnalysisContent {
-    const topComment = comments[0]?.text || '暂无评论样本';
-    return {
-      summary: transcript ? '已结合视频字幕/口播文本、互动数据和评论样本生成初步判断。' : '已基于视频标题、互动数据和评论样本生成初步判断。',
-      hookType: '标题/话题驱动',
-      openingPattern: video.desc || '从具体场景或观点切入',
-      contentRhythm: '先抛出观点,再展开解释,最后引导讨论',
-      conflict: '用户需求与现实体验之间的反差',
-      proofPoint: '互动数据和评论反馈',
-      audienceEmotion: '好奇、认同或补充表达',
-      commentTrigger: topComment,
-      reusableFrame: '提出具体问题/观点 -> 给出场景化解释 -> 总结可执行建议 -> 抛出评论话题',
-      reusableAngles: [
-        `${video.desc || '该主题'}的同类经验分享`,
-        `围绕评论关注点做一条观点型内容`,
-        `把该视频结构改写成产品/账号适用的口播脚本`,
-      ],
-      riskNotes: transcript ? [] : ['当前未接入完整字幕,结论为结构推断。'],
-      evidenceRefs: transcript ? [transcript.slice(0, 120), ...comments.slice(0, 2).map((item) => item.text)] : (comments.length ? comments.slice(0, 3).map((item) => item.text) : ['暂无评论样本']),
-    };
-  }
-
   private persistAnalysis(analysis: ViralAnalysis): ViralAnalysis {
-    const items = this.readAll();
+    const items = this.readAll().filter((item) => item.id !== analysis.id);
     items.unshift(analysis);
-    this.writeAll(items);
-    this.insight.createAnalysis(analysis).catch((err) => {
-      console.warn('[ViralAnalysisService] 云端保存分析失败,已保留本地数据:', err?.message || err);
-    });
+    this.writeAll(items.slice(0, 300));
     return analysis;
   }
 
@@ -402,29 +310,6 @@ export class ViralAnalysisService {
     return current;
   }
 
-  private parseJsonFromText(raw: string): any {
-    const trimmed = String(raw || '').trim();
-    const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
-    const source = fenced || trimmed;
-    const start = source.indexOf('{');
-    const end = source.lastIndexOf('}');
-    if (start < 0 || end <= start) return {};
-    try {
-      return JSON.parse(source.slice(start, end + 1));
-    } catch {
-      return {};
-    }
-  }
-
-  private toText(value: unknown): string {
-    return typeof value === 'string' ? value.trim() : '';
-  }
-
-  private toStringArray(value: unknown): string[] {
-    if (!Array.isArray(value)) return [];
-    return value.map((item) => String(item || '').trim()).filter(Boolean);
-  }
-
   private pickCoverUrl(detail: any): string {
     const candidates = [
       ...(Array.isArray(detail?.video?.cover?.url_list) ? detail.video.cover.url_list : []),