From ce44d803b73a65b2cc31db5bcc662139029463d3 Mon Sep 17 00:00:00 2001
From: doum <doum>
Date: 星期五, 03 七月 2026 10:07:32 +0800
Subject: [PATCH] 海康电表维护

---
 admin/src/views/business/collectionMedia.vue                                                                                |  370 +++++
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeRequest.java                |   23 
 server/snapshot_infer/requirements.txt                                                                                      |    9 
 server/db/business.delivery_media_snapshot.sql                                                                              |   16 
 server/snapshot_infer/tools/export_feedback.py                                                                              |   80 +
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeResponse.java               |   62 
 server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeliverySnapshotManualRequest.java                 |   21 
 server/snapshot_infer/app/schemas.py                                                                                        |   53 
 server/snapshot_infer/models/version.json                                                                                   |    8 
 server/snapshot_infer/training/evaluate.py                                                                                  |  171 ++
 server/snapshot_infer/app/pipeline.py                                                                                       |  112 +
 server/snapshot_infer/docs/training_troubleshooting.md                                                                      |   96 +
 server/snapshot_infer/training/extract_frames.py                                                                            |    7 
 server/snapshot_infer/app/asr.py                                                                                            |   81 +
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferClient.java                   |  122 +
 server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java                      |   28 
 server/snapshot_infer/tools/convert_labelstudio.py                                                                          |   64 +
 server/snapshot_infer/training/config.yaml                                                                                  |   28 
 server/snapshot_infer/README.md                                                                                             |  127 +
 server/snapshot_infer/tools/export_media_list.py                                                                            |  136 ++
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/MediaFrameUtil.java                      |  142 ++
 server/snapshot_infer/app/fusion.py                                                                                         |   52 
 server/snapshot_infer/training/requirements-train.txt                                                                       |   10 
 server/snapshot_infer/app/main.py                                                                                           |   38 
 server/snapshot_infer/data/annotations.jsonl                                                                                |    4 
 server/snapshot_infer/app/__init__.py                                                                                       |    0 
 server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java                                                     |    2 
 server/db/business.delivery_media_snapshot_feedback.sql                                                                     |   13 
 server/db/business.collection_media.snapshot.alter.sql                                                                      |    5 
 server/snapshot_infer/app/frame_sampler.py                                                                                  |   45 
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java |   67 
 server/snapshot_infer/data/annotations_poc_sample.jsonl                                                                     |    2 
 server/snapshot_infer/app/quality.py                                                                                        |   54 
 admin/src/api/business/collectionMedia.js                                                                                   |   13 
 server/snapshot_infer/training/train.py                                                                                     |  206 +++
 server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotMapper.java                        |    7 
 server/system_service/src/main/java/com/doumee/core/utils/Constants.java                                                    |    2 
 server/visits/dmvisit_service/src/main/resources/application-dev.yml                                                        |   10 
 server/snapshot_infer/training/export_onnx.py                                                                               |  128 ++
 server/visits/dmvisit_service/src/main/resources/application-pro.yml                                                        |   10 
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferProperties.java               |   40 
 server/snapshot_infer/data/labels.csv                                                                                       |  291 ++++
 server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java                           |    2 
 server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java                              |    8 
 server/db/business.collection_media.sql                                                                                     |    3 
 server/snapshot_infer/docs/annotation_spec.md                                                                               |   58 
 server/snapshot_infer/app/video_io.py                                                                                       |   67 +
 server/snapshot_infer/training/prepare_dataset.py                                                                           |  150 ++
 server/snapshot_infer/app/onnx_infer.py                                                                                     |  100 +
 server/snapshot_infer/docs/deploy_checklist.md                                                                              |   34 
 server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshotFeedback.java                |   31 
 server/snapshot_infer/app/temporal.py                                                                                       |   47 
 server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshot.java                        |   46 
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/DeliverySnapshotService.java                        |   17 
 server/snapshot_infer/tools/benchmark_cpu.py                                                                                |   52 
 server/snapshot_infer/.gitignore                                                                                            |   10 
 server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotFeedbackMapper.java                |    7 
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/CollectionMediaConstants.java            |   29 
 server/snapshot_infer/tools/label_studio_config.xml                                                                         |    9 
 server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/DeliverySnapshotServiceImpl.java    |  396 ++++++
 60 files changed, 3,801 insertions(+), 20 deletions(-)

diff --git a/admin/src/api/business/collectionMedia.js b/admin/src/api/business/collectionMedia.js
index a5bbd01..05bf8e0 100644
--- a/admin/src/api/business/collectionMedia.js
+++ b/admin/src/api/business/collectionMedia.js
@@ -102,3 +102,16 @@
     return new Blob([blob], { type: 'video/mp4' })
   })
 }
+
+export function analyzeMediaSnapshot (id) {
+  return request.post(`/visitsAdmin/cloudService/business/collectionStation/media/snapshot/analyze/${id}`, {})
+}
+
+export function fetchMediaSnapshots (id, cacheBust) {
+  const config = cacheBust ? { params: { _t: cacheBust } } : {}
+  return request.get(`/visitsAdmin/cloudService/business/collectionStation/media/snapshot/${id}`, config)
+}
+
+export function saveManualMediaSnapshot (data) {
+  return request.post('/visitsAdmin/cloudService/business/collectionStation/media/snapshot/manual', data)
+}
diff --git a/admin/src/views/business/collectionMedia.vue b/admin/src/views/business/collectionMedia.vue
index d6ca607..2745fb5 100644
--- a/admin/src/views/business/collectionMedia.vue
+++ b/admin/src/views/business/collectionMedia.vue
@@ -51,19 +51,38 @@
         <el-table-column prop="endTime" label="缁撴潫鏃堕棿" min-width="160" />
         <el-table-column prop="downloadStatus" label="涓嬭浇鐘舵��" width="90">
           <template slot-scope="{row}">
-            <span v-if="row.downloadStatus === 1">宸蹭笅杞�</span>
-            <span v-else-if="row.downloadStatus === 2">澶辫触</span>
-            <span v-else-if="row.downloadStatus === 3">涓嬭浇涓�</span>
-            <span v-else>寰呬笅杞�</span>
+            <span :class="downloadStatusClass(row)">{{ downloadStatusLabel(row) }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="filePathLocal" label="鏈湴璺緞" min-width="160" show-overflow-tooltip />
-        <el-table-column label="鎿嶄綔" width="220" fixed="right">
+        <el-table-column prop="snapshotStatus" label="蹇収" width="90">
           <template slot-scope="{row}">
-            <el-button type="text" v-if="canPreview(row)" @click="handlePreview(row)">棰勮</el-button>
+            <span :class="snapshotStatusClass(row)">{{ snapshotStatusLabel(row) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="filePathLocal" label="鏈湴璺緞" min-width="120">
+          <template slot-scope="{row}">
+            <div v-if="isDownloadedVideo(row)" class="list-video-cell" @click="handlePreview(row)">
+              <video
+                :key="'thumb-' + row.id"
+                :src="buildListVideoSrc(row)"
+                class="list-video-thumb"
+                muted
+                preload="metadata"
+                playsinline
+              />
+            </div>
+            <span v-else class="path-text" :title="row.filePathLocal">{{ row.filePathLocal || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="鎿嶄綔" width="240" fixed="right">
+          <template slot-scope="{row}">
             <el-button type="text" v-if="canDownloadToStation(row)" v-permissions="['business:collectionMedia:download', 'business:collectionStation:sync']"
               @click="handleDownload(row)">涓嬭浇</el-button>
             <el-button type="text" v-if="row.downloadStatus === 1" @click="handleSaveLocal(row)">涓嬭浇鍒版湰鍦�</el-button>
+            <el-button type="text" v-if="row.downloadStatus === 1 && row.snapshotStatus !== 1"
+              @click="handleAnalyzeSnapshot(row)">鐢熸垚蹇収</el-button>
+            <el-button type="text" v-if="row.downloadStatus === 1" @click="openManualCorrect(row)">浜哄伐绾犳</el-button>
+            <el-button type="text" v-if="row.snapshotStatus === 2" @click="handleViewSnapshot(row)">鏌ョ湅蹇収</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -82,6 +101,44 @@
         <div v-else-if="!previewLoading" class="preview-empty">鏆備笉鏀寔璇ョ被鍨嬮瑙�</div>
       </div>
     </el-dialog>
+
+    <el-dialog :title="snapshotTitle" :visible.sync="snapshotVisible" width="720px" append-to-body>
+      <div v-loading="snapshotLoading" class="snapshot-wrap">
+        <div v-if="snapshotList.length" class="snapshot-grid">
+          <div v-for="item in snapshotList" :key="item.id + '-' + snapshotCacheVersion" class="snapshot-item">
+            <div class="snapshot-label">{{ snapshotTypeLabel(item.snapshotType) }}</div>
+            <el-image v-if="item.fileUrlFull" :src="buildSnapshotImageUrl(item.fileUrlFull)" fit="contain"
+              class="snapshot-image" :preview-src-list="[buildSnapshotImageUrl(item.fileUrlFull)]" />
+            <div class="snapshot-meta">
+              <span>鏃跺埢: {{ item.timestampSec }}s</span>
+              <span v-if="item.confidence">缃俊搴�: {{ item.confidence }}</span>
+              <span>鏉ユ簮: {{ item.source || '-' }}</span>
+            </div>
+          </div>
+        </div>
+        <div v-else-if="!snapshotLoading" class="preview-empty">鏆傛棤蹇収锛岃鍏堢偣鍑汇�岀敓鎴愬揩鐓с��</div>
+        <div v-if="snapshotMediaRow" class="snapshot-actions">
+          <el-button type="primary" plain @click="openManualCorrect(snapshotMediaRow)">浜哄伐绾犳鏃跺埢</el-button>
+        </div>
+      </div>
+    </el-dialog>
+
+    <el-dialog title="浜哄伐绾犳蹇収鏃跺埢" :visible.sync="manualCorrectVisible" width="860px" append-to-body
+      @close="closeManualCorrect">
+      <div v-loading="manualCorrectLoading" class="manual-correct-wrap">
+        <video v-if="manualPreviewSrc" ref="manualVideo" :src="manualPreviewSrc" controls preload="metadata"
+          playsinline class="preview-video" @loadedmetadata="onManualVideoLoaded" @timeupdate="onManualTimeUpdate" />
+        <div class="manual-slider">
+          <span>褰撳墠鏃跺埢: {{ manualTimestampSec.toFixed(1) }}s</span>
+          <el-slider v-model="manualTimestampSec" :min="0" :max="manualDurationSec" :step="0.5"
+            @input="seekManualVideo" />
+        </div>
+        <div class="manual-buttons">
+          <el-button type="primary" :loading="manualSaving" @click="saveManualSnapshot(1)">璁句负闂ㄥご鍥�</el-button>
+          <el-button type="success" :loading="manualSaving" @click="saveManualSnapshot(2)">璁句负浜や粯鍥�</el-button>
+        </div>
+      </div>
+    </el-dialog>
   </TableLayout>
 </template>
 
@@ -90,7 +147,16 @@
 import TableLayout from '@/layouts/TableLayout'
 import Pagination from '@/components/common/Pagination'
 import { syncMedia, downloadMedia, batchDownloadMedia, list as fetchStationList } from '@/api/business/collectionStation'
-import { fetchPreviewText, fetchPreviewBlob, fetchMediaFile, ensureMp4Blob, buildPreviewUrl } from '@/api/business/collectionMedia'
+import {
+  fetchPreviewText,
+  fetchPreviewBlob,
+  fetchMediaFile,
+  ensureMp4Blob,
+  buildPreviewUrl,
+  analyzeMediaSnapshot,
+  fetchMediaSnapshots,
+  saveManualMediaSnapshot
+} from '@/api/business/collectionMedia'
 
 export default {
   name: 'CollectionMedia',
@@ -113,7 +179,23 @@
       previewRow: null,
       previewUseDirectUrl: false,
       previewBlobUrl: '',
-      downloadPollTimer: null
+      downloadPollTimer: null,
+      snapshotVisible: false,
+      snapshotLoading: false,
+      snapshotTitle: '閰嶉�佸揩鐓�',
+      snapshotList: [],
+      snapshotPollTimer: null,
+      snapshotMediaRow: null,
+      manualCorrectVisible: false,
+      manualCorrectLoading: false,
+      manualCorrectRow: null,
+      manualPreviewSrc: '',
+      manualBlobUrl: '',
+      manualTimestampSec: 0,
+      manualDurationSec: 600,
+      manualSaving: false,
+      manualSeekFromSlider: false,
+      snapshotCacheVersion: 0
     }
   },
   created () {
@@ -131,7 +213,9 @@
   },
   beforeDestroy () {
     this.revokePreviewUrl()
+    this.revokeManualPreviewUrl()
     this.stopDownloadPoll()
+    this.stopSnapshotPoll()
   },
   methods: {
     loadStations () {
@@ -151,8 +235,11 @@
       if (row.mediaType === 2) return '闊抽'
       return '瑙嗛'
     },
-    canPreview (row) {
-      return row.downloadStatus === 1 && (row.fileUrlFull || row.filePathLocal)
+    isDownloadedVideo (row) {
+      return row.downloadStatus === 1 && row.filePathLocal && this.resolvePreviewMode(row) === 'video'
+    },
+    buildListVideoSrc (row) {
+      return row.fileUrlFull || buildPreviewUrl(row.id)
     },
     canDownloadToStation (row) {
       return row.downloadStatus !== 1 && row.downloadStatus !== 3
@@ -199,6 +286,94 @@
       }).catch(err => {
         this.$message.error(err.message || '涓嬭浇鍒版湰鍦板け璐�')
       })
+    },
+    snapshotTypeLabel (type) {
+      return type === 2 ? '璐у搧浜や粯鍥�' : '鍒板簵闂ㄥご鍥�'
+    },
+    downloadStatusLabel (row) {
+      const map = { 0: '寰呬笅杞�', 1: '宸蹭笅杞�', 2: '澶辫触', 3: '涓嬭浇涓�' }
+      return map[row.downloadStatus] != null ? map[row.downloadStatus] : '寰呬笅杞�'
+    },
+    downloadStatusClass (row) {
+      const map = { 0: 'status-info', 1: 'status-success', 2: 'status-danger', 3: 'status-primary' }
+      return map[row.downloadStatus] || 'status-info'
+    },
+    snapshotStatusLabel (row) {
+      if (row.snapshotStatus === 2) return '宸插畬鎴�'
+      if (row.snapshotStatus === 1) return '鍒嗘瀽涓�'
+      if (row.snapshotStatus === 3) return '澶辫触'
+      if (row.downloadStatus === 1) return '鏈垎鏋�'
+      return '-'
+    },
+    snapshotStatusClass (row) {
+      if (row.snapshotStatus === 2) return 'status-success'
+      if (row.snapshotStatus === 1) return 'status-warning'
+      if (row.snapshotStatus === 3) return 'status-danger'
+      if (row.downloadStatus === 1) return 'status-info'
+      return 'status-muted'
+    },
+    buildSnapshotImageUrl (url) {
+      if (!url) return ''
+      const sep = url.indexOf('?') >= 0 ? '&' : '?'
+      return `${url}${sep}_t=${this.snapshotCacheVersion}`
+    },
+    handleAnalyzeSnapshot (row) {
+      analyzeMediaSnapshot(row.id).then(res => {
+        this.$message.success(res || '宸叉彁浜ゅ揩鐓у垎鏋�')
+        this.search()
+        this.startSnapshotPoll(row.id)
+      })
+    },
+    handleViewSnapshot (row) {
+      this.snapshotMediaRow = row
+      this.snapshotTitle = (row.fileName || '濯掍綋') + ' - 閰嶉�佸揩鐓�'
+      this.snapshotVisible = true
+      this.loadSnapshots(row.id)
+    },
+    loadSnapshots (mediaId) {
+      this.snapshotLoading = true
+      this.snapshotList = []
+      this.snapshotCacheVersion = Date.now()
+      fetchMediaSnapshots(mediaId, this.snapshotCacheVersion).then(list => {
+        this.snapshotList = list || []
+        this.snapshotLoading = false
+      }).catch(err => {
+        this.snapshotLoading = false
+        this.$message.error(err.message || '鍔犺浇蹇収澶辫触')
+      })
+    },
+    startSnapshotPoll (mediaId) {
+      this.stopSnapshotPoll()
+      let count = 0
+      this.snapshotPollTimer = setInterval(() => {
+        count++
+        if (count > 40) {
+          this.stopSnapshotPoll()
+          return
+        }
+        this.api.fetchList({
+          page: this.tableData.pagination.pageIndex,
+          capacity: this.tableData.pagination.pageSize,
+          model: this.searchForm,
+          sorts: this.tableData.sorts
+        }).then(data => {
+          this.tableData.list = data.records
+          this.tableData.pagination.total = data.total
+          const row = (data.records || []).find(item => item.id === mediaId)
+          if (row && row.snapshotStatus !== 1) {
+            this.stopSnapshotPoll()
+            if (row.snapshotStatus === 2 && this.snapshotVisible) {
+              this.loadSnapshots(mediaId)
+            }
+          }
+        }).catch(() => {})
+      }, 3000)
+    },
+    stopSnapshotPoll () {
+      if (this.snapshotPollTimer) {
+        clearInterval(this.snapshotPollTimer)
+        this.snapshotPollTimer = null
+      }
     },
     startDownloadPoll () {
       this.stopDownloadPoll()
@@ -333,6 +508,92 @@
         URL.revokeObjectURL(this.previewBlobUrl)
         this.previewBlobUrl = ''
       }
+    },
+    openManualCorrect (row) {
+      if (row.downloadStatus !== 1) {
+        this.$message.warning('璇峰厛涓嬭浇濯掍綋鏂囦欢')
+        return
+      }
+      this.manualCorrectRow = row
+      this.manualCorrectVisible = true
+      this.manualCorrectLoading = true
+      this.manualTimestampSec = 0
+      this.manualDurationSec = 600
+      this.revokeManualPreviewUrl()
+      fetchPreviewBlob(row.id)
+        .then(blob => ensureMp4Blob(blob))
+        .then(blob => {
+          this.manualBlobUrl = URL.createObjectURL(blob)
+          this.manualPreviewSrc = this.manualBlobUrl
+          this.manualCorrectLoading = false
+        })
+        .catch(err => {
+          this.manualCorrectLoading = false
+          this.$message.error(err.message || '鍔犺浇瑙嗛澶辫触')
+        })
+    },
+    closeManualCorrect () {
+      this.revokeManualPreviewUrl()
+      this.manualPreviewSrc = ''
+      this.manualCorrectRow = null
+    },
+    revokeManualPreviewUrl () {
+      if (this.manualBlobUrl) {
+        URL.revokeObjectURL(this.manualBlobUrl)
+        this.manualBlobUrl = ''
+      }
+    },
+    onManualVideoLoaded () {
+      const video = this.$refs.manualVideo
+      if (video && video.duration && isFinite(video.duration)) {
+        this.manualDurationSec = Math.max(1, Math.floor(video.duration))
+      }
+    },
+    onManualTimeUpdate () {
+      if (this.manualSeekFromSlider) {
+        return
+      }
+      const video = this.$refs.manualVideo
+      if (video) {
+        this.manualTimestampSec = Math.round(video.currentTime * 2) / 2
+      }
+    },
+    seekManualVideo (val) {
+      const video = this.$refs.manualVideo
+      if (!video) {
+        return
+      }
+      this.manualSeekFromSlider = true
+      video.currentTime = val
+      setTimeout(() => {
+        this.manualSeekFromSlider = false
+      }, 200)
+    },
+    saveManualSnapshot (snapshotType) {
+      if (!this.manualCorrectRow) {
+        return
+      }
+      this.manualSaving = true
+      saveManualMediaSnapshot({
+        mediaId: this.manualCorrectRow.id,
+        snapshotType,
+        timestampSec: this.manualTimestampSec
+      }).then(res => {
+        this.manualSaving = false
+        this.$message.success(res || '淇濆瓨鎴愬姛')
+        const mediaId = this.manualCorrectRow.id
+        this.snapshotCacheVersion = Date.now()
+        this.search()
+        if (this.snapshotMediaRow && this.snapshotMediaRow.id === mediaId) {
+          this.snapshotMediaRow = { ...this.snapshotMediaRow, snapshotStatus: 2 }
+        }
+        if (this.snapshotVisible && this.snapshotMediaRow && this.snapshotMediaRow.id === mediaId) {
+          this.loadSnapshots(mediaId)
+        }
+      }).catch(err => {
+        this.manualSaving = false
+        this.$message.error(err.message || '淇濆瓨澶辫触')
+      })
     }
   }
 }
@@ -369,4 +630,91 @@
   color: #909399;
   padding: 40px 0;
 }
+.snapshot-wrap {
+  min-height: 120px;
+}
+.snapshot-grid {
+  display: flex;
+  gap: 16px;
+  flex-wrap: wrap;
+}
+.snapshot-item {
+  flex: 1;
+  min-width: 280px;
+}
+.snapshot-label {
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+.snapshot-image {
+  width: 100%;
+  max-height: 280px;
+  background: #f5f7fa;
+}
+.snapshot-meta {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #606266;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.snapshot-actions {
+  margin-top: 16px;
+  text-align: center;
+}
+.manual-correct-wrap {
+  min-height: 200px;
+}
+.manual-slider {
+  margin-top: 16px;
+}
+.manual-buttons {
+  margin-top: 16px;
+  display: flex;
+  gap: 12px;
+  justify-content: center;
+}
+.list-video-cell {
+  display: inline-block;
+  cursor: pointer;
+  line-height: 0;
+}
+.list-video-thumb {
+  width: 70px;
+  height: 70px;
+  object-fit: cover;
+  background: #000;
+  border-radius: 4px;
+  vertical-align: middle;
+}
+.path-text {
+  display: inline-block;
+  max-width: 160px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.status-success {
+  color: #67c23a;
+  font-weight: 500;
+}
+.status-danger {
+  color: #f56c6c;
+  font-weight: 500;
+}
+.status-warning {
+  color: #e6a23c;
+  font-weight: 500;
+}
+.status-primary {
+  color: #409eff;
+  font-weight: 500;
+}
+.status-info {
+  color: #909399;
+}
+.status-muted {
+  color: #c0c4cc;
+}
 </style>
diff --git a/server/db/business.collection_media.snapshot.alter.sql b/server/db/business.collection_media.snapshot.alter.sql
new file mode 100644
index 0000000..c79c35e
--- /dev/null
+++ b/server/db/business.collection_media.snapshot.alter.sql
@@ -0,0 +1,5 @@
+-- collection_media 澧炲姞蹇収鍒嗘瀽鐘舵��
+ALTER TABLE `collection_media`
+  ADD COLUMN `snapshot_status` int(11) DEFAULT '0' COMMENT '0鏈垎鏋� 1鍒嗘瀽涓� 2瀹屾垚 3澶辫触' AFTER `download_time`,
+  ADD COLUMN `snapshot_time` datetime DEFAULT NULL COMMENT '蹇収鍒嗘瀽瀹屾垚鏃堕棿' AFTER `snapshot_status`,
+  ADD COLUMN `snapshot_message` varchar(512) DEFAULT NULL COMMENT '蹇収鍒嗘瀽澶辫触鍘熷洜' AFTER `snapshot_time`;
diff --git a/server/db/business.collection_media.sql b/server/db/business.collection_media.sql
index 217091c..866575e 100644
--- a/server/db/business.collection_media.sql
+++ b/server/db/business.collection_media.sql
@@ -15,6 +15,9 @@
   `file_path_local` varchar(512) DEFAULT NULL COMMENT 'FTP鏈湴鐩稿璺緞',
   `download_status` int(11) DEFAULT '0' COMMENT '0寰呬笅杞� 1宸蹭笅杞� 2澶辫触 3涓嬭浇涓�',
   `download_time` datetime DEFAULT NULL COMMENT '涓嬭浇瀹屾垚鏃堕棿',
+  `snapshot_status` int(11) DEFAULT '0' COMMENT '0鏈垎鏋� 1鍒嗘瀽涓� 2瀹屾垚 3澶辫触',
+  `snapshot_time` datetime DEFAULT NULL COMMENT '蹇収鍒嗘瀽瀹屾垚鏃堕棿',
+  `snapshot_message` varchar(512) DEFAULT NULL COMMENT '蹇収鍒嗘瀽澶辫触鍘熷洜',
   `create_date` datetime DEFAULT NULL COMMENT '鍒涘缓鏃堕棿',
   `isdeleted` int(11) DEFAULT '0' COMMENT '鏄惁鍒犻櫎0鍚� 1鏄�',
   PRIMARY KEY (`id`),
diff --git a/server/db/business.delivery_media_snapshot.sql b/server/db/business.delivery_media_snapshot.sql
new file mode 100644
index 0000000..da5663d
--- /dev/null
+++ b/server/db/business.delivery_media_snapshot.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS `delivery_media_snapshot` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '涓婚敭',
+  `media_id` int(11) NOT NULL COMMENT 'collection_media.id',
+  `transport_code` varchar(64) DEFAULT NULL COMMENT '杩愬崟鍙�(鍙��)',
+  `snapshot_type` int(11) NOT NULL COMMENT '1闂ㄥご 2浜や粯',
+  `timestamp_sec` decimal(10,2) DEFAULT NULL COMMENT '瑙嗛鍐呯鏁�',
+  `file_path` varchar(512) DEFAULT NULL COMMENT 'FTP鐩稿璺緞',
+  `confidence` decimal(5,4) DEFAULT NULL COMMENT '缃俊搴�',
+  `source` varchar(16) DEFAULT NULL COMMENT 'ai/asr/hybrid/voice/manual/mock',
+  `model_version` varchar(32) DEFAULT NULL COMMENT '妯″瀷鐗堟湰',
+  `create_date` datetime DEFAULT NULL COMMENT '鍒涘缓鏃堕棿',
+  `isdeleted` int(11) DEFAULT '0' COMMENT '0鍚� 1鏄�',
+  PRIMARY KEY (`id`),
+  KEY `idx_media_id` (`media_id`),
+  KEY `idx_transport_code` (`transport_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='閰嶉�佸獟浣撳揩鐓�(闂ㄥご/浜や粯)';
diff --git a/server/db/business.delivery_media_snapshot_feedback.sql b/server/db/business.delivery_media_snapshot_feedback.sql
new file mode 100644
index 0000000..37a4dad
--- /dev/null
+++ b/server/db/business.delivery_media_snapshot_feedback.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS `delivery_media_snapshot_feedback` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '涓婚敭',
+  `media_id` int(11) NOT NULL COMMENT 'collection_media.id',
+  `snapshot_type` int(11) NOT NULL COMMENT '1闂ㄥご 2浜や粯',
+  `ai_time_sec` decimal(10,2) DEFAULT NULL COMMENT 'AI鍘熸椂鍒�',
+  `manual_time_sec` decimal(10,2) NOT NULL COMMENT '浜哄伐绾犳鏃跺埢',
+  `model_version` varchar(32) DEFAULT NULL COMMENT '绾犳鏃舵ā鍨嬬増鏈�',
+  `create_date` datetime DEFAULT NULL COMMENT '鍒涘缓鏃堕棿',
+  `isdeleted` int(11) DEFAULT '0' COMMENT '0鍚� 1鏄�',
+  PRIMARY KEY (`id`),
+  KEY `idx_media_id` (`media_id`),
+  KEY `idx_create_date` (`create_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蹇収浜哄伐绾犳鍙嶉(鐢ㄤ簬澧為噺璁粌)';
diff --git a/server/snapshot_infer/.gitignore b/server/snapshot_infer/.gitignore
new file mode 100644
index 0000000..a917ec8
--- /dev/null
+++ b/server/snapshot_infer/.gitignore
@@ -0,0 +1,10 @@
+models/*.onnx
+models/*.pt
+models/*.pth
+data/frames/
+data/raw/
+__pycache__/
+*.pyc
+.venv/
+venv/
+*.whl
diff --git a/server/snapshot_infer/README.md b/server/snapshot_infer/README.md
new file mode 100644
index 0000000..b4737f9
--- /dev/null
+++ b/server/snapshot_infer/README.md
@@ -0,0 +1,127 @@
+# snapshot-infer
+
+閰嶉�佷换鍔¤棰� **闂ㄥご / 浜や粯** 鏃跺埢妫�娴嬫帹鐞嗘湇鍔★紙ONNX 瑙嗚 + faster-whisper ASR 铻嶅悎锛夈��
+
+## 鏋舵瀯
+
+1. Java `dmvisit_service` 涓嬭浇 MP4 鍒� FTP 鍚庯紝寮傛璋冪敤 `POST /analyze`
+2. 鏈湇鍔′笅杞借棰� 鈫� 骞惰 **鎶藉抚 ONNX 鎵撳垎** + **ASR 鍏抽敭璇�** 鈫� 铻嶅悎鏃跺埢
+3. Java 渚� FFmpeg 鎴抚骞朵笂浼� FTP锛坄delivery_media_snapshot` 琛級
+
+## 鐜瑕佹眰
+
+- Python 3.9+锛圵indows 鐢� `py` 鍚姩鍣級
+- **ffmpeg / ffprobe** 鍦� PATH 鎴栬缃� `FFMPEG_DIR`
+- `models/` 鐩綍涓嬫斁缃缁冨鍑虹殑 ONNX锛�
+  - `storefront_int8.onnx` / `handover_int8.onnx`锛堟垨 float 鐗堬級
+  - `version.json`
+
+## 瀹夎涓庡惎鍔紙Windows CPU锛�
+
+```powershell
+cd server/snapshot_infer
+py -m pip install -r requirements.txt
+# 璁粌渚濊禆锛堜粎璁粌鏈猴級
+# py -m pip install -r training/requirements-train.txt
+
+$env:SNAPSHOT_MODEL_DIR = "./models"
+$env:WHISPER_MODEL = "tiny"
+$env:SNAPSHOT_SAMPLE_FPS = "0.5"
+py -m uvicorn app.main:app --host 0.0.0.0 --port 8095
+```
+
+Linux:
+
+```bash
+cd server/snapshot_infer
+python3 -m pip install -r requirements.txt
+export SNAPSHOT_MODEL_DIR=./models WHISPER_MODEL=tiny SNAPSHOT_SAMPLE_FPS=0.5
+python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8095
+```
+
+## API
+
+| 鏂规硶 | 璺緞 | 璇存槑 |
+|------|------|------|
+| GET | `/health` | 杩斿洖 `status`, `modelVersion`, ONNX/ASR 鍔犺浇鐘舵�� |
+| POST | `/analyze` | 涓绘帹鐞嗭紙璇锋眰/鍝嶅簲 camelCase锛屼笌 Java DTO 瀵归綈锛� |
+
+### `/health` 绀轰緥
+
+```json
+{
+  "status": "ok",
+  "modelVersion": "1.0.0",
+  "onnxStorefrontLoaded": true,
+  "onnxHandoverLoaded": true,
+  "asrAvailable": true
+}
+```
+
+`status=degraded` 琛ㄧず ONNX 鏈姞杞藉畬鏁达紝闇�閮ㄧ讲妯″瀷鏂囦欢銆�
+
+## dmvisit 閰嶇疆
+
+`application-dev.yml` / `application-pro.yml`:
+
+```yaml
+snapshot:
+  infer:
+    base-url: http://127.0.0.1:8095
+    sample-fps: 0.5
+    fail-open-mock: false   # 鐢熶骇蹇呴』涓� false
+```
+
+- **dev** 鍙 `fail-open-mock: true` 鍦ㄦ棤 ONNX 鏃跺洖閫� 25%/75% mock
+- **pro** 宸查粯璁� `fail-open-mock: false`锛屾帹鐞嗗け璐ュ啓鍏� `snapshot_status=3`
+
+## 璁粌娴佺▼
+
+璇﹁ [`docs/annotation_spec.md`](docs/annotation_spec.md)銆�
+
+```powershell
+cd server/snapshot_infer/training
+py -m pip install -r requirements-train.txt
+
+# 1. 鍑嗗鏍囨敞 JSONL -> data/annotations.jsonl
+# 2. 鎶藉抚鎵撴爣
+py prepare_dataset.py -c config.yaml
+
+# 3. 璁粌
+py train.py -c config.yaml
+
+# 4. 瀵煎嚭 ONNX锛圵indows 鑻� INT8 閲忓寲宕╂簝锛屼細鑷姩浣跨敤 float .onnx锛�
+py export_onnx.py -c config.yaml --version 1.0.0
+# 鎴栬烦杩囬噺鍖栵細py export_onnx.py -c config.yaml --version 1.0.0 --no-quantize
+
+# 5. 璇勪及
+py evaluate.py -c config.yaml --annotations ../data/annotations.jsonl
+```
+
+### Windows 鏃犳硶鐢熸垚 `*_int8.onnx`
+
+閮ㄥ垎 Windows 鐜涓� `onnxruntime.quantization` 浼氳繘绋嬪穿婧冿紙exit `3221225477`锛夈��**涓嶅奖鍝嶄娇鐢�**锛�
+
+- 瀵煎嚭鑴氭湰浼氳嚜鍔ㄥ洖閫�鍒� `storefront.onnx` / `handover.onnx`锛坒loat32锛�
+- `version.json` 浼氳褰曞疄闄呮枃浠跺悕
+- 鎺ㄧ悊鏈嶅姟鍚屾牱鍙姞杞� float 妯″瀷锛屼粎閫熷害鐣ユ參浜� INT8
+
+鑻ュ繀椤� INT8锛屽彲鍦� Linux 璁粌鏈烘墽琛� `export_onnx.py`锛屾垨灏濊瘯 `py -m pip install onnxruntime==1.16.3` 鍚庨噸璇曘��
+
+## 宸ュ叿
+
+| 鑴氭湰 | 鐢ㄩ�� |
+|------|------|
+| `tools/export_media_list.py --mysql` | 浠� DB 瀵煎嚭寰呮爣娉ㄦ竻鍗� |
+| `tools/export_feedback.py` | 瀵煎嚭浜哄伐绾犳鍙嶉涓鸿缁� JSONL |
+| `tools/benchmark_cpu.py <video_url>` | CPU 鍗曟潯鍘嬫祴 |
+
+## 鐩戞帶
+
+- 鍚姩鍚庤闂� `GET http://127.0.0.1:8095/health`
+- `onnxStorefrontLoaded=false` 鈫� 妫�鏌� `models/` 璺緞涓庣幆澧冨彉閲� `SNAPSHOT_MODEL_DIR`
+- 鍒嗘瀽澶辫触鍘熷洜瑙� `collection_media.snapshot_message`
+
+## 鏁版嵁搴擄紙闇�鎵ц SQL锛�
+
+- `server/db/business.delivery_media_snapshot_feedback.sql` 鈥� 浜哄伐绾犳鍙嶉琛�
diff --git a/server/snapshot_infer/app/__init__.py b/server/snapshot_infer/app/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/snapshot_infer/app/__init__.py
diff --git a/server/snapshot_infer/app/asr.py b/server/snapshot_infer/app/asr.py
new file mode 100644
index 0000000..c0ec6e9
--- /dev/null
+++ b/server/snapshot_infer/app/asr.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+import logging
+import os
+import subprocess
+import tempfile
+from typing import List, Optional, Tuple
+
+from app.schemas import AsrHit, KeywordConfig
+from app.video_io import get_ffmpeg_cmd
+
+logger = logging.getLogger(__name__)
+
+_whisper_model = None
+
+
+def _get_whisper():
+    global _whisper_model
+    if _whisper_model is None:
+        from faster_whisper import WhisperModel
+        model_size = os.environ.get("WHISPER_MODEL", "tiny")
+        _whisper_model = WhisperModel(model_size, device="cpu", compute_type="int8")
+        logger.info("鍔犺浇 Whisper 妯″瀷: %s", model_size)
+    return _whisper_model
+
+
+def extract_audio_wav(video_path: str) -> str:
+    out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
+    out.close()
+    cmd = [
+        get_ffmpeg_cmd("ffmpeg"), "-y", "-i", video_path,
+        "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
+        out.name,
+    ]
+    subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+    return out.name
+
+
+def transcribe(video_path: str) -> List[Tuple[str, float, float]]:
+    wav = extract_audio_wav(video_path)
+    try:
+        model = _get_whisper()
+        segments, _ = model.transcribe(wav, language="zh", vad_filter=True)
+        return [(seg.text.strip(), seg.start, seg.end) for seg in segments if seg.text.strip()]
+    except Exception as e:
+        logger.warning("ASR 澶辫触: %s", e)
+        return []
+    finally:
+        if os.path.isfile(wav):
+            os.remove(wav)
+
+
+def match_keywords(
+    segments: List[Tuple[str, float, float]],
+    keywords: KeywordConfig,
+) -> List[AsrHit]:
+    hits: List[AsrHit] = []
+    for text, start, _end in segments:
+        for kw in keywords.storefront:
+            if kw in text:
+                hits.append(AsrHit(keyword=kw, time_sec=round(start, 2)))
+                break
+        for kw in keywords.handover:
+            if kw in text:
+                hits.append(AsrHit(keyword=kw, time_sec=round(start, 2)))
+                break
+    return hits
+
+
+def best_asr_time(hits: List[AsrHit], keywords: List[str]) -> Optional[float]:
+    for hit in hits:
+        if hit.keyword in keywords:
+            return hit.time_sec
+    return None
+
+
+def asr_available() -> bool:
+    try:
+        import faster_whisper  # noqa: F401
+        return True
+    except ImportError:
+        return False
diff --git a/server/snapshot_infer/app/frame_sampler.py b/server/snapshot_infer/app/frame_sampler.py
new file mode 100644
index 0000000..a0c33ba
--- /dev/null
+++ b/server/snapshot_infer/app/frame_sampler.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+import logging
+import os
+import subprocess
+import tempfile
+from typing import List, Tuple
+
+from app.video_io import get_ffmpeg_cmd
+
+logger = logging.getLogger(__name__)
+
+
+def sample_frames(video_path: str, sample_fps: float, duration: float) -> List[Tuple[float, str]]:
+    """鎸� sample_fps 鎶藉抚锛岃繑鍥� [(time_sec, jpg_path), ...]"""
+    if sample_fps <= 0:
+        sample_fps = 0.5
+    step = 1.0 / sample_fps
+    out_dir = tempfile.mkdtemp(prefix="snap_frames_")
+    frames: List[Tuple[float, str]] = []
+    t = 0.0
+    idx = 0
+    ffmpeg = get_ffmpeg_cmd("ffmpeg")
+    while t <= duration:
+        out_path = os.path.join(out_dir, f"{idx:06d}.jpg")
+        cmd = [
+            ffmpeg, "-y", "-ss", f"{t:.3f}",
+            "-i", video_path,
+            "-frames:v", "1", "-q:v", "2",
+            out_path,
+        ]
+        print(f"cmd{cmd}")
+        subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+        if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
+            frames.append((round(t, 3), out_path))
+        t += step
+        idx += 1
+    return frames
+
+
+def cleanup_frames(frames: List[Tuple[float, str]]) -> None:
+    if not frames:
+        return
+    out_dir = os.path.dirname(frames[0][1])
+    import shutil
+    shutil.rmtree(out_dir, ignore_errors=True)
diff --git a/server/snapshot_infer/app/fusion.py b/server/snapshot_infer/app/fusion.py
new file mode 100644
index 0000000..64b4dd4
--- /dev/null
+++ b/server/snapshot_infer/app/fusion.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+from typing import List, Optional, Tuple
+
+from app.schemas import AsrHit, KeywordConfig, SnapshotHit
+
+
+def fuse_hit(
+    vision: Optional[Tuple[float, float]],
+    asr_time: Optional[float],
+    asr_weight: float = 0.7,
+    window: float = 2.0,
+) -> Optional[SnapshotHit]:
+    if vision and asr_time is not None:
+        vt, vc = vision
+        if abs(vt - asr_time) <= window * 3:
+            t = round(asr_weight * asr_time + (1 - asr_weight) * vt, 2)
+            conf = min(0.99, vc + 0.1)
+            return SnapshotHit(time_sec=t, confidence=round(conf, 4), source="hybrid")
+        t = round(asr_weight * asr_time + (1 - asr_weight) * vt, 2)
+        return SnapshotHit(time_sec=t, confidence=round(vc, 4), source="hybrid")
+    if asr_time is not None:
+        return SnapshotHit(time_sec=round(asr_time, 2), confidence=0.75, source="asr")
+    if vision:
+        return SnapshotHit(time_sec=round(vision[0], 2), confidence=round(vision[1], 4), source="ai")
+    return None
+
+
+def fuse_results(
+    sf_vision: Optional[Tuple[float, float]],
+    ho_vision: Optional[Tuple[float, float]],
+    asr_hits: List[AsrHit],
+    keywords: KeywordConfig,
+    duration: float,
+) -> Tuple[Optional[SnapshotHit], Optional[SnapshotHit]]:
+    from app.asr import best_asr_time
+
+    sf_asr = best_asr_time(asr_hits, keywords.storefront)
+    ho_asr = best_asr_time(asr_hits, keywords.handover)
+
+    storefront = fuse_hit(sf_vision, sf_asr)
+    min_ho = (storefront.time_sec + 30.0) if storefront else 0.0
+    if ho_vision and ho_vision[0] < min_ho:
+        ho_vision = None
+    if ho_asr is not None and ho_asr < min_ho:
+        ho_asr = None
+
+    handover = fuse_hit(ho_vision, ho_asr)
+
+    if storefront and handover and handover.time_sec <= storefront.time_sec:
+        handover.time_sec = round(min(duration - 1, storefront.time_sec + 60), 2)
+
+    return storefront, handover
diff --git a/server/snapshot_infer/app/main.py b/server/snapshot_infer/app/main.py
new file mode 100644
index 0000000..fb3ecfa
--- /dev/null
+++ b/server/snapshot_infer/app/main.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+"""閰嶉�佽棰戦棬澶�/浜や粯鏃跺埢妫�娴嬫帹鐞嗘湇鍔★紙ONNX + ASR锛夈��"""
+import logging
+import os
+
+from fastapi import FastAPI
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+
+from app.asr import asr_available
+from app.pipeline import get_registry, run_analyze
+from app.schemas import AnalyzeRequest, HealthResponse
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
+
+app = FastAPI(title="snapshot-infer", version="2.0.0")
+
+
+def _camel_json(model: BaseModel) -> JSONResponse:
+    return JSONResponse(content=model.model_dump(by_alias=True, exclude_none=True))
+
+
+@app.get("/health")
+def health():
+    reg = get_registry()
+    body = HealthResponse(
+        status="ok" if reg.ready else "degraded",
+        model_version=reg.version,
+        onnx_storefront_loaded=reg.storefront.loaded if reg.storefront else False,
+        onnx_handover_loaded=reg.handover.loaded if reg.handover else False,
+        asr_available=asr_available(),
+    )
+    return _camel_json(body)
+
+
+@app.post("/analyze")
+def analyze(req: AnalyzeRequest):
+    return _camel_json(run_analyze(req))
diff --git a/server/snapshot_infer/app/onnx_infer.py b/server/snapshot_infer/app/onnx_infer.py
new file mode 100644
index 0000000..a63d5f3
--- /dev/null
+++ b/server/snapshot_infer/app/onnx_infer.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+import json
+import logging
+import os
+from typing import List, Optional, Tuple
+
+import numpy as np
+import onnxruntime as ort
+from PIL import Image
+
+logger = logging.getLogger(__name__)
+
+IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
+IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
+
+
+class OnnxClassifier:
+    def __init__(self, model_path: str, image_size: int = 224):
+        self.image_size = image_size
+        self.session: Optional[ort.InferenceSession] = None
+        if model_path and os.path.isfile(model_path):
+            self.session = ort.InferenceSession(
+                model_path,
+                providers=["CPUExecutionProvider"],
+            )
+            logger.info("鍔犺浇 ONNX: %s", model_path)
+
+    @property
+    def loaded(self) -> bool:
+        return self.session is not None
+
+    def preprocess(self, image_path: str) -> np.ndarray:
+        img = Image.open(image_path).convert("RGB").resize((self.image_size, self.image_size))
+        arr = np.array(img).astype(np.float32) / 255.0
+        arr = (arr - IMAGENET_MEAN) / IMAGENET_STD
+        return arr.transpose(2, 0, 1)[None]
+
+    def predict_batch(self, image_paths: List[str], batch_size: int = 16) -> List[float]:
+        if not self.session:
+            return [0.0] * len(image_paths)
+        scores = []
+        for i in range(0, len(image_paths), batch_size):
+            batch_paths = image_paths[i : i + batch_size]
+            batch = np.concatenate([self.preprocess(p) for p in batch_paths], axis=0)
+            logits = self.session.run(None, {"input": batch})[0]
+            for logit in logits:
+                scores.append(float(1.0 / (1.0 + np.exp(-logit[0]))))
+        return scores
+
+
+class ModelRegistry:
+    def __init__(self, model_dir: str):
+        self.model_dir = model_dir
+        self.version = "unknown"
+        self.image_size = 224
+        self.storefront: Optional[OnnxClassifier] = None
+        self.handover: Optional[OnnxClassifier] = None
+        self._load()
+
+    def _load(self):
+        version_path = os.path.join(self.model_dir, "version.json")
+        sf_name = ho_name = None
+        if os.path.isfile(version_path):
+            with open(version_path, encoding="utf-8") as f:
+                meta = json.load(f)
+            self.version = meta.get("model_version", "1.0.0")
+            self.image_size = int(meta.get("image_size", 224))
+            sf_name = meta.get("storefront_model")
+            ho_name = meta.get("handover_model")
+
+        def resolve(name: str, fallback: str) -> str:
+            if name:
+                path = os.path.join(self.model_dir, name)
+                if os.path.isfile(path):
+                    return path
+            for suffix in (f"{fallback}_int8.onnx", f"{fallback}.onnx"):
+                path = os.path.join(self.model_dir, suffix)
+                if os.path.isfile(path):
+                    return path
+            return ""
+
+        sf_path = resolve(sf_name, "storefront")
+        ho_path = resolve(ho_name, "handover")
+        self.storefront = OnnxClassifier(sf_path, self.image_size) if sf_path else OnnxClassifier("", self.image_size)
+        self.handover = OnnxClassifier(ho_path, self.image_size) if ho_path else OnnxClassifier("", self.image_size)
+
+    @property
+    def ready(self) -> bool:
+        return self.storefront.loaded and self.handover.loaded
+
+
+def score_frames(
+    registry: ModelRegistry,
+    frames: List[Tuple[float, str]],
+) -> Tuple[List[Tuple[float, float]], List[Tuple[float, float]]]:
+    times = [f[0] for f in frames]
+    paths = [f[1] for f in frames]
+    sf_scores = registry.storefront.predict_batch(paths)
+    ho_scores = registry.handover.predict_batch(paths)
+    return list(zip(times, sf_scores)), list(zip(times, ho_scores))
diff --git a/server/snapshot_infer/app/pipeline.py b/server/snapshot_infer/app/pipeline.py
new file mode 100644
index 0000000..ea0bb9c
--- /dev/null
+++ b/server/snapshot_infer/app/pipeline.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+import logging
+import os
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+from app.asr import asr_available, match_keywords, transcribe
+from app.frame_sampler import cleanup_frames, sample_frames
+from app.fusion import fuse_results
+from app.onnx_infer import ModelRegistry, score_frames
+from app.quality import refine_time_in_window
+from app.schemas import AnalyzeRequest, AnalyzeResponse, KeywordConfig
+from app.temporal import find_peaks_ordered
+from app.video_io import temp_video
+
+logger = logging.getLogger(__name__)
+
+_registry: ModelRegistry = None
+
+
+def get_registry() -> ModelRegistry:
+    global _registry
+    if _registry is None:
+        model_dir = os.environ.get("SNAPSHOT_MODEL_DIR", os.path.join(os.path.dirname(__file__), "..", "models"))
+        _registry = ModelRegistry(os.path.abspath(model_dir))
+    return _registry
+
+
+def run_analyze(req: AnalyzeRequest) -> AnalyzeResponse:
+    registry = get_registry()
+    if not registry.ready:
+        return AnalyzeResponse(
+            success=False,
+            model_version=registry.version,
+            message="ONNX 妯″瀷鏈姞杞斤紝璇峰皢 storefront/handover ONNX 鏀惧叆 models/ 鐩綍",
+        )
+    if not req.video_url:
+        return AnalyzeResponse(success=False, model_version=registry.version, message="video_url 涓嶈兘涓虹┖")
+
+    keywords = req.keywords or KeywordConfig()
+    sample_fps = req.sample_fps if req.sample_fps and req.sample_fps > 0 else float(os.environ.get("SNAPSHOT_SAMPLE_FPS", "0.5"))
+
+    try:
+        with temp_video(req.video_url, req.duration_sec or 0.0) as (video_path, duration):
+            if req.duration_sec and req.duration_sec > 0:
+                duration = req.duration_sec
+
+            asr_hits = []
+            sf_vision = ho_vision = None
+
+            def vision_task():
+                frames = sample_frames(video_path, sample_fps, duration)
+                try:
+                    sf_scores, ho_scores = score_frames(registry, frames)
+                    return find_peaks_ordered(sf_scores, ho_scores, duration), frames
+                finally:
+                    cleanup_frames(frames)
+
+            def asr_task():
+                if not req.enable_asr or not asr_available():
+                    return []
+                segments = transcribe(video_path)
+                return match_keywords(segments, keywords)
+
+            with ThreadPoolExecutor(max_workers=2) as pool:
+                futures = {pool.submit(vision_task): "vision"}
+                if req.enable_asr:
+                    futures[pool.submit(asr_task)] = "asr"
+                vision_result = None
+                for fut in as_completed(futures):
+                    if futures[fut] == "vision":
+                        vision_result, _ = fut.result()
+                    else:
+                        asr_hits = fut.result()
+
+            if vision_result:
+                sf_vision, ho_vision = vision_result
+
+            storefront, handover = fuse_results(sf_vision, ho_vision, asr_hits, keywords, duration)
+
+            if storefront:
+                t, _ = refine_time_in_window(video_path, storefront.time_sec)
+                storefront.time_sec = t
+            if handover:
+                t, _ = refine_time_in_window(video_path, handover.time_sec)
+                handover.time_sec = t
+
+            if not storefront or not handover:
+                return AnalyzeResponse(
+                    success=False,
+                    model_version=registry.version,
+                    duration_sec=duration,
+                    storefront=storefront,
+                    handover=handover,
+                    asr_hits=asr_hits,
+                    message="鏈兘妫�娴嬪埌闂ㄥご鎴栦氦浠樻椂鍒�",
+                )
+
+            return AnalyzeResponse(
+                success=True,
+                model_version=registry.version,
+                duration_sec=round(duration, 2),
+                storefront=storefront,
+                handover=handover,
+                asr_hits=asr_hits,
+            )
+    except Exception as e:
+        logger.exception("鍒嗘瀽澶辫触 media_id=%s", req.media_id)
+        return AnalyzeResponse(
+            success=False,
+            model_version=registry.version,
+            message=str(e),
+        )
diff --git a/server/snapshot_infer/app/quality.py b/server/snapshot_infer/app/quality.py
new file mode 100644
index 0000000..9d81365
--- /dev/null
+++ b/server/snapshot_infer/app/quality.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+import logging
+import subprocess
+from typing import Optional, Tuple
+
+import cv2
+import numpy as np
+
+from app.video_io import get_ffmpeg_cmd
+
+logger = logging.getLogger(__name__)
+
+
+def laplacian_score(image_path: str) -> float:
+    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
+    if img is None:
+        return 0.0
+    return float(cv2.Laplacian(img, cv2.CV_64F).var())
+
+
+def refine_time_in_window(
+    video_path: str,
+    center_sec: float,
+    window_sec: float = 2.0,
+    step: float = 0.5,
+) -> Tuple[float, float]:
+    """鍦� [center-window, center+window] 鍐呴�夋竻鏅板害鏈�楂樺抚锛岃繑鍥� (best_sec, score)銆�"""
+    import os
+    import tempfile
+
+    best_t = center_sec
+    best_score = 0.0
+    ffmpeg = get_ffmpeg_cmd("ffmpeg")
+    t = max(0.0, center_sec - window_sec)
+    end = center_sec + window_sec
+    tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
+    tmp.close()
+    try:
+        while t <= end:
+            cmd = [
+                ffmpeg, "-y", "-ss", f"{t:.3f}", "-i", video_path,
+                "-frames:v", "1", "-q:v", "2", tmp.name,
+            ]
+            subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+            if os.path.isfile(tmp.name) and os.path.getsize(tmp.name) > 0:
+                score = laplacian_score(tmp.name)
+                if score > best_score:
+                    best_score = score
+                    best_t = t
+            t += step
+    finally:
+        if os.path.isfile(tmp.name):
+            os.remove(tmp.name)
+    return round(best_t, 2), best_score
diff --git a/server/snapshot_infer/app/schemas.py b/server/snapshot_infer/app/schemas.py
new file mode 100644
index 0000000..7f27b9d
--- /dev/null
+++ b/server/snapshot_infer/app/schemas.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class KeywordConfig(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    storefront: List[str] = Field(default_factory=lambda: ["鍒板簵", "鍒拌揪", "闂ㄥご"])
+    handover: List[str] = Field(default_factory=lambda: ["浜や粯", "浜よ揣", "绛炬敹"])
+
+
+class AnalyzeRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    media_id: int = Field(alias="mediaId")
+    video_url: Optional[str] = Field(default=None, alias="videoUrl")
+    sample_fps: float = Field(default=0.5, alias="sampleFps")
+    enable_asr: bool = Field(default=True, alias="enableAsr")
+    keywords: Optional[KeywordConfig] = None
+    duration_sec: Optional[float] = Field(default=None, alias="durationSec")
+
+
+class SnapshotHit(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    time_sec: float = Field(alias="timeSec")
+    confidence: float
+    source: str
+
+
+class AsrHit(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    keyword: str
+    time_sec: float = Field(alias="timeSec")
+
+
+class AnalyzeResponse(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    success: bool = True
+    model_version: str = Field(default="1.0.0", alias="modelVersion")
+    duration_sec: float = Field(default=0.0, alias="durationSec")
+    storefront: Optional[SnapshotHit] = None
+    handover: Optional[SnapshotHit] = None
+    asr_hits: List[AsrHit] = Field(default_factory=list, alias="asrHits")
+    message: Optional[str] = None
+
+
+class HealthResponse(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    status: str
+    model_version: str = Field(alias="modelVersion")
+    onnx_storefront_loaded: bool = Field(default=False, alias="onnxStorefrontLoaded")
+    onnx_handover_loaded: bool = Field(default=False, alias="onnxHandoverLoaded")
+    asr_available: bool = Field(default=False, alias="asrAvailable")
diff --git a/server/snapshot_infer/app/temporal.py b/server/snapshot_infer/app/temporal.py
new file mode 100644
index 0000000..2dc53f0
--- /dev/null
+++ b/server/snapshot_infer/app/temporal.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+from typing import List, Optional, Tuple
+
+
+def smooth_scores(time_scores: List[Tuple[float, float]], window: int = 3) -> List[Tuple[float, float]]:
+    if len(time_scores) <= 1 or window <= 1:
+        return time_scores
+    half = window // 2
+    smoothed = []
+    for i, (t, _) in enumerate(time_scores):
+        lo = max(0, i - half)
+        hi = min(len(time_scores), i + half + 1)
+        avg = sum(s for _, s in time_scores[lo:hi]) / (hi - lo)
+        smoothed.append((t, avg))
+    return smoothed
+
+
+def find_peak(
+    time_scores: List[Tuple[float, float]],
+    min_time: float = 0.0,
+    min_confidence: float = 0.3,
+) -> Optional[Tuple[float, float]]:
+    if not time_scores:
+        return None
+    candidates = [(t, s) for t, s in time_scores if t >= min_time and s >= min_confidence]
+    if not candidates:
+        candidates = time_scores
+    best = max(candidates, key=lambda x: x[1])
+    if best[1] < 0.1:
+        return None
+    return best
+
+
+def find_peaks_ordered(
+    sf_scores: List[Tuple[float, float]],
+    ho_scores: List[Tuple[float, float]],
+    duration: float,
+    min_gap: float = 30.0,
+) -> Tuple[Optional[Tuple[float, float]], Optional[Tuple[float, float]]]:
+    sf = find_peak(smooth_scores(sf_scores))
+    min_ho = (sf[0] + min_gap) if sf else 0.0
+    ho = find_peak(smooth_scores(ho_scores), min_time=min_ho)
+    if sf and ho and ho[0] <= sf[0]:
+        ho = find_peak(smooth_scores(ho_scores), min_time=sf[0] + 1.0)
+    if sf and not ho and duration > sf[0] + min_gap:
+        ho = find_peak(smooth_scores(ho_scores), min_time=sf[0] + min_gap)
+    return sf, ho
diff --git a/server/snapshot_infer/app/video_io.py b/server/snapshot_infer/app/video_io.py
new file mode 100644
index 0000000..4e38510
--- /dev/null
+++ b/server/snapshot_infer/app/video_io.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+import json
+import logging
+import os
+import shutil
+import subprocess
+import tempfile
+from contextlib import contextmanager
+from typing import Optional
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+def get_ffmpeg_cmd(name: str = "ffmpeg") -> str:
+    ffmpeg_dir = "D:/code/ffmpeg/"
+#     ffmpeg_dir = os.environ.get("FFMPEG_DIR", "")
+    print(f"ffmpeg_dir:{ffmpeg_dir}")
+    if ffmpeg_dir:
+        exe = "ffmpeg.exe" if os.name == "nt" else "ffmpeg"
+        print(f"exe:{exe}")
+        if name == "ffprobe":
+            exe = "ffprobe.exe" if os.name == "nt" else "ffprobe"
+        return os.path.join(ffmpeg_dir, exe)
+    return name
+
+
+def probe_duration(video_path: str) -> float:
+    cmd = [
+        get_ffmpeg_cmd("ffprobe"),
+        "-v", "error",
+        "-show_entries", "format=duration",
+        "-of", "default=noprint_wrappers=1:nokey=1",
+        video_path,
+    ]
+    try:
+        out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip()
+        return float(out) if out else 0.0
+    except Exception as e:
+        logger.warning("ffprobe 澶辫触: %s", e)
+        return 0.0
+
+
+def download_video(url: str, dest_path: str, timeout: float = 600.0) -> None:
+    with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as resp:
+        resp.raise_for_status()
+        with open(dest_path, "wb") as f:
+            for chunk in resp.iter_bytes(chunk_size=65536):
+                f.write(chunk)
+
+
+@contextmanager
+def temp_video(video_url: Optional[str], duration_hint: float = 0.0):
+    tmp_dir = tempfile.mkdtemp(prefix="snap_infer_")
+    video_path = os.path.join(tmp_dir, "video.mp4")
+    try:
+        if video_url:
+            download_video(video_url, video_path)
+        else:
+            raise ValueError("video_url 涓嶈兘涓虹┖")
+        duration = probe_duration(video_path)
+        if duration <= 0:
+            duration = duration_hint if duration_hint > 0 else 1200.0
+        yield video_path, duration
+    finally:
+        shutil.rmtree(tmp_dir, ignore_errors=True)
diff --git a/server/snapshot_infer/data/annotations.jsonl b/server/snapshot_infer/data/annotations.jsonl
new file mode 100644
index 0000000..769dad1
--- /dev/null
+++ b/server/snapshot_infer/data/annotations.jsonl
@@ -0,0 +1,4 @@
+{"media_id": 1, "video_path": "http://192.168.0.3/file/collection_media/20260611/0986a054-ccbe-408b-86fc-b834e9c2f4dd.mp4", "storefront_time_sec": 25.5, "handover_time_sec": 49.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250601", "split": "train", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�1"}
+{"media_id": 2, "video_path": "http://192.168.0.3/file/collection_media/20260611/6808c257-8df7-43f7-b8dc-03789e184929.mp4", "storefront_time_sec": 18.5, "handover_time_sec": 37.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250603", "split": "val", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�3"}
+{"media_id": 3, "video_path": "http://192.168.0.3/file/collection_media/20260611/0986a054-ccbe-408b-86fc-b834e9c2f4dd.mp4", "storefront_time_sec": 25.5, "handover_time_sec": 49.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250602", "split": "val", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�2"}
+{"media_id": 4, "video_path": "http://192.168.0.3/file/collection_media/20260611/6808c257-8df7-43f7-b8dc-03789e184929.mp4", "storefront_time_sec": 18.5, "handover_time_sec": 37.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250603", "split": "train", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�4"}
\ No newline at end of file
diff --git a/server/snapshot_infer/data/annotations_poc_sample.jsonl b/server/snapshot_infer/data/annotations_poc_sample.jsonl
new file mode 100644
index 0000000..6a592ed
--- /dev/null
+++ b/server/snapshot_infer/data/annotations_poc_sample.jsonl
@@ -0,0 +1,2 @@
+{"media_id": 1, "video_path": "http://192.168.0.3/file/collection_media/20260611/6808c257-8df7-43f7-b8dc-03789e184929.mp4", "storefront_time_sec": 25.5, "handover_time_sec": 49.5, "store_type": "鍏徃", "has_voice_marker": true, "driver_date": "demo_20250601", "split": "train", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�1"}
+{"media_id": 2, "video_path": "http://192.168.0.3/file/collection_media/20260611/17911eb5-ee71-421f-af72-bd804d563201.mp4", "storefront_time_sec": 18.5, "handover_time_sec": 37.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250602", "split": "val", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�2"}
diff --git a/server/snapshot_infer/data/labels.csv b/server/snapshot_infer/data/labels.csv
new file mode 100644
index 0000000..ca7d75c
--- /dev/null
+++ b/server/snapshot_infer/data/labels.csv
@@ -0,0 +1,291 @@
+frame_path,label,task,split
+1/21000.jpg,1,storefront,train
+1/22000.jpg,1,storefront,train
+1/23000.jpg,1,storefront,train
+1/24000.jpg,1,storefront,train
+1/25000.jpg,1,storefront,train
+1/26000.jpg,1,storefront,train
+1/27000.jpg,1,storefront,train
+1/28000.jpg,1,storefront,train
+1/29000.jpg,1,storefront,train
+1/30000.jpg,1,storefront,train
+1/45000.jpg,1,handover,train
+1/46000.jpg,1,handover,train
+1/47000.jpg,1,handover,train
+1/48000.jpg,1,handover,train
+1/49000.jpg,1,handover,train
+1/50000.jpg,1,handover,train
+1/51000.jpg,1,handover,train
+1/52000.jpg,1,handover,train
+1/53000.jpg,1,handover,train
+1/54000.jpg,1,handover,train
+1/0.jpg,0,other,train
+1/1000.jpg,0,other,train
+1/2000.jpg,0,other,train
+1/3000.jpg,0,other,train
+1/4000.jpg,0,other,train
+1/5000.jpg,0,other,train
+1/6000.jpg,0,other,train
+1/7000.jpg,0,other,train
+1/8000.jpg,0,other,train
+1/9000.jpg,0,other,train
+1/10000.jpg,0,other,train
+1/11000.jpg,0,other,train
+1/12000.jpg,0,other,train
+1/13000.jpg,0,other,train
+1/14000.jpg,0,other,train
+1/15000.jpg,0,other,train
+1/16000.jpg,0,other,train
+1/17000.jpg,0,other,train
+1/18000.jpg,0,other,train
+1/19000.jpg,0,other,train
+1/20000.jpg,0,other,train
+1/31000.jpg,0,other,train
+1/32000.jpg,0,other,train
+1/33000.jpg,0,other,train
+1/34000.jpg,0,other,train
+1/35000.jpg,0,other,train
+1/36000.jpg,0,other,train
+1/37000.jpg,0,other,train
+1/38000.jpg,0,other,train
+1/39000.jpg,0,other,train
+1/40000.jpg,0,other,train
+1/41000.jpg,0,other,train
+1/42000.jpg,0,other,train
+1/43000.jpg,0,other,train
+1/44000.jpg,0,other,train
+1/55000.jpg,0,other,train
+1/56000.jpg,0,other,train
+1/57000.jpg,0,other,train
+1/58000.jpg,0,other,train
+1/59000.jpg,0,other,train
+1/60000.jpg,0,other,train
+1/61000.jpg,0,other,train
+1/62000.jpg,0,other,train
+1/63000.jpg,0,other,train
+1/64000.jpg,0,other,train
+1/65000.jpg,0,other,train
+1/66000.jpg,0,other,train
+1/67000.jpg,0,other,train
+1/68000.jpg,0,other,train
+1/69000.jpg,0,other,train
+1/70000.jpg,0,other,train
+1/71000.jpg,0,other,train
+1/72000.jpg,0,other,train
+1/73000.jpg,0,other,train
+1/74000.jpg,0,other,train
+1/75000.jpg,0,other,train
+1/76000.jpg,0,other,train
+1/77000.jpg,0,other,train
+1/78000.jpg,0,other,train
+1/79000.jpg,0,other,train
+1/80000.jpg,0,other,train
+1/81000.jpg,0,other,train
+1/82000.jpg,0,other,train
+1/83000.jpg,0,other,train
+1/84000.jpg,0,other,train
+1/85000.jpg,0,other,train
+1/86000.jpg,0,other,train
+1/87000.jpg,0,other,train
+1/88000.jpg,0,other,train
+1/89000.jpg,0,other,train
+1/90000.jpg,0,other,train
+1/91000.jpg,0,other,train
+1/92000.jpg,0,other,train
+1/93000.jpg,0,other,train
+1/94000.jpg,0,other,train
+1/95000.jpg,0,other,train
+1/96000.jpg,0,other,train
+1/97000.jpg,0,other,train
+2/14000.jpg,1,storefront,val
+2/15000.jpg,1,storefront,val
+2/16000.jpg,1,storefront,val
+2/17000.jpg,1,storefront,val
+2/18000.jpg,1,storefront,val
+2/19000.jpg,1,storefront,val
+2/20000.jpg,1,storefront,val
+2/21000.jpg,1,storefront,val
+2/22000.jpg,1,storefront,val
+2/23000.jpg,1,storefront,val
+2/33000.jpg,1,handover,val
+2/34000.jpg,1,handover,val
+2/35000.jpg,1,handover,val
+2/36000.jpg,1,handover,val
+2/37000.jpg,1,handover,val
+2/38000.jpg,1,handover,val
+2/39000.jpg,1,handover,val
+2/40000.jpg,1,handover,val
+2/41000.jpg,1,handover,val
+2/42000.jpg,1,handover,val
+2/0.jpg,0,other,val
+2/1000.jpg,0,other,val
+2/2000.jpg,0,other,val
+2/3000.jpg,0,other,val
+2/4000.jpg,0,other,val
+2/5000.jpg,0,other,val
+2/6000.jpg,0,other,val
+2/7000.jpg,0,other,val
+2/8000.jpg,0,other,val
+2/9000.jpg,0,other,val
+2/10000.jpg,0,other,val
+2/11000.jpg,0,other,val
+2/12000.jpg,0,other,val
+2/13000.jpg,0,other,val
+2/24000.jpg,0,other,val
+2/25000.jpg,0,other,val
+2/26000.jpg,0,other,val
+2/27000.jpg,0,other,val
+2/28000.jpg,0,other,val
+2/29000.jpg,0,other,val
+2/30000.jpg,0,other,val
+2/31000.jpg,0,other,val
+2/32000.jpg,0,other,val
+2/43000.jpg,0,other,val
+2/44000.jpg,0,other,val
+2/45000.jpg,0,other,val
+2/46000.jpg,0,other,val
+3/21000.jpg,1,storefront,val
+3/22000.jpg,1,storefront,val
+3/23000.jpg,1,storefront,val
+3/24000.jpg,1,storefront,val
+3/25000.jpg,1,storefront,val
+3/26000.jpg,1,storefront,val
+3/27000.jpg,1,storefront,val
+3/28000.jpg,1,storefront,val
+3/29000.jpg,1,storefront,val
+3/30000.jpg,1,storefront,val
+3/45000.jpg,1,handover,val
+3/46000.jpg,1,handover,val
+3/47000.jpg,1,handover,val
+3/48000.jpg,1,handover,val
+3/49000.jpg,1,handover,val
+3/50000.jpg,1,handover,val
+3/51000.jpg,1,handover,val
+3/52000.jpg,1,handover,val
+3/53000.jpg,1,handover,val
+3/54000.jpg,1,handover,val
+3/0.jpg,0,other,val
+3/1000.jpg,0,other,val
+3/2000.jpg,0,other,val
+3/3000.jpg,0,other,val
+3/4000.jpg,0,other,val
+3/5000.jpg,0,other,val
+3/6000.jpg,0,other,val
+3/7000.jpg,0,other,val
+3/8000.jpg,0,other,val
+3/9000.jpg,0,other,val
+3/10000.jpg,0,other,val
+3/11000.jpg,0,other,val
+3/12000.jpg,0,other,val
+3/13000.jpg,0,other,val
+3/14000.jpg,0,other,val
+3/15000.jpg,0,other,val
+3/16000.jpg,0,other,val
+3/17000.jpg,0,other,val
+3/18000.jpg,0,other,val
+3/19000.jpg,0,other,val
+3/20000.jpg,0,other,val
+3/31000.jpg,0,other,val
+3/32000.jpg,0,other,val
+3/33000.jpg,0,other,val
+3/34000.jpg,0,other,val
+3/35000.jpg,0,other,val
+3/36000.jpg,0,other,val
+3/37000.jpg,0,other,val
+3/38000.jpg,0,other,val
+3/39000.jpg,0,other,val
+3/40000.jpg,0,other,val
+3/41000.jpg,0,other,val
+3/42000.jpg,0,other,val
+3/43000.jpg,0,other,val
+3/44000.jpg,0,other,val
+3/55000.jpg,0,other,val
+3/56000.jpg,0,other,val
+3/57000.jpg,0,other,val
+3/58000.jpg,0,other,val
+3/59000.jpg,0,other,val
+3/60000.jpg,0,other,val
+3/61000.jpg,0,other,val
+3/62000.jpg,0,other,val
+3/63000.jpg,0,other,val
+3/64000.jpg,0,other,val
+3/65000.jpg,0,other,val
+3/66000.jpg,0,other,val
+3/67000.jpg,0,other,val
+3/68000.jpg,0,other,val
+3/69000.jpg,0,other,val
+3/70000.jpg,0,other,val
+3/71000.jpg,0,other,val
+3/72000.jpg,0,other,val
+3/73000.jpg,0,other,val
+3/74000.jpg,0,other,val
+3/75000.jpg,0,other,val
+3/76000.jpg,0,other,val
+3/77000.jpg,0,other,val
+3/78000.jpg,0,other,val
+3/79000.jpg,0,other,val
+3/80000.jpg,0,other,val
+3/81000.jpg,0,other,val
+3/82000.jpg,0,other,val
+3/83000.jpg,0,other,val
+3/84000.jpg,0,other,val
+3/85000.jpg,0,other,val
+3/86000.jpg,0,other,val
+3/87000.jpg,0,other,val
+3/88000.jpg,0,other,val
+3/89000.jpg,0,other,val
+3/90000.jpg,0,other,val
+3/91000.jpg,0,other,val
+3/92000.jpg,0,other,val
+3/93000.jpg,0,other,val
+3/94000.jpg,0,other,val
+3/95000.jpg,0,other,val
+3/96000.jpg,0,other,val
+3/97000.jpg,0,other,val
+4/14000.jpg,1,storefront,train
+4/15000.jpg,1,storefront,train
+4/16000.jpg,1,storefront,train
+4/17000.jpg,1,storefront,train
+4/18000.jpg,1,storefront,train
+4/19000.jpg,1,storefront,train
+4/20000.jpg,1,storefront,train
+4/21000.jpg,1,storefront,train
+4/22000.jpg,1,storefront,train
+4/23000.jpg,1,storefront,train
+4/33000.jpg,1,handover,train
+4/34000.jpg,1,handover,train
+4/35000.jpg,1,handover,train
+4/36000.jpg,1,handover,train
+4/37000.jpg,1,handover,train
+4/38000.jpg,1,handover,train
+4/39000.jpg,1,handover,train
+4/40000.jpg,1,handover,train
+4/41000.jpg,1,handover,train
+4/42000.jpg,1,handover,train
+4/0.jpg,0,other,train
+4/1000.jpg,0,other,train
+4/2000.jpg,0,other,train
+4/3000.jpg,0,other,train
+4/4000.jpg,0,other,train
+4/5000.jpg,0,other,train
+4/6000.jpg,0,other,train
+4/7000.jpg,0,other,train
+4/8000.jpg,0,other,train
+4/9000.jpg,0,other,train
+4/10000.jpg,0,other,train
+4/11000.jpg,0,other,train
+4/12000.jpg,0,other,train
+4/13000.jpg,0,other,train
+4/24000.jpg,0,other,train
+4/25000.jpg,0,other,train
+4/26000.jpg,0,other,train
+4/27000.jpg,0,other,train
+4/28000.jpg,0,other,train
+4/29000.jpg,0,other,train
+4/30000.jpg,0,other,train
+4/31000.jpg,0,other,train
+4/32000.jpg,0,other,train
+4/43000.jpg,0,other,train
+4/44000.jpg,0,other,train
+4/45000.jpg,0,other,train
+4/46000.jpg,0,other,train
diff --git a/server/snapshot_infer/docs/annotation_spec.md b/server/snapshot_infer/docs/annotation_spec.md
new file mode 100644
index 0000000..536b18d
--- /dev/null
+++ b/server/snapshot_infer/docs/annotation_spec.md
@@ -0,0 +1,58 @@
+# 閰嶉�佽棰戦棬澶�/浜や粯鏃跺埢鏍囨敞瑙勮寖
+
+## 鐩爣
+
+涓� ONNX 甯у垎绫绘ā鍨嬫彁渚涜缁冩爣绛撅細姣忔潯瑙嗛鏍囨敞 **闂ㄥご鏈�浣虫椂鍒�** 涓� **浜や粯鏈�浣虫椂鍒�**锛堝悇 1 涓鏁帮級銆�
+
+## 瀛楁璇存槑
+
+| 瀛楁 | 绫诲瀷 | 蹇呭~ | 璇存槑 |
+|------|------|------|------|
+| `media_id` | int | 鏄� | 瀵瑰簲 `collection_media.id` |
+| `video_path` | string | 鏄� | 鏈湴璺緞鎴� FTP HTTP URL |
+| `storefront_time_sec` | float | 鏄� | 闂ㄥご鏈�浣冲抚鏃跺埢锛堢锛� |
+| `handover_time_sec` | float | 鏄� | 浜や粯鏈�浣冲抚鏃跺埢锛堢锛� |
+| `store_type` | string | 鍚� | 渚垮埄搴�/瓒呭競/椁愰ギ/鍏朵粬 |
+| `has_voice_marker` | bool | 鍚� | 鏄惁鍚�屽埌搴�/浜や粯銆嶈闊� |
+| `recorder_sn` | string | 鍚� | 璁惧 SN |
+| `driver_date` | string | 鍚� | 鍙告満+鏃ユ湡锛岀敤浜� train/val/test 鍒嗙粍 |
+| `split` | string | 鏄� | `train` / `val` / `test` |
+| `notes` | string | 鍚� | badcase 璇存槑 |
+
+## 绫诲埆杈圭晫
+
+- **storefront锛堥棬澶达級**锛氬簵鎷涖�侀棬鐗屻�佸簵閾哄叆鍙d负涓讳綋锛涗汉鍙叆鐢讳絾璐у搧闈炰富浣�
+- **handover锛堜氦浠橈級**锛氳揣鍝�/鍖呰鍦ㄧ敾闈腑蹇冿紝鍙閫掍氦銆佹斁缃�佺鏀跺姩浣�
+- **other锛堣礋鏍锋湰锛�**锛氳杞︺�佷粨搴撱�佸簵鍐呰蛋鍔ㄣ�佺┖闀滅瓑锛堣缁冩椂鑷姩浠庨潪 卤5s 绐楀彛閲囨牱锛�
+
+## 鏍囨敞鎿嶄綔
+
+1. 鎾斁鏁存 MP4锛屾殏鍋滃湪 **鏈�娓呮櫚銆佹瀯鍥炬渶濂�** 鐨勯棬澶寸敾闈紝璁板綍褰撳墠绉掓暟
+2. 缁х画鎾斁锛屽湪 **璐у搧浜ゆ帴鏈�娓呮櫚** 鐨勪竴甯ц褰曠鏁�
+3. 绾︽潫锛歚handover_time_sec > storefront_time_sec`锛堥�氬父鐩稿樊鏁板崄绉掍互涓婏級
+4. 鑻ユ煇鏉¤棰戞棤浜や粯鍦烘櫙锛堜粎鍒板簵锛夛紝鍦� `notes` 鏍囨敞銆屾棤浜や粯銆嶏紝璇ユ潯鏆備笉绾冲叆璁粌
+
+## 瀵煎嚭鏍煎紡锛圝SONL锛�
+
+姣忚涓�鏉� JSON锛�
+
+```json
+{"media_id": 123, "video_path": "http://host/collection_media/20250609/123.mp4", "storefront_time_sec": 742.5, "handover_time_sec": 1085.2, "store_type": "渚垮埄搴�", "has_voice_marker": true, "driver_date": "driver001_20250609", "split": "train"}
+```
+
+## 鏁版嵁鍒掑垎
+
+- train / val / test = **70% / 15% / 15%**
+- 鎸� `driver_date` 鎴� `recorder_sn + 鏃ユ湡` **鍒嗙粍鍒掑垎**锛岄伩鍏嶅悓鍙告満鍚屽ぉ瑙嗛娉勬紡鍒版祴璇曢泦
+
+## 瑙勬ā寤鸿
+
+| 闃舵 | 瑙嗛鏁� |
+|------|--------|
+| POC | 80~100 |
+| 鍐呮祴 | 300+ |
+| 涓婄嚎 | 1000+ |
+
+## Label Studio
+
+瑙� [`label_studio_config.xml`](../tools/label_studio_config.xml)銆傚鍏� `tools/export_media_list.py` 鐢熸垚鐨� CSV 鍚庯紝鏍囨敞涓や釜鏃堕棿鐐瑰苟瀵煎嚭 JSON锛屽啀鐢� `tools/convert_labelstudio.py` 杞负 JSONL銆�
diff --git a/server/snapshot_infer/docs/deploy_checklist.md b/server/snapshot_infer/docs/deploy_checklist.md
new file mode 100644
index 0000000..6f530f0
--- /dev/null
+++ b/server/snapshot_infer/docs/deploy_checklist.md
@@ -0,0 +1,34 @@
+# 閮ㄧ讲涓庤瘎浼版鏌ユ竻鍗�
+
+## 涓婄嚎鍓�
+
+- [ ] 鎵ц `business.delivery_media_snapshot_feedback.sql`
+- [ ] 璁粌骞堕儴缃� `models/storefront_int8.onnx`銆乣models/handover_int8.onnx`銆乣version.json`
+- [ ] 鎺ㄧ悊鏈哄畨瑁� ffmpeg锛屼笖鑳借闂� Java 鏋勯�犵殑 `video_url`锛團TP HTTP 鍓嶇紑锛�
+- [ ] `application-pro.yml` 涓� `snapshot.infer.fail-open-mock: false`
+- [ ] `GET /health` 杩斿洖 `status=ok` 涓斾袱涓� ONNX loaded=true
+
+## 鎬ц兘鐩爣锛�10 鍒嗛挓瑙嗛锛孋PU锛�
+
+| 鎸囨爣 | POC 鐩爣 |
+|------|----------|
+| 鍗曟潯 analyze 鑰楁椂 | < 10 鍒嗛挓 |
+| 闂ㄥご MAE | < 8s |
+| 浜や粯 MAE | < 8s |
+| 椤哄簭姝g‘鐜� | > 95% |
+
+鍘嬫祴鍛戒护锛�
+
+```powershell
+cd server/snapshot_infer
+py tools/benchmark_cpu.py "http://your-ftp-host/collection_media/xxx.mp4" --sample-fps 0.5
+```
+
+## 鏁呴殰鎺掓煡
+
+| 鐜拌薄 | 澶勭悊 |
+|------|------|
+| health degraded | 妫�鏌� ONNX 鏂囦欢鏄惁鍦� SNAPSHOT_MODEL_DIR |
+| analyze 瓒呮椂 | 澧炲ぇ Java `read-timeout-ms`锛涢檷浣� `sample-fps` |
+| ASR 鎱� | 纭 `WHISPER_MODEL=tiny`锛涙垨 `enable-asr: false` 绾瑙� |
+| Java snapshot_status=3 | 鏌ョ湅 `snapshot_message` 涓庢帹鐞嗘湇鍔℃棩蹇� |
diff --git a/server/snapshot_infer/docs/training_troubleshooting.md b/server/snapshot_infer/docs/training_troubleshooting.md
new file mode 100644
index 0000000..ab7e65f
--- /dev/null
+++ b/server/snapshot_infer/docs/training_troubleshooting.md
@@ -0,0 +1,96 @@
+# 璁粌鏁堟灉涓嶄匠 鈥� 鎺掓煡涓庢敼杩涙寚鍗�
+
+## 宸蹭慨澶嶇殑浠g爜闂锛堝繀鍋氾級
+
+姝ゅ墠 `train.py` **鍙鍙� `task=storefront/handover` 涓� `label=1` 鐨勮**锛宍task=other` 鐨勮礋鏍锋湰鍏ㄩ儴琚拷鐣ワ紝妯″瀷鐩稿綋浜庛�屽叏鍥鹃兘鏄绫汇�嶏紝楠岃瘉蹇呯劧寰堝樊銆�
+
+`prepare_dataset.py` 宸叉敼涓猴細**姣忎竴甯у悓鏃跺啓鍏� storefront / handover 涓よ锛屽惈 label 0/1**銆�
+
+璇� **閲嶆柊鐢熸垚鏁版嵁骞惰缁�**锛�
+
+```powershell
+cd server/snapshot_infer/training
+py prepare_dataset.py -c config.yaml
+py train.py -c config.yaml
+py export_onnx.py -c config.yaml --version 1.0.1
+py evaluate.py -c config.yaml --split val
+```
+
+璁粌寮�濮嬫椂浼氭墦鍗� `train pos=... neg=...`锛�**neg 蹇呴』 > 0**銆�
+
+---
+
+## 浣犲綋鍓嶆暟鎹殑闂
+
+鏌ョ湅 `data/annotations.jsonl`锛�
+
+| 闂 | 璇存槑 |
+|------|------|
+| 浠� **4 鏉�** 鏍囨敞 | 杩滀綆浜� POC 寤鸿 80~100 鏉� |
+| 瀹為檯鍙湁 **2 涓笉鍚岃棰�** | media 1/3 鍚屼竴鏂囦欢锛�2/4 鍚屼竴鏂囦欢 |
+| train/val **閲嶅鍚屼竴瑙嗛** | 楠岃瘉鎸囨爣铏氶珮鎴栧け鐪燂紝鏃犳硶鍙嶆槧娉涘寲 |
+| 鍦烘櫙鍗曚竴 | 鍏ㄦ槸銆屽叕鍙搞�嶅鍐咃紝妯″瀷瀛︿笉鍒扮湡瀹為棬澶�/浜や粯 |
+
+**缁撹**锛氬嵆浣夸唬鐮佹纭紝4 鏉℃牱鏈篃鍑犱箮涓嶅彲鑳借鍑哄彲鐢ㄦā鍨嬨�傞渶瑕佺户缁爣娉� **80+ 鏉$湡瀹為厤閫佽棰�**锛屼笖 train/val 鎸� **鍙告満+鏃ユ湡** 鍒嗙粍锛屼笉鑳藉悓涓�瑙嗛杩� train 鍙堣繘 val銆�
+
+---
+
+## 鏍囨敞璐ㄩ噺妫�鏌�
+
+1. **闂ㄥご鏃跺埢**锛氬簵鎷�/鍏ュ彛鏈�娓呮櫚鐨勪竴甯э紙涓嶆槸杞﹀唴銆佷笉鏄儗褰憋級
+2. **浜や粯鏃跺埢**锛氳揣鍝�/浜ゆ帴鍔ㄤ綔鏈�娓呮櫚鐨勪竴甯�
+3. 绾︽潫锛歚handover_time_sec > storefront_time_sec + 20s`
+4. 鏈夎闊虫爣璁扮殑瑙嗛锛屽彲寮� ASR 铻嶅悎锛坄enable-asr: true`锛夛紝鍑忚交绾瑙夊帇鍔�
+
+---
+
+## 璁粌鍙傛暟寤鸿锛坄config.yaml`锛�
+
+```yaml
+sampling:
+  sample_fps: 1.0          # 涓庢帹鐞� sample-fps 涓�鑷�
+  positive_window_sec: 3.0   # 姝f牱鏈獥鍙� 卤3s锛堝師 5s 杩囧浼氭ā绯婂嘲鍊硷級
+  other_downsample_ratio: 2  # 璐熸牱鏈笉瑕佸お灏�
+
+train:
+  batch_size: 32
+  epochs: 40
+  lr: 0.0005               # 鍏ㄩ噺寰皟鏃跺彲鐣ラ檷
+  early_stop_patience: 8
+  freeze_backbone: true      # 鏍锋湰 <200 鏃惰嚜鍔ㄥ喕缁� backbone锛堝凡瀹炵幇锛�
+```
+
+鏍锋湰閲� >300 鍚庯紝鍙 `freeze_backbone: false` 鍋氬垎灞傚涔犵巼寰皟銆�
+
+---
+
+## 璇勪及鎸囨爣鎬庝箞鐪�
+
+`evaluate.py` 杈撳嚭锛�
+
+| 鎸囨爣 | POC 鐩爣 |
+|------|----------|
+| 闂ㄥご MAE | < 8s |
+| 浜や粯 MAE | < 8s |
+| 椤哄簭姝g‘鐜� | > 95% |
+| 鍙� 5 绉掑懡涓巼 | > 70% |
+
+鑻ュ抚绾� loss 浣庝絾 MAE 浠嶅ぇ锛氭鏌� **sample_fps 璁粌涓庢帹鐞嗘槸鍚︿竴鑷�**銆佹爣娉ㄦ椂鍒绘槸鍚﹀噯銆�
+
+---
+
+## 鐭湡鍙敤鏂规锛堟暟鎹笉澶熸椂锛�
+
+1. **ASR 涓轰富**锛氬徃鏈哄埌搴�/浜や粯鏃舵竻鏅拌銆屽埌搴椼�嶃�屼氦浠樸�嶏紝寮� `snapshot.infer.enable-asr: true`锛岃瑙変粎杈呭姪
+2. **浜哄伐绾犳**锛欰dmin銆屼汉宸ョ籂姝c�嶅啓鍏� feedback锛岀Н绱悗鍐嶈
+3. **dev mock 鍏滃簳**锛歚fail-open-mock: true` 浠呯敤浜庤仈璋冿紝涓嶆槸鐢熶骇鏂规
+
+---
+
+## 鎺ㄨ崘杩唬椤哄簭
+
+1. 閲嶆柊 `prepare_dataset` + `train` + `export`锛堜慨澶嶈礋鏍锋湰锛�
+2. 鏍囨敞鎵╁埌 50~100 鏉★紝鍘绘帀 val 涓� train 閲嶅瑙嗛
+3. 璺� `evaluate.py`锛岃褰� MAE
+4. 涓婄嚎 snapshot-infer锛岀敤 10 鏉$湡瀹炶棰戜汉宸ョ湅銆屾煡鐪嬪揩鐓с�嶆晥鏋�
+5. badcase 浜哄伐绾犳 鈫� `export_feedback.py` 鈫� 鍚堝苟杩� `annotations.jsonl` 澧為噺璁粌
diff --git a/server/snapshot_infer/models/version.json b/server/snapshot_infer/models/version.json
new file mode 100644
index 0000000..6e3f754
--- /dev/null
+++ b/server/snapshot_infer/models/version.json
@@ -0,0 +1,8 @@
+{
+  "model_version": "1.0.0",
+  "storefront_model": "storefront.onnx",
+  "handover_model": "handover.onnx",
+  "image_size": 224,
+  "quantized": false,
+  "exported_at": "2026-06-12T09:24:48.314213Z"
+}
\ No newline at end of file
diff --git a/server/snapshot_infer/requirements.txt b/server/snapshot_infer/requirements.txt
new file mode 100644
index 0000000..af35971
--- /dev/null
+++ b/server/snapshot_infer/requirements.txt
@@ -0,0 +1,9 @@
+fastapi>=0.100.0
+uvicorn>=0.22.0
+pydantic>=2.0.0
+onnxruntime>=1.16.0
+opencv-python-headless>=4.8.0
+numpy>=1.24.0
+pillow>=10.0.0
+faster-whisper>=1.0.0
+httpx>=0.25.0
diff --git a/server/snapshot_infer/tools/benchmark_cpu.py b/server/snapshot_infer/tools/benchmark_cpu.py
new file mode 100644
index 0000000..ac0d539
--- /dev/null
+++ b/server/snapshot_infer/tools/benchmark_cpu.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""CPU 鍘嬫祴锛氬鍗曟潯瑙嗛閲嶅璋冪敤 pipeline锛岀粺璁¤�楁椂銆�"""
+import argparse
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from app.schemas import AnalyzeRequest
+from app.pipeline import run_analyze, get_registry
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("video_url", help="鍙闂殑 MP4 URL")
+    parser.add_argument("--media-id", type=int, default=1)
+    parser.add_argument("--duration", type=float, default=0)
+    parser.add_argument("--sample-fps", type=float, default=0.5)
+    parser.add_argument("--no-asr", action="store_true")
+    parser.add_argument("--repeat", type=int, default=1)
+    args = parser.parse_args()
+
+    reg = get_registry()
+    print(f"model_version={reg.version} ready={reg.ready}")
+    if not reg.ready:
+        print("璀﹀憡: ONNX 妯″瀷鏈氨缁紝缁撴灉鍙兘澶辫触")
+
+    req = AnalyzeRequest(
+        media_id=args.media_id,
+        video_url=args.video_url,
+        sample_fps=args.sample_fps,
+        enable_asr=not args.no_asr,
+        duration_sec=args.duration if args.duration > 0 else None,
+    )
+    times = []
+    for i in range(args.repeat):
+        t0 = time.perf_counter()
+        resp = run_analyze(req)
+        elapsed = time.perf_counter() - t0
+        times.append(elapsed)
+        print(f"run {i+1}: success={resp.success} elapsed={elapsed:.1f}s "
+              f"storefront={resp.storefront.time_sec if resp.storefront else None} "
+              f"handover={resp.handover.time_sec if resp.handover else None} "
+              f"msg={resp.message or ''}")
+    if times:
+        print(f"avg={sum(times)/len(times):.1f}s min={min(times):.1f}s max={max(times):.1f}s")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server/snapshot_infer/tools/convert_labelstudio.py b/server/snapshot_infer/tools/convert_labelstudio.py
new file mode 100644
index 0000000..6d675df
--- /dev/null
+++ b/server/snapshot_infer/tools/convert_labelstudio.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""灏� Label Studio 瀵煎嚭 JSON 杞负璁粌鐢� JSONL銆�"""
+import argparse
+import json
+import sys
+
+
+def convert(in_path, out_path, default_split="train"):
+    with open(in_path, encoding="utf-8") as f:
+        tasks = json.load(f)
+    if isinstance(tasks, dict):
+        tasks = tasks.get("tasks") or tasks.get("data") or [tasks]
+
+    count = 0
+    with open(out_path, "w", encoding="utf-8") as out:
+        for task in tasks:
+            data = task.get("data") or task
+            media_id = data.get("media_id") or data.get("id")
+            video_path = data.get("video_path") or data.get("video")
+            annotations = task.get("annotations") or []
+            storefront = handover = None
+            for ann in annotations:
+                for r in ann.get("result") or []:
+                    if r.get("type") != "timelinelabels":
+                        continue
+                    labels = (r.get("value") or {}).get("timelinelabels") or []
+                    ranges = (r.get("value") or {}).get("ranges") or []
+                    if not ranges:
+                        continue
+                    t = float(ranges[0].get("start", 0))
+                    if "storefront" in labels:
+                        storefront = t
+                    if "handover" in labels:
+                        handover = t
+            if storefront is None or handover is None:
+                continue
+            item = {
+                "media_id": int(media_id) if media_id else count,
+                "video_path": video_path,
+                "storefront_time_sec": round(storefront, 2),
+                "handover_time_sec": round(handover, 2),
+                "store_type": data.get("store_type", ""),
+                "has_voice_marker": bool(data.get("has_voice_marker")),
+                "driver_date": data.get("driver_date", ""),
+                "split": data.get("split") or default_split,
+                "notes": data.get("notes", ""),
+            }
+            out.write(json.dumps(item, ensure_ascii=False) + "\n")
+            count += 1
+    print(f"杞崲 {count} 鏉� -> {out_path}")
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("input", help="Label Studio 瀵煎嚭 JSON")
+    parser.add_argument("-o", "--output", default="data/annotations.jsonl")
+    parser.add_argument("--split", default="train")
+    args = parser.parse_args()
+    convert(args.input, args.output, args.split)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server/snapshot_infer/tools/export_feedback.py b/server/snapshot_infer/tools/export_feedback.py
new file mode 100644
index 0000000..f8e45a2
--- /dev/null
+++ b/server/snapshot_infer/tools/export_feedback.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""浠� MySQL delivery_media_snapshot_feedback 瀵煎嚭澧為噺璁粌 JSONL銆�"""
+import argparse
+import json
+import os
+import sys
+
+try:
+    import pymysql
+except ImportError:
+    pymysql = None
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--host", default=os.environ.get("MYSQL_HOST", "127.0.0.1"))
+    parser.add_argument("--port", type=int, default=int(os.environ.get("MYSQL_PORT", "3306")))
+    parser.add_argument("--user", default=os.environ.get("MYSQL_USER", "root"))
+    parser.add_argument("--password", default=os.environ.get("MYSQL_PASSWORD", ""))
+    parser.add_argument("--database", default=os.environ.get("MYSQL_DATABASE", "wuhuyancao"))
+    parser.add_argument("--ftp-prefix", default=os.environ.get("FTP_RESOURCE_PREFIX", "http://127.0.0.1/files"))
+    parser.add_argument("-o", "--output", default="data/feedback_export.jsonl")
+    args = parser.parse_args()
+
+    if pymysql is None:
+        print("璇峰畨瑁� pymysql", file=sys.stderr)
+        sys.exit(1)
+
+    media_folder = os.environ.get("COLLECTION_MEDIA_FOLDER", "/collection_media/")
+    conn = pymysql.connect(
+        host=args.host, port=args.port, user=args.user,
+        password=args.password, database=args.database, charset="utf8mb4",
+    )
+    sql = """
+        SELECT f.media_id, f.snapshot_type, f.ai_time_sec, f.manual_time_sec,
+               m.file_path_local, m.start_time, m.recorder_sn
+        FROM delivery_media_snapshot_feedback f
+        JOIN collection_media m ON m.id = f.media_id AND m.isdeleted = 0
+        WHERE f.isdeleted = 0
+        ORDER BY f.id
+    """
+    groups = {}
+    with conn.cursor() as cur:
+        cur.execute(sql)
+        for row in cur.fetchall():
+            media_id, snap_type, ai_t, manual_t, path_local, start_time, recorder_sn = row
+            if media_id not in groups:
+                video_url = args.ftp_prefix.rstrip("/") + "/" + media_folder.strip("/") + "/" + (path_local or "").lstrip("/")
+                driver_date = ""
+                if start_time:
+                    driver_date = f"{recorder_sn or 'unknown'}_{start_time.strftime('%Y%m%d')}"
+                groups[media_id] = {
+                    "media_id": media_id,
+                    "video_path": video_url,
+                    "storefront_time_sec": None,
+                    "handover_time_sec": None,
+                    "driver_date": driver_date,
+                    "split": "train",
+                    "notes": "from_feedback",
+                }
+            if snap_type == 1:
+                groups[media_id]["storefront_time_sec"] = float(manual_t)
+            elif snap_type == 2:
+                groups[media_id]["handover_time_sec"] = float(manual_t)
+    conn.close()
+
+    os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
+    count = 0
+    with open(args.output, "w", encoding="utf-8") as out:
+        for item in groups.values():
+            if item["storefront_time_sec"] is None or item["handover_time_sec"] is None:
+                continue
+            out.write(json.dumps(item, ensure_ascii=False) + "\n")
+            count += 1
+    print(f"瀵煎嚭 {count} 鏉� -> {args.output}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server/snapshot_infer/tools/export_media_list.py b/server/snapshot_infer/tools/export_media_list.py
new file mode 100644
index 0000000..6056918
--- /dev/null
+++ b/server/snapshot_infer/tools/export_media_list.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""浠� MySQL collection_media 瀵煎嚭寰呮爣娉ㄨ棰戞竻鍗曪紙CSV + JSONL 妯℃澘锛夈��"""
+import argparse
+import csv
+import json
+import os
+import sys
+
+try:
+    import pymysql
+except ImportError:
+    pymysql = None
+
+
+def export_from_mysql(host, port, user, password, database, ftp_prefix, limit, out_csv, out_jsonl):
+    if pymysql is None:
+        print("璇峰畨瑁� pymysql: py -m pip install pymysql", file=sys.stderr)
+        sys.exit(1)
+    conn = pymysql.connect(
+        host=host, port=port, user=user, password=password,
+        database=database, charset="utf8mb4",
+    )
+    sql = """
+        SELECT id, file_name, file_path_local, start_time, end_time, recorder_sn
+        FROM collection_media
+        WHERE isdeleted = 0 AND download_status = 1 AND media_type = 0
+          AND file_path_local IS NOT NULL AND file_path_local != ''
+        ORDER BY id DESC
+        LIMIT %s
+    """
+    with conn.cursor() as cur:
+        cur.execute(sql, (limit,))
+        rows = cur.fetchall()
+    conn.close()
+
+    media_folder = os.environ.get("COLLECTION_MEDIA_FOLDER", "/collection_media/")
+    if not media_folder.endswith("/"):
+        media_folder += "/"
+
+    records = []
+    for row in rows:
+        media_id, file_name, file_path_local, start_time, end_time, recorder_sn = row
+        video_url = ftp_prefix.rstrip("/") + "/" + media_folder.lstrip("/") + file_path_local.lstrip("/")
+        driver_date = ""
+        if start_time:
+            driver_date = f"{recorder_sn or 'unknown'}_{start_time.strftime('%Y%m%d')}"
+        records.append({
+            "media_id": media_id,
+            "file_name": file_name or "",
+            "video_path": video_url,
+            "recorder_sn": recorder_sn or "",
+            "driver_date": driver_date,
+            "storefront_time_sec": "",
+            "handover_time_sec": "",
+            "store_type": "",
+            "has_voice_marker": "",
+            "split": "",
+            "notes": "",
+        })
+
+    os.makedirs(os.path.dirname(out_csv) or ".", exist_ok=True)
+    with open(out_csv, "w", newline="", encoding="utf-8-sig") as f:
+        writer = csv.DictWriter(f, fieldnames=list(records[0].keys()) if records else [])
+        writer.writeheader()
+        writer.writerows(records)
+
+    with open(out_jsonl, "w", encoding="utf-8") as f:
+        for r in records:
+            template = {
+                "media_id": r["media_id"],
+                "video_path": r["video_path"],
+                "storefront_time_sec": 0.0,
+                "handover_time_sec": 0.0,
+                "store_type": "",
+                "has_voice_marker": False,
+                "driver_date": r["driver_date"],
+                "split": "train",
+                "notes": "TODO: 濉啓鏍囨敞",
+            }
+            f.write(json.dumps(template, ensure_ascii=False) + "\n")
+
+    print(f"瀵煎嚭 {len(records)} 鏉� -> {out_csv}, {out_jsonl}")
+
+
+def export_from_csv(in_csv, out_jsonl):
+    records = []
+    with open(in_csv, newline="", encoding="utf-8-sig") as f:
+        for row in csv.DictReader(f):
+            records.append(row)
+    with open(out_jsonl, "w", encoding="utf-8") as f:
+        for r in records:
+            item = {
+                "media_id": int(r["media_id"]),
+                "video_path": r.get("video_path") or r.get("video_url", ""),
+                "storefront_time_sec": float(r["storefront_time_sec"]) if r.get("storefront_time_sec") else 0.0,
+                "handover_time_sec": float(r["handover_time_sec"]) if r.get("handover_time_sec") else 0.0,
+                "store_type": r.get("store_type", ""),
+                "has_voice_marker": str(r.get("has_voice_marker", "")).lower() in ("1", "true", "yes"),
+                "driver_date": r.get("driver_date", ""),
+                "split": r.get("split") or "train",
+                "notes": r.get("notes", ""),
+            }
+            f.write(json.dumps(item, ensure_ascii=False) + "\n")
+    print(f"杞崲 {len(records)} 鏉� -> {out_jsonl}")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="瀵煎嚭 collection_media 寰呮爣娉ㄦ竻鍗�")
+    parser.add_argument("--mysql", action="store_true", help="浠� MySQL 璇诲彇")
+    parser.add_argument("--host", default=os.environ.get("MYSQL_HOST", "127.0.0.1"))
+    parser.add_argument("--port", type=int, default=int(os.environ.get("MYSQL_PORT", "3306")))
+    parser.add_argument("--user", default=os.environ.get("MYSQL_USER", "root"))
+    parser.add_argument("--password", default=os.environ.get("MYSQL_PASSWORD", ""))
+    parser.add_argument("--database", default=os.environ.get("MYSQL_DATABASE", "wuhuyancao"))
+    parser.add_argument("--ftp-prefix", default=os.environ.get("FTP_RESOURCE_PREFIX", "http://127.0.0.1/files"))
+    parser.add_argument("--limit", type=int, default=200)
+    parser.add_argument("--out-csv", default="data/annotation_tasks.csv")
+    parser.add_argument("--out-jsonl", default="data/annotations_template.jsonl")
+    parser.add_argument("--from-csv", help="浠庡凡濉� CSV 杞� JSONL")
+    args = parser.parse_args()
+
+    if args.from_csv:
+        export_from_csv(args.from_csv, args.out_jsonl)
+    elif args.mysql:
+        export_from_mysql(
+            args.host, args.port, args.user, args.password, args.database,
+            args.ftp_prefix, args.limit, args.out_csv, args.out_jsonl,
+        )
+    else:
+        print("璇锋寚瀹� --mysql 鎴� --from-csv", file=sys.stderr)
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server/snapshot_infer/tools/label_studio_config.xml b/server/snapshot_infer/tools/label_studio_config.xml
new file mode 100644
index 0000000..f943041
--- /dev/null
+++ b/server/snapshot_infer/tools/label_studio_config.xml
@@ -0,0 +1,9 @@
+<View>
+  <Header value="閰嶉�佽棰戯細闂ㄥご / 浜や粯鏃跺埢鏍囨敞"/>
+  <Video name="video" value="$video_path" sync="audio"/>
+  <Labels name="event" toName="video">
+    <Label value="storefront" background="#FFA500"/>
+    <Label value="handover" background="#008000"/>
+  </Labels>
+  <TextArea name="notes" toName="video" placeholder="澶囨敞锛氶棬搴楃被鍨嬨�佹槸鍚︽湁璇煶鏍囪绛�"/>
+</View>
diff --git a/server/snapshot_infer/training/config.yaml b/server/snapshot_infer/training/config.yaml
new file mode 100644
index 0000000..521f297
--- /dev/null
+++ b/server/snapshot_infer/training/config.yaml
@@ -0,0 +1,28 @@
+# 璁粌閰嶇疆
+data:
+  annotations_jsonl: ../data/annotations.jsonl
+  frames_dir: ../data/frames
+  labels_csv: ../data/labels.csv
+
+sampling:
+  sample_fps: 1.0
+  positive_window_sec: 3.0
+  other_downsample_ratio: 2
+
+model:
+  backbone: mobilenet_v3_small
+  image_size: 224
+  pos_weight: null
+
+train:
+  batch_size: 32
+  epochs: 40
+  lr: 0.0005
+  early_stop_patience: 8
+  num_workers: 0
+  freeze_backbone: true
+
+export:
+  opset: 17
+  quantize: true
+  output_dir: ../models
diff --git a/server/snapshot_infer/training/evaluate.py b/server/snapshot_infer/training/evaluate.py
new file mode 100644
index 0000000..d02778c
--- /dev/null
+++ b/server/snapshot_infer/training/evaluate.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""瑙嗛绾ц瘎浼帮細涓庢帹鐞� pipeline 涓�鑷寸殑鏃跺簭骞虫粦 + 椤哄簭绾︽潫銆�"""
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import numpy as np
+import onnxruntime as ort
+import yaml
+from PIL import Image
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+from app.temporal import find_peaks_ordered, smooth_scores
+
+
+def load_config(path):
+    with open(path, encoding="utf-8") as f:
+        return yaml.safe_load(f)
+
+
+def resolve_model(models_dir, name):
+    version_path = models_dir / "version.json"
+    if version_path.is_file():
+        meta = json.loads(version_path.read_text(encoding="utf-8"))
+        key = f"{name}_model"
+        if meta.get(key):
+            p = models_dir / meta[key]
+            if p.is_file():
+                return p
+    for suffix in (f"{name}_int8.onnx", f"{name}.onnx"):
+        p = models_dir / suffix
+        if p.is_file():
+            return p
+    return None
+
+
+def ffprobe_duration(video_path):
+    cmd = [
+        "ffprobe", "-v", "error", "-show_entries", "format=duration",
+        "-of", "default=noprint_wrappers=1:nokey=1", video_path,
+    ]
+    try:
+        out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip()
+        return float(out) if out else 0.0
+    except Exception:
+        return 0.0
+
+
+def download_if_needed(video_path, cache_dir):
+    if os.path.isfile(video_path):
+        return video_path, None
+    if not video_path.startswith("http"):
+        return None, None
+    import httpx
+    os.makedirs(cache_dir, exist_ok=True)
+    local = os.path.join(cache_dir, "eval_tmp.mp4")
+    with httpx.stream("GET", video_path, timeout=600.0, follow_redirects=True) as r:
+        r.raise_for_status()
+        with open(local, "wb") as f:
+            for chunk in r.iter_bytes():
+                f.write(chunk)
+    return local, local
+
+
+def sample_scores(video_path, session, sample_fps, image_size):
+    duration = ffprobe_duration(video_path)
+    if duration <= 0:
+        return [], duration
+    times, scores = [], []
+    t, step = 0.0, 1.0 / sample_fps
+    tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
+    tmp.close()
+    mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
+    std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
+    try:
+        while t <= duration:
+            subprocess.run(
+                ["ffmpeg", "-y", "-ss", str(t), "-i", video_path, "-frames:v", "1", "-q:v", "2", tmp.name],
+                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+            )
+            if os.path.isfile(tmp.name) and os.path.getsize(tmp.name) > 0:
+                img = Image.open(tmp.name).convert("RGB").resize((image_size, image_size))
+                arr = (np.array(img).astype(np.float32) / 255.0 - mean) / std
+                arr = arr.transpose(2, 0, 1)[None].astype(np.float32)
+                logit = session.run(None, {"input": arr})[0][0][0]
+                prob = float(1.0 / (1.0 + np.exp(-logit)))
+                times.append(round(t, 2))
+                scores.append(prob)
+            t += step
+    finally:
+        if os.path.isfile(tmp.name):
+            os.remove(tmp.name)
+    return list(zip(times, scores)), duration
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("-c", "--config", default=str(Path(__file__).parent / "config.yaml"))
+    parser.add_argument("--annotations", default="../data/annotations.jsonl")
+    parser.add_argument("--models-dir", default="../models")
+    parser.add_argument("--sample-fps", type=float, default=0.5)
+    parser.add_argument("--split", default="val")
+    args = parser.parse_args()
+    cfg = load_config(args.config)
+    size = cfg["model"]["image_size"]
+    models_dir = Path(args.config).resolve().parent / args.models_dir
+
+    sf_path = resolve_model(models_dir, "storefront")
+    ho_path = resolve_model(models_dir, "handover")
+    if not sf_path or not ho_path:
+        print("鏈壘鍒� ONNX 妯″瀷锛岃鍏� export_onnx.py")
+        return
+
+    sf_sess = ort.InferenceSession(str(sf_path), providers=["CPUExecutionProvider"])
+    ho_sess = ort.InferenceSession(str(ho_path), providers=["CPUExecutionProvider"])
+
+    ann_path = Path(args.config).resolve().parent / args.annotations
+    items = []
+    with open(ann_path, encoding="utf-8") as f:
+        for line in f:
+            if line.strip():
+                items.append(json.loads(line))
+    val_items = [i for i in items if i.get("split") == args.split] or items
+
+    cache_dir = str(Path(args.config).resolve().parent / "../data/eval_cache")
+    sf_mae = ho_mae = order_ok = hit5 = n = 0
+    for item in val_items:
+        vp = item["video_path"]
+        local, tmp = download_if_needed(vp, cache_dir)
+        if not local:
+            print(f"璺宠繃 media_id={item['media_id']}: 瑙嗛涓嶅彲璁块棶")
+            continue
+        sf_scores, duration = sample_scores(local, sf_sess, args.sample_fps, size)
+        ho_scores, _ = sample_scores(local, ho_sess, args.sample_fps, size)
+        sf_peak, ho_peak = find_peaks_ordered(sf_scores, ho_scores, duration)
+        pred_sf = sf_peak[0] if sf_peak else 0.0
+        pred_ho = ho_peak[0] if ho_peak else 0.0
+        gt_sf = float(item["storefront_time_sec"])
+        gt_ho = float(item["handover_time_sec"])
+        sf_err = abs(pred_sf - gt_sf)
+        ho_err = abs(pred_ho - gt_ho)
+        sf_mae += sf_err
+        ho_mae += ho_err
+        if pred_ho > pred_sf:
+            order_ok += 1
+        if sf_err <= 5 and ho_err <= 5:
+            hit5 += 1
+        n += 1
+        print(
+            f"media_id={item['media_id']} gt_sf={gt_sf}s pred_sf={pred_sf:.1f}s err={sf_err:.1f}s | "
+            f"gt_ho={gt_ho}s pred_ho={pred_ho:.1f}s err={ho_err:.1f}s"
+        )
+        if tmp and os.path.isfile(tmp):
+            os.remove(tmp)
+
+    if n == 0:
+        print("鏃犲彲鐢ㄩ獙璇佹牱鏈�")
+        return
+    print("---")
+    print(f"鏍锋湰鏁�={n}")
+    print(f"闂ㄥご MAE={sf_mae/n:.2f}s  浜や粯 MAE={ho_mae/n:.2f}s")
+    print(f"椤哄簭姝g‘鐜�={order_ok/n*100:.1f}%  鍙�5绉掑懡涓巼={hit5/n*100:.1f}%")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server/snapshot_infer/training/export_onnx.py b/server/snapshot_infer/training/export_onnx.py
new file mode 100644
index 0000000..93a0d6b
--- /dev/null
+++ b/server/snapshot_infer/training/export_onnx.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""瀵煎嚭 PyTorch 鏉冮噸涓� ONNX锛屽苟鍙�� INT8 鍔ㄦ�侀噺鍖栥��"""
+import argparse
+import json
+import os
+import shutil
+import subprocess
+import sys
+from datetime import datetime
+from pathlib import Path
+
+import torch
+import yaml
+from train import build_model
+
+
+def load_config(path):
+    with open(path, encoding="utf-8") as f:
+        cfg = yaml.safe_load(f)
+    base = Path(path).resolve().parent
+    out = cfg["export"]["output_dir"]
+    if not os.path.isabs(out):
+        cfg["export"]["output_dir"] = str((base / out).resolve())
+    return cfg
+
+
+def try_quantize_subprocess(onnx_path: str, int8_path: str) -> bool:
+    """鍦ㄥ瓙杩涚▼鎵ц ORT 閲忓寲锛岄伩鍏嶄富杩涚▼鍥� Windows 涓� ORT bug 宕╂簝銆�"""
+    code = (
+        "import sys\n"
+        "from onnxruntime.quantization import QuantType, quantize_dynamic\n"
+        "quantize_dynamic(sys.argv[1], sys.argv[2], weight_type=QuantType.QUInt8)\n"
+        "print('OK')\n"
+    )
+    try:
+        result = subprocess.run(
+            [sys.executable, "-c", code, onnx_path, int8_path],
+            capture_output=True,
+            text=True,
+            timeout=600,
+        )
+    except subprocess.TimeoutExpired:
+        print(f"閲忓寲瓒呮椂: {int8_path}")
+        return False
+    if result.returncode != 0:
+        err = (result.stderr or result.stdout or "").strip()
+        if err:
+            print(f"閲忓寲澶辫触 exit={result.returncode}: {err[:500]}")
+        else:
+            print(f"閲忓寲澶辫触 exit={result.returncode}锛圵indows 涓� ORT 閲忓寲鍣ㄥ彲鑳藉穿婧冿級")
+        return False
+    return os.path.isfile(int8_path) and os.path.getsize(int8_path) > 0
+
+
+def export_one(task, cfg, do_quantize: bool):
+    out_dir = cfg["export"]["output_dir"]
+    os.makedirs(out_dir, exist_ok=True)
+    size = cfg["model"]["image_size"]
+    ckpt = os.path.join(out_dir, f"{task}.pt")
+    if not os.path.isfile(ckpt):
+        raise FileNotFoundError(f"鏈壘鍒版潈閲� {ckpt}锛岃鍏堣繍琛� train.py")
+
+    model = build_model()
+    state = torch.load(ckpt, map_location="cpu")
+    model.load_state_dict(state)
+    model.eval()
+
+    dummy = torch.randn(1, 3, size, size)
+    onnx_path = os.path.join(out_dir, f"{task}.onnx")
+    torch.onnx.export(
+        model,
+        dummy,
+        onnx_path,
+        input_names=["input"],
+        output_names=["logits"],
+        dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}},
+        opset_version=cfg["export"]["opset"],
+    )
+    print(f"瀵煎嚭 float ONNX: {onnx_path}")
+
+    int8_path = os.path.join(out_dir, f"{task}_int8.onnx")
+    if do_quantize and cfg["export"].get("quantize"):
+        if try_quantize_subprocess(onnx_path, int8_path):
+            print(f"閲忓寲 INT8: {int8_path}")
+            return os.path.basename(int8_path)
+        print(
+            f"WARN: {task}_int8.onnx 閲忓寲鏈垚鍔燂紝鎺ㄧ悊鏈嶅姟灏嗕娇鐢� float 妯″瀷 {task}.onnx\n"
+            "      甯歌鍘熷洜: Windows 涓� onnxruntime.quantization 宕╂簝銆�"
+            " 鍙崌绾�/闄嶇骇 onnxruntime锛屾垨浣跨敤 --no-quantize 璺宠繃閲忓寲銆�"
+        )
+    elif do_quantize:
+        print(f"璺宠繃閲忓寲锛坈onfig quantize=false锛�")
+
+    return os.path.basename(onnx_path)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="瀵煎嚭 storefront/handover ONNX 妯″瀷")
+    parser.add_argument("-c", "--config", default=str(Path(__file__).parent / "config.yaml"))
+    parser.add_argument("--version", default="1.0.0")
+    parser.add_argument("--no-quantize", action="store_true", help="浠呭鍑� float .onnx锛屼笉灏濊瘯 INT8")
+    args = parser.parse_args()
+    cfg = load_config(args.config)
+    do_quantize = not args.no_quantize
+
+    paths = {}
+    for task in ("storefront", "handover"):
+        paths[task] = export_one(task, cfg, do_quantize)
+
+    version_info = {
+        "model_version": args.version,
+        "storefront_model": paths["storefront"],
+        "handover_model": paths["handover"],
+        "image_size": cfg["model"]["image_size"],
+        "quantized": do_quantize and paths["storefront"].endswith("_int8.onnx"),
+        "exported_at": datetime.utcnow().isoformat() + "Z",
+    }
+    version_path = os.path.join(cfg["export"]["output_dir"], "version.json")
+    with open(version_path, "w", encoding="utf-8") as f:
+        json.dump(version_info, f, indent=2, ensure_ascii=False)
+    print(f"鍐欏叆 {version_path}")
+    print(f"  storefront -> {paths['storefront']}")
+    print(f"  handover   -> {paths['handover']}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server/snapshot_infer/training/extract_frames.py b/server/snapshot_infer/training/extract_frames.py
new file mode 100644
index 0000000..1a603dc
--- /dev/null
+++ b/server/snapshot_infer/training/extract_frames.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""prepare_dataset 鍒悕鍏ュ彛銆�"""
+from prepare_dataset import main
+
+if __name__ == "__main__":
+    main()
diff --git a/server/snapshot_infer/training/prepare_dataset.py b/server/snapshot_infer/training/prepare_dataset.py
new file mode 100644
index 0000000..e960262
--- /dev/null
+++ b/server/snapshot_infer/training/prepare_dataset.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""浠� annotations.jsonl 鐢熸垚 labels.csv锛堝抚璺緞, label, task锛夈��"""
+import argparse
+import csv
+import json
+import os
+import random
+import subprocess
+import sys
+from pathlib import Path
+
+import yaml
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def load_config(path):
+    with open(path, encoding="utf-8") as f:
+        cfg = yaml.safe_load(f)
+    base = Path(path).resolve().parent
+    for key in ("annotations_jsonl", "frames_dir", "labels_csv"):
+        p = cfg["data"][key]
+        if not os.path.isabs(p):
+            cfg["data"][key] = str((base / p).resolve())
+    cfg["data"]["output_dir"] = cfg["export"]["output_dir"]
+    if not os.path.isabs(cfg["export"]["output_dir"]):
+        cfg["export"]["output_dir"] = str((base / cfg["export"]["output_dir"]).resolve())
+    return cfg
+
+
+def ffprobe_duration(video_path, ffmpeg_dir=""):
+    ffprobe = "ffprobe"
+    if ffmpeg_dir:
+        ffprobe = os.path.join(ffmpeg_dir, "ffprobe.exe" if os.name == "nt" else "ffprobe")
+    cmd = [
+        ffprobe, "-v", "error", "-show_entries", "format=duration",
+        "-of", "default=noprint_wrappers=1:nokey=1", video_path,
+    ]
+    try:
+        out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip()
+        return float(out) if out else 0.0
+    except Exception:
+        return 0.0
+
+
+def extract_frame_at(video_path, out_path, sec, ffmpeg_dir=""):
+    ffmpeg = "ffmpeg"
+    if ffmpeg_dir:
+        ffmpeg = os.path.join(ffmpeg_dir, "ffmpeg.exe" if os.name == "nt" else "ffmpeg")
+    os.makedirs(os.path.dirname(out_path), exist_ok=True)
+    cmd = [
+        ffmpeg, "-y", "-ss", str(sec), "-i", video_path,
+        "-frames:v", "1", "-q:v", "2", out_path,
+    ]
+    subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
+
+
+def process_video(item, cfg, ffmpeg_dir):
+    media_id = item["media_id"]
+    video_path = item["video_path"]
+    if not os.path.isfile(video_path) and not video_path.startswith("http"):
+        print(f"璺宠繃 media_id={media_id}: 瑙嗛涓嶅瓨鍦� {video_path}")
+        return []
+
+    local_video = video_path
+    tmp_video = None
+    if video_path.startswith("http"):
+        import httpx
+        tmp_video = os.path.join(cfg["data"]["frames_dir"], f"_tmp_{media_id}.mp4")
+        os.makedirs(cfg["data"]["frames_dir"], exist_ok=True)
+        with httpx.stream("GET", video_path, timeout=600.0) as r:
+            r.raise_for_status()
+            with open(tmp_video, "wb") as f:
+                for chunk in r.iter_bytes():
+                    f.write(chunk)
+        local_video = tmp_video
+
+    duration = ffprobe_duration(local_video, ffmpeg_dir)
+    if duration <= 0:
+        duration = max(item.get("handover_time_sec", 600), 600)
+
+    sample_fps = cfg["sampling"]["sample_fps"]
+    win = cfg["sampling"]["positive_window_sec"]
+    sf_t = float(item["storefront_time_sec"])
+    ho_t = float(item["handover_time_sec"])
+
+    rows = []
+    t = 0.0
+    step = 1.0 / sample_fps
+    other_count = 0
+    other_budget = max(1, int(duration * sample_fps / cfg["sampling"]["other_downsample_ratio"]))
+    while t <= duration:
+        rel = f"{media_id}/{int(t * 1000)}.jpg"
+        out_path = os.path.join(cfg["data"]["frames_dir"], rel)
+        is_sf = abs(t - sf_t) <= win
+        is_ho = abs(t - ho_t) <= win
+        need_other = not is_sf and not is_ho and other_count < other_budget
+        if is_sf or is_ho or need_other:
+            extract_frame_at(local_video, out_path, t, ffmpeg_dir)
+            if os.path.isfile(out_path):
+                split = item.get("split", "train")
+                sf_label = 1 if is_sf else 0
+                ho_label = 1 if is_ho else 0
+                rows.append((rel, sf_label, "storefront", split))
+                rows.append((rel, ho_label, "handover", split))
+                if need_other:
+                    other_count += 1
+        t += step
+    if tmp_video and os.path.isfile(tmp_video):
+        os.remove(tmp_video)
+    return rows
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("-c", "--config", default=str(Path(__file__).parent / "config.yaml"))
+    parser.add_argument("--ffmpeg-dir", default=os.environ.get("FFMPEG_DIR", ""))
+    parser.add_argument("--limit", type=int, default=0)
+    args = parser.parse_args()
+    cfg = load_config(args.config)
+    os.makedirs(cfg["data"]["frames_dir"], exist_ok=True)
+
+    items = []
+    with open(cfg["data"]["annotations_jsonl"], encoding="utf-8") as f:
+        for line in f:
+            line = line.strip()
+            if line:
+                items.append(json.loads(line))
+    if args.limit:
+        items = items[: args.limit]
+
+    all_rows = []
+    for i, item in enumerate(items):
+        if item.get("storefront_time_sec", 0) <= 0 or item.get("handover_time_sec", 0) <= 0:
+            print(f"璺宠繃鏈爣娉� media_id={item.get('media_id')}")
+            continue
+        print(f"[{i+1}/{len(items)}] media_id={item['media_id']}")
+        all_rows.extend(process_video(item, cfg, args.ffmpeg_dir))
+
+    with open(cfg["data"]["labels_csv"], "w", newline="", encoding="utf-8") as f:
+        w = csv.writer(f)
+        w.writerow(["frame_path", "label", "task", "split"])
+        for row in all_rows:
+            w.writerow(row)
+    print(f"鍐欏叆 {len(all_rows)} 琛� -> {cfg['data']['labels_csv']}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server/snapshot_infer/training/requirements-train.txt b/server/snapshot_infer/training/requirements-train.txt
new file mode 100644
index 0000000..3420c7b
--- /dev/null
+++ b/server/snapshot_infer/training/requirements-train.txt
@@ -0,0 +1,10 @@
+torch>=2.0.0
+torchvision>=0.15.0
+onnx>=1.14.0
+onnxruntime>=1.16.0
+pyyaml>=6.0
+pillow>=9.0.0
+opencv-python-headless>=4.8.0
+numpy>=1.24.0
+tqdm>=4.65.0
+httpx>=0.25.0
diff --git a/server/snapshot_infer/training/train.py b/server/snapshot_infer/training/train.py
new file mode 100644
index 0000000..8fc2ef9
--- /dev/null
+++ b/server/snapshot_infer/training/train.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""璁粌 storefront / handover 浜屽垎绫绘ā鍨嬶紙MobileNetV3-Small锛夈��"""
+import argparse
+import csv
+import os
+from pathlib import Path
+
+import torch
+import torch.nn as nn
+import yaml
+from PIL import Image
+from torch.utils.data import DataLoader, Dataset
+from torchvision import models, transforms
+from tqdm import tqdm
+
+
+class FrameDataset(Dataset):
+    def __init__(self, rows, frames_dir, transform):
+        self.rows = rows
+        self.frames_dir = frames_dir
+        self.transform = transform
+
+    def __len__(self):
+        return len(self.rows)
+
+    def __getitem__(self, idx):
+        path, label = self.rows[idx]
+        img = Image.open(os.path.join(self.frames_dir, path)).convert("RGB")
+        return self.transform(img), torch.tensor(label, dtype=torch.float32)
+
+
+def load_config(path):
+    with open(path, encoding="utf-8") as f:
+        cfg = yaml.safe_load(f)
+    base = Path(path).resolve().parent
+    for key in ("frames_dir", "labels_csv"):
+        p = cfg["data"][key]
+        if not os.path.isabs(p):
+            cfg["data"][key] = str((base / p).resolve())
+    out = cfg["export"]["output_dir"]
+    if not os.path.isabs(out):
+        cfg["export"]["output_dir"] = str((base / out).resolve())
+    return cfg
+
+
+def read_labels(csv_path, task, split=None):
+    """璇诲彇鏌愪换鍔$殑璁粌鏍锋湰锛氳 task 琛屽惈 label 0/1锛涘吋瀹规棫鐗� task=other 璐熸牱鏈��"""
+    rows = []
+    with open(csv_path, newline="", encoding="utf-8") as f:
+        for r in csv.DictReader(f):
+            if split and r.get("split") and r["split"] != split:
+                continue
+            row_task = r["task"]
+            label = int(r["label"])
+            if row_task == task:
+                rows.append((r["frame_path"], label))
+            elif row_task == "other" and label == 0:
+                rows.append((r["frame_path"], 0))
+    return rows
+
+
+def count_labels(rows):
+    pos = sum(1 for _, y in rows if y == 1)
+    neg = len(rows) - pos
+    return pos, neg
+
+
+def build_model(freeze_backbone=False):
+    m = models.mobilenet_v3_small(weights=models.MobileNet_V3_Small_Weights.DEFAULT)
+    if freeze_backbone:
+        for p in m.features.parameters():
+            p.requires_grad = False
+    in_f = m.classifier[0].in_features
+    m.classifier = nn.Sequential(
+        nn.Linear(in_f, 128),
+        nn.Hardswish(),
+        nn.Dropout(0.2),
+        nn.Linear(128, 1),
+    )
+    return m
+
+
+def build_transforms(size, augment=False):
+    if augment:
+        return transforms.Compose([
+            transforms.Resize((size, size)),
+            transforms.RandomApply([
+                transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2),
+            ], p=0.7),
+            transforms.RandomApply([
+                transforms.GaussianBlur(kernel_size=3),
+            ], p=0.2),
+            transforms.ToTensor(),
+            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
+        ])
+    return transforms.Compose([
+        transforms.Resize((size, size)),
+        transforms.ToTensor(),
+        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
+    ])
+
+
+def train_task(task, cfg):
+    frames_dir = cfg["data"]["frames_dir"]
+    train_rows = read_labels(cfg["data"]["labels_csv"], task, "train")
+    val_rows = read_labels(cfg["data"]["labels_csv"], task, "val")
+    if not train_rows:
+        train_rows = read_labels(cfg["data"]["labels_csv"], task)
+    if not val_rows:
+        val_rows = train_rows[: max(1, len(train_rows) // 10)]
+
+    train_pos, train_neg = count_labels(train_rows)
+    val_pos, val_neg = count_labels(val_rows)
+    print(f"[{task}] train pos={train_pos} neg={train_neg} | val pos={val_pos} neg={val_neg}")
+    if train_neg == 0:
+        print(f"WARN: [{task}] 璁粌闆嗘棤璐熸牱鏈紒璇烽噸鏂拌繍琛� prepare_dataset.py 鐢熸垚 labels.csv")
+    if train_pos < 10:
+        print(f"WARN: [{task}] 姝f牱鏈繃灏戯紙<10锛夛紝寤鸿鏍囨敞鑷冲皯 80+ 鏉¤棰�")
+
+    size = cfg["model"]["image_size"]
+    train_loader = DataLoader(
+        FrameDataset(train_rows, frames_dir, build_transforms(size, augment=True)),
+        batch_size=cfg["train"]["batch_size"],
+        shuffle=True,
+        num_workers=cfg["train"]["num_workers"],
+    )
+    val_loader = DataLoader(
+        FrameDataset(val_rows, frames_dir, build_transforms(size, augment=False)),
+        batch_size=cfg["train"]["batch_size"],
+        shuffle=False,
+        num_workers=cfg["train"]["num_workers"],
+    )
+
+    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+    freeze = cfg["train"].get("freeze_backbone", True) and train_pos < 200
+    model = build_model(freeze_backbone=freeze).to(device)
+    if freeze:
+        print(f"[{task}] 灏忔牱鏈ā寮忥細鍐荤粨 backbone锛屼粎璁粌鍒嗙被澶�")
+
+    pos_count = max(train_pos, 1)
+    neg_count = max(train_neg, 1)
+    auto_pos_weight = min(neg_count / pos_count, 10.0)
+    pos_weight_val = cfg["model"].get("pos_weight") or auto_pos_weight
+    pos_weight = torch.tensor([pos_weight_val], device=device)
+    print(f"[{task}] pos_weight={pos_weight_val:.2f}")
+
+    criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
+    lr = cfg["train"]["lr"]
+    if freeze:
+        optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=lr)
+    else:
+        optimizer = torch.optim.Adam([
+            {"params": model.features.parameters(), "lr": lr * 0.1},
+            {"params": model.classifier.parameters(), "lr": lr},
+        ])
+
+    best_loss = float("inf")
+    patience = 0
+    os.makedirs(cfg["export"]["output_dir"], exist_ok=True)
+    ckpt_path = os.path.join(cfg["export"]["output_dir"], f"{task}.pt")
+
+    for epoch in range(cfg["train"]["epochs"]):
+        model.train()
+        for x, y in tqdm(train_loader, desc=f"{task} epoch {epoch+1}"):
+            x, y = x.to(device), y.to(device).unsqueeze(1)
+            optimizer.zero_grad()
+            loss = criterion(model(x), y)
+            loss.backward()
+            optimizer.step()
+
+        model.eval()
+        val_loss = 0.0
+        n = 0
+        with torch.no_grad():
+            for x, y in val_loader:
+                x, y = x.to(device), y.to(device).unsqueeze(1)
+                val_loss += criterion(model(x), y).item() * x.size(0)
+                n += x.size(0)
+        val_loss /= max(n, 1)
+        print(f"{task} epoch {epoch+1} val_loss={val_loss:.4f}")
+        if val_loss < best_loss:
+            best_loss = val_loss
+            patience = 0
+            torch.save(model.state_dict(), ckpt_path)
+        else:
+            patience += 1
+            if patience >= cfg["train"]["early_stop_patience"]:
+                break
+    print(f"淇濆瓨 {ckpt_path}")
+    return ckpt_path
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("-c", "--config", default=str(Path(__file__).parent / "config.yaml"))
+    parser.add_argument("--task", choices=["storefront", "handover", "both"], default="both")
+    args = parser.parse_args()
+    cfg = load_config(args.config)
+    tasks = ["storefront", "handover"] if args.task == "both" else [args.task]
+    for t in tasks:
+        train_task(t, cfg)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server/system_service/src/main/java/com/doumee/core/utils/Constants.java b/server/system_service/src/main/java/com/doumee/core/utils/Constants.java
index ef249bc..81aa4d0 100644
--- a/server/system_service/src/main/java/com/doumee/core/utils/Constants.java
+++ b/server/system_service/src/main/java/com/doumee/core/utils/Constants.java
@@ -242,8 +242,6 @@
     public static final String CS_DOWNLOAD_BATCH_SIZE = "CS_DOWNLOAD_BATCH_SIZE";
     public static final String CS_FFMPEG_PATH = "CS_FFMPEG_PATH";
     public static final String COLLECTION_MEDIA_FOLDER = "COLLECTION_MEDIA";
-    /** 濯掍綋涓嬭浇鐘舵�侊細涓嬭浇涓� */
-    public static final int COLLECTION_MEDIA_DOWNLOADING = 3;
     public static final String SMS ="SMS" ;
     public static final String SMS_COMNAME = "SMS_COMNAME";
     public static final String SMS_IP ="SMS_IP" ;
diff --git a/server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java b/server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java
index d27f3b5..4dd1f61 100644
--- a/server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java
+++ b/server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java
@@ -462,7 +462,7 @@
      * @return String
      * @throws Exception
      */
-    public static String getNowShortDate() throws Exception {
+    public static String getNowShortDate()  {
         String nowDate = "";
         try {
             java.sql.Date date = null;
diff --git a/server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java b/server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java
index d9a3500..5002bcc 100644
--- a/server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java
+++ b/server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java
@@ -5,11 +5,14 @@
 import com.doumee.core.annotation.pr.PreventRepeat;
 import com.doumee.core.utils.Constants;
 import com.doumee.dao.admin.request.CollectionMediaSyncRequest;
+import com.doumee.dao.admin.request.DeliverySnapshotManualRequest;
 import com.doumee.dao.business.model.CollectionMedia;
 import com.doumee.dao.business.model.CollectionDockDevice;
 import com.doumee.dao.business.model.CollectionStation;
+import com.doumee.dao.business.model.DeliveryMediaSnapshot;
 import com.doumee.service.business.CollectionMediaSyncService;
 import com.doumee.service.business.CollectionStationService;
+import com.doumee.service.business.DeliverySnapshotService;
 import com.doumee.service.business.third.model.ApiResponse;
 import com.doumee.service.business.third.model.PageData;
 import com.doumee.service.business.third.model.PageWrap;
@@ -30,6 +33,8 @@
     private CollectionStationService collectionStationService;
     @Autowired
     private CollectionMediaSyncService collectionMediaSyncService;
+    @Autowired
+    private DeliverySnapshotService deliverySnapshotService;
 
     @PreventRepeat
     @ApiOperation("鏂板缓閲囬泦绔�")
@@ -175,4 +180,27 @@
                                   javax.servlet.http.HttpServletResponse response) {
         collectionMediaSyncService.downloadMediaFile(id, request, response);
     }
+
+    @PreventRepeat
+    @ApiOperation("鎻愪氦濯掍綋蹇収鍒嗘瀽(闂ㄥご/浜や粯)")
+    @PostMapping("/media/snapshot/analyze/{id}")
+    @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
+    public ApiResponse<String> analyzeMediaSnapshot(@PathVariable Integer id) {
+        return ApiResponse.success(deliverySnapshotService.submitAnalyze(id));
+    }
+
+    @ApiOperation("鏌ヨ濯掍綋蹇収(闂ㄥご/浜や粯)")
+    @GetMapping("/media/snapshot/{id}")
+    @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
+    public ApiResponse<List<DeliveryMediaSnapshot>> listMediaSnapshot(@PathVariable Integer id) {
+        return ApiResponse.success(deliverySnapshotService.listByMediaId(id));
+    }
+
+    @PreventRepeat
+    @ApiOperation("鎵嬪姩鎸囧畾濯掍綋蹇収鏃堕棿鐐�")
+    @PostMapping("/media/snapshot/manual")
+    @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
+    public ApiResponse<String> saveManualMediaSnapshot(@RequestBody DeliverySnapshotManualRequest request) {
+        return ApiResponse.success(deliverySnapshotService.saveManual(request));
+    }
 }
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java b/server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java
index 7e9c51f..857acf5 100644
--- a/server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java
@@ -10,7 +10,7 @@
     private String fileName;
     private String playbackUri;
     private String contentType;
-    private int mediaType;
+    private Integer mediaType;
     private Long fileSize;
     private Date startTime;
     private Date endTime;
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeliverySnapshotManualRequest.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeliverySnapshotManualRequest.java
new file mode 100644
index 0000000..1541c1e
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeliverySnapshotManualRequest.java
@@ -0,0 +1,21 @@
+package com.doumee.dao.admin.request;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+@ApiModel("鎵嬪姩鎸囧畾濯掍綋蹇収")
+public class DeliverySnapshotManualRequest {
+
+    @ApiModelProperty(value = "濯掍綋ID", required = true)
+    private Integer mediaId;
+
+    @ApiModelProperty(value = "1闂ㄥご 2浜や粯", required = true)
+    private Integer snapshotType;
+
+    @ApiModelProperty(value = "瑙嗛鍐呯鏁�", required = true)
+    private BigDecimal timestampSec;
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotFeedbackMapper.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotFeedbackMapper.java
new file mode 100644
index 0000000..8ce3402
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotFeedbackMapper.java
@@ -0,0 +1,7 @@
+package com.doumee.dao.business;
+
+import com.doumee.dao.business.model.DeliveryMediaSnapshotFeedback;
+import com.github.yulichang.base.MPJBaseMapper;
+
+public interface DeliveryMediaSnapshotFeedbackMapper extends MPJBaseMapper<DeliveryMediaSnapshotFeedback> {
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotMapper.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotMapper.java
new file mode 100644
index 0000000..a2a1cdd
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotMapper.java
@@ -0,0 +1,7 @@
+package com.doumee.dao.business;
+
+import com.doumee.dao.business.model.DeliveryMediaSnapshot;
+import com.github.yulichang.base.MPJBaseMapper;
+
+public interface DeliveryMediaSnapshotMapper extends MPJBaseMapper<DeliveryMediaSnapshot> {
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java
index 328a5d7..9057e42 100644
--- a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java
@@ -52,6 +52,14 @@
     private Integer downloadStatus;
 
     private Date downloadTime;
+
+    @ApiModelProperty(value = "0鏈垎鏋� 1鍒嗘瀽涓� 2瀹屾垚 3澶辫触")
+    private Integer snapshotStatus;
+
+    private Date snapshotTime;
+
+    private String snapshotMessage;
+
     private Date createDate;
     private Integer isdeleted;
 }
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshot.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshot.java
new file mode 100644
index 0000000..a8c0a55
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshot.java
@@ -0,0 +1,46 @@
+package com.doumee.dao.business.model;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@ApiModel("閰嶉�佸獟浣撳揩鐓�")
+@TableName("`delivery_media_snapshot`")
+public class DeliveryMediaSnapshot {
+
+    @TableId(type = IdType.AUTO)
+    private Integer id;
+
+    private Integer mediaId;
+
+    private String transportCode;
+
+    @ApiModelProperty(value = "1闂ㄥご 2浜や粯")
+    private Integer snapshotType;
+
+    private BigDecimal timestampSec;
+
+    private String filePath;
+
+    private BigDecimal confidence;
+
+    private String source;
+
+    private String modelVersion;
+
+    private Date createDate;
+
+    private Integer isdeleted;
+
+    @TableField(exist = false)
+    @ApiModelProperty(value = "FTP瀹屾暣璁块棶鍦板潃")
+    private String fileUrlFull;
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshotFeedback.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshotFeedback.java
new file mode 100644
index 0000000..72b3f52
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshotFeedback.java
@@ -0,0 +1,31 @@
+package com.doumee.dao.business.model;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("`delivery_media_snapshot_feedback`")
+public class DeliveryMediaSnapshotFeedback {
+
+    @TableId(type = IdType.AUTO)
+    private Integer id;
+
+    private Integer mediaId;
+
+    private Integer snapshotType;
+
+    private BigDecimal aiTimeSec;
+
+    private BigDecimal manualTimeSec;
+
+    private String modelVersion;
+
+    private Date createDate;
+
+    private Integer isdeleted;
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/DeliverySnapshotService.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/DeliverySnapshotService.java
new file mode 100644
index 0000000..420cb9b
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/DeliverySnapshotService.java
@@ -0,0 +1,17 @@
+package com.doumee.service.business;
+
+import com.doumee.dao.admin.request.DeliverySnapshotManualRequest;
+import com.doumee.dao.business.model.DeliveryMediaSnapshot;
+
+import java.util.List;
+
+public interface DeliverySnapshotService {
+
+    String submitAnalyze(Integer mediaId);
+
+    List<DeliveryMediaSnapshot> listByMediaId(Integer mediaId);
+
+    String saveManual(DeliverySnapshotManualRequest request);
+
+    void submitAnalyzeAsync(Integer mediaId);
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/CollectionMediaConstants.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/CollectionMediaConstants.java
new file mode 100644
index 0000000..0d773dd
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/CollectionMediaConstants.java
@@ -0,0 +1,29 @@
+package com.doumee.service.business.collection;
+
+/**
+ * 閲囬泦绔欏獟浣�/蹇収涓氬姟甯搁噺锛堝畾涔夊湪 dmvisit_service锛岄伩鍏嶄緷璧栨湭瀹夎鐨� system_service 鏂扮増鏈級銆�
+ */
+public final class CollectionMediaConstants {
+
+    /** 濯掍綋涓嬭浇鐘舵�侊細涓嬭浇涓� */
+    public static final int DOWNLOAD_STATUS_DOWNLOADING = 3;
+
+    /** 濯掍綋蹇収鐘舵�侊細鏈垎鏋� */
+    public static final int SNAPSHOT_STATUS_NONE = 0;
+    /** 濯掍綋蹇収鐘舵�侊細鍒嗘瀽涓� */
+    public static final int SNAPSHOT_STATUS_PROCESSING = 1;
+    /** 濯掍綋蹇収鐘舵�侊細瀹屾垚 */
+    public static final int SNAPSHOT_STATUS_DONE = 2;
+    /** 濯掍綋蹇収鐘舵�侊細澶辫触 */
+    public static final int SNAPSHOT_STATUS_FAILED = 3;
+
+    /** 蹇収绫诲瀷锛氶棬澶� */
+    public static final int SNAPSHOT_TYPE_STOREFRONT = 1;
+    /** 蹇収绫诲瀷锛氫氦浠� */
+    public static final int SNAPSHOT_TYPE_HANDOVER = 2;
+
+    public static final String COLLECTION_SNAPSHOT_FOLDER = "COLLECTION_SNAPSHOT";
+
+    private CollectionMediaConstants() {
+    }
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/MediaFrameUtil.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/MediaFrameUtil.java
new file mode 100644
index 0000000..888bd18
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/MediaFrameUtil.java
@@ -0,0 +1,142 @@
+package com.doumee.service.business.collection;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 浠庤棰戞寚瀹氭椂鍒绘埅鍙� JPEG 甯с��
+ */
+@Slf4j
+public final class MediaFrameUtil {
+
+    private static final long SNAPSHOT_TIMEOUT_MINUTES = 10;
+
+    private MediaFrameUtil() {
+    }
+
+    public static double probeDurationSec(String ffmpegPath, File source) {
+        if (source == null || !source.exists() || source.length() <= 0) {
+            return 0;
+        }
+        String ffprobe = resolveFfprobe(ffmpegPath);
+        List<String> command = Arrays.asList(
+                ffprobe,
+                "-v", "error",
+                "-show_entries", "format=duration",
+                "-of", "default=noprint_wrappers=1:nokey=1",
+                source.getAbsolutePath()
+        );
+        try {
+            ProcessBuilder builder = new ProcessBuilder(command);
+            builder.redirectErrorStream(true);
+            Process process = builder.start();
+            String output = readStream(process.getInputStream());
+            boolean finished = process.waitFor(2, TimeUnit.MINUTES);
+            if (!finished) {
+                process.destroyForcibly();
+                return 0;
+            }
+            if (process.exitValue() != 0 || StringUtils.isBlank(output)) {
+                return 0;
+            }
+            return Double.parseDouble(output.trim());
+        } catch (Exception e) {
+            log.warn("ffprobe 璇诲彇鏃堕暱澶辫触: {}", e.getMessage());
+            return 0;
+        }
+    }
+
+    public static boolean extractFrame(String ffmpegPath, File source, File target, double second) {
+        if (source == null || !source.exists() || source.length() <= 0) {
+            return false;
+        }
+        if (second < 0) {
+            second = 0;
+        }
+        String ffmpeg = resolveExecutable(ffmpegPath, "ffmpeg");
+        String sec = formatSeconds(second);
+        List<String> command = Arrays.asList(
+                ffmpeg,
+                "-y",
+                "-ss", sec,
+                "-i", source.getAbsolutePath(),
+                "-frames:v", "1",
+                "-q:v", "2",
+                target.getAbsolutePath()
+        );
+        try {
+            ProcessBuilder builder = new ProcessBuilder(command);
+            builder.redirectErrorStream(true);
+            Process process = builder.start();
+            readStream(process.getInputStream());
+            boolean finished = process.waitFor(SNAPSHOT_TIMEOUT_MINUTES, TimeUnit.MINUTES);
+            if (!finished) {
+                process.destroyForcibly();
+                log.error("FFmpeg 鎴抚瓒呮椂 source={} sec={}", source.getAbsolutePath(), sec);
+                return false;
+            }
+            if (process.exitValue() != 0) {
+                log.error("FFmpeg 鎴抚澶辫触 exitCode={} source={} sec={}", process.exitValue(), source.getAbsolutePath(), sec);
+                return false;
+            }
+            return target.exists() && target.length() > 0;
+        } catch (Exception e) {
+            log.error("FFmpeg 鎴抚寮傚父 source={} sec={}: {}", source.getAbsolutePath(), sec, e.getMessage());
+            return false;
+        }
+    }
+
+    private static String formatSeconds(double second) {
+        if (second <= 0) {
+            return "0";
+        }
+        return String.format(Locale.US, "%.3f", second);
+    }
+
+    private static String readStream(java.io.InputStream in) throws Exception {
+        StringBuilder sb = new StringBuilder();
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                sb.append(line).append('\n');
+            }
+        }
+        return sb.toString();
+    }
+
+    private static String resolveExecutable(String configuredPath, String defaultName) {
+        if (StringUtils.isNotBlank(configuredPath)) {
+            return configuredPath.trim();
+        }
+        return defaultName;
+    }
+
+    private static String resolveFfprobe(String ffmpegPath) {
+        if (StringUtils.isBlank(ffmpegPath)) {
+            return "ffprobe";
+        }
+        String path = ffmpegPath.trim();
+        if (path.contains("/") || path.contains("\\")) {
+            int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
+            if (slash >= 0) {
+                String dir = path.substring(0, slash + 1);
+                String name = path.substring(slash + 1);
+                if (name.toLowerCase().endsWith(".exe")) {
+                    return dir + name.replaceAll("(?i)ffmpeg\\.exe$", "ffprobe.exe");
+                }
+                return dir + name.replaceAll("(?i)ffmpeg$", "ffprobe");
+            }
+            return path.replaceAll("(?i)ffmpeg", "ffprobe");
+        }
+        return "ffprobe";
+    }
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java
index 8edca1c..9a3336f 100644
--- a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java
@@ -19,12 +19,13 @@
 import com.doumee.dao.business.model.CollectionMedia;
 import com.doumee.dao.business.model.CollectionStation;
 import com.doumee.service.business.CollectionMediaSyncService;
+import com.doumee.service.business.DeliverySnapshotService;
+import com.doumee.service.business.collection.CollectionMediaConstants;
 import com.doumee.service.business.third.model.PageData;
 import com.doumee.service.business.third.model.PageWrap;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -54,6 +55,8 @@
     private CollectionMediaMapper collectionMediaMapper;
     @Autowired
     private CollectionStationMapper collectionStationMapper;
+    @Autowired
+    private DeliverySnapshotService deliverySnapshotService;
     @Autowired
     private SystemDictDataBiz systemDictDataBiz;
     @Resource(name = "asyncExecutor")
@@ -105,13 +108,19 @@
         if (station == null || Constants.equalsInteger(station.getIsdeleted(), Constants.ONE)) {
             throw new BusinessException(ResponseStatus.DATA_EMPTY);
         }
-        List<MediaItemDTO> items = isapiClient.searchMediaAll(station, startTime, endTime, trackId, IsapiConstants.MAX_PAGE_RESULTS);
+        List<MediaItemDTO> items = isapiClient.searchMediaAll(station, startTime, endTime,
+                resolveSyncTrackId(trackId), IsapiConstants.MAX_PAGE_RESULTS);
         log.info("閲囬泦绔欏獟浣撴绱� stationId={} ip={} range={}~{} track={} found={}",
-                stationId, station.getIp(), startTime, endTime, trackId, items.size());
+                stationId, station.getIp(), startTime, endTime, resolveSyncTrackId(trackId), items.size());
         int count = 0;
+        int skipped = 0;
         Date now = new Date();
         for (MediaItemDTO item : items) {
             if (StringUtils.isBlank(item.getFileIndex())) {
+                continue;
+            }
+            if (!isSyncableMp4Video(item)) {
+                skipped++;
                 continue;
             }
             Long exists = collectionMediaMapper.selectCount(new QueryWrapper<CollectionMedia>().lambda()
@@ -140,7 +149,54 @@
             collectionMediaMapper.insert(media);
             count++;
         }
+        if (skipped > 0) {
+            log.info("閲囬泦绔欏獟浣撶储寮曡烦杩囬潪MP4瑙嗛 stationId={} skipped={}", stationId, skipped);
+        }
         return count;
+    }
+
+    /** 鍚屾绱㈠紩浠呮绱富鐮佹祦褰曞儚 track锛堥粯璁� 101锛夛紝涓嶅寘鍚姄鍥� track 103 */
+    private String resolveSyncTrackId(String trackId) {
+        if (StringUtils.isNotBlank(trackId)) {
+            String val = trackId.trim();
+            if (!"auto".equalsIgnoreCase(val) && !"*".equals(val) && !"0".equals(val)) {
+                return val;
+            }
+        }
+        return IsapiConstants.DEFAULT_TRACK_ID;
+    }
+
+    /** 鍚屾鍏ュ簱锛氫粎 MP4 瑙嗛锛堟墿灞曞悕 .mp4锛屼笖闈炲浘鐗�/闊抽绫诲瀷锛� */
+    private static boolean isSyncableMp4Video(MediaItemDTO item) {
+        if (item.getMediaType() != null && item.getMediaType() != 0) {
+            return false;
+        }
+        String trackId = StringUtils.defaultString(item.getTrackId());
+        if (trackId.endsWith("3")) {
+            return false;
+        }
+        return isMp4FileName(resolveItemFileName(item));
+    }
+
+    private static String resolveItemFileName(MediaItemDTO item) {
+        if (StringUtils.isNotBlank(item.getFileName())) {
+            return item.getFileName();
+        }
+        String uri = item.getPlaybackUri();
+        if (StringUtils.isBlank(uri)) {
+            return null;
+        }
+        java.util.regex.Matcher m = java.util.regex.Pattern
+                .compile("[?&](?:name|filename|fileName)=([^&\\s]+)", java.util.regex.Pattern.CASE_INSENSITIVE)
+                .matcher(uri.replace("&amp;", "&"));
+        if (m.find()) {
+            try {
+                return java.net.URLDecoder.decode(m.group(1), StandardCharsets.UTF_8.name());
+            } catch (Exception e) {
+                return m.group(1);
+            }
+        }
+        return null;
     }
 
     @Override
@@ -152,7 +208,7 @@
         if (Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE)) {
             throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "鏂囦欢宸蹭笅杞斤紝鏃犻渶閲嶅涓嬭浇");
         }
-        if (Constants.equalsInteger(media.getDownloadStatus(), Constants.COLLECTION_MEDIA_DOWNLOADING)) {
+        if (Constants.equalsInteger(media.getDownloadStatus(), CollectionMediaConstants.DOWNLOAD_STATUS_DOWNLOADING)) {
             throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "鏂囦欢姝e湪涓嬭浇涓紝璇风◢鍚庡埛鏂�");
         }
         CollectionStation station = collectionStationMapper.selectById(media.getStationId());
@@ -160,7 +216,7 @@
             throw new BusinessException(ResponseStatus.DATA_EMPTY);
         }
         CollectionMedia downloading = new CollectionMedia();
-        downloading.setDownloadStatus(Constants.COLLECTION_MEDIA_DOWNLOADING);
+        downloading.setDownloadStatus(CollectionMediaConstants.DOWNLOAD_STATUS_DOWNLOADING);
         int updated = collectionMediaMapper.update(downloading, new QueryWrapper<CollectionMedia>().lambda()
                 .eq(CollectionMedia::getId, mediaId)
                 .in(CollectionMedia::getDownloadStatus, Constants.ZERO, 2));
@@ -195,6 +251,7 @@
             update.setDownloadTime(new Date());
             collectionMediaMapper.updateById(update);
             log.info("寮傛涓嬭浇鎴愬姛 mediaId={} path={}", mediaId, path);
+            deliverySnapshotService.submitAnalyzeAsync(mediaId);
         } catch (Exception e) {
             markDownloadFailed(mediaId);
             log.error("寮傛涓嬭浇寮傚父 mediaId={}: {}", mediaId, e.getMessage(), e);
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/DeliverySnapshotServiceImpl.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/DeliverySnapshotServiceImpl.java
new file mode 100644
index 0000000..7d814e1
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/DeliverySnapshotServiceImpl.java
@@ -0,0 +1,396 @@
+package com.doumee.service.business.impl.collection;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.doumee.biz.system.SystemDictDataBiz;
+import com.doumee.core.constants.ResponseStatus;
+import com.doumee.core.exception.BusinessException;
+import com.doumee.core.utils.Constants;
+import com.doumee.core.utils.DateUtil;
+import com.doumee.core.utils.FtpUtil;
+import com.doumee.service.business.collection.CollectionMediaConstants;
+import com.doumee.service.business.collection.MediaFrameUtil;
+import com.doumee.dao.admin.request.DeliverySnapshotManualRequest;
+import com.doumee.dao.business.CollectionMediaMapper;
+import com.doumee.dao.business.DeliveryMediaSnapshotFeedbackMapper;
+import com.doumee.dao.business.DeliveryMediaSnapshotMapper;
+import com.doumee.dao.business.model.CollectionMedia;
+import com.doumee.dao.business.model.DeliveryMediaSnapshot;
+import com.doumee.dao.business.model.DeliveryMediaSnapshotFeedback;
+import com.doumee.service.business.DeliverySnapshotService;
+import com.doumee.service.business.snapshot.SnapshotAnalyzeRequest;
+import com.doumee.service.business.snapshot.SnapshotAnalyzeResponse;
+import com.doumee.service.business.snapshot.SnapshotInferClient;
+import com.doumee.service.business.snapshot.SnapshotInferProperties;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+
+@Slf4j
+@Service
+public class DeliverySnapshotServiceImpl implements DeliverySnapshotService {
+
+    @Autowired
+    private CollectionMediaMapper collectionMediaMapper;
+    @Autowired
+    private DeliveryMediaSnapshotMapper deliveryMediaSnapshotMapper;
+    @Autowired
+    private DeliveryMediaSnapshotFeedbackMapper deliveryMediaSnapshotFeedbackMapper;
+    @Autowired
+    private SystemDictDataBiz systemDictDataBiz;
+    @Autowired
+    private SnapshotInferClient snapshotInferClient;
+    @Autowired
+    private SnapshotInferProperties snapshotInferProperties;
+    @Resource(name = "asyncExecutor")
+    private Executor asyncExecutor;
+
+    @Override
+    public String submitAnalyze(Integer mediaId) {
+        CollectionMedia media = requireDownloadedMedia(mediaId);
+        if (Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING)) {
+            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "蹇収鍒嗘瀽杩涜涓紝璇风◢鍚庡埛鏂�");
+        }
+        markSnapshotProcessing(mediaId);
+        asyncExecutor.execute(() -> executeAnalyze(mediaId));
+        return "宸叉彁浜ゅ揩鐓у垎鏋愪换鍔★紝璇风◢鍚庡埛鏂版煡鐪�";
+    }
+
+    @Override
+    public void submitAnalyzeAsync(Integer mediaId) {
+        if (!snapshotInferProperties.isAutoOnDownload()) {
+            return;
+        }
+        try {
+            CollectionMedia media = collectionMediaMapper.selectById(mediaId);
+            if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
+                return;
+            }
+            if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE)
+                    || StringUtils.isBlank(media.getFilePathLocal())) {
+                return;
+            }
+            if (Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING)
+                    || Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_DONE)) {
+                return;
+            }
+            if (media.getMediaType() != null && media.getMediaType() != 0) {
+                return;
+            }
+            markSnapshotProcessing(mediaId);
+            asyncExecutor.execute(() -> executeAnalyze(mediaId));
+        } catch (Exception e) {
+            log.warn("鑷姩鎻愪氦蹇収鍒嗘瀽澶辫触 mediaId={}: {}", mediaId, e.getMessage());
+        }
+    }
+
+    @Override
+    public List<DeliveryMediaSnapshot> listByMediaId(Integer mediaId) {
+        List<DeliveryMediaSnapshot> list = deliveryMediaSnapshotMapper.selectList(new QueryWrapper<DeliveryMediaSnapshot>().lambda()
+                .eq(DeliveryMediaSnapshot::getMediaId, mediaId)
+                .eq(DeliveryMediaSnapshot::getIsdeleted, Constants.ZERO)
+                .orderByAsc(DeliveryMediaSnapshot::getSnapshotType));
+        list.forEach(this::fillSnapshotUrl);
+        return list;
+    }
+
+    @Override
+    public String saveManual(DeliverySnapshotManualRequest request) {
+        if (request == null || request.getMediaId() == null || request.getSnapshotType() == null
+                || request.getTimestampSec() == null) {
+            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "鍙傛暟涓嶅畬鏁�");
+        }
+        if (!Constants.equalsInteger(request.getSnapshotType(), CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT)
+                && !Constants.equalsInteger(request.getSnapshotType(), CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER)) {
+            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "蹇収绫诲瀷鏃犳晥");
+        }
+        CollectionMedia media = requireDownloadedMedia(request.getMediaId());
+        File videoFile = null;
+        File frameFile = null;
+        try {
+            videoFile = downloadMediaToTemp(media);
+            frameFile = File.createTempFile("hk_snapshot_", ".jpg");
+            if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, frameFile, request.getTimestampSec().doubleValue())) {
+                throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "鎴抚澶辫触");
+            }
+            String relativePath = uploadSnapshot(frameFile, media.getId(), request.getSnapshotType());
+            saveFeedback(media.getId(), request.getSnapshotType(), request.getTimestampSec());
+            upsertSnapshot(media.getId(), request.getSnapshotType(), request.getTimestampSec(),
+                    relativePath, null, "manual", null);
+            CollectionMedia update = new CollectionMedia();
+            update.setId(media.getId());
+            update.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_DONE);
+            update.setSnapshotTime(new Date());
+            update.setSnapshotMessage(null);
+            collectionMediaMapper.updateById(update);
+            return "淇濆瓨鎴愬姛";
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("鎵嬪姩淇濆瓨蹇収澶辫触 mediaId={}: {}", request.getMediaId(), e.getMessage(), e);
+            throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "淇濆瓨蹇収澶辫触");
+        } finally {
+            deleteQuietly(videoFile);
+            deleteQuietly(frameFile);
+        }
+    }
+
+    private void executeAnalyze(Integer mediaId) {
+        CollectionMedia media = collectionMediaMapper.selectById(mediaId);
+        if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
+            return;
+        }
+        File videoFile = null;
+        File storefrontFrame = null;
+        File handoverFrame = null;
+        try {
+            videoFile = downloadMediaToTemp(media);
+            double duration = MediaFrameUtil.probeDurationSec(getFfmpegPath(), videoFile);
+            if (duration <= 0) {
+                duration = estimateDuration(media);
+            }
+            SnapshotAnalyzeRequest analyzeRequest = new SnapshotAnalyzeRequest();
+            analyzeRequest.setMediaId(mediaId);
+            analyzeRequest.setVideoUrl(buildVideoUrl(media));
+            analyzeRequest.setSampleFps(snapshotInferProperties.getSampleFps());
+            analyzeRequest.setEnableAsr(snapshotInferProperties.isEnableAsr());
+            analyzeRequest.setDurationSec(duration);
+            SnapshotAnalyzeResponse analyzeResponse = snapshotInferClient.analyze(analyzeRequest);
+            if (Boolean.FALSE.equals(analyzeResponse.getSuccess())) {
+                throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(),
+                        StringUtils.defaultIfBlank(analyzeResponse.getMessage(), "蹇収鎺ㄧ悊澶辫触"));
+            }
+
+            double storefrontSec = analyzeResponse.getStorefront().getTimeSec();
+            double handoverSec = analyzeResponse.getHandover().getTimeSec();
+            if (handoverSec <= storefrontSec) {
+                handoverSec = Math.min(duration > 0 ? duration - 1 : storefrontSec + 60, storefrontSec + 60);
+            }
+
+            storefrontFrame = File.createTempFile("hk_storefront_", ".jpg");
+            handoverFrame = File.createTempFile("hk_handover_", ".jpg");
+            if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, storefrontFrame, storefrontSec)) {
+                throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "闂ㄥご鍥炬埅甯уけ璐�");
+            }
+            if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, handoverFrame, handoverSec)) {
+                throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "浜や粯鍥炬埅甯уけ璐�");
+            }
+
+            String storefrontPath = uploadSnapshot(storefrontFrame, mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT);
+            String handoverPath = uploadSnapshot(handoverFrame, mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER);
+
+            upsertSnapshot(mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT,
+                    BigDecimal.valueOf(storefrontSec).setScale(2, RoundingMode.HALF_UP),
+                    storefrontPath,
+                    analyzeResponse.getStorefront().getConfidence(),
+                    StringUtils.defaultIfBlank(analyzeResponse.getStorefront().getSource(), "ai"),
+                    analyzeResponse.getModelVersion());
+            upsertSnapshot(mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER,
+                    BigDecimal.valueOf(handoverSec).setScale(2, RoundingMode.HALF_UP),
+                    handoverPath,
+                    analyzeResponse.getHandover().getConfidence(),
+                    StringUtils.defaultIfBlank(analyzeResponse.getHandover().getSource(), "ai"),
+                    analyzeResponse.getModelVersion());
+
+            CollectionMedia done = new CollectionMedia();
+            done.setId(mediaId);
+            done.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_DONE);
+            done.setSnapshotTime(new Date());
+            done.setSnapshotMessage(null);
+            collectionMediaMapper.updateById(done);
+            log.info("蹇収鍒嗘瀽瀹屾垚 mediaId={} storefrontSec={} handoverSec={}", mediaId, storefrontSec, handoverSec);
+        } catch (Exception e) {
+            log.error("蹇収鍒嗘瀽澶辫触 mediaId={}: {}", mediaId, e.getMessage(), e);
+            CollectionMedia fail = new CollectionMedia();
+            fail.setId(mediaId);
+            fail.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_FAILED);
+            fail.setSnapshotMessage(StringUtils.left(e.getMessage(), 500));
+            collectionMediaMapper.updateById(fail);
+        } finally {
+            deleteQuietly(videoFile);
+            deleteQuietly(storefrontFrame);
+            deleteQuietly(handoverFrame);
+        }
+    }
+
+    private void markSnapshotProcessing(Integer mediaId) {
+        CollectionMedia processing = new CollectionMedia();
+        processing.setId(mediaId);
+        processing.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING);
+        processing.setSnapshotMessage(null);
+        collectionMediaMapper.updateById(processing);
+    }
+
+    private CollectionMedia requireDownloadedMedia(Integer mediaId) {
+        CollectionMedia media = collectionMediaMapper.selectById(mediaId);
+        if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
+            throw new BusinessException(ResponseStatus.DATA_EMPTY);
+        }
+        if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE)
+                || StringUtils.isBlank(media.getFilePathLocal())) {
+            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "璇峰厛涓嬭浇濯掍綋鏂囦欢");
+        }
+        return media;
+    }
+
+    private File downloadMediaToTemp(CollectionMedia media) throws IOException {
+        FtpUtil ftp = createFtpClient();
+        File temp = File.createTempFile("hk_media_snap_", resolveSuffix(media));
+        String remote = getMediaFolder() + media.getFilePathLocal();
+        String local = ftp.download(remote, temp.getAbsolutePath());
+        if (StringUtils.isBlank(local)) {
+            ftp.disconnect();
+            throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "涓嬭浇濯掍綋鏂囦欢澶辫触");
+        }
+        ftp.disconnect();
+        return temp;
+    }
+
+    private String uploadSnapshot(File frameFile, Integer mediaId, int snapshotType) throws IOException {
+        FtpUtil ftp = createFtpClient();
+        try {
+            String folder = getSnapshotFolder();
+            String suffix = snapshotType == CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT ? "_storefront.jpg" : "_handover.jpg";
+            String relative = DateUtil.getNowShortDate() + "/" + mediaId + suffix;
+            String remote = folder + relative;
+            try (FileInputStream in = new FileInputStream(frameFile)) {
+                if (!ftp.uploadInputstream(in, remote)) {
+                    throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "涓婁紶蹇収澶辫触");
+                }
+            }
+            return relative;
+        } finally {
+            ftp.disconnect();
+        }
+    }
+
+    private void upsertSnapshot(Integer mediaId, int snapshotType, BigDecimal timestampSec, String filePath,
+                                Double confidence, String source, String modelVersion) {
+        deliveryMediaSnapshotMapper.update(null, new UpdateWrapper<DeliveryMediaSnapshot>().lambda()
+                .eq(DeliveryMediaSnapshot::getMediaId, mediaId)
+                .eq(DeliveryMediaSnapshot::getSnapshotType, snapshotType)
+                .set(DeliveryMediaSnapshot::getIsdeleted, Constants.ONE));
+        DeliveryMediaSnapshot row = new DeliveryMediaSnapshot();
+        row.setMediaId(mediaId);
+        row.setSnapshotType(snapshotType);
+        row.setTimestampSec(timestampSec);
+        row.setFilePath(filePath);
+        if (confidence != null) {
+            row.setConfidence(BigDecimal.valueOf(confidence).setScale(4, RoundingMode.HALF_UP));
+        }
+        row.setSource(source);
+        row.setModelVersion(modelVersion);
+        row.setCreateDate(new Date());
+        row.setIsdeleted(Constants.ZERO);
+        deliveryMediaSnapshotMapper.insert(row);
+    }
+
+    private void fillSnapshotUrl(DeliveryMediaSnapshot snapshot) {
+        if (StringUtils.isBlank(snapshot.getFilePath())) {
+            return;
+        }
+        try {
+            String prefix = systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_RESOURCE_PATH).getCode();
+            snapshot.setFileUrlFull(prefix + getSnapshotFolder() + snapshot.getFilePath());
+        } catch (Exception e) {
+            log.warn("鏋勫缓蹇収URL澶辫触 id={}: {}", snapshot.getId(), e.getMessage());
+        }
+    }
+
+    private String buildVideoUrl(CollectionMedia media) {
+        try {
+            String prefix = systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_RESOURCE_PATH).getCode();
+            return prefix + getMediaFolder() + media.getFilePathLocal();
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private double estimateDuration(CollectionMedia media) {
+        if (media.getStartTime() != null && media.getEndTime() != null) {
+            long ms = media.getEndTime().getTime() - media.getStartTime().getTime();
+            if (ms > 0) {
+                return ms / 1000.0;
+            }
+        }
+        return 1200.0;
+    }
+
+    private FtpUtil createFtpClient() throws IOException {
+        return new FtpUtil(
+                systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_HOST).getCode(),
+                Integer.parseInt(systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PORT).getCode()),
+                systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_USERNAME).getCode(),
+                systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PWD).getCode());
+    }
+
+    private String getMediaFolder() {
+        try {
+            return systemDictDataBiz.queryByCode(Constants.FTP, Constants.COLLECTION_MEDIA_FOLDER).getCode();
+        } catch (Exception e) {
+            return "/collection_media/";
+        }
+    }
+
+    private String getSnapshotFolder() {
+        try {
+            return systemDictDataBiz.queryByCode(Constants.FTP, CollectionMediaConstants.COLLECTION_SNAPSHOT_FOLDER).getCode();
+        } catch (Exception e) {
+            return "/collection_snapshot/";
+        }
+    }
+
+    private String getFfmpegPath() {
+        try {
+            return systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_FFMPEG_PATH).getCode();
+        } catch (Exception e) {
+            return "ffmpeg";
+        }
+    }
+
+    private String resolveSuffix(CollectionMedia media) {
+        if (StringUtils.isNotBlank(media.getFileName()) && media.getFileName().contains(".")) {
+            return media.getFileName().substring(media.getFileName().lastIndexOf('.')).toLowerCase();
+        }
+        return ".mp4";
+    }
+
+    private void saveFeedback(Integer mediaId, int snapshotType, BigDecimal manualTimeSec) {
+        DeliveryMediaSnapshot existing = deliveryMediaSnapshotMapper.selectOne(new QueryWrapper<DeliveryMediaSnapshot>().lambda()
+                .eq(DeliveryMediaSnapshot::getMediaId, mediaId)
+                .eq(DeliveryMediaSnapshot::getSnapshotType, snapshotType)
+                .eq(DeliveryMediaSnapshot::getIsdeleted, Constants.ZERO)
+                .orderByDesc(DeliveryMediaSnapshot::getId)
+                .last("LIMIT 1"));
+        DeliveryMediaSnapshotFeedback feedback = new DeliveryMediaSnapshotFeedback();
+        feedback.setMediaId(mediaId);
+        feedback.setSnapshotType(snapshotType);
+        if (existing != null && existing.getTimestampSec() != null) {
+            feedback.setAiTimeSec(existing.getTimestampSec());
+            feedback.setModelVersion(existing.getModelVersion());
+        }
+        feedback.setManualTimeSec(manualTimeSec);
+        feedback.setCreateDate(new Date());
+        feedback.setIsdeleted(Constants.ZERO);
+        deliveryMediaSnapshotFeedbackMapper.insert(feedback);
+    }
+
+    private void deleteQuietly(File file) {
+        if (file != null && file.exists() && !file.delete()) {
+            log.warn("涓存椂鏂囦欢鍒犻櫎澶辫触: {}", file.getAbsolutePath());
+        }
+    }
+
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeRequest.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeRequest.java
new file mode 100644
index 0000000..206043f
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeRequest.java
@@ -0,0 +1,23 @@
+package com.doumee.service.business.snapshot;
+
+import lombok.Data;
+
+import java.util.Arrays;
+import java.util.List;
+
+@Data
+public class SnapshotAnalyzeRequest {
+
+    private Integer mediaId;
+    private String videoUrl;
+    private Double sampleFps;
+    private Boolean enableAsr;
+    private Double durationSec;
+    private KeywordConfig keywords = new KeywordConfig();
+
+    @Data
+    public static class KeywordConfig {
+        private List<String> storefront = Arrays.asList("鍒板簵", "鍒拌揪", "闂ㄥご");
+        private List<String> handover = Arrays.asList("浜や粯", "浜よ揣", "绛炬敹");
+    }
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeResponse.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeResponse.java
new file mode 100644
index 0000000..0eadae2
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeResponse.java
@@ -0,0 +1,62 @@
+package com.doumee.service.business.snapshot;
+
+
+
+import lombok.Data;
+
+
+
+import java.util.ArrayList;
+
+import java.util.List;
+
+
+
+@Data
+
+public class SnapshotAnalyzeResponse {
+
+
+
+    private Boolean success;
+
+    private String modelVersion;
+
+    private Double durationSec;
+
+    private SnapshotHit storefront;
+
+    private SnapshotHit handover;
+
+    private String message;
+
+    private List<AsrHit> asrHits = new ArrayList<>();
+
+
+
+    @Data
+
+    public static class SnapshotHit {
+
+        private Double timeSec;
+
+        private Double confidence;
+
+        private String source;
+
+    }
+
+
+
+    @Data
+
+    public static class AsrHit {
+
+        private String keyword;
+
+        private Double timeSec;
+
+    }
+
+}
+
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferClient.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferClient.java
new file mode 100644
index 0000000..c16cce9
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferClient.java
@@ -0,0 +1,122 @@
+package com.doumee.service.business.snapshot;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class SnapshotInferClient {
+
+    @Autowired
+    private SnapshotInferProperties properties;
+
+    public boolean healthCheck() {
+        if (StringUtils.isBlank(properties.getBaseUrl())) {
+            return false;
+        }
+        String url = normalizeBaseUrl() + "/health";
+        try {
+            HttpResponse response = HttpRequest.get(url)
+                    .timeout(properties.getConnectTimeoutMs())
+                    .execute();
+            if (response.getStatus() != 200) {
+                return false;
+            }
+            JSONObject json = JSON.parseObject(response.body());
+            return json != null && "ok".equalsIgnoreCase(json.getString("status"));
+        } catch (Exception e) {
+            log.warn("snapshot-infer health 澶辫触: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    public SnapshotAnalyzeResponse analyze(SnapshotAnalyzeRequest request) {
+        if (StringUtils.isBlank(properties.getBaseUrl())) {
+            return resolveFailure(request, "snapshot.infer.base-url 鏈厤缃�");
+        }
+        String url = normalizeBaseUrl() + "/analyze";
+        String payload = JSON.toJSONString(request);
+        try {
+            HttpResponse response = HttpRequest.post(url)
+                    .body(payload)
+                    .contentType("application/json")
+                    .setConnectionTimeout(properties.getConnectTimeoutMs())
+                    .timeout(properties.getReadTimeoutMs())
+                    .execute();
+            String body = response.body();
+            if (response.getStatus() != 200) {
+                log.warn("snapshot-infer analyze HTTP {} body={}", response.getStatus(), body);
+                return resolveFailure(request, "鎺ㄧ悊鏈嶅姟 HTTP " + response.getStatus());
+            }
+            SnapshotAnalyzeResponse parsed = JSON.parseObject(body, SnapshotAnalyzeResponse.class);
+            if (parsed == null) {
+                return resolveFailure(request, "鎺ㄧ悊鍝嶅簲涓虹┖");
+            }
+            if (Boolean.FALSE.equals(parsed.getSuccess())) {
+                String msg = StringUtils.defaultIfBlank(parsed.getMessage(), "鎺ㄧ悊鏈娴嬪埌鏈夋晥鏃跺埢");
+                log.warn("snapshot-infer analyze 澶辫触: {}", msg);
+                return resolveFailure(request, msg);
+            }
+            if (parsed.getStorefront() == null || parsed.getHandover() == null) {
+                return resolveFailure(request, "鎺ㄧ悊鍝嶅簲缂哄皯闂ㄥご鎴栦氦浠樻椂鍒�");
+            }
+            if (parsed.getSuccess() == null) {
+                parsed.setSuccess(true);
+            }
+            if (parsed.getAsrHits() == null) {
+                parsed.setAsrHits(new java.util.ArrayList<>());
+            }
+            return parsed;
+        } catch (Exception e) {
+            log.warn("snapshot-infer analyze 寮傚父: {}", e.getMessage());
+            return resolveFailure(request, e.getMessage());
+        }
+    }
+
+    private SnapshotAnalyzeResponse resolveFailure(SnapshotAnalyzeRequest request, String message) {
+        if (properties.isFailOpenMock()) {
+            log.warn("fail-open-mock 鍚敤锛屼娇鐢ㄦ湰鍦� mock: {}", message);
+            return buildLocalMock(request);
+        }
+        SnapshotAnalyzeResponse fail = new SnapshotAnalyzeResponse();
+        fail.setSuccess(false);
+        fail.setMessage(message);
+        fail.setModelVersion("unavailable");
+        return fail;
+    }
+
+    private SnapshotAnalyzeResponse buildLocalMock(SnapshotAnalyzeRequest request) {
+        double duration = request.getDurationSec() != null && request.getDurationSec() > 0
+                ? request.getDurationSec() : 1200.0;
+        double t1 = Math.round(duration * 0.25 * 100.0) / 100.0;
+        double t2 = Math.round(duration * 0.75 * 100.0) / 100.0;
+        if (t2 <= t1) {
+            t2 = t1 + 60.0;
+        }
+        SnapshotAnalyzeResponse response = new SnapshotAnalyzeResponse();
+        response.setSuccess(true);
+        response.setModelVersion("local-mock");
+        response.setDurationSec(duration);
+        SnapshotAnalyzeResponse.SnapshotHit storefront = new SnapshotAnalyzeResponse.SnapshotHit();
+        storefront.setTimeSec(t1);
+        storefront.setConfidence(0.5);
+        storefront.setSource("local-mock");
+        SnapshotAnalyzeResponse.SnapshotHit handover = new SnapshotAnalyzeResponse.SnapshotHit();
+        handover.setTimeSec(t2);
+        handover.setConfidence(0.5);
+        handover.setSource("local-mock");
+        response.setStorefront(storefront);
+        response.setHandover(handover);
+        return response;
+    }
+
+    private String normalizeBaseUrl() {
+        return properties.getBaseUrl().trim().replaceAll("/+$", "");
+    }
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferProperties.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferProperties.java
new file mode 100644
index 0000000..59a21c2
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferProperties.java
@@ -0,0 +1,40 @@
+package com.doumee.service.business.snapshot;
+
+
+
+import lombok.Data;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import org.springframework.stereotype.Component;
+
+
+
+@Data
+
+@Component
+
+@ConfigurationProperties(prefix = "snapshot.infer")
+
+public class SnapshotInferProperties {
+
+
+
+    private String baseUrl = "http://127.0.0.1:8095";
+
+    private int connectTimeoutMs = 5000;
+
+    private int readTimeoutMs = 600000;
+
+    private boolean enableAsr = true;
+
+    private boolean autoOnDownload = true;
+
+    private double sampleFps = 0.5;
+
+    /** 鎺ㄧ悊澶辫触鏃舵槸鍚﹀洖閫�鍒版湰鍦� 25%/75% mock锛堢敓浜х幆澧冨簲涓� false锛� */
+
+    private boolean failOpenMock = false;
+
+}
+
diff --git a/server/visits/dmvisit_service/src/main/resources/application-dev.yml b/server/visits/dmvisit_service/src/main/resources/application-dev.yml
index 8dd392d..adcfeeb 100644
--- a/server/visits/dmvisit_service/src/main/resources/application-dev.yml
+++ b/server/visits/dmvisit_service/src/main/resources/application-dev.yml
@@ -19,6 +19,16 @@
 
 debug_model: true
 
+snapshot:
+  infer:
+    base-url: http://127.0.0.1:8095
+    connect-timeout-ms: 5000
+    read-timeout-ms: 600000
+    enable-asr: true
+    auto-on-download: true
+    sample-fps: 0.5
+    fail-open-mock: true
+
 ########################鍚屾鏁版嵁妯″紡  ########################
 data-sync:
   org-user-data-origin: 0 #缁勭粐鏁版嵁 0鑷缓 2浠ユ捣搴蜂负涓� 1鍗庢櫉ERP绯荤粺
diff --git a/server/visits/dmvisit_service/src/main/resources/application-pro.yml b/server/visits/dmvisit_service/src/main/resources/application-pro.yml
index 003f70c..c39980a 100644
--- a/server/visits/dmvisit_service/src/main/resources/application-pro.yml
+++ b/server/visits/dmvisit_service/src/main/resources/application-pro.yml
@@ -17,6 +17,16 @@
 
 debug_model: false
 
+snapshot:
+  infer:
+    base-url: http://127.0.0.1:8095
+    connect-timeout-ms: 5000
+    read-timeout-ms: 600000
+    enable-asr: true
+    auto-on-download: true
+    sample-fps: 0.5
+    fail-open-mock: false
+
 ########################鍚屾鏁版嵁妯″紡  ########################
 data-sync:
   org-user-data-origin: 3 #缁勭粐鏁版嵁 0鑷缓 2浠ユ捣搴蜂负涓� 1鍗庢櫉ERP绯荤粺 3绠�閬撲簯 4閽夐拤

--
Gitblit v1.9.3