Compare commits

...

No commits in common. "main" and "project/video-workflow" have entirely different histories.

15 changed files with 7276 additions and 12 deletions

View File

@ -1,2 +0,0 @@
DATABASE_URL=sqlite:///db.sqlite
DEBUG=true

View File

@ -1,3 +0,0 @@
# MCP Demo
通过远程 MCP Server 推送的代码项目

1403
app.js.backup Normal file

File diff suppressed because it is too large Load Diff

1403
app.js.current Normal file

File diff suppressed because it is too large Load Diff

53
check切割.py Normal file
View File

@ -0,0 +1,53 @@
import json
import urllib.request
resp = urllib.request.urlopen("http://localhost:5001/api/footage?per_page=60")
data = json.loads(resp.read().decode())
items = data.get("items", [])
# Find the target video
for item in items:
if "763456798450254550912" in item.get("filename", ""):
screener = item.get("screener", {})
scenes = screener.get("scenes", [])
transcription = screener.get("transcription", "")
print(f"Video: {item['filename']}")
print(f"Duration: {item.get('duration', 'N/A')}s")
print(f"Total scenes: {len(scenes)}")
print()
# Check scene timing
print("=== Scene Timing Analysis ===")
total_scene_duration = 0
for i, scene in enumerate(scenes):
start = scene.get("start_time", 0)
end = scene.get("end_time", 0)
duration = scene.get("duration", 0)
total_scene_duration += duration
# Check if duration matches end - start
calculated_duration = end - start
match = abs(duration - calculated_duration) < 0.1
print(f"Scene {scene.get('shot_index')}: {start}s - {end}s ({duration}s) {'' if match else '❌ MISMATCH'}")
print(f"\nTotal scene duration: {total_scene_duration}s")
print(f"Video duration: {item.get('duration', 'N/A')}s")
# Check speech alignment
print("\n=== Speech Alignment ===")
all_dialogue = " ".join([s.get("dialogue", "") for s in scenes if s.get("dialogue")])
print(f"Total dialogue length: {len(all_dialogue)} chars")
print(f"Transcription length: {len(transcription)} chars")
# Check if dialogue matches transcription
if transcription:
# Simple check: see if dialogue words appear in transcription
dialogue_words = set(all_dialogue.replace("", "").replace("", "").replace("", ""))
trans_words = set(transcription.replace("", "").replace("", "").replace("", ""))
overlap = dialogue_words & trans_words
print(f"Dialogue words overlap with transcription: {len(overlap)} chars")
break

View File

@ -1 +0,0 @@
ok

279
index.html.current Normal file
View File

@ -0,0 +1,279 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>拍摄素材管理</title>
<link rel="stylesheet" href="/static/style.css?v=36">
</head>
<body>
<div class="app-layout">
<!-- Left Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<span class="sidebar-logo">🎬</span>
<span class="sidebar-title">拍摄剪辑</span>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-item" onclick="showCreateFolderModal()" style="cursor:pointer;color:#1a73e8">
<span class="nav-icon"></span>
<span class="nav-label">新建文件夹</span>
</div>
<div class="nav-item active" data-filter="" onclick="filterByNav(this, 'all')">
<span class="nav-icon">📁</span>
<span class="nav-label">全部</span>
<span class="nav-count" id="countAll">0</span>
</div>
<div class="nav-item" data-filter="未分析" onclick="filterByNav(this, 'unanalyzed')">
<span class="nav-icon">🏷️</span>
<span class="nav-label">未标签</span>
<span class="nav-count" id="countUnanalyzed">0</span>
</div>
<div class="nav-item" data-filter="已分析" onclick="filterByNav(this, 'analyzed')">
<span class="nav-icon">✅</span>
<span class="nav-label">待拉片</span>
<span class="nav-count" id="countAnalyzed">0</span>
</div>
<div class="nav-item" data-filter="已剪辑" onclick="filterByNav(this, 'clipped')">
<span class="nav-icon">🎬</span>
<span class="nav-label">已拉片</span>
<span class="nav-count" id="countClipped">0</span>
</div>
<div class="nav-item" data-filter="recycle" onclick="filterByNav(this, 'recycle')">
<span class="nav-icon">🗑️</span>
<span class="nav-label">回收站</span>
<span class="nav-count" id="countRecycle">0</span>
</div>
</div>
<div class="nav-divider"></div>
<div class="nav-section">
<div class="nav-section-title">任务管理</div>
<div id="taskList">
<div class="nav-item" onclick="showTasks()">
<span class="nav-icon">📋</span>
<span class="nav-label">分析任务</span>
<span class="nav-count" id="countRunning">0</span>
</div>
</div>
</div>
<div class="nav-divider"></div>
<div class="nav-section">
<div class="nav-section-title">智能文件夹</div>
<div id="categoryList"></div>
</div>
</nav>
<div class="sidebar-footer">
<button class="btn btn-block btn-upload" onclick="openUploadModal()">📤 上传素材</button>
</div>
</aside>
<!-- Main Area -->
<div class="main-area">
<!-- Top Bar -->
<div class="topbar">
<div class="topbar-left">
<button class="btn btn-icon" id="btnToggleSidebar" onclick="toggleSidebar()">☰</button>
<div class="breadcrumb">
<span class="breadcrumb-item" id="breadcrumbRoot">拍摄剪辑</span>
<span class="breadcrumb-sep"></span>
<span class="breadcrumb-item active" id="breadcrumbCurrent">全部</span>
</div>
</div>
<div class="topbar-center">
<div class="slider-group">
<span class="slider-label">小</span>
<input type="range" id="cardSizeSlider" min="160" max="360" value="240" oninput="setCardSize(this.value)">
<span class="slider-label">大</span>
</div>
</div>
<div class="topbar-right">
<div class="view-toggle">
<button class="view-btn active" id="btnGrid" onclick="setView('grid')">▦</button>
<button class="view-btn" id="btnList" onclick="setView('list')">☰</button>
</div>
<div class="search-box">
<input type="text" id="search" placeholder="搜索文件名或AI描述..." oninput="applyFilters()">
</div>
<button class="btn btn-analyze" id="btnAnalyze" onclick="startAnalysis()">🔍 分析</button>
</div>
</div>
<!-- Filter Bar -->
<div class="filterbar">
<div class="filter-group">
<label>时长:</label>
<input type="number" id="minDur" placeholder="最小" min="0" step="1" style="width:55px">
<span>-</span>
<input type="number" id="maxDur" placeholder="最大" min="0" step="1" style="width:55px">
<span>秒</span>
</div>
<div class="filter-group">
<label>分辨率:</label>
<select id="resolutionFilter" onchange="applyFilters()">
<option value="">全部</option>
<option value="4k">4K</option>
<option value="1080p">1080P</option>
<option value="vertical">竖屏</option>
</select>
</div>
<div class="filter-results" id="filterResults"></div>
<button class="btn btn-select-all" onclick="selectAllPage()">☑ 全选本页</button>
</div>
<!-- Grid -->
<div class="content-area">
<div class="grid-container">
<div class="grid" id="grid"></div>
<div class="empty-state" id="emptyState" style="display:none">
<p>📭 没有找到匹配的素材</p>
</div>
</div>
<!-- Pagination -->
<div class="pagination" id="pagination"></div>
</div>
</div>
</div>
<!-- Upload Modal -->
<div class="modal" id="uploadModal">
<div class="modal-overlay" onclick="closeUploadModal()"></div>
<div class="modal-content" style="max-width:600px">
<button class="modal-close" onclick="event.stopPropagation();closeUploadModal()">✕</button>
<div class="upload-header">
<h2>📤 上传视频素材</h2>
<p>支持 MP4、MOV、AVI、MKV 等格式,最大 4GB</p>
</div>
<div class="upload-body">
<div class="upload-dropzone" id="dropzone">
<div class="dropzone-icon">📁</div>
<p>拖拽文件到此处,或点击选择文件</p>
<input type="file" id="fileInput" multiple accept="video/*,.mp4,.mov,.avi,.mkv,.mts,.mxf,.wmv" style="position:absolute;width:100%;height:100%;top:0;left:0;opacity:0;cursor:pointer;z-index:10">
</div>
<div class="upload-category">
<label>分类:</label>
<input type="text" id="uploadCategory" value="上传素材" placeholder="输入分类名称">
</div>
<div class="upload-filelist" id="uploadFilelist"></div>
<div class="upload-progress" id="uploadProgress" style="display:none">
<div class="progress-bar">
<div class="progress-fill" id="uploadProgressFill"></div>
</div>
<span class="progress-text" id="uploadProgressText">上传中...</span>
</div>
<div class="upload-result" id="uploadResult" style="display:none"></div>
<button class="btn btn-upload-submit" id="btnUploadSubmit" onclick="submitUpload()" disabled>开始上传</button>
</div>
</div>
</div>
<!-- Task Panel -->
<div class="task-panel" id="taskPanel" style="display:none">
<div class="task-panel-header">
<span>📋 分析任务</span>
<button class="btn btn-icon" onclick="hideTasks()">✕</button>
</div>
<div class="task-panel-body" id="taskPanelBody"></div>
</div>
<!-- Analysis Progress -->
<div class="progress-overlay" id="progressBar" style="display:none">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<span class="progress-text" id="progressText">分析中...</span>
</div>
<!-- Video Preview Modal -->
<div class="modal" id="modal">
<div class="modal-overlay" onclick="closeModal()"></div>
<div class="modal-content">
<button class="modal-close" onclick="event.stopPropagation();closeModal()">✕</button>
<div class="modal-tabs">
<button class="modal-tab active" onclick="switchModalTab('preview', this)">🎬 预览</button>
<button class="modal-tab" onclick="switchModalTab('report', this)">📊 分析报告</button>
</div>
<div class="modal-tab-content" id="tabPreview">
<video id="videoPlayer" controls preload="auto"></video>
<div class="clip-toolbar" id="clipToolbar">
<div class="clip-timeline-wrapper">
<div class="clip-timeline" id="clipTimeline">
<div class="clip-played" id="clipPlayed"></div>
<div class="clip-selection" id="clipSelection"></div>
<div class="clip-marker clip-marker-in" id="clipMarkerIn"></div>
<div class="clip-marker clip-marker-out" id="clipMarkerOut"></div>
<div class="clip-playhead" id="clipPlayhead"></div>
</div>
<div class="clip-timeaxis" id="clipTimeaxis"></div>
</div>
<div class="clip-row">
<button class="btn btn-clip-set" onclick="setClipStart()">📍 入点</button>
<span class="clip-time" id="clipStart">00:00.000</span>
<span class="clip-sep">→</span>
<button class="btn btn-clip-set" onclick="setClipEnd()">📍 出点</button>
<span class="clip-time" id="clipEnd">00:00.000</span>
<span class="clip-duration" id="clipDuration">时长: 0s</span>
<button class="btn btn-clip-play" onclick="playClipRange()">▶ 播放选区</button>
<button class="btn btn-clip-split" onclick="splitClip()">✂️ 分割</button>
<button class="btn btn-clip-save" onclick="saveClip()">💾 保存片段</button>
<button class="btn btn-clip-add" onclick="addClipToList()"> 添加到列表</button>
</div>
<div class="clip-list" id="clipList"></div>
</div>
<div class="modal-info" id="modalInfo"></div>
</div>
<div class="modal-tab-content" id="tabReport" style="display:none">
<div class="report-container" id="reportContainer"></div>
<div class="scene-section" id="sceneSection" style="display:none">
<div id="sceneResults"></div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-analyze-modal" id="modalAnalyzeBtn" onclick="analyzeFromModal()">🔍 分析</button>
<button class="btn btn-prev" onclick="navigateModal(-1)">◀ 上一个</button>
<button class="btn btn-next" onclick="navigateModal(1)">下一个 ▶</button>
</div>
</div>
</div>
<!-- 新建文件夹模态框 -->
<div class="modal" id="createFolderModal">
<div class="modal-overlay" onclick="closeCreateFolderModal()"></div>
<div class="modal-content" style="max-width:400px">
<button class="modal-close" onclick="closeCreateFolderModal()">✕</button>
<h3 style="margin:0 0 16px 0">新建文件夹</h3>
<input type="text" id="newFolderName" placeholder="输入文件夹名称" maxlength="50" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:6px;font-size:14px;box-sizing:border-box">
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-clear" onclick="closeCreateFolderModal()">取消</button>
<button class="btn btn-upload" onclick="createFolder()">创建</button>
</div>
</div>
</div>
<!-- Selected Bar -->
<div class="selected-bar" id="selectedBar" style="display:none">
<span>已选择 <strong id="selectedCount">0</strong> 个素材</span>
<div id="normalActions">
<button class="btn btn-export" onclick="exportSelected()">📥 导出报告</button>
<button class="btn btn-analyze-selected" onclick="analyzeSelected()">🔍 分析选中</button>
<button class="btn btn-move" onclick="showMoveMenu()">📁 移动到文件夹</button>
<button class="btn btn-delete-selected" onclick="deleteSelected()">🗑 移入回收站</button>
</div>
<div id="recycleActions" style="display:none">
<button class="btn btn-restore" onclick="restoreSelected()">♻️ 恢复选中</button>
<button class="btn btn-permanent-delete" onclick="permanentDeleteSelected()">🔥 彻底删除</button>
</div>
<button class="btn btn-clear" onclick="clearSelection()">✕ 清空</button>
</div>
<script src="/static/folder_functions.js"></script>
<script src="/static/app.js?v=99"></script>
</body>
</html>

2
mimo_result.txt Normal file
View File

@ -0,0 +1,2 @@
### 1) 顶部区域
这是表格的表头栏,背景为浅灰色,文字为黑色加粗字体,从左到右依次排列信息列标题,最右侧还有一个**文档复制/分享类图标**,为两个重叠的矩形样式,整体横向分割出表格

62
mimo_result2.txt Normal file
View File

@ -0,0 +1,62 @@
### 表头文字
镜号、时间码、景别、画面描述、台词/旁白、音效/配乐、备注
---
### 镜头1镜号1
- 时间码00:00-00:03
- 景别:中景
- 画面描述女主白衣短袖手持玻璃密封罐装绿豆。背景红色复古小冰箱、熊猫磁贴、“EVERYTHING WILL BE FINE”装饰画。桌面红搪瓷牡丹花盘
- 台词/旁白:天热可别去超市买绿豆了,贵不说吧,还不容易出沙。
- 音效/配乐轻快BGM渐入器皿声
- 备注:痛点切入,悬念感
---
### 镜头2镜号2
- 时间码00:03-00:06
- 景别:特写俯拍
- 画面描述:红盘正上方,绿豆倾泻而下堆满盘子,红绿对比鲜明,颗粒饱满
- 台词/旁白:你看我找到这个,到手足足三斤才啥价呀,比超市散装都便宜了。
- 音效/配乐BGM绿豆倾倒“哗啦啦”ASMR
- 备注:量大+便宜,视听双刺激
---
### 镜头3镜号3
- 时间码00:06-00:11
- 景别:特写俯拍
- 画面描述:白皙手指伸入绿豆堆,轻轻拨动捧起,展示干净饱满无杂质
- 台词/旁白:主要它没有杂质和坏的,都是精挑细选的绿豆,品质特别好。
- 音效/配乐BGM手拨绿豆沙沙声
- 备注:信任感构建
---
### 镜头4镜号4
- 时间码00:11-00:16
- 景别:特写斜俯拍
- 画面描述:黄色电炖锅内,绿豆汤绵密起沙,白色瓷勺轻搅带起浓稠绿豆沙
- 台词/旁白:这夏天用它熬个绿豆沙沙的,这绵绵沙沙的,特别清凉好喝。
- 音效/配乐舒缓治愈BGM勺搅“咕嘟”声
- 备注:成品诱惑,激发食欲
---
### 镜头5镜号5
- 时间码00:16-00:21
- 景别:中景
- 画面描述:切回女主正面,双手端满盘绿豆,笑容诚恳直视镜头
- 台词/旁白:关键这价格是真合适,给你买上一次的话,一夏天都够了。
- 音效/配乐BGM渐强至高点
- 备注:价格锚定+周期暗示
---
### 镜头6镜号6
- 时间码00:21-00:23
- 景别:图文定格
- 画面描述:产品/成品定格,出现购买链接或“点击左下角”引导
- 台词/旁白:(无台词)
- 音效/配乐:音乐收尾;提示音“叮”
- 备注CTA转化引导

419
server_index.html Normal file
View File

@ -0,0 +1,419 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>拍摄素材管理</title>
<style>.nav-icon { width: 16px; height: 16px; object-fit: contain; }</style>
<link rel="stylesheet" href="/static/style.css?v=23">
</head>
<body>
<div class="app-layout">
<!-- Left Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<span class="sidebar-logo">🎬</span>
<span class="sidebar-title">拍摄剪辑</span>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-item active" data-filter="" onclick="filterByNav(this, 'all')">
<img class="nav-icon" src="/static/icons/Folder.svg" alt="">
<span class="nav-label">全部</span>
<span class="nav-count" id="countAll">0</span>
</div>
<div class="nav-item" data-filter="未分析" onclick="filterByNav(this, 'unanalyzed')">
<img class="nav-icon" src="/static/icons/add.svg" alt="">
<span class="nav-label">未标签</span>
<span class="nav-count" id="countUnanalyzed">0</span>
</div>
<div class="nav-item" data-filter="已分析" onclick="filterByNav(this, 'analyzed')">
<img class="nav-icon" src="/static/icons/check.svg" alt="">
<span class="nav-label">待拉片</span>
<span class="nav-count" id="countAnalyzed">0</span>
</div>
<div class="nav-item" data-filter="已剪辑" onclick="filterByNav(this, 'clipped')">
<img class="nav-icon" src="/static/icons/Play.svg" alt="">
<span class="nav-label">已拉片</span>
<span class="nav-count" id="countClipped">0</span>
</div>
<div class="nav-item" data-filter="recycle" onclick="filterByNav(this, 'recycle')">
<img class="nav-icon" src="/static/icons/Delete.svg" alt="">
<span class="nav-label">回收站</span>
<span class="nav-count" id="countRecycle">0</span>
</div>
</div>
<div class="nav-divider"></div>
<div class="nav-section">
<div class="nav-section-title">任务管理</div>
<div id="taskList">
<div class="nav-item" onclick="showTasks()">
<img class="nav-icon" src="/static/icons/TaskState_running.svg" alt="">
<span class="nav-label">分析任务</span>
<span class="nav-count" id="countRunning">0</span>
</div>
</div>
</div>
<div class="nav-divider"></div>
<div class="nav-section">
<div class="nav-section-title">智能文件夹</div>
<div id="categoryList"></div>
</div>
</nav>
<div class="nav-divider"></div>
<div class="nav-section">
<div class="nav-section-title">AI 工具</div>
<div class="nav-item" id="navVoiceClone" onclick="showVoiceClone()">
<span class="nav-icon">🎤</span>
<span class="nav-label">语音克隆</span>
</div>
</div>
<div class="sidebar-footer">
<button class="btn btn-block btn-upload" onclick="openUploadModal()">📤 上传素材</button>
</div>
</aside>
<!-- Main Area -->
<div class="main-area">
<!-- Top Bar -->
<div class="topbar">
<div class="topbar-left">
<button class="btn btn-icon" id="btnToggleSidebar" onclick="toggleSidebar()"></button>
<div class="breadcrumb">
<span class="breadcrumb-item" id="breadcrumbRoot">拍摄剪辑</span>
<span class="breadcrumb-sep"></span>
<span class="breadcrumb-item active" id="breadcrumbCurrent">全部</span>
</div>
</div>
<div class="topbar-center">
<div class="slider-group">
<span class="slider-label"></span>
<input type="range" id="cardSizeSlider" min="160" max="360" value="240" oninput="setCardSize(this.value)">
<span class="slider-label"></span>
</div>
</div>
<div class="topbar-right">
<div class="view-toggle">
<button class="view-btn active" id="btnGrid" onclick="setView('grid')"></button>
<button class="view-btn" id="btnList" onclick="setView('list')"></button>
</div>
<div class="search-box">
<input type="text" id="search" placeholder="搜索文件名或AI描述..." oninput="applyFilters()">
</div>
<button class="btn btn-analyze" id="btnAnalyze" onclick="startAnalysis()">🔍 分析</button>
</div>
</div>
<!-- Filter Bar -->
<div class="filterbar">
<div class="filter-group">
<label>时长:</label>
<input type="number" id="minDur" placeholder="最小" min="0" step="1" style="width:55px">
<span>-</span>
<input type="number" id="maxDur" placeholder="最大" min="0" step="1" style="width:55px">
<span></span>
</div>
<div class="filter-group">
<label>分辨率:</label>
<select id="resolutionFilter" onchange="applyFilters()">
<option value="">全部</option>
<option value="4k">4K</option>
<option value="1080p">1080P</option>
<option value="vertical">竖屏</option>
</select>
</div>
<div class="filter-results" id="filterResults"></div>
<button class="btn btn-select-all" onclick="selectAllPage()">☑ 全选本页</button>
</div>
<!-- Grid -->
<div class="content-area">
<div class="grid-container">
<div id="analysisProgress" style="display:none"></div>
<div class="grid" id="grid"></div>
<div class="empty-state" id="emptyState" style="display:none">
<p>📭 没有找到匹配的素材</p>
</div>
</div>
<!-- Pagination -->
<div class="pagination" id="pagination"></div>
</div>
<!-- Voice Clone Panel -->
<div id="voiceClonePanel">
<div class="vc-container">
<div class="vc-header">
<h2>🎤 语音克隆</h2>
<p class="vc-subtitle">上传音频/视频样本,使用 AI 克隆声音并合成语音</p>
</div>
<!-- Upload Zone -->
<div class="vc-upload-zone" id="vcUploadZone">
<div class="vc-upload-icon">📁</div>
<div class="vc-upload-text">拖拽音频/视频文件到这里,或点击选择</div>
<div class="vc-upload-hint">支持 WAV, MP3, M4A, AAC, FLAC, MP4, MOV, AVI, MKV</div>
<input type="file" id="vcFileInput" accept="audio/*,video/*" style="position:absolute;width:100%;height:100%;top:0;left:0;opacity:0;cursor:pointer;z-index:10">
</div>
<div class="vc-file-info" id="vcFileInfo" style="display:none">
<span class="vc-file-icon" id="vcFileIcon">🎵</span>
<span class="vc-file-name" id="vcFileName"></span>
<span class="vc-file-type" id="vcFileType"></span>
<button class="btn btn-sm" onclick="vcClearFile()">✕ 移除</button>
</div>
<!-- Presets -->
<div class="vc-section">
<div class="vc-section-title">语音预设</div>
<div class="vc-preset-group">
<button class="vc-preset-btn active" data-preset="natural" onclick="vcSetPreset('natural', this)">🌤 自然</button>
<button class="vc-preset-btn" data-preset="warm" onclick="vcSetPreset('warm', this)">☀️ 温暖</button>
<button class="vc-preset-btn" data-preset="confident" onclick="vcSetPreset('confident', this)">💪 自信</button>
<button class="vc-preset-btn" data-preset="expressive" onclick="vcSetPreset('expressive', this)">🎭 表现力</button>
<button class="vc-preset-btn" data-preset="stable" onclick="vcSetPreset('stable', this)">⚖️ 稳定</button>
<button class="vc-preset-btn" data-preset="creative" onclick="vcSetPreset('creative', this)">🎨 创意</button>
</div>
</div>
<!-- Text Input -->
<div class="vc-section">
<div class="vc-section-title">合成文本</div>
<textarea id="vcTextInput" class="vc-textarea" rows="3" placeholder="输入要合成的文字...">你好,这是一个声音克隆测试。</textarea>
</div>
<!-- Advanced Settings -->
<div class="vc-section">
<div class="vc-section-toggle" onclick="vcToggleAdvanced()">
<span>⚙️ 高级设置</span>
<span id="vcAdvancedArrow"></span>
</div>
<div class="vc-advanced" id="vcAdvanced" style="display:none">
<div class="vc-slider-group">
<label>稳定性</label>
<input type="range" id="vcStability" min="0" max="100" value="70" oninput="vcUpdateSlider(this)">
<span class="vc-slider-value" id="vcStabilityVal">0.7</span>
</div>
<div class="vc-slider-group">
<label>相似度</label>
<input type="range" id="vcSimilarity" min="0" max="100" value="80" oninput="vcUpdateSlider(this)">
<span class="vc-slider-value" id="vcSimilarityVal">0.8</span>
</div>
<div class="vc-slider-group">
<label>表现力</label>
<input type="range" id="vcStyle" min="0" max="100" value="0" oninput="vcUpdateSlider(this)">
<span class="vc-slider-value" id="vcStyleVal">0.0</span>
</div>
<div class="vc-slider-group">
<label>语速</label>
<input type="range" id="vcSpeed" min="50" max="200" value="100" oninput="vcUpdateSlider(this)">
<span class="vc-slider-value" id="vcSpeedVal">1.0</span>
</div>
<div class="vc-slider-group">
<label>音调</label>
<input type="range" id="vcPitch" min="-100" max="100" value="0" oninput="vcUpdateSlider(this)">
<span class="vc-slider-value" id="vcPitchVal">0.0</span>
</div>
<div class="vc-slider-group">
<label>强调</label>
<input type="range" id="vcEmphasis" min="0" max="100" value="0" oninput="vcUpdateSlider(this)">
<span class="vc-slider-value" id="vcEmphasisVal">0.0</span>
</div>
<div class="vc-slider-group">
<label>气息感</label>
<input type="range" id="vcBreathiness" min="0" max="100" value="0" oninput="vcUpdateSlider(this)">
<span class="vc-slider-value" id="vcBreathinessVal">0.0</span>
</div>
<div class="vc-slider-group">
<label>说话人增强</label>
<label class="vc-switch">
<input type="checkbox" id="vcSpeakerBoost" checked>
<span class="vc-switch-slider"></span>
</label>
</div>
</div>
</div>
<!-- Generate Button -->
<div class="vc-section">
<button class="btn btn-vc-generate" id="vcGenerateBtn" onclick="vcGenerate()" disabled>
🔄 生成克隆声音
</button>
</div>
<!-- Loading -->
<div class="vc-loading" id="vcLoading" style="display:none">
<div class="vc-spinner"></div>
<span id="vcLoadingText">正在生成...</span>
</div>
<!-- Error -->
<div class="vc-error" id="vcError" style="display:none"></div>
<!-- Player -->
<div class="vc-player" id="vcPlayer" style="display:none">
<div class="vc-player-header">播放结果</div>
<div class="vc-player-controls">
<button class="btn btn-vc-play" id="vcPlayBtn" onclick="vcTogglePlay()">▶ 播放</button>
<button class="btn btn-vc-stop" onclick="vcStop()">⏹ 停止</button>
<div class="vc-progress-wrapper">
<span class="vc-time" id="vcCurrentTime">00:00</span>
<input type="range" class="vc-progress" id="vcProgress" min="0" max="100" value="0" oninput="vcSeek(this.value)">
<span class="vc-time" id="vcDuration">00:00</span>
</div>
<div class="vc-speed-group">
<button class="vc-speed-btn" onclick="vcSetSpeed(0.5, this)">0.5x</button>
<button class="vc-speed-btn" onclick="vcSetSpeed(0.75, this)">0.75x</button>
<button class="vc-speed-btn active" onclick="vcSetSpeed(1.0, this)">1x</button>
<button class="vc-speed-btn" onclick="vcSetSpeed(1.25, this)">1.25x</button>
<button class="vc-speed-btn" onclick="vcSetSpeed(1.5, this)">1.5x</button>
<button class="vc-speed-btn" onclick="vcSetSpeed(2.0, this)">2x</button>
</div>
<button class="btn btn-vc-save" onclick="vcSaveAudio()">⬇ 保存</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Modal -->
<div class="modal" id="uploadModal">
<div class="modal-overlay" onclick="closeUploadModal()"></div>
<div class="modal-content" style="max-width:600px">
<button class="modal-close" onclick="event.stopPropagation();closeUploadModal()"></button>
<div class="upload-header">
<h2>📤 上传视频素材</h2>
<p>支持 MP4、MOV、AVI、MKV 等格式,最大 4GB</p>
</div>
<div class="upload-body">
<div class="upload-dropzone" id="dropzone">
<div class="dropzone-icon">📁</div>
<p>拖拽文件到此处,或点击选择文件</p>
<input type="file" id="fileInput" multiple accept="video/*,.mp4,.mov,.avi,.mkv,.mts,.mxf,.wmv" style="position:absolute;width:100%;height:100%;top:0;left:0;opacity:0;cursor:pointer;z-index:10">
</div>
<div class="upload-category">
<label>分类:</label>
<input type="text" id="uploadCategory" value="上传素材" placeholder="输入分类名称">
</div>
<div class="upload-filelist" id="uploadFilelist"></div>
<div class="upload-progress" id="uploadProgress" style="display:none">
<div class="progress-bar">
<div class="progress-fill" id="uploadProgressFill"></div>
</div>
<span class="progress-text" id="uploadProgressText">上传中...</span>
</div>
<div class="upload-result" id="uploadResult" style="display:none"></div>
<button class="btn btn-upload-submit" id="btnUploadSubmit" onclick="submitUpload()" disabled>开始上传</button>
</div>
</div>
</div>
<!-- Task Panel -->
<div class="task-panel" id="taskPanel" style="display:none">
<div class="task-panel-header">
<span>📋 分析任务</span>
<button class="btn btn-icon" onclick="hideTasks()"></button>
</div>
<div class="task-panel-body" id="taskPanelBody"></div>
</div>
<!-- Analysis Progress -->
<div class="progress-overlay" id="progressBar" style="display:none">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<span class="progress-text" id="progressText">分析中...</span>
</div>
<!-- Video Preview Modal -->
<div class="modal" id="modal">
<div class="modal-overlay" onclick="closeModal()"></div>
<div class="modal-content">
<button class="modal-close" onclick="event.stopPropagation();closeModal()"></button>
<div class="modal-tabs">
<button class="modal-tab active" onclick="switchModalTab('preview', this)">🎬 预览</button>
<button class="modal-tab" onclick="switchModalTab('report', this)">📊 分析报告</button>
</div>
<div class="modal-tab-content" id="tabPreview">
<video id="videoPlayer" controls preload="auto"></video>
<div class="clip-toolbar" id="clipToolbar">
<div class="clip-timeline-wrapper">
<div class="clip-timeline" id="clipTimeline">
<div class="clip-played" id="clipPlayed"></div>
<div class="clip-selection" id="clipSelection"></div>
<div class="clip-marker clip-marker-in" id="clipMarkerIn"></div>
<div class="clip-marker clip-marker-out" id="clipMarkerOut"></div>
<div class="clip-playhead" id="clipPlayhead"></div>
</div>
<div class="clip-timeaxis" id="clipTimeaxis"></div>
</div>
<div class="clip-row">
<button class="btn btn-clip-set" onclick="setClipStart()">📍 入点</button>
<span class="clip-time" id="clipStart">00:00.000</span>
<span class="clip-sep"></span>
<button class="btn btn-clip-set" onclick="setClipEnd()">📍 出点</button>
<span class="clip-time" id="clipEnd">00:00.000</span>
<span class="clip-duration" id="clipDuration">时长: 0s</span>
<button class="btn btn-clip-play" onclick="playClipRange()">▶ 播放选区</button>
<button class="btn btn-clip-split" onclick="splitClip()">✂️ 分割</button>
<button class="btn btn-clip-save" onclick="saveClip()">💾 保存片段</button>
<button class="btn btn-clip-add" onclick="addClipToList()"> 添加到列表</button>
</div>
<div class="clip-list" id="clipList"></div>
</div>
<div class="modal-info" id="modalInfo"></div>
</div>
<div class="modal-tab-content" id="tabReport" style="display:none">
<div class="report-container" id="reportContainer"></div>
<div class="scene-section" id="sceneSection" style="display:none">
<div class="report-section">
<div class="report-section-title">🎤 语音转文字</div>
<div class="report-desc" id="transcriptionResult" style="min-height:40px"></div>
</div>
<div class="report-section">
<div class="report-section-title">📊 废镜头统计</div>
<div id="wasteStats"></div>
</div>
<div class="report-section">
<div class="report-section-title">🎬 逐镜头分析</div>
<div id="sceneResults"></div>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-analyze-modal" id="modalAnalyzeBtn" onclick="analyzeFromModal()">🔍 分析</button>
<button class="btn btn-prev" onclick="navigateModal(-1)">◀ 上一个</button>
<button class="btn btn-next" onclick="navigateModal(1)">下一个 ▶</button>
</div>
</div>
</div>
<!-- Selected Bar -->
<div class="selected-bar" id="selectedBar" style="display:none">
<span>已选择 <strong id="selectedCount">0</strong> 个素材</span>
<div id="normalActions">
<button class="btn btn-export" onclick="exportSelected()">📥 导出报告</button>
<button class="btn btn-analyze-selected" onclick="analyzeSelected()">🔍 分析选中</button>
<button class="btn btn-delete-selected" onclick="deleteSelected()">🗑 移入回收站</button>
</div>
<div id="recycleActions" style="display:none">
<button class="btn btn-restore" onclick="restoreSelected()">♻️ 恢复选中</button>
<button class="btn btn-permanent-delete" onclick="permanentDeleteSelected()">🔥 彻底删除</button>
</div>
<button class="btn btn-clear" onclick="clearSelection()">✕ 清空</button>
</div>
<script src="/static/app.js?v=1783450100"></script>
</body>
</html>

1643
server_style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +0,0 @@
def main():
print("Hello MCP!")
if __name__ == "__main__":
main()

View File

@ -1 +0,0 @@
push ok

1303
style.css.current Normal file

File diff suppressed because it is too large Load Diff

709
web.py.current Normal file
View File

@ -0,0 +1,709 @@
"""
Footage Screener 鈥?Web UI (Database-backed)
"""
import json
import os
import sys
import threading
import time
from pathlib import Path
from werkzeug.utils import secure_filename
from flask import Flask, render_template, jsonify, send_file, request, Response
from db import (
init_db, insert_footage, list_footage, get_footage_by_id, get_stats,
upsert_screener, delete_footage as db_delete_footage,
soft_delete_footage, restore_footage, permanent_delete_footage,
set_clip_status, create_task, update_task, complete_task,
list_tasks, get_running_task, migrate_from_json,
get_db, list_all_categories, create_category as db_create_category,
move_footage_to_category,
)
from analyzer import analyze_video as video_analyze
sys.stdout.reconfigure(encoding="utf-8")
app = Flask(__name__,
template_folder=os.path.join(os.path.dirname(__file__), "templates"),
static_folder=os.path.join(os.path.dirname(__file__), "static"))
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3600 # Cache static files 1 hour
app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024 * 1024 # 4GB max upload
# Unified video storage directory
VIDEO_STORAGE_DIR = os.environ.get("VIDEO_STORAGE_DIR") or os.path.join(os.path.dirname(__file__), "videos")
UPLOAD_DIR = os.path.join(VIDEO_STORAGE_DIR, "uploads")
THUMB_DIR = os.path.join(os.path.dirname(__file__), "static", "thumbs")
ALLOWED_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".mts", ".mxf", ".wmv", ".flv", ".mpg", ".mpeg", ".3gp", ".webm", ".ts", ".m4v"}
# Global state
_analysis_running = False
_analysis_progress = {"current": 0, "total": 0, "file": "", "status": "idle"}
# === Routes ===
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/footage")
def api_footage():
category = request.args.get("category", "")
search = request.args.get("search", "")
ai_search = request.args.get("ai_search", "")
min_dur = request.args.get("min_duration", type=float, default=0)
max_dur = request.args.get("max_duration", type=float, default=99999)
screener_filter = request.args.get("screener", "")
page = request.args.get("page", type=int, default=1)
per_page = request.args.get("per_page", type=int, default=60)
items, total = list_footage(
category=category, search=search, ai_search=ai_search,
min_dur=min_dur, max_dur=max_dur,
screener_filter=screener_filter,
page=page, per_page=per_page,
status=request.args.get("status", "active"),
)
return jsonify({
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": max(1, (total + per_page - 1) // per_page),
})
@app.route("/api/stats")
def api_stats():
return jsonify(get_stats())
@app.route("/video/<int:clip_id>")
def serve_video(clip_id):
"""Stream video file with range request support."""
clip = get_footage_by_id(clip_id)
if not clip:
return "Clip not found", 404
full_path = clip.get("filepath", "")
if not os.path.exists(full_path):
return "File not found", 404
ext = clip["filename"].lower().rsplit(".", 1)[-1]
mime_map = {"mp4": "video/mp4", "mov": "video/quicktime", "avi": "video/x-msvideo",
"mkv": "video/x-matroska", "mts": "video/mp2t"}
mime = mime_map.get(ext, "video/mp4")
file_size = os.path.getsize(full_path)
range_header = request.headers.get("Range")
if range_header:
byte_start = int(range_header.replace("bytes=", "").split("-")[0])
byte_end = min(byte_start + 5 * 1024 * 1024, file_size - 1)
length = byte_end - byte_start + 1
def generate_range():
with open(full_path, "rb") as f:
f.seek(byte_start)
remaining = length
while remaining > 0:
chunk = f.read(min(65536, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
return Response(
generate_range(), status=206, mimetype=mime,
headers={
"Content-Range": f"bytes {byte_start}-{byte_end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(length),
},
)
return send_file(full_path, mimetype=mime)
# === Analysis API ===
@app.route("/api/analyze", methods=["POST"])
def api_analyze():
global _analysis_running
if _analysis_running:
return jsonify({"error": "Analysis already running"}), 409
data = request.json or {}
mode = data.get("mode", "all")
selected_ids = set(data.get("selected_ids", []))
cv_only = data.get("cv_only", False)
# Get targets from DB
if mode == "selected" and selected_ids:
targets = [get_footage_by_id(i) for i in selected_ids]
targets = [t for t in targets if t]
elif mode == "unanalyzed":
items, _ = list_footage(screener_filter="未分析", per_page=9999)
targets = items
else:
items, _ = list_footage(per_page=9999)
targets = items
if not targets:
return jsonify({"error": "No videos to analyze"}), 400
_analysis_running = True
task_id = create_task(mode, len(targets))
thread = threading.Thread(target=_run_analysis, args=(targets, cv_only, task_id), daemon=True)
thread.start()
return jsonify({"message": f"Analyzing {len(targets)} videos...", "total": len(targets), "task_id": task_id})
def _analyze_single(clip: dict, cv_only: bool, task_id: int, progress: dict) -> bool:
"""Analyze a single video using MiMo 2.5."""
filepath = clip.get("filepath", "")
footage_id = clip.get("id", 0)
if not filepath or not os.path.exists(filepath) or not footage_id:
return False
try:
# Use new AI-only analyzer
result = video_analyze(filepath)
if result.error:
print(f"Analysis error for {clip.get('filename')}: {result.error}")
# Calculate decision score
final_score = result.final_score
# Determine decision based on quality
if result.quality_score >= 7:
decision = "KEEP"
elif result.quality_score >= 4:
decision = "REVIEW"
else:
decision = "DELETE"
decision_reason = result.decision_reason or result.quality_reason
upsert_screener(footage_id, {
"cv_score": 0,
"ai_score": round(final_score, 1),
"final_score": round(final_score, 1),
"decision": decision,
"decision_reason": decision_reason,
"blur_score": 0,
"shake_score": 0,
"black_frame_ratio": 0,
"overexposed_ratio": 0,
"underexposed_ratio": 0,
"is_silent": 0,
"ai_valid": 1 if decision == "KEEP" else 0,
"ai_description": result.description,
"ai_quality": result.quality_score,
"ai_reason": result.quality_reason,
"ai_mode": result.analysis_mode,
"issues": [],
"transcription": result.transcription,
"scenes": result.shots,
})
return True
except Exception as e:
print(f"Error analyzing {clip.get('filename')}: {e}")
return False
def _safe_scene_analysis(filepath: str) -> dict:
"""Scene analysis with error protection."""
try:
from scene_analyzer import analyze_scenes
return analyze_scenes(filepath)
except Exception as e:
print(f"Scene analysis error: {e}")
return {"transcription": "", "scenes": [], "summary": str(e)}
def _run_analysis(targets: list[dict], cv_only: bool = False, task_id: int = 0):
global _analysis_running, _analysis_progress
_analysis_progress = {"current": 0, "total": len(targets), "file": "", "status": "running"}
error_msg = ""
import concurrent.futures
# Process videos in batches of 3 for parallel execution
batch_size = 3
for batch_start in range(0, len(targets), batch_size):
batch = targets[batch_start:batch_start + batch_size]
with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as executor:
futures = {}
for clip in batch:
future = executor.submit(_analyze_single, clip, cv_only, task_id, _analysis_progress)
futures[future] = clip
for future in concurrent.futures.as_completed(futures):
clip = futures[future]
try:
success = future.result(timeout=600)
_analysis_progress["current"] += 1
_analysis_progress["file"] = clip.get("filename", "")
if task_id:
update_task(task_id, completed_count=_analysis_progress["current"],
current_file=clip.get("filename", ""))
except Exception as e:
error_msg = str(e)
_analysis_progress["current"] += 1 # count errors too
print(f"Error analyzing {clip.get('filename')}: {e}")
if task_id:
update_task(task_id, completed_count=_analysis_progress["current"],
current_file=clip.get("filename", ""))
if task_id:
complete_task(task_id, error_msg)
_analysis_progress["status"] = "done"
_analysis_running = False
@app.route("/api/clip", methods=["POST"])
def api_clip():
"""Clip a segment from a video."""
data = request.json or {}
footage_id = data.get("id")
start = data.get("start", 0)
end = data.get("end", 0)
filename = data.get("filename", "clip")
if not footage_id or end <= start:
return jsonify({"error": "Invalid parameters"}), 400
clip = get_footage_by_id(footage_id)
if not clip:
return jsonify({"error": "Clip not found"}), 404
filepath = clip.get("filepath", "")
if not os.path.exists(filepath):
return jsonify({"error": "File not found"}), 404
# Create output filename
clip_dir = os.path.join(os.path.dirname(__file__), "clips")
os.makedirs(clip_dir, exist_ok=True)
stem = Path(filename).stem
ext = Path(filename).suffix
output_name = f"{stem}_clip_{int(start)}-{int(end)}{ext}"
output_path = os.path.join(clip_dir, output_name)
# FFmpeg clip
cmd = [
"ffmpeg", "-y", "-v", "quiet",
"-ss", str(start),
"-i", filepath,
"-t", str(end - start),
"-c", "copy",
output_path,
]
try:
proc = subprocess.run(cmd, capture_output=True, timeout=60,
encoding="utf-8", errors="replace")
if proc.returncode == 0 and os.path.exists(output_path):
return jsonify({"ok": True, "output": output_name, "path": output_path})
else:
return jsonify({"error": "FFmpeg failed"}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/clip-status", methods=["POST"])
def api_clip_status():
"""Mark footage as clipped or unclipped."""
data = request.json or {}
footage_id = data.get("id")
status = data.get("status", "clipped") # "clipped" or ""
if not footage_id:
return jsonify({"error": "No ID"}), 400
set_clip_status(footage_id, status)
return jsonify({"ok": True})
@app.route("/api/analyze-scenes", methods=["POST"])
def api_analyze_scenes():
"""Analyze all scenes/shots in a video."""
data = request.json or {}
footage_id = data.get("id")
if not footage_id:
return jsonify({"error": "No footage ID"}), 400
clip = get_footage_by_id(footage_id)
if not clip:
return jsonify({"error": "Clip not found"}), 404
filepath = clip.get("filepath", "")
if not os.path.exists(filepath):
return jsonify({"error": "File not found"}), 404
from scene_analyzer import analyze_scenes
result = analyze_scenes(filepath)
return jsonify(result)
@app.route("/api/tasks")
def api_tasks():
"""List analysis tasks."""
tasks = list_tasks(limit=20)
running = get_running_task()
return jsonify({"tasks": tasks, "running": running})
@app.route("/api/analyze/status")
def api_analyze_status():
return jsonify(_analysis_progress)
@app.route("/api/scan", methods=["POST"])
def api_scan():
data = request.json or {}
base_dir = data.get("directory", "")
if not base_dir or not os.path.exists(base_dir):
return jsonify({"error": "Invalid directory"}), 400
from scanner import scan_directory
footage = scan_directory(base_dir)
return jsonify({"message": f"Scanned {len(footage)} clips", "count": len(footage)})
@app.route("/api/batch/delete", methods=["POST"])
def api_batch_delete():
data = request.json or {}
confirm = data.get("confirm", False)
ids = data.get("ids", [])
if ids:
# Soft delete specific items by ID (move to recycle bin)
if not confirm:
items = [get_footage_by_id(i) for i in ids]
items = [i for i in items if i]
return jsonify({"count": len(items), "files": [f["filename"] for f in items]})
moved = []
for fid in ids:
clip = get_footage_by_id(fid)
if not clip:
continue
soft_delete_footage(fid)
moved.append(clip["filename"])
return jsonify({"deleted": len(moved), "files": moved})
else:
# Soft delete all DELETE-category items
items, _ = list_footage(screener_filter="DELETE", per_page=9999)
if not confirm:
return jsonify({"count": len(items), "files": [f["filename"] for f in items]})
moved = []
for f in items:
soft_delete_footage(f["id"])
moved.append(f["filename"])
return jsonify({"deleted": len(moved), "files": moved})
@app.route("/api/restore", methods=["POST"])
def api_restore():
"""Restore items from recycle bin."""
data = request.json or {}
ids = data.get("ids", [])
if not ids:
return jsonify({"error": "No IDs provided"}), 400
restored = []
for fid in ids:
clip = get_footage_by_id(fid)
if clip:
restore_footage(fid)
restored.append(clip["filename"])
return jsonify({"restored": len(restored), "files": restored})
@app.route("/api/permanent-delete", methods=["POST"])
def api_permanent_delete():
"""Permanently delete items from recycle bin."""
data = request.json or {}
ids = data.get("ids", [])
if not ids:
return jsonify({"error": "No IDs provided"}), 400
deleted = []
for fid in ids:
filepath = permanent_delete_footage(fid)
if filepath and os.path.exists(filepath):
try:
os.remove(filepath)
except Exception:
pass
clip = get_footage_by_id(fid)
if clip:
deleted.append(clip["filename"])
return jsonify({"deleted": len(deleted), "files": deleted})
@app.route("/api/export")
def api_export():
selected_ids = request.args.get("ids", "")
if selected_ids:
ids = [int(x) for x in selected_ids.split(",") if x.strip()]
items = [get_footage_by_id(i) for i in ids]
items = [i for i in items if i]
else:
items, _ = list_footage(per_page=9999)
report = {
"total": len(items),
"keep": len([f for f in items if f.get("screener", {}).get("decision") == "KEEP"]),
"review": len([f for f in items if f.get("screener", {}).get("decision") == "REVIEW"]),
"delete": len([f for f in items if f.get("screener", {}).get("decision") == "DELETE"]),
"clips": items,
}
return jsonify(report)
# === Upload API ===
@app.route("/api/upload", methods=["POST"])
def api_upload():
"""Upload video files. Saves to uploads/ dir, extracts metadata, writes to DB."""
if "files" not in request.files:
return jsonify({"error": "No files provided"}), 400
files = request.files.getlist("files")
category = request.form.get("category", "涓婁紶绱犳潗")
os.makedirs(UPLOAD_DIR, exist_ok=True)
from scanner import generate_thumbnail
uploaded = []
errors = []
for f in files:
if not f.filename:
continue
ext = os.path.splitext(f.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
errors.append({"filename": f.filename, "error": f"Unsupported format: {ext}"})
continue
# Save file (handle duplicates)
filename = secure_filename(f.filename)
if not filename:
filename = f"upload_{int(time.time())}{ext}"
dest_path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(dest_path):
name, ext2 = os.path.splitext(filename)
filename = f"{name}_{int(time.time())}{ext2}"
dest_path = os.path.join(UPLOAD_DIR, filename)
try:
f.save(dest_path)
except Exception as e:
errors.append({"filename": f.filename, "error": str(e)})
continue
# Extract metadata
from cv_analyzer import _get_video_info
info = _get_video_info(dest_path)
# Generate thumbnail
thumb_name = f"{category}_{filename}.jpg"
thumb_path = os.path.join(THUMB_DIR, thumb_name)
thumb_rel = f"thumbs/{thumb_name}"
generate_thumbnail(dest_path, thumb_path)
# Insert to DB
clip = {
"filename": filename,
"filepath": dest_path,
"category": category,
"thumbnail": thumb_rel,
"duration": round(info["duration"], 2),
"width": info["width"],
"height": info["height"],
"fps": round(info["fps"], 2),
"total_frames": info["total_frames"],
"size_mb": round(os.path.getsize(dest_path) / 1024 / 1024, 1),
}
clip_id = insert_footage(clip)
clip["id"] = clip_id
uploaded.append(clip)
return jsonify({
"uploaded": len(uploaded),
"errors": len(errors),
"files": uploaded,
"error_details": errors,
})
@app.route("/api/upload/status")
def api_upload_status():
"""Check upload directory."""
if not os.path.exists(UPLOAD_DIR):
return jsonify({"count": 0, "total_size_mb": 0})
files = [f for f in os.listdir(UPLOAD_DIR) if os.path.isfile(os.path.join(UPLOAD_DIR, f))]
total_size = sum(os.path.getsize(os.path.join(UPLOAD_DIR, f)) for f in files)
return jsonify({"count": len(files), "total_size_mb": round(total_size / 1024 / 1024, 1)})
@app.route("/api/scene-notes/batch", methods=["POST"])
def update_scene_notes_batch():
"""Update notes for multiple scenes at once."""
data = request.json or {}
footage_id = data.get("footage_id")
updates = data.get("updates", [])
if not footage_id or not updates:
return jsonify({"error": "footage_id and updates required"}), 400
try:
with get_db() as conn:
# Get current scenes
row = conn.execute("SELECT scenes FROM screener WHERE footage_id = ?", (footage_id,)).fetchone()
if not row:
return jsonify({"error": "No analysis found for this footage"}), 404
scenes = json.loads(row["scenes"]) if row["scenes"] else []
# Apply updates
for update in updates:
scene_index = update.get("scene_index")
notes = update.get("notes", "")
for scene in scenes:
if scene.get("scene_index") == scene_index or scene.get("shot_index") == scene_index:
scene["notes"] = notes
break
# Save back to database
conn.execute("UPDATE screener SET scenes = ? WHERE footage_id = ?",
(json.dumps(scenes, ensure_ascii=False), footage_id))
conn.commit()
return jsonify({"success": True, "updated": len(updates)})
except Exception as e:
return jsonify({"error": str(e)}), 500
# === Scene Notes API ===
@app.route("/api/scene-notes", methods=["POST"])
def update_scene_notes():
data = request.json or {}
footage_id = data.get("footage_id")
scene_index = data.get("scene_index")
notes = data.get("notes", "")
if not footage_id or scene_index is None:
return jsonify({"error": "footage_id and scene_index required"}), 400
try:
with get_db() as conn:
# Get current scenes
row = conn.execute("SELECT scenes FROM screener WHERE footage_id = ?", (footage_id,)).fetchone()
if not row:
return jsonify({"error": "No analysis found for this footage"}), 404
scenes = json.loads(row["scenes"]) if row["scenes"] else []
# Find and update the scene
updated = False
for scene in scenes:
if scene.get("scene_index") == scene_index or scene.get("shot_index") == scene_index:
scene["notes"] = notes
updated = True
break
if not updated:
return jsonify({"error": "Scene not found"}), 404
# Save back to database
conn.execute("UPDATE screener SET scenes = ? WHERE footage_id = ?",
(json.dumps(scenes, ensure_ascii=False), footage_id))
conn.commit()
return jsonify({"success": True, "notes": notes})
except Exception as e:
return jsonify({"error": str(e)}), 500
# === Category Management API ===
@app.route("/api/categories", methods=["GET"])
def list_categories():
"""获取所有分类列表"""
categories = list_all_categories()
return jsonify({"categories": categories})
@app.route("/api/category/create", methods=["POST"])
def create_category():
"""创建新分类(文件夹)"""
data = request.json or {}
name = data.get("name", "").strip()
if not name:
return jsonify({"error": "分类名称不能为空"}), 400
if len(name) > 50:
return jsonify({"error": "分类名称不能超过50个字符"}), 400
if db_create_category(name):
return jsonify({"success": True, "category": name})
else:
return jsonify({"error": "分类已存在"}), 400
@app.route("/api/category/move", methods=["POST"])
def move_to_category():
"""将选中的视频移动到指定分类"""
data = request.json or {}
ids = data.get("ids", [])
category = data.get("category", "").strip()
if not ids:
return jsonify({"error": "请选择要移动的视频"}), 400
if not category:
return jsonify({"error": "请选择目标分类"}), 400
moved = move_footage_to_category(ids, category)
return jsonify({"success": True, "moved": moved})
if __name__ == "__main__":
# Auto-migrate from JSON if DB is empty
init_db()
json_cache = os.path.join(os.path.dirname(__file__), "data", "footage_cache.json")
stats = get_stats()
if stats["categories"]["全部"]["count"] == 0 and os.path.exists(json_cache):
print("Migrating from JSON cache...")
count = migrate_from_json(json_cache)
print(f"Migrated {count} items.")
# Fix any stuck running tasks from previous session
from db import get_db as _get_db
with _get_db() as _conn:
stuck = _conn.execute("SELECT COUNT(*) FROM tasks WHERE status='running'").fetchone()[0]
if stuck > 0:
_conn.execute("UPDATE tasks SET status='completed', completed_at=CURRENT_TIMESTAMP WHERE status='running'")
print(f"Fixed {stuck} stuck task(s)")
port = int(os.environ.get("PORT", 5000))
host = os.environ.get("HOST", "0.0.0.0") # 0.0.0.0 = 灞€鍩熺綉鍙闂? print(f"\n 馃幀 Footage Screener Web UI")
print(f" 鏈満璁块棶: http://localhost:{port}")
print(f" 灞€鍩熺綉璁块棶: http://<浣犵殑IP>:{port}")
print(f" 鎸?Ctrl+C 鍋滄鏈嶅姟\n")
app.run(host=host, port=port, debug=False)