Compare commits

...

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

15 changed files with 12 additions and 7276 deletions

2
.env.example Normal file
View File

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

3
README.md Normal file
View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +0,0 @@
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

1
health.txt Normal file
View File

@ -0,0 +1 @@
ok

View File

@ -1,279 +0,0 @@
<!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>

View File

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

View File

@ -1,62 +0,0 @@
### 表头文字
镜号、时间码、景别、画面描述、台词/旁白、音效/配乐、备注
---
### 镜头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转化引导

View File

@ -1,419 +0,0 @@
<!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>

File diff suppressed because it is too large Load Diff

5
src/main.py Normal file
View File

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

1
status.txt Normal file
View File

@ -0,0 +1 @@
push ok

File diff suppressed because it is too large Load Diff

View File

@ -1,709 +0,0 @@
"""
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)