Browse Source

Merge branch 'decode_prompt' of weapp/video_decode into master

jihuaqiang 4 ngày trước cách đây
mục cha
commit
9d3bc305ad

+ 29 - 0
examples/html/visualize/script.js

@@ -1,6 +1,35 @@
 // 全局元素索引(将由Python动态注入)
 // const elementIndex = {};
 
+// 卡片展开/收起功能
+function toggleCardDetails(cardId) {
+    const detailsElement = document.getElementById(cardId + '-details');
+    const cardElement = document.querySelector('[data-card-id="' + cardId + '"]');
+    
+    if (!detailsElement || !cardElement) {
+        return;
+    }
+    
+    // 切换显示/隐藏
+    if (detailsElement.style.display === 'none' || !detailsElement.style.display) {
+        detailsElement.style.display = 'block';
+        cardElement.classList.add('expanded');
+        // 更新图标
+        const toggleIcon = cardElement.querySelector('.toggle-icon');
+        if (toggleIcon) {
+            toggleIcon.textContent = '▲';
+        }
+    } else {
+        detailsElement.style.display = 'none';
+        cardElement.classList.remove('expanded');
+        // 更新图标
+        const toggleIcon = cardElement.querySelector('.toggle-icon');
+        if (toggleIcon) {
+            toggleIcon.textContent = '▼';
+        }
+    }
+}
+
 // 显示元素详情模态框
 function showElementDetail(elementId) {
     const elem = elementIndex[elementId];

+ 294 - 0
examples/html/visualize/style.css

@@ -2575,3 +2575,297 @@ body {
     color: #495057;
     line-height: 1.6;
 }
+
+/* ===== V2 卡片样式 ===== */
+
+.point-card {
+    margin-bottom: 20px;
+    background: white;
+    border-radius: 8px;
+    border: 1px solid #e5e7eb;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+    transition: all 0.3s ease;
+    overflow: hidden;
+}
+
+.point-card:hover {
+    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
+}
+
+.point-card-header {
+    padding: 15px 20px;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    background: #f8f9fa;
+    transition: background 0.2s;
+    user-select: none;
+}
+
+.point-card-header:hover {
+    background: #e9ecef;
+}
+
+.point-card.expanded .point-card-header {
+    background: #e7f3ff;
+    border-bottom: 1px solid #dee2e6;
+}
+
+.point-card-header .point-number {
+    display: inline-block;
+    background: #667eea;
+    color: white;
+    padding: 4px 10px;
+    border-radius: 4px;
+    font-weight: 600;
+    font-size: 0.85rem;
+    flex-shrink: 0;
+}
+
+.point-card-header .point-time {
+    color: #6c757d;
+    font-size: 0.9rem;
+    flex-shrink: 0;
+}
+
+.point-card-header .point-units {
+    color: #6c757d;
+    font-size: 0.85rem;
+    flex-shrink: 0;
+}
+
+.point-card-header .point-text {
+    flex: 1;
+    font-size: 1.05rem;
+    font-weight: 600;
+    color: #1f2937;
+}
+
+.toggle-icon {
+    color: #667eea;
+    font-size: 0.9rem;
+    transition: transform 0.3s;
+    flex-shrink: 0;
+}
+
+.point-card.expanded .toggle-icon {
+    transform: rotate(180deg);
+}
+
+.point-card-details {
+    display: none;
+    padding: 20px;
+    background: white;
+    animation: slideDown 0.3s ease;
+}
+
+.point-card.expanded .point-card-details {
+    display: block;
+}
+
+@keyframes slideDown {
+    from {
+        opacity: 0;
+        transform: translateY(-10px);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+.detail-section {
+    margin-bottom: 20px;
+    padding-bottom: 15px;
+    border-bottom: 1px solid #e9ecef;
+}
+
+.detail-section:last-child {
+    border-bottom: none;
+    margin-bottom: 0;
+}
+
+.detail-section strong {
+    display: block;
+    margin-bottom: 8px;
+    color: #495057;
+    font-size: 0.95rem;
+}
+
+.detail-text {
+    color: #4b5563;
+    line-height: 1.6;
+    font-size: 0.95rem;
+}
+
+.detail-list {
+    list-style: none;
+    padding-left: 0;
+    margin: 10px 0;
+}
+
+.detail-list li {
+    padding: 8px 12px;
+    margin-bottom: 8px;
+    background: #f8f9fa;
+    border-radius: 4px;
+    border-left: 3px solid #667eea;
+}
+
+.tag-group {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-top: 10px;
+}
+
+.label-tag {
+    display: inline-block;
+    padding: 5px 12px;
+    border-radius: 4px;
+    font-size: 0.85rem;
+    font-weight: 500;
+}
+
+.l1-tag {
+    background: #dbeafe;
+    color: #1e40af;
+}
+
+.l2-tag {
+    background: #fef3c7;
+    color: #92400e;
+}
+
+.l3-tag {
+    background: #f3e8ff;
+    color: #6b21a8;
+}
+
+.l4-tag {
+    background: #fce7f3;
+    color: #9f1239;
+}
+
+.keyword-tag {
+    display: inline-block;
+    padding: 4px 10px;
+    background: #e0e7ff;
+    color: #3730a3;
+    border-radius: 4px;
+    font-size: 0.85rem;
+    margin: 2px;
+}
+
+.sub-section {
+    margin-top: 15px;
+    padding: 12px;
+    background: #f8f9fa;
+    border-radius: 6px;
+    border-left: 3px solid #667eea;
+}
+
+.sub-section strong {
+    color: #495057;
+    font-size: 0.9rem;
+}
+
+.form-item {
+    margin-top: 10px;
+    padding: 10px;
+    background: white;
+    border-radius: 4px;
+    font-size: 0.9rem;
+    line-height: 1.6;
+    color: #4b5563;
+}
+
+.form-item strong {
+    color: #667eea;
+    margin-right: 8px;
+}
+
+.element-name {
+    font-size: 1rem;
+    color: #1f2937;
+    margin-bottom: 8px;
+    padding-bottom: 5px;
+    border-bottom: 1px solid #e5e7eb;
+}
+
+.logic-flow {
+    margin-top: 15px;
+}
+
+.logic-stage {
+    margin-bottom: 20px;
+    padding: 15px;
+    background: #f8f9fa;
+    border-radius: 6px;
+    border-left: 4px solid #667eea;
+}
+
+.stage-number {
+    font-size: 0.9rem;
+    color: #667eea;
+    font-weight: 600;
+    margin-bottom: 5px;
+}
+
+.stage-name {
+    font-size: 1.05rem;
+    font-weight: 600;
+    color: #1f2937;
+    margin-bottom: 8px;
+}
+
+.stage-desc {
+    font-size: 0.95rem;
+    color: #4b5563;
+    line-height: 1.6;
+}
+
+.section-divider {
+    height: 2px;
+    background: linear-gradient(to right, transparent, #dee2e6, transparent);
+    margin: 30px 0;
+}
+
+.section-info {
+    font-size: 0.9rem;
+    color: #6c757d;
+    margin-bottom: 15px;
+    padding: 8px 12px;
+    background: #f8f9fa;
+    border-radius: 4px;
+}
+
+.hook-item, .golden-item {
+    padding: 10px 15px;
+    margin-bottom: 10px;
+    background: #fff3cd;
+    border-radius: 6px;
+    border-left: 4px solid #ffc107;
+    font-size: 0.95rem;
+    line-height: 1.6;
+    color: #856404;
+}
+
+.golden-item {
+    background: #d1ecf1;
+    border-left-color: #17a2b8;
+    color: #0c5460;
+}
+
+.model-item {
+    padding: 10px 15px;
+    margin-bottom: 10px;
+    background: #e7f3ff;
+    border-radius: 6px;
+    border-left: 4px solid #667eea;
+    font-size: 0.9rem;
+    line-height: 1.6;
+    color: #1e40af;
+    font-family: 'Courier New', monospace;
+}

+ 3 - 3
examples/run_batch_script_v2.py

@@ -65,10 +65,10 @@ def main() -> None:
         }
 
     existing_results: List[Dict[str, Any]] = output_data.get("results", []) or []
-    # 用 video_id + video URL 去重,避免重复处理(兼容旧字段名 channel_content_id)
+    # 用 video_id + video URL 去重,避免重复处理(兼容旧字段名 channel_content_id 和 video
     processed_keys = {
         f"{item.get('video_data', {}).get('video_id', '') or item.get('video_data', {}).get('channel_content_id','')}|"
-        f"{item.get('video_data', {}).get('video','')}"
+        f"{item.get('video_data', {}).get('video_url', '') or item.get('video_data', {}).get('video','')}"
         for item in existing_results
     }
 
@@ -77,7 +77,7 @@ def main() -> None:
     for item in raw_list:
         video_data = item or {}
         video_id = video_data.get("video_id", "") or video_data.get("channel_content_id", "")  # 兼容旧字段名
-        video_url = video_data.get("video", "")
+        video_url = video_data.get("video_url", "") or video_data.get("video", "")  # 兼容旧字段名 video
 
         key = f"{video_id}|{video_url}"
         if key in processed_keys:

+ 2 - 0
examples/static/visualize_v2/__init__.py

@@ -0,0 +1,2 @@
+# V2 可视化模块
+

+ 176 - 0
examples/static/visualize_v2/tab1.py

@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""
+Tab1内容生成器 - 结构化内容库
+包含:选题信息表、模型信息表、层级标签树
+"""
+import html as html_module
+from typing import Dict, Any, List
+
+
+def render_topic_info_card(topic_info: Dict[str, Any]) -> str:
+    """渲染选题信息表卡片"""
+    macro_topic = topic_info.get("宏观母题", "")
+    sub_topics = topic_info.get("高潜子题列表", [])
+    
+    html = '<div class="point-card topic-card" data-card-id="topic-info">\n'
+    html += '<div class="point-card-header" onclick="toggleCardDetails(\'topic-info\')">\n'
+    html += '<span class="point-text">选题信息表</span>\n'
+    html += '<span class="toggle-icon">▼</span>\n'
+    html += '</div>\n'
+    
+    html += '<div class="point-card-details" id="topic-info-details">\n'
+    
+    if macro_topic:
+        html += '<div class="detail-section">\n'
+        html += '<strong>宏观母题:</strong>\n'
+        html += f'<div class="detail-text">{html_module.escape(macro_topic)}</div>\n'
+        html += '</div>\n'
+    
+    if sub_topics:
+        html += '<div class="detail-section">\n'
+        html += '<strong>高潜子题列表:</strong>\n'
+        html += '<ul class="detail-list">\n'
+        for sub_topic in sub_topics:
+            html += f'<li>{html_module.escape(str(sub_topic))}</li>\n'
+        html += '</ul>\n'
+        html += '</div>\n'
+    else:
+        html += '<div class="detail-section">\n'
+        html += '<strong>高潜子题列表:</strong> 暂无\n'
+        html += '</div>\n'
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+
+
+def render_model_info_card(model_info: Dict[str, Any]) -> str:
+    """渲染模型信息表卡片"""
+    logic_models = model_info.get("抽象逻辑模型列表", [])
+    
+    html = '<div class="point-card model-card" data-card-id="model-info">\n'
+    html += '<div class="point-card-header" onclick="toggleCardDetails(\'model-info\')">\n'
+    html += '<span class="point-text">模型信息表</span>\n'
+    html += '<span class="toggle-icon">▼</span>\n'
+    html += '</div>\n'
+    
+    html += '<div class="point-card-details" id="model-info-details">\n'
+    
+    if logic_models:
+        html += '<div class="detail-section">\n'
+        html += '<strong>抽象逻辑模型列表:</strong>\n'
+        html += '<ul class="detail-list">\n'
+        for model in logic_models:
+            html += f'<li class="model-item">{html_module.escape(str(model))}</li>\n'
+        html += '</ul>\n'
+        html += '</div>\n'
+    else:
+        html += '<div class="detail-section">\n'
+        html += '<strong>抽象逻辑模型列表:</strong> 暂无\n'
+        html += '</div>\n'
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+
+
+def render_label_tree_card(label_tree: Dict[str, Any]) -> str:
+    """渲染层级标签树卡片"""
+    html = '<div class="point-card label-tree-card" data-card-id="label-tree">\n'
+    html += '<div class="point-card-header" onclick="toggleCardDetails(\'label-tree\')">\n'
+    html += '<span class="point-text">层级标签树</span>\n'
+    html += '<span class="toggle-icon">▼</span>\n'
+    html += '</div>\n'
+    
+    html += '<div class="point-card-details" id="label-tree-details">\n'
+    
+    # L1_实例名词
+    l1_nouns = label_tree.get("L1_实例名词", [])
+    if l1_nouns:
+        html += '<div class="detail-section">\n'
+        html += '<strong>L1_实例名词:</strong>\n'
+        html += '<div class="tag-group">\n'
+        for noun in l1_nouns:
+            html += f'<span class="label-tag l1-tag">{html_module.escape(str(noun))}</span>\n'
+        html += '</div>\n'
+        html += '</div>\n'
+    
+    # L1_限定词
+    l1_qualifiers = label_tree.get("L1_限定词", [])
+    if l1_qualifiers:
+        html += '<div class="detail-section">\n'
+        html += '<strong>L1_限定词:</strong>\n'
+        html += '<div class="tag-group">\n'
+        for qualifier in l1_qualifiers:
+            html += f'<span class="label-tag l1-tag">{html_module.escape(str(qualifier))}</span>\n'
+        html += '</div>\n'
+        html += '</div>\n'
+    
+    # L2_具体品类
+    l2_categories = label_tree.get("L2_具体品类", [])
+    if l2_categories:
+        html += '<div class="detail-section">\n'
+        html += '<strong>L2_具体品类:</strong>\n'
+        html += '<div class="tag-group">\n'
+        for category in l2_categories:
+            html += f'<span class="label-tag l2-tag">{html_module.escape(str(category))}</span>\n'
+        html += '</div>\n'
+        html += '</div>\n'
+    
+    # L3_兴趣领域
+    l3_interests = label_tree.get("L3_兴趣领域", [])
+    if l3_interests:
+        html += '<div class="detail-section">\n'
+        html += '<strong>L3_兴趣领域:</strong>\n'
+        html += '<div class="tag-group">\n'
+        for interest in l3_interests:
+            html += f'<span class="label-tag l3-tag">{html_module.escape(str(interest))}</span>\n'
+        html += '</div>\n'
+        html += '</div>\n'
+    
+    # L4_情绪价值
+    l4_emotions = label_tree.get("L4_情绪价值", [])
+    if l4_emotions:
+        html += '<div class="detail-section">\n'
+        html += '<strong>L4_情绪价值:</strong>\n'
+        html += '<div class="tag-group">\n'
+        for emotion in l4_emotions:
+            html += f'<span class="label-tag l4-tag">{html_module.escape(str(emotion))}</span>\n'
+        html += '</div>\n'
+        html += '</div>\n'
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+
+
+def generate_tab1_content(data: Dict[str, Any]) -> str:
+    """生成Tab1内容:结构化内容库"""
+    html = '<div class="tab-content" id="tab1">\n'
+    html += '<div class="section">\n'
+    html += '<h3>结构化内容库</h3>\n'
+    
+    structured_content = data.get("结构化内容库", {})
+    
+    if not structured_content:
+        html += '<p>暂无数据</p>\n'
+    else:
+        # 选题信息表
+        topic_info = structured_content.get("选题信息表", {})
+        if topic_info:
+            html += render_topic_info_card(topic_info)
+        
+        # 模型信息表
+        model_info = structured_content.get("模型信息表", {})
+        if model_info:
+            html += render_model_info_card(model_info)
+        
+        # 层级标签树
+        label_tree = structured_content.get("层级标签树", {})
+        if label_tree:
+            html += render_label_tree_card(label_tree)
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+

+ 155 - 0
examples/static/visualize_v2/tab2.py

@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""
+Tab2内容生成器 - L3单元解构
+包含:单元列表,每个单元包含单元编号、时间范围、单元核心概括、完整文案、实质
+"""
+import html as html_module
+from typing import Dict, Any, List
+
+
+def render_unit_card(unit: Dict[str, Any], idx: int) -> str:
+    """渲染单元卡片"""
+    unit_num = unit.get("单元编号", idx)
+    time_range = unit.get("时间范围", "")
+    core_summary = unit.get("单元核心概括", "")
+    full_text = unit.get("完整文案", "")
+    substance = unit.get("实质", {})
+    
+    card_id = f'unit-{unit_num}'
+    
+    html = f'<div class="point-card unit-card" data-card-id="{card_id}">\n'
+    html += f'<div class="point-card-header" onclick="toggleCardDetails(\'{card_id}\')">\n'
+    html += f'<span class="point-number">单元 #{unit_num}</span>\n'
+    if time_range:
+        html += f'<span class="point-time">{html_module.escape(time_range)}</span>\n'
+    if core_summary:
+        html += f'<span class="point-text">{html_module.escape(core_summary)}</span>\n'
+    html += '<span class="toggle-icon">▼</span>\n'
+    html += '</div>\n'
+    
+    html += f'<div class="point-card-details" id="{card_id}-details">\n'
+    
+    # 完整文案
+    if full_text:
+        html += '<div class="detail-section">\n'
+        html += '<strong>完整文案:</strong>\n'
+        html += f'<div class="detail-text">{html_module.escape(full_text)}</div>\n'
+        html += '</div>\n'
+    
+    # 实质内容
+    if substance:
+        # 具体元素
+        concrete_elements = substance.get("具体元素", {})
+        if concrete_elements:
+            html += '<div class="detail-section">\n'
+            html += '<strong>具体元素:</strong>\n'
+            
+            keywords = concrete_elements.get("关键词", [])
+            if keywords:
+                html += '<div class="sub-section">\n'
+                html += '<strong>关键词:</strong>\n'
+                html += '<div class="tag-group">\n'
+                for keyword in keywords:
+                    html += f'<span class="keyword-tag">{html_module.escape(str(keyword))}</span>\n'
+                html += '</div>\n'
+                html += '</div>\n'
+            
+            forms = concrete_elements.get("对应形式", {})
+            if forms:
+                html += '<div class="sub-section">\n'
+                html += '<strong>对应形式:</strong>\n'
+                if forms.get("文案形式"):
+                    html += f'<div class="form-item"><strong>文案形式:</strong>{html_module.escape(forms["文案形式"])}</div>\n'
+                if forms.get("画面形式"):
+                    html += f'<div class="form-item"><strong>画面形式:</strong>{html_module.escape(forms["画面形式"])}</div>\n'
+                if forms.get("声音形式"):
+                    html += f'<div class="form-item"><strong>声音形式:</strong>{html_module.escape(forms["声音形式"])}</div>\n'
+                html += '</div>\n'
+            
+            html += '</div>\n'
+        
+        # 具象概念
+        concrete_concepts = substance.get("具象概念", {})
+        if concrete_concepts:
+            html += '<div class="detail-section">\n'
+            html += '<strong>具象概念:</strong>\n'
+            
+            keywords = concrete_concepts.get("关键词", [])
+            if keywords:
+                html += '<div class="sub-section">\n'
+                html += '<strong>关键词:</strong>\n'
+                html += '<div class="tag-group">\n'
+                for keyword in keywords:
+                    html += f'<span class="keyword-tag">{html_module.escape(str(keyword))}</span>\n'
+                html += '</div>\n'
+                html += '</div>\n'
+            
+            forms = concrete_concepts.get("对应形式", {})
+            if forms:
+                html += '<div class="sub-section">\n'
+                html += '<strong>对应形式:</strong>\n'
+                if forms.get("文案形式"):
+                    html += f'<div class="form-item"><strong>文案形式:</strong>{html_module.escape(forms["文案形式"])}</div>\n'
+                if forms.get("画面形式"):
+                    html += f'<div class="form-item"><strong>画面形式:</strong>{html_module.escape(forms["画面形式"])}</div>\n'
+                if forms.get("声音形式"):
+                    html += f'<div class="form-item"><strong>声音形式:</strong>{html_module.escape(forms["声音形式"])}</div>\n'
+                html += '</div>\n'
+            
+            html += '</div>\n'
+        
+        # 抽象概念
+        abstract_concepts = substance.get("抽象概念", {})
+        if abstract_concepts:
+            html += '<div class="detail-section">\n'
+            html += '<strong>抽象概念:</strong>\n'
+            
+            keywords = abstract_concepts.get("关键词", [])
+            if keywords:
+                html += '<div class="sub-section">\n'
+                html += '<strong>关键词:</strong>\n'
+                html += '<div class="tag-group">\n'
+                for keyword in keywords:
+                    html += f'<span class="keyword-tag">{html_module.escape(str(keyword))}</span>\n'
+                html += '</div>\n'
+                html += '</div>\n'
+            
+            forms = abstract_concepts.get("对应形式", {})
+            if forms:
+                html += '<div class="sub-section">\n'
+                html += '<strong>对应形式:</strong>\n'
+                if forms.get("文案形式"):
+                    html += f'<div class="form-item"><strong>文案形式:</strong>{html_module.escape(forms["文案形式"])}</div>\n'
+                if forms.get("画面形式"):
+                    html += f'<div class="form-item"><strong>画面形式:</strong>{html_module.escape(forms["画面形式"])}</div>\n'
+                if forms.get("声音形式"):
+                    html += f'<div class="form-item"><strong>声音形式:</strong>{html_module.escape(forms["声音形式"])}</div>\n'
+                html += '</div>\n'
+            
+            html += '</div>\n'
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+
+
+def generate_tab2_content(data: Dict[str, Any]) -> str:
+    """生成Tab2内容:L3单元解构"""
+    html = '<div class="tab-content" id="tab2" style="display: none;">\n'
+    html += '<div class="section">\n'
+    html += '<h3>L3单元解构</h3>\n'
+    
+    l3_deconstruction = data.get("L3单元解构", {})
+    unit_list = l3_deconstruction.get("单元列表", [])
+    
+    if not unit_list:
+        html += '<p>暂无数据</p>\n'
+    else:
+        html += f'<div class="section-info">共 {len(unit_list)} 个单元</div>\n'
+        for idx, unit in enumerate(unit_list, start=1):
+            html += render_unit_card(unit, idx)
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+

+ 196 - 0
examples/static/visualize_v2/tab3.py

@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+"""
+Tab3内容生成器 - 整体结构理解
+包含:整体解构(节点基础信息、整体实质×形式、纵向逻辑流)、段落解构
+"""
+import html as html_module
+from typing import Dict, Any, List
+
+
+def render_overall_deconstruction_card(overall: Dict[str, Any]) -> str:
+    """渲染整体解构卡片"""
+    card_id = "overall-deconstruction"
+    
+    html = f'<div class="point-card overall-card" data-card-id="{card_id}">\n'
+    html += f'<div class="point-card-header" onclick="toggleCardDetails(\'{card_id}\')">\n'
+    html += '<span class="point-text">整体解构</span>\n'
+    html += '<span class="toggle-icon">▼</span>\n'
+    html += '</div>\n'
+    
+    html += f'<div class="point-card-details" id="{card_id}-details">\n'
+    
+    # 节点基础信息
+    basic_info = overall.get("节点基础信息", "")
+    if basic_info:
+        html += '<div class="detail-section">\n'
+        html += '<strong>节点基础信息:</strong>\n'
+        html += f'<div class="detail-text">{html_module.escape(basic_info)}</div>\n'
+        html += '</div>\n'
+    
+    # 整体实质×形式
+    substance_form = overall.get("整体实质×形式", {})
+    if substance_form:
+        html += '<div class="detail-section">\n'
+        html += '<strong>整体实质×形式:</strong>\n'
+        
+        # 处理可能是字符串或字典的情况
+        if isinstance(substance_form, dict):
+            if substance_form.get("抽象概念"):
+                html += f'<div class="form-item"><strong>抽象概念:</strong>{html_module.escape(substance_form["抽象概念"])}</div>\n'
+            if substance_form.get("画面形式"):
+                html += f'<div class="form-item"><strong>画面形式:</strong>{html_module.escape(substance_form["画面形式"])}</div>\n'
+            if substance_form.get("文案形式"):
+                html += f'<div class="form-item"><strong>文案形式:</strong>{html_module.escape(substance_form["文案形式"])}</div>\n'
+            if substance_form.get("声音形式"):
+                html += f'<div class="form-item"><strong>声音形式:</strong>{html_module.escape(substance_form["声音形式"])}</div>\n'
+        elif isinstance(substance_form, str):
+            html += f'<div class="form-item">{html_module.escape(substance_form)}</div>\n'
+        
+        html += '</div>\n'
+    
+    # 纵向逻辑流
+    logic_flow = overall.get("纵向逻辑流", [])
+    if logic_flow:
+        html += '<div class="detail-section">\n'
+        html += '<strong>纵向逻辑流:</strong>\n'
+        html += '<div class="logic-flow">\n'
+        for stage in logic_flow:
+            stage_num = stage.get("阶段编号", "")
+            stage_name = stage.get("阶段逻辑名称", "")
+            stage_desc = stage.get("阶段逻辑描述", "")
+            
+            html += '<div class="logic-stage">\n'
+            if stage_num:
+                html += f'<div class="stage-number">阶段 {stage_num}</div>\n'
+            if stage_name:
+                html += f'<div class="stage-name">{html_module.escape(stage_name)}</div>\n'
+            if stage_desc:
+                html += f'<div class="stage-desc">{html_module.escape(stage_desc)}</div>\n'
+            html += '</div>\n'
+        html += '</div>\n'
+        html += '</div>\n'
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+
+
+def render_paragraph_card(paragraph: Dict[str, Any], idx: int) -> str:
+    """渲染段落解构卡片"""
+    para_num = paragraph.get("段落序号", idx)
+    time_range = paragraph.get("时间范围", "")
+    units = paragraph.get("包含单元", [])
+    full_text = paragraph.get("段落完整文案", "")
+    
+    card_id = f'paragraph-{para_num}'
+    
+    html = f'<div class="point-card paragraph-card" data-card-id="{card_id}">\n'
+    html += f'<div class="point-card-header" onclick="toggleCardDetails(\'{card_id}\')">\n'
+    html += f'<span class="point-number">段落 #{para_num}</span>\n'
+    if time_range:
+        html += f'<span class="point-time">{html_module.escape(time_range)}</span>\n'
+    if units:
+        units_str = ", ".join([str(u) for u in units])
+        html += f'<span class="point-units">包含单元: {units_str}</span>\n'
+    html += '<span class="toggle-icon">▼</span>\n'
+    html += '</div>\n'
+    
+    html += f'<div class="point-card-details" id="{card_id}-details">\n'
+    
+    # 段落完整文案
+    if full_text:
+        html += '<div class="detail-section">\n'
+        html += '<strong>段落完整文案:</strong>\n'
+        html += f'<div class="detail-text">{html_module.escape(full_text)}</div>\n'
+        html += '</div>\n'
+    
+    # 具体元素实质和形式
+    concrete_elements = paragraph.get("具体元素实质和形式", [])
+    if concrete_elements:
+        html += '<div class="detail-section">\n'
+        html += '<strong>具体元素实质和形式:</strong>\n'
+        for elem in concrete_elements:
+            elem_name = elem.get("具体元素名称", "")
+            html += '<div class="sub-section">\n'
+            if elem_name:
+                html += f'<div class="element-name"><strong>{html_module.escape(elem_name)}</strong></div>\n'
+            if elem.get("对应形式-文案"):
+                html += f'<div class="form-item"><strong>对应形式-文案:</strong>{html_module.escape(elem["对应形式-文案"])}</div>\n'
+            if elem.get("对应形式-画面"):
+                html += f'<div class="form-item"><strong>对应形式-画面:</strong>{html_module.escape(elem["对应形式-画面"])}</div>\n'
+            if elem.get("对应形式-声音"):
+                html += f'<div class="form-item"><strong>对应形式-声音:</strong>{html_module.escape(elem["对应形式-声音"])}</div>\n'
+            html += '</div>\n'
+        html += '</div>\n'
+    
+    # 具象概念实质和形式
+    concrete_concepts = paragraph.get("具象概念实质和形式", [])
+    if concrete_concepts:
+        html += '<div class="detail-section">\n'
+        html += '<strong>具象概念实质和形式:</strong>\n'
+        for concept in concrete_concepts:
+            concept_name = concept.get("具象概念名称", "")
+            html += '<div class="sub-section">\n'
+            if concept_name:
+                html += f'<div class="element-name"><strong>{html_module.escape(concept_name)}</strong></div>\n'
+            if concept.get("对应形式-文案"):
+                html += f'<div class="form-item"><strong>对应形式-文案:</strong>{html_module.escape(concept["对应形式-文案"])}</div>\n'
+            if concept.get("对应形式-画面"):
+                html += f'<div class="form-item"><strong>对应形式-画面:</strong>{html_module.escape(concept["对应形式-画面"])}</div>\n'
+            if concept.get("对应形式-声音"):
+                html += f'<div class="form-item"><strong>对应形式-声音:</strong>{html_module.escape(concept["对应形式-声音"])}</div>\n'
+            html += '</div>\n'
+        html += '</div>\n'
+    
+    # 抽象概念实质和形式
+    abstract_concepts = paragraph.get("抽象概念实质和形式", [])
+    if abstract_concepts:
+        html += '<div class="detail-section">\n'
+        html += '<strong>抽象概念实质和形式:</strong>\n'
+        for concept in abstract_concepts:
+            concept_name = concept.get("抽象概念名称", "")
+            html += '<div class="sub-section">\n'
+            if concept_name:
+                html += f'<div class="element-name"><strong>{html_module.escape(concept_name)}</strong></div>\n'
+            if concept.get("对应形式-文案"):
+                html += f'<div class="form-item"><strong>对应形式-文案:</strong>{html_module.escape(concept["对应形式-文案"])}</div>\n'
+            if concept.get("对应形式-画面"):
+                html += f'<div class="form-item"><strong>对应形式-画面:</strong>{html_module.escape(concept["对应形式-画面"])}</div>\n'
+            if concept.get("对应形式-声音"):
+                html += f'<div class="form-item"><strong>对应形式-声音:</strong>{html_module.escape(concept["对应形式-声音"])}</div>\n'
+            html += '</div>\n'
+        html += '</div>\n'
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+
+
+def generate_tab3_content(data: Dict[str, Any]) -> str:
+    """生成Tab3内容:整体结构理解"""
+    html = '<div class="tab-content" id="tab3" style="display: none;">\n'
+    html += '<div class="section">\n'
+    html += '<h3>整体结构理解</h3>\n'
+    
+    structure_understanding = data.get("整体结构理解", {})
+    
+    if not structure_understanding:
+        html += '<p>暂无数据</p>\n'
+    else:
+        # 整体解构
+        overall = structure_understanding.get("整体解构", {})
+        if overall:
+            html += render_overall_deconstruction_card(overall)
+        
+        # 段落解构
+        paragraph_list = structure_understanding.get("段落解构", [])
+        if paragraph_list:
+            html += '<div class="section-divider"></div>\n'
+            html += f'<div class="section-info">共 {len(paragraph_list)} 个段落</div>\n'
+            for idx, paragraph in enumerate(paragraph_list, start=1):
+                html += render_paragraph_card(paragraph, idx)
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+

+ 67 - 0
examples/static/visualize_v2/tab4.py

@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""
+Tab4内容生成器 - 金句提取
+包含:script_type、hooks、golden_sentences
+"""
+import html as html_module
+from typing import Dict, Any, List
+
+
+def generate_tab4_content(data: Dict[str, Any]) -> str:
+    """生成Tab4内容:金句提取"""
+    html = '<div class="tab-content" id="tab4" style="display: none;">\n'
+    html += '<div class="section">\n'
+    html += '<h3>金句提取</h3>\n'
+    
+    golden_sentences = data.get("金句提取", {})
+    
+    if not golden_sentences:
+        html += '<p>暂无数据</p>\n'
+    else:
+        card_id = "golden-sentences"
+        
+        html += f'<div class="point-card golden-card" data-card-id="{card_id}">\n'
+        html += f'<div class="point-card-header" onclick="toggleCardDetails(\'{card_id}\')">\n'
+        html += '<span class="point-text">金句提取结果</span>\n'
+        html += '<span class="toggle-icon">▼</span>\n'
+        html += '</div>\n'
+        
+        html += f'<div class="point-card-details" id="{card_id}-details">\n'
+        
+        # script_type
+        script_type = golden_sentences.get("script_type", "")
+        if script_type:
+            html += '<div class="detail-section">\n'
+            html += '<strong>脚本类型:</strong>\n'
+            html += f'<div class="detail-text">{html_module.escape(script_type)}</div>\n'
+            html += '</div>\n'
+        
+        # hooks
+        hooks = golden_sentences.get("hooks", [])
+        if hooks:
+            html += '<div class="detail-section">\n'
+            html += '<strong>Hooks(钩子):</strong>\n'
+            html += '<ul class="detail-list">\n'
+            for hook in hooks:
+                html += f'<li class="hook-item">{html_module.escape(str(hook))}</li>\n'
+            html += '</ul>\n'
+            html += '</div>\n'
+        
+        # golden_sentences
+        sentences = golden_sentences.get("golden_sentences", [])
+        if sentences:
+            html += '<div class="detail-section">\n'
+            html += '<strong>金句:</strong>\n'
+            html += '<ul class="detail-list">\n'
+            for sentence in sentences:
+                html += f'<li class="golden-item">{html_module.escape(str(sentence))}</li>\n'
+            html += '</ul>\n'
+            html += '</div>\n'
+        
+        html += '</div>\n'
+        html += '</div>\n'
+    
+    html += '</div>\n'
+    html += '</div>\n'
+    return html
+

+ 285 - 0
examples/visualize_script_results_v2.py

@@ -0,0 +1,285 @@
+#!/usr/bin/env python3
+"""
+脚本结果可视化工具 V2
+功能:为 output_demo_script_v2.json 中的每个视频生成独立的HTML可视化页面
+交互形式:卡片+点击详情
+"""
+
+import json
+import argparse
+import sys
+from pathlib import Path
+from datetime import datetime
+from typing import List, Dict, Any, Optional
+import html as html_module
+
+# 保证可以从项目根目录导入
+PROJECT_ROOT = Path(__file__).parent.parent
+if str(PROJECT_ROOT) not in sys.path:
+    sys.path.insert(0, str(PROJECT_ROOT))
+
+# 导入tab模块
+from static.visualize_v2.tab1 import generate_tab1_content
+from static.visualize_v2.tab2 import generate_tab2_content
+from static.visualize_v2.tab3 import generate_tab3_content
+from static.visualize_v2.tab4 import generate_tab4_content
+
+
+class ScriptResultVisualizerV2:
+    """脚本结果可视化器 V2"""
+
+    def __init__(self, json_file: str = None):
+        """
+        初始化可视化器
+
+        Args:
+            json_file: JSON文件路径
+        """
+        if json_file is None:
+            self.json_file = None
+        else:
+            self.json_file = Path(json_file)
+            if not self.json_file.is_absolute():
+                self.json_file = Path.cwd() / json_file
+
+    def load_json_data(self, file_path: Path) -> Optional[Dict[str, Any]]:
+        """
+        加载JSON文件
+
+        Args:
+            file_path: JSON文件路径
+
+        Returns:
+            JSON数据字典,加载失败返回None
+        """
+        try:
+            with open(file_path, 'r', encoding='utf-8') as f:
+                return json.load(f)
+        except Exception as e:
+            print(f"加载文件失败 {file_path}: {e}")
+            return None
+
+    def generate_html(self, data: Dict[str, Any], video_data: Dict[str, Any], json_filename: str) -> str:
+        """生成完整的HTML页面"""
+        # 开始构建HTML
+        html = '<!DOCTYPE html>\n'
+        html += '<html lang="zh-CN">\n'
+        html += '<head>\n'
+        html += '    <meta charset="UTF-8">\n'
+        html += '    <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
+        html += f'    <title>脚本结果可视化 V2 - {json_filename}</title>\n'
+        html += '    <link rel="stylesheet" href="visualize/style.css">\n'
+        html += '</head>\n'
+        html += '<body>\n'
+
+        html += '<div class="container">\n'
+
+        # 页眉
+        html += '<div class="header">\n'
+        html += '    <h1>脚本结果可视化 V2</h1>\n'
+
+        # 显示视频信息
+        video_title = video_data.get("title", "")
+        video_id = video_data.get("video_id", "")
+        if video_title:
+            html += f'    <div class="subtitle">{html_module.escape(video_title)}</div>\n'
+        if video_id:
+            html += f'    <div class="subtitle">视频ID: {video_id}</div>\n'
+        html += f'    <div class="subtitle">{json_filename}</div>\n'
+        html += f'    <div class="subtitle">生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</div>\n'
+        html += '</div>\n'
+
+        # Tab导航
+        html += '<div class="tabs">\n'
+        html += '    <button class="tab active" onclick="switchTab(\'tab1\')">结构化内容库</button>\n'
+        html += '    <button class="tab" onclick="switchTab(\'tab2\')">L3单元解构</button>\n'
+        html += '    <button class="tab" onclick="switchTab(\'tab3\')">整体结构理解</button>\n'
+        html += '    <button class="tab" onclick="switchTab(\'tab4\')">金句提取</button>\n'
+        html += '</div>\n'
+
+        # 主内容
+        html += '<div class="content">\n'
+
+        # Tab1内容:结构化内容库
+        html += generate_tab1_content(data)
+
+        # Tab2内容:L3单元解构
+        html += generate_tab2_content(data)
+
+        # Tab3内容:整体结构理解
+        html += generate_tab3_content(data)
+
+        # Tab4内容:金句提取
+        html += generate_tab4_content(data)
+
+        html += '</div>\n'
+
+        # 页脚
+        html += '<div class="footer">\n'
+        html += f'    <p>生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>\n'
+        html += '</div>\n'
+
+        html += '</div>\n'
+
+        # JavaScript
+        html += '<script src="visualize/script.js"></script>\n'
+
+        html += '</body>\n'
+        html += '</html>\n'
+
+        return html
+
+    def save_all_html(self, output_dir: str | Path | None = None) -> List[str]:
+        """
+        基于 output_demo_script_v2.json,为其中每个视频生成一个独立的 HTML 页面。
+
+        支持结构:
+        {
+          "results": [
+            {
+              "video_data": {...},
+              "script_result": {...}
+            },
+            ...
+          ]
+        }
+        """
+        if self.json_file is None:
+            print("❌ 错误: 未指定JSON文件")
+            return []
+
+        # 加载JSON数据
+        data = self.load_json_data(self.json_file)
+        if data is None:
+            return []
+
+        results = data.get("results") or []
+        if not isinstance(results, list) or not results:
+            print("⚠️  JSON 中未找到有效的 results 数组")
+            return []
+
+        # 确定输出目录
+        if output_dir is None:
+            # 默认输出到examples/html目录
+            output_dir = Path(__file__).parent / "html"
+        else:
+            output_dir = Path(output_dir)
+            if not output_dir.is_absolute():
+                output_dir = Path.cwd() / output_dir
+
+        # 创建输出目录
+        output_dir.mkdir(parents=True, exist_ok=True)
+
+        # 确保样式和脚本文件可用:从 html/visualize 拷贝到 输出目录/visualize
+        source_visualize_dir = Path(__file__).parent / "html" / "visualize"
+        target_visualize_dir = output_dir / "visualize"
+        if source_visualize_dir.exists() and source_visualize_dir.is_dir():
+            import shutil
+            target_visualize_dir.mkdir(parents=True, exist_ok=True)
+            for item in source_visualize_dir.iterdir():
+                dst = target_visualize_dir / item.name
+                if item.is_file():
+                    # 如果源文件和目标文件是同一个,跳过
+                    if item.resolve() != dst.resolve():
+                        shutil.copy2(item, dst)
+
+        generated_paths: List[str] = []
+
+        print(f"📁 检测到 output_demo_script_v2 格式,包含 {len(results)} 条结果")
+
+        for idx, item in enumerate(results, start=1):
+            script_data = item.get("script_result")
+            if not isinstance(script_data, dict):
+                print(f"⚠️  跳过第 {idx} 条结果:缺少 script_result 字段或结构不正确")
+                continue
+
+            video_data = item.get("video_data") or {}
+            video_id = video_data.get("video_id") or video_data.get("channel_content_id")
+
+            # 用于 HTML 内部展示的"文件名"标签
+            json_label = f"{self.json_file.name}#{idx}"
+
+            # 生成输出文件名:{video_id}_v2.html
+            if video_id:
+                output_filename = f"{video_id}_v2.html"
+            else:
+                output_filename = f"{self.json_file.stem}_{idx}_v2.html"
+
+            output_path = output_dir / output_filename
+
+            html_content = self.generate_html(script_data, video_data, json_label)
+
+            with open(output_path, "w", encoding="utf-8") as f:
+                f.write(html_content)
+
+            generated_paths.append(str(output_path))
+            print(f"✅ HTML文件已生成: {output_path}")
+
+        if not generated_paths:
+            print("⚠️  未能从 JSON 中生成任何 HTML 文件")
+
+        return generated_paths
+
+
+def main():
+    """主函数"""
+    # 解析命令行参数
+    parser = argparse.ArgumentParser(
+        description='脚本结果可视化工具 V2 - 基于 output_demo_script_v2.json 为每个视频生成独立的HTML页面',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+使用示例:
+  # 在当前 examples 目录下使用默认的 output_demo_script_v2.json 并输出到 examples/html
+  python visualize_script_results_v2.py
+
+  # 指定 JSON 文件
+  python visualize_script_results_v2.py examples/output_demo_script_v2.json
+
+  # 指定 JSON 文件和输出目录
+  python visualize_script_results_v2.py examples/output_demo_script_v2.json --output-dir examples/html
+        """
+    )
+
+    parser.add_argument(
+        'json_file',
+        type=str,
+        nargs='?',
+        help='JSON文件路径(默认为 examples/output_demo_script_v2.json)'
+    )
+
+    parser.add_argument(
+        '-o', '--output-dir',
+        type=str,
+        default=None,
+        help='输出目录路径(默认: examples/html)'
+    )
+
+    args = parser.parse_args()
+
+    # 确定 JSON 文件路径
+    if args.json_file:
+        json_path = Path(args.json_file)
+        if not json_path.is_absolute():
+            json_path = Path.cwd() / json_path
+    else:
+        # 默认使用 examples/output_demo_script_v2.json
+        json_path = Path(__file__).parent / "output_demo_script_v2.json"
+
+    print("🚀 开始生成脚本结果可视化 V2...")
+    print(f"📁 JSON文件: {json_path}")
+    print(f"📄 输出目录: {args.output_dir or (Path(__file__).parent / 'html')}")
+    print()
+
+    visualizer = ScriptResultVisualizerV2(json_file=str(json_path))
+    generated_files = visualizer.save_all_html(output_dir=args.output_dir)
+
+    if generated_files:
+        print()
+        print(f"🎉 完成! 共生成 {len(generated_files)} 个HTML文件")
+        # 提示其中一个示例文件
+        print(f"📄 示例: 请在浏览器中打开: {generated_files[0]}")
+
+
+if __name__ == "__main__":
+    main()
+