Compare commits
No commits in common. "project/video-workflow" and "main" have entirely different histories.
project/vi
...
main
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
DATABASE_URL=sqlite:///db.sqlite
|
||||
DEBUG=true
|
||||
1403
app.js.backup
1403
app.js.backup
File diff suppressed because it is too large
Load Diff
1403
app.js.current
1403
app.js.current
File diff suppressed because it is too large
Load Diff
53
check切割.py
53
check切割.py
@ -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
1
health.txt
Normal file
@ -0,0 +1 @@
|
||||
ok
|
||||
@ -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>
|
||||
@ -1,2 +0,0 @@
|
||||
### 1) 顶部区域
|
||||
这是表格的表头栏,背景为浅灰色,文字为黑色加粗字体,从左到右依次排列信息列标题,最右侧还有一个**文档复制/分享类图标**,为两个重叠的矩形样式,整体横向分割出表格
|
||||
@ -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转化引导
|
||||
@ -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>
|
||||
1643
server_style.css
1643
server_style.css
File diff suppressed because it is too large
Load Diff
5
src/main.py
Normal file
5
src/main.py
Normal file
@ -0,0 +1,5 @@
|
||||
def main():
|
||||
print("Hello MCP!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
status.txt
Normal file
1
status.txt
Normal file
@ -0,0 +1 @@
|
||||
push ok
|
||||
1303
style.css.current
1303
style.css.current
File diff suppressed because it is too large
Load Diff
709
web.py.current
709
web.py.current
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user