diff --git a/wails/assets/assets/index-BDrFF8AO.css b/wails/assets/assets/index-BDrFF8AO.css
deleted file mode 100644
index 4a66165..0000000
--- a/wails/assets/assets/index-BDrFF8AO.css
+++ /dev/null
@@ -1 +0,0 @@
-.video-tab[data-v-54d3cc11]{max-width:900px;margin:0 auto}h2[data-v-54d3cc11]{margin-bottom:20px;color:#333}.form-group[data-v-54d3cc11]{margin-bottom:20px}label[data-v-54d3cc11]{display:block;margin-bottom:8px;font-weight:500;color:#555}.input-group[data-v-54d3cc11]{display:flex;gap:10px}.input-group input[data-v-54d3cc11]{flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:4px}.input-group button[data-v-54d3cc11]{padding:8px 16px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer}.input-group button[data-v-54d3cc11]:hover{background:#5a5080}.radio-group[data-v-54d3cc11]{display:flex;flex-direction:column;gap:10px}.radio-group label[data-v-54d3cc11]{display:flex;align-items:center;gap:8px;font-weight:400}input[type=number][data-v-54d3cc11]{width:100px;padding:8px;border:1px solid #ddd;border-radius:4px}.hint[data-v-54d3cc11]{margin-left:10px;color:#888;font-size:14px}.btn-primary[data-v-54d3cc11]{padding:12px 24px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:16px}.btn-primary[data-v-54d3cc11]:hover:not(:disabled){background:#5a5080}.btn-primary[data-v-54d3cc11]:disabled{background:#ccc;cursor:not-allowed}.folder-list[data-v-54d3cc11]{border:1px solid #ddd;border-radius:4px;padding:10px;max-height:200px;overflow-y:auto}.folder-item[data-v-54d3cc11]{display:flex;justify-content:space-between;padding:8px;border-bottom:1px solid #eee}.folder-item[data-v-54d3cc11]:last-child{border-bottom:none}.video-count[data-v-54d3cc11]{color:#888;font-size:14px}.results[data-v-54d3cc11]{margin-top:30px}.results h3[data-v-54d3cc11]{margin-bottom:15px}table[data-v-54d3cc11]{width:100%;border-collapse:collapse;background:#fff;border-radius:4px;overflow:hidden}thead[data-v-54d3cc11]{background:#7163ba;color:#fff}th[data-v-54d3cc11],td[data-v-54d3cc11]{padding:12px;text-align:left;border-bottom:1px solid #eee}.success[data-v-54d3cc11]{color:#28a745}.error[data-v-54d3cc11]{color:#dc3545}.progress-bar[data-v-54d3cc11]{position:relative;width:100px;height:20px;background:#eee;border-radius:10px;overflow:hidden}.progress-fill[data-v-54d3cc11]{position:absolute;top:0;left:0;height:100%;background:#28a745;transition:width .3s}.progress-text[data-v-54d3cc11]{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:12px;z-index:1}.extract-tab[data-v-efad5166]{max-width:900px;margin:0 auto}h2[data-v-efad5166]{margin-bottom:20px;color:#333}.form-group[data-v-efad5166]{margin-bottom:20px}label[data-v-efad5166]{display:block;margin-bottom:8px;font-weight:500;color:#555}.input-group[data-v-efad5166]{display:flex;gap:10px}.input-group input[data-v-efad5166]{flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:4px}.input-group button[data-v-efad5166]{padding:8px 16px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer}.input-group button[data-v-efad5166]:hover{background:#5a5080}input[type=number][data-v-efad5166]{width:100px;padding:8px;border:1px solid #ddd;border-radius:4px}.hint[data-v-efad5166]{margin-top:5px;color:#888;font-size:14px}.btn-primary[data-v-efad5166]{padding:12px 24px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:16px}.btn-primary[data-v-efad5166]:hover:not(:disabled){background:#5a5080}.btn-primary[data-v-efad5166]:disabled{background:#ccc;cursor:not-allowed}.btn-secondary[data-v-efad5166]{padding:12px 24px;background:#6c757d;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:16px}.btn-secondary[data-v-efad5166]:hover:not(:disabled){background:#5a6268}.btn-secondary[data-v-efad5166]:disabled{background:#ccc;cursor:not-allowed}.video-list[data-v-efad5166]{border:1px solid #ddd;border-radius:4px;padding:10px;max-height:200px;overflow-y:auto}.video-item[data-v-efad5166]{padding:8px;border-bottom:1px solid #eee}.video-item[data-v-efad5166]:last-child{border-bottom:none}.help-info[data-v-efad5166]{margin-top:20px;padding:15px;background:#f8f9fa;border-radius:4px;white-space:pre-line;line-height:1.6}.results[data-v-efad5166]{margin-top:30px}.results h3[data-v-efad5166]{margin-bottom:15px}.result-summary[data-v-efad5166]{padding:15px;background:#fff;border-radius:4px;border:1px solid #ddd}.result-summary p[data-v-efad5166]{margin:5px 0}.dialog-overlay[data-v-123e9c29]{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.dialog[data-v-123e9c29]{background:#fff;border-radius:8px;width:400px;max-width:90vw;box-shadow:0 4px 20px #0000004d}.dialog-header[data-v-123e9c29]{display:flex;justify-content:space-between;align-items:center;padding:20px;border-bottom:1px solid #eee}.dialog-header h3[data-v-123e9c29]{margin:0;color:#333}.close-btn[data-v-123e9c29]{background:none;border:none;font-size:24px;cursor:pointer;color:#999;padding:0;width:30px;height:30px;line-height:30px}.close-btn[data-v-123e9c29]:hover{color:#333}.dialog-body[data-v-123e9c29]{padding:20px}.form-group[data-v-123e9c29]{margin-bottom:15px}.form-group label[data-v-123e9c29]{display:block;margin-bottom:5px;color:#555;font-weight:500}.form-group input[data-v-123e9c29]{width:100%;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px}.form-group input[data-v-123e9c29]:focus{outline:none;border-color:#7163ba}.error-message[data-v-123e9c29]{color:#dc3545;font-size:14px;margin-top:10px}.dialog-footer[data-v-123e9c29]{display:flex;justify-content:flex-end;gap:10px;padding:20px;border-top:1px solid #eee}.btn-primary[data-v-123e9c29]{padding:8px 16px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer}.btn-primary[data-v-123e9c29]:hover:not(:disabled){background:#5a5080}.btn-primary[data-v-123e9c29]:disabled{background:#ccc;cursor:not-allowed}.btn-secondary[data-v-123e9c29]{padding:8px 16px;background:#6c757d;color:#fff;border:none;border-radius:4px;cursor:pointer}.btn-secondary[data-v-123e9c29]:hover{background:#5a6268}.app-container[data-v-75b1df04]{display:flex;width:100vw;height:100vh;background:linear-gradient(to bottom,#fefefe,#ededef)}.sidebar[data-v-75b1df04]{width:70px;background:#7163ba;display:flex;flex-direction:column;justify-content:space-between;padding:10px 0;border-right:2px solid #ebedf3}.menu-items[data-v-75b1df04]{display:flex;flex-direction:column;gap:5px}.menu-item[data-v-75b1df04]{width:50px;height:50px;margin:0 auto;background:transparent;border:2px solid #000;border-radius:5px;color:#fff;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:12px;transition:all .2s}.menu-item[data-v-75b1df04]:hover{background:#5a5080}.menu-item.active[data-v-75b1df04]{background:#8b4513}.menu-item .icon[data-v-75b1df04]{font-size:18px}.separator[data-v-75b1df04]{height:1px;background:#ffffff4d;margin:5px 10px}.menu-bottom[data-v-75b1df04]{display:flex;flex-direction:column}.main-content[data-v-75b1df04]{flex:1;padding:20px;overflow:auto}.login-overlay[data-v-75b1df04]{position:fixed;top:0;left:0;right:0;bottom:0;background:#000000b3;display:flex;align-items:center;justify-content:center;z-index:999}.login-message[data-v-75b1df04]{background:#fff;padding:40px;border-radius:8px;text-align:center;box-shadow:0 4px 20px #0000004d}.login-message h2[data-v-75b1df04]{margin:0 0 10px;color:#333;font-size:24px}.login-message p[data-v-75b1df04]{margin:0;color:#666;font-size:16px}.main-interface[data-v-75b1df04]{display:flex;width:100%;height:100%}
diff --git a/wails/assets/assets/index-BaD48VVT.css b/wails/assets/assets/index-BaD48VVT.css
new file mode 100644
index 0000000..f6888bc
--- /dev/null
+++ b/wails/assets/assets/index-BaD48VVT.css
@@ -0,0 +1 @@
+.video-tab[data-v-976c97bb]{max-width:900px;margin:0 auto}h2[data-v-976c97bb]{margin-bottom:20px;color:#333}.form-group[data-v-976c97bb]{margin-bottom:20px}label[data-v-976c97bb]{display:block;margin-bottom:8px;font-weight:500;color:#555}.input-group[data-v-976c97bb]{display:flex;gap:10px}.input-group input[data-v-976c97bb]{flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:4px}.input-group button[data-v-976c97bb]{padding:8px 16px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer}.input-group button[data-v-976c97bb]:hover{background:#5a5080}.radio-group[data-v-976c97bb]{display:flex;flex-direction:column;gap:10px}.radio-group label[data-v-976c97bb]{display:flex;align-items:center;gap:8px;font-weight:400}input[type=number][data-v-976c97bb]{width:100px;padding:8px;border:1px solid #ddd;border-radius:4px}.hint[data-v-976c97bb]{margin-left:10px;color:#888;font-size:14px}.btn-primary[data-v-976c97bb]{padding:12px 24px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:16px}.btn-primary[data-v-976c97bb]:hover:not(:disabled){background:#5a5080}.btn-primary[data-v-976c97bb]:disabled{background:#ccc;cursor:not-allowed}.folder-list[data-v-976c97bb]{border:1px solid #ddd;border-radius:4px;padding:10px;max-height:200px;overflow-y:auto}.folder-item[data-v-976c97bb]{display:flex;justify-content:space-between;padding:8px;border-bottom:1px solid #eee}.folder-item[data-v-976c97bb]:last-child{border-bottom:none}.video-count[data-v-976c97bb]{color:#888;font-size:14px}.results[data-v-976c97bb]{margin-top:30px}.results h3[data-v-976c97bb]{margin-bottom:15px}table[data-v-976c97bb]{width:100%;border-collapse:collapse;background:#fff;border-radius:4px;overflow:hidden}thead[data-v-976c97bb]{background:#7163ba;color:#fff}th[data-v-976c97bb],td[data-v-976c97bb]{padding:12px;text-align:left;border-bottom:1px solid #eee}.success[data-v-976c97bb]{color:#28a745}.error[data-v-976c97bb]{color:#dc3545}.progress-bar[data-v-976c97bb]{position:relative;width:100px;height:20px;background:#eee;border-radius:10px;overflow:hidden}.progress-fill[data-v-976c97bb]{position:absolute;top:0;left:0;height:100%;background:#28a745;transition:width .3s}.progress-text[data-v-976c97bb]{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:12px;z-index:1}.extract-tab[data-v-32ae0b7c]{max-width:900px;margin:0 auto}h2[data-v-32ae0b7c]{margin-bottom:20px;color:#333}.form-group[data-v-32ae0b7c]{margin-bottom:20px}label[data-v-32ae0b7c]{display:block;margin-bottom:8px;font-weight:500;color:#555}.input-group[data-v-32ae0b7c]{display:flex;gap:10px}.input-group input[data-v-32ae0b7c]{flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:4px}.input-group button[data-v-32ae0b7c]{padding:8px 16px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer}.input-group button[data-v-32ae0b7c]:hover{background:#5a5080}input[type=number][data-v-32ae0b7c]{width:100px;padding:8px;border:1px solid #ddd;border-radius:4px}.hint[data-v-32ae0b7c]{margin-top:5px;color:#888;font-size:14px}.btn-primary[data-v-32ae0b7c]{padding:12px 24px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:16px}.btn-primary[data-v-32ae0b7c]:hover:not(:disabled){background:#5a5080}.btn-primary[data-v-32ae0b7c]:disabled{background:#ccc;cursor:not-allowed}.btn-secondary[data-v-32ae0b7c]{padding:12px 24px;background:#6c757d;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:16px}.btn-secondary[data-v-32ae0b7c]:hover:not(:disabled){background:#5a6268}.btn-secondary[data-v-32ae0b7c]:disabled{background:#ccc;cursor:not-allowed}.video-list[data-v-32ae0b7c]{border:1px solid #ddd;border-radius:4px;padding:10px;max-height:200px;overflow-y:auto}.video-item[data-v-32ae0b7c]{padding:8px;border-bottom:1px solid #eee}.video-item[data-v-32ae0b7c]:last-child{border-bottom:none}.help-info[data-v-32ae0b7c]{margin-top:20px;padding:15px;background:#f8f9fa;border-radius:4px;white-space:pre-line;line-height:1.6}.results[data-v-32ae0b7c]{margin-top:30px}.results h3[data-v-32ae0b7c]{margin-bottom:15px}.result-summary[data-v-32ae0b7c]{padding:15px;background:#fff;border-radius:4px;border:1px solid #ddd}.result-summary p[data-v-32ae0b7c]{margin:5px 0}.dialog-overlay[data-v-f054741d]{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.dialog[data-v-f054741d]{background:#fff;border-radius:8px;width:400px;max-width:90vw;box-shadow:0 4px 20px #0000004d}.dialog-header[data-v-f054741d]{display:flex;justify-content:space-between;align-items:center;padding:20px;border-bottom:1px solid #eee}.dialog-header h3[data-v-f054741d]{margin:0;color:#333}.close-btn[data-v-f054741d]{background:none;border:none;font-size:24px;cursor:pointer;color:#999;padding:0;width:30px;height:30px;line-height:30px}.close-btn[data-v-f054741d]:hover{color:#333}.dialog-body[data-v-f054741d]{padding:20px}.form-group[data-v-f054741d]{margin-bottom:15px}.form-group label[data-v-f054741d]{display:block;margin-bottom:5px;color:#555;font-weight:500}.form-group input[data-v-f054741d]{width:100%;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:14px}.form-group input[data-v-f054741d]:focus{outline:none;border-color:#7163ba}.error-message[data-v-f054741d]{color:#dc3545;font-size:14px;margin-top:10px}.dialog-footer[data-v-f054741d]{display:flex;justify-content:flex-end;gap:10px;padding:20px;border-top:1px solid #eee}.btn-primary[data-v-f054741d]{padding:8px 16px;background:#7163ba;color:#fff;border:none;border-radius:4px;cursor:pointer}.btn-primary[data-v-f054741d]:hover:not(:disabled){background:#5a5080}.btn-primary[data-v-f054741d]:disabled{background:#ccc;cursor:not-allowed}.btn-secondary[data-v-f054741d]{padding:8px 16px;background:#6c757d;color:#fff;border:none;border-radius:4px;cursor:pointer}.btn-secondary[data-v-f054741d]:hover{background:#5a6268}.app-container[data-v-8a8ce913]{display:flex;width:100vw;height:100vh;background:linear-gradient(to bottom,#fefefe,#ededef)}.sidebar[data-v-8a8ce913]{width:70px;background:#7163ba;display:flex;flex-direction:column;justify-content:space-between;padding:10px 0;border-right:2px solid #ebedf3}.menu-items[data-v-8a8ce913]{display:flex;flex-direction:column;gap:5px}.menu-item[data-v-8a8ce913]{width:50px;height:50px;margin:0 auto;background:transparent;border:2px solid #000;border-radius:5px;color:#fff;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:12px;transition:all .2s}.menu-item[data-v-8a8ce913]:hover{background:#5a5080}.menu-item.active[data-v-8a8ce913]{background:#8b4513}.menu-item .icon[data-v-8a8ce913]{font-size:18px}.separator[data-v-8a8ce913]{height:1px;background:#ffffff4d;margin:5px 10px}.menu-bottom[data-v-8a8ce913]{display:flex;flex-direction:column}.main-content[data-v-8a8ce913]{flex:1;padding:20px;overflow:auto}.login-overlay[data-v-8a8ce913]{position:fixed;top:0;left:0;right:0;bottom:0;background:#000000b3;display:flex;align-items:center;justify-content:center;z-index:999}.login-message[data-v-8a8ce913]{background:#fff;padding:40px;border-radius:8px;text-align:center;box-shadow:0 4px 20px #0000004d}.login-message h2[data-v-8a8ce913]{margin:0 0 10px;color:#333;font-size:24px}.login-message p[data-v-8a8ce913]{margin:0;color:#666;font-size:16px}.main-interface[data-v-8a8ce913]{display:flex;width:100%;height:100%}
diff --git a/wails/assets/assets/index-DSuQhuGl.js b/wails/assets/assets/index-IwiMqFON.js
similarity index 99%
rename from wails/assets/assets/index-DSuQhuGl.js
rename to wails/assets/assets/index-IwiMqFON.js
index 04a86c0..e1ced2f 100644
--- a/wails/assets/assets/index-DSuQhuGl.js
+++ b/wails/assets/assets/index-IwiMqFON.js
@@ -14,4 +14,4 @@ import{Create as _e,Call as vt}from"@wailsio/runtime";(function(){const t=docume
* @vue/runtime-dom v3.5.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
-**/let qs;const jn=typeof window<"u"&&window.trustedTypes;if(jn)try{qs=jn.createPolicy("vue",{createHTML:e=>e})}catch{}const si=qs?e=>qs.createHTML(e):e=>e,il="http://www.w3.org/2000/svg",ol="http://www.w3.org/1998/Math/MathML",Re=typeof document<"u"?document:null,Hn=Re&&Re.createElement("template"),ll={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const r=t==="svg"?Re.createElementNS(il,e):t==="mathml"?Re.createElementNS(ol,e):s?Re.createElement(e,{is:s}):Re.createElement(e);return e==="select"&&n&&n.multiple!=null&&r.setAttribute("multiple",n.multiple),r},createText:e=>Re.createTextNode(e),createComment:e=>Re.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Re.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,r,i){const o=s?s.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),s),!(r===i||!(r=r.nextSibling)););else{Hn.innerHTML=si(n==="svg"?``:n==="mathml"?``:e);const l=Hn.content;if(n==="svg"||n==="mathml"){const u=l.firstChild;for(;u.firstChild;)l.appendChild(u.firstChild);l.removeChild(u)}t.insertBefore(l,s)}return[o?o.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},cl=Symbol("_vtc");function ul(e,t,s){const n=e[cl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const ls=Symbol("_vod"),ni=Symbol("_vsh"),fl={name:"show",beforeMount(e,{value:t},{transition:s}){e[ls]=e.style.display==="none"?"":e.style.display,s&&t?s.beforeEnter(e):Ct(e,t)},mounted(e,{value:t},{transition:s}){s&&t&&s.enter(e)},updated(e,{value:t,oldValue:s},{transition:n}){!t!=!s&&(n?t?(n.beforeEnter(e),Ct(e,!0),n.enter(e)):n.leave(e,()=>{Ct(e,!1)}):Ct(e,t))},beforeUnmount(e,{value:t}){Ct(e,t)}};function Ct(e,t){e.style.display=t?e[ls]:"none",e[ni]=!t}const al=Symbol(""),dl=/(?:^|;)\s*display\s*:/;function hl(e,t,s){const n=e.style,r=X(s);let i=!1;if(s&&!r){if(t)if(X(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();s[l]==null&&Qt(n,l,"")}else for(const o in t)s[o]==null&&Qt(n,o,"");for(const o in s)o==="display"&&(i=!0),Qt(n,o,s[o])}else if(r){if(t!==s){const o=n[al];o&&(s+=";"+o),n.cssText=s,i=dl.test(s)}}else t&&e.removeAttribute("style");ls in e&&(e[ls]=i?n.display:"",e[ni]&&(n.display="none"))}const Vn=/\s*!important$/;function Qt(e,t,s){if(R(s))s.forEach(n=>Qt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=pl(e,t);Vn.test(s)?e.setProperty(it(n),s.replace(Vn,""),"important"):e[n]=s}}const Un=["Webkit","Moz","ms"],Fs={};function pl(e,t){const s=Fs[t];if(s)return s;let n=Je(t);if(n!=="filter"&&n in e)return Fs[t]=n;n=nr(n);for(let r=0;rRs||(_l.then(()=>Rs=0),Rs=Date.now());function yl(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;Me(xl(n,s.value),t,5,[n])};return s.value=e,s.attached=bl(),s}function xl(e,t){if(R(t)){const s=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{s.call(e),e._stopped=!0},t.map(n=>r=>!r._stopped&&n&&n(r))}else return t}const Jn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Sl=(e,t,s,n,r,i)=>{const o=r==="svg";t==="class"?ul(e,n,o):t==="style"?hl(e,s,n):us(t)?Gs(t)||ml(e,t,s,n,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):wl(e,t,n,o))?(Wn(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Bn(e,t,n,o,i,t!=="value")):e._isVueCE&&(/[A-Z]/.test(t)||!X(n))?Wn(e,Je(t),n,i,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),Bn(e,t,n,o))};function wl(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&Jn(t)&&D(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return Jn(t)&&X(s)?!1:t in e}const cs=e=>{const t=e.props["onUpdate:modelValue"]||!1;return R(t)?s=>Yt(t,s):t};function Cl(e){e.target.composing=!0}function Gn(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const ht=Symbol("_assign");function Yn(e,t,s){return t&&(e=e.trim()),s&&(e=Xs(e)),e}const rt={created(e,{modifiers:{lazy:t,trim:s,number:n}},r){e[ht]=cs(r);const i=n||r.props&&r.props.type==="number";tt(e,t?"change":"input",o=>{o.target.composing||e[ht](Yn(e.value,s,i))}),(s||i)&&tt(e,"change",()=>{e.value=Yn(e.value,s,i)}),t||(tt(e,"compositionstart",Cl),tt(e,"compositionend",Gn),tt(e,"change",Gn))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:r,number:i}},o){if(e[ht]=cs(o),e.composing)return;const l=(i||e.type==="number")&&!/^0\d/.test(e.value)?Xs(e.value):e.value,u=t??"";l!==u&&(document.activeElement===e&&e.type!=="range"&&(n&&t===s||r&&e.value.trim()===u)||(e.value=u))}},zn={created(e,{value:t},s){e.checked=es(t,s.props.value),e[ht]=cs(s),tt(e,"change",()=>{e[ht](Tl(e))})},beforeUpdate(e,{value:t,oldValue:s},n){e[ht]=cs(n),t!==s&&(e.checked=es(t,n.props.value))}};function Tl(e){return"_value"in e?e._value:e.value}const El=["ctrl","shift","alt","meta"],Ol={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>El.some(s=>e[`${s}Key`]&&!t.includes(s))},Pl=(e,t)=>{const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=(r,...i)=>{for(let o=0;o{const t=Il().createApp(...e),{mount:s}=t;return t.mount=n=>{const r=Rl(n);if(!r)return;const i=t._component;!D(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=s(r,!1,Fl(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t};function Fl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Rl(e){return X(e)?document.querySelector(e):e}class dn{constructor(t={}){"videoPath"in t||(this.videoPath=""),"outputPath"in t||(this.outputPath=""),"success"in t||(this.success=!1),Object.assign(this,t)}static createFrom(t={}){let s=typeof t=="string"?JSON.parse(t):t;return new dn(s)}}class bs{constructor(t={}){"path"in t||(this.path=""),"name"in t||(this.name=""),"videoCount"in t||(this.videoCount=0),"videoPaths"in t||(this.videoPaths=[]),Object.assign(this,t)}static createFrom(t={}){const s=$l;let n=typeof t=="string"?JSON.parse(t):t;return"videoPaths"in n&&(n.videoPaths=s(n.videoPaths)),new bs(n)}}class hn{constructor(t={}){"Code"in t||(this.Code=0),"Msg"in t||(this.Msg=""),"Data"in t||(this.Data=null),Object.assign(this,t)}static createFrom(t={}){let s=typeof t=="string"?JSON.parse(t):t;return new hn(s)}}class pn{constructor(t={}){"index"in t||(this.index=0),"fileName"in t||(this.fileName=""),"filePath"in t||(this.filePath=""),"size"in t||(this.size=""),"seconds"in t||(this.seconds=0),"status"in t||(this.status=""),"progress"in t||(this.progress=""),Object.assign(this,t)}static createFrom(t={}){let s=typeof t=="string"?JSON.parse(t):t;return new pn(s)}}const $l=_e.Array(_e.Any),Dl=bs.createFrom;_e.Array(Dl);function Ll(e,t){return vt.ByID(2350837569,e,t).then(s=>jl(s))}const Nl=hn.createFrom,jl=_e.Nullable(Nl);function Hl(e){return vt.ByID(1728131056,e).then(t=>Kl(t))}function Vl(e){return vt.ByID(2398906893,e).then(t=>Bl(t))}const Ul=dn.createFrom,Kl=_e.Array(Ul),Bl=_e.Array(_e.Any);function ri(){return vt.ByID(23551676)}_e.Array(_e.Any);function Wl(e){return vt.ByID(2660164351,e).then(t=>Jl(t))}function kl(e){return vt.ByID(2209109868,e).then(t=>Yl(t))}const ql=pn.createFrom,Jl=_e.Array(ql),Gl=bs.createFrom,Yl=_e.Array(Gl),ys=(e,t)=>{const s=e.__vccOpts||e;for(const[n,r]of t)s[n]=r;return s},zl={class:"video-tab"},Xl={class:"form-group"},Zl={class:"input-group"},Ql={key:0,class:"form-group"},ec={class:"folder-list"},tc={class:"video-count"},sc={class:"form-group"},nc={class:"radio-group"},rc={class:"form-group"},ic=["max","disabled"],oc={class:"hint"},lc={class:"form-group"},cc={class:"input-group"},uc={class:"form-group"},fc=["disabled"],ac={key:1,class:"results"},dc={class:"progress-bar"},hc={class:"progress-text"},pc={__name:"VideoTab",setup(e){const t=Y(""),s=Y([]),n=Y(1),r=Y(1),i=Y(0),o=Y(""),l=Y(!1),u=Y([]),d=ct(()=>t.value&&r.value>0&&r.value<=i.value&&!l.value),a=async()=>{try{const P=await ri();if(P&&P.trim()){t.value=P.trim();try{s.value=await kl(t.value),w()}catch(g){alert("列出文件夹失败: "+g.message)}}}catch(P){console.error("选择文件夹失败:",P),alert("选择文件夹失败: "+(P.message||"未知错误"))}},p=()=>{const P=document.createElement("input");P.type="file",P.accept="image/*",P.onchange=g=>{const E=g.target.files[0];E&&(o.value=E.name)},P.click()},w=()=>{n.value===1?i.value=s.value.reduce((P,g)=>P*g.videoCount,1):i.value=s.value.length>0?s.value[0].videoCount:0},O=async()=>{if(d.value){l.value=!0,u.value=[];try{const P={folderPath:t.value,num:r.value,joinType:n.value,auditImagePath:o.value,folderInfos:s.value};try{const g=await Wl(P);g&&(u.value=g)}catch(g){alert("拼接视频失败: "+g.message)}}catch(P){alert("拼接失败: "+P.message)}finally{l.value=!1}}};return(P,g)=>(J(),Q("div",zl,[g[14]||(g[14]=_("h2",null,"视频拼接",-1)),_("div",Xl,[g[5]||(g[5]=_("label",null,"选择文件夹:",-1)),_("div",Zl,[Pe(_("input",{type:"text","onUpdate:modelValue":g[0]||(g[0]=E=>t.value=E),placeholder:"请选择包含视频文件夹的目录",readonly:""},null,512),[[rt,t.value]]),_("button",{onClick:a},"选择文件夹")])]),s.value.length>0?(J(),Q("div",Ql,[g[6]||(g[6]=_("label",null,"文件夹列表:",-1)),_("div",ec,[(J(!0),Q(de,null,Hs(s.value,(E,$)=>(J(),Q("div",{key:$,class:"folder-item"},[_("span",null,z(E.name),1),_("span",tc,z(E.videoCount)+" 个视频",1)]))),128))])])):ge("",!0),_("div",sc,[g[9]||(g[9]=_("label",null,"拼接模式:",-1)),_("div",nc,[_("label",null,[Pe(_("input",{type:"radio","onUpdate:modelValue":g[1]||(g[1]=E=>n.value=E),value:1},null,512),[[zn,n.value]]),g[7]||(g[7]=Ws(" 组合拼接(从每个文件夹随机选择) ",-1))]),_("label",null,[Pe(_("input",{type:"radio","onUpdate:modelValue":g[2]||(g[2]=E=>n.value=E),value:2},null,512),[[zn,n.value]]),g[8]||(g[8]=Ws(" 顺序拼接(按索引顺序) ",-1))])])]),_("div",rc,[g[10]||(g[10]=_("label",null,"生成数量:",-1)),Pe(_("input",{type:"number","onUpdate:modelValue":g[3]||(g[3]=E=>r.value=E),min:1,max:i.value,disabled:l.value},null,8,ic),[[rt,r.value,void 0,{number:!0}]]),_("span",oc,"最大可生成:"+z(i.value)+" 个",1)]),_("div",lc,[g[11]||(g[11]=_("label",null,"审核图片(可选):",-1)),_("div",cc,[Pe(_("input",{type:"text","onUpdate:modelValue":g[4]||(g[4]=E=>o.value=E),placeholder:"选择审核图片",readonly:""},null,512),[[rt,o.value]]),_("button",{onClick:p},"选择图片")])]),_("div",uc,[_("button",{class:"btn-primary",onClick:O,disabled:!d.value||l.value},z(l.value?"处理中...":"开始拼接"),9,fc)]),u.value.length>0?(J(),Q("div",ac,[g[13]||(g[13]=_("h3",null,"拼接结果",-1)),_("table",null,[g[12]||(g[12]=_("thead",null,[_("tr",null,[_("th",null,"序号"),_("th",null,"文件名"),_("th",null,"大小"),_("th",null,"时长"),_("th",null,"状态"),_("th",null,"进度")])],-1)),_("tbody",null,[(J(!0),Q(de,null,Hs(u.value,E=>(J(),Q("tr",{key:E.index},[_("td",null,z(E.index),1),_("td",null,z(E.fileName),1),_("td",null,z(E.size),1),_("td",null,z(E.seconds)+"秒",1),_("td",{class:pt(E.status==="拼接成功"?"success":"error")},z(E.status),3),_("td",null,[_("div",dc,[_("div",{class:"progress-fill",style:ds({width:E.progress})},null,4),_("span",hc,z(E.progress),1)])])]))),128))])])])):ge("",!0)]))}},gc=ys(pc,[["__scopeId","data-v-54d3cc11"]]),mc={class:"extract-tab"},vc={class:"form-group"},_c={class:"input-group"},bc={key:0,class:"form-group"},yc={class:"video-list"},xc={class:"hint"},Sc={class:"form-group"},wc=["disabled"],Cc={class:"form-group"},Tc=["disabled"],Ec=["disabled"],Oc={key:1,class:"help-info"},Pc={key:2,class:"results"},Ac={class:"result-summary"},Ic={__name:"ExtractTab",setup(e){const t=Y(""),s=Y([]),n=Y(1),r=Y(!1),i=Y(""),o=Y([]),l=ct(()=>t.value&&s.value.length>0&&n.value>0&&!r.value),u=ct(()=>t.value&&s.value.length>0&&!r.value),d=ct(()=>o.value.filter(g=>g.success).length),a=ct(()=>o.value.filter(g=>!g.success).length),p=async()=>{try{const g=await ri();if(g&&g.trim()){t.value=g.trim();try{s.value=await Vl(t.value)}catch(E){alert("列出视频失败: "+E.message)}}}catch(g){console.error("选择文件夹失败:",g),alert("选择文件夹失败: "+(g.message||"未知错误"))}},w=g=>g.split("/").pop()||g.split("\\").pop()||g,O=async()=>{if(l.value){r.value=!0,o.value=[],i.value=`开始处理,每个视频将生成 ${n.value} 个抽帧视频...`;try{const g={folderPath:t.value,extractCount:n.value},E=s.value.length*n.value;i.value=`处理中... (0/${E})`;try{const $=await Hl(g);$&&(o.value=$,i.value=`全部完成! 共处理 ${E} 个任务,成功 ${d.value} 个,失败 ${a.value} 个`)}catch($){alert("抽帧失败: "+$.message),i.value="处理失败"}}catch(g){alert("抽帧失败: "+g.message),i.value="处理失败: "+g.message}finally{r.value=!1}}},P=async()=>{if(u.value){r.value=!0,o.value=[],i.value="开始修改元数据...";try{if(extractService&&extractService.ModifyVideosMetadata){const g=s.value.length;i.value=`处理中... (0/${g})`;const E=await extractService.ModifyVideosMetadata(t.value);E&&(o.value=E,i.value=`全部完成! 共处理 ${g} 个任务,成功 ${d.value} 个,失败 ${a.value} 个`)}}catch(g){alert("修改失败: "+g.message),i.value="处理失败: "+g.message}finally{r.value=!1}}};return(g,E)=>(J(),Q("div",mc,[E[6]||(E[6]=_("h2",null,"视频抽帧",-1)),_("div",vc,[E[2]||(E[2]=_("label",null,"选择文件夹:",-1)),_("div",_c,[Pe(_("input",{type:"text","onUpdate:modelValue":E[0]||(E[0]=$=>t.value=$),placeholder:"请选择包含视频文件的目录",readonly:""},null,512),[[rt,t.value]]),_("button",{onClick:p},"选择文件夹")])]),s.value.length>0?(J(),Q("div",bc,[E[3]||(E[3]=_("label",null,"视频文件:",-1)),_("div",yc,[(J(!0),Q(de,null,Hs(s.value,($,V)=>(J(),Q("div",{key:V,class:"video-item"},z(w($)),1))),128))]),_("p",xc,"共 "+z(s.value.length)+" 个视频文件",1)])):ge("",!0),_("div",Sc,[E[4]||(E[4]=_("label",null,"每个视频生成数量:",-1)),Pe(_("input",{type:"number","onUpdate:modelValue":E[1]||(E[1]=$=>n.value=$),min:1,disabled:r.value},null,8,wc),[[rt,n.value,void 0,{number:!0}]])]),_("div",Cc,[_("button",{class:"btn-primary",onClick:O,disabled:!l.value||r.value},z(r.value?"处理中...":"开始抽帧"),9,Tc),_("button",{class:"btn-secondary",onClick:P,disabled:!u.value||r.value,style:{"margin-left":"10px"}},z(r.value?"处理中...":"开始修改元数据"),9,Ec)]),i.value?(J(),Q("div",Oc,z(i.value),1)):ge("",!0),o.value.length>0?(J(),Q("div",Pc,[E[5]||(E[5]=_("h3",null,"处理结果",-1)),_("div",Ac,[_("p",null,"成功: "+z(d.value)+" 个",1),_("p",null,"失败: "+z(a.value)+" 个",1)])])):ge("",!0)]))}},Mc=ys(Ic,[["__scopeId","data-v-efad5166"]]),Fc={class:"dialog-header"},Rc={class:"dialog-body"},$c={class:"form-group"},Dc={class:"form-group"},Lc={key:0,class:"error-message"},Nc={class:"dialog-footer"},jc=["disabled"],Hc={__name:"LoginDialog",props:{allowClose:{type:Boolean,default:!1}},emits:["close","login-success"],setup(e,{emit:t}){const s=e,n=t,r=Y(""),i=Y(""),o=Y(!1),l=Y(""),u=()=>{s.allowClose&&n("close")},d=async()=>{if(!r.value||!i.value){l.value="请输入用户名和密码";return}o.value=!0,l.value="";try{const a=await Ll(r.value,i.value);a&&a.Code===200?(n("login-success"),n("close")):l.value=a&&a.Msg||"登录失败"}catch(a){l.value="登录失败: "+(a.message||a)}finally{o.value=!1}};return(a,p)=>(J(),Q("div",{class:"dialog-overlay",onClick:u},[_("div",{class:"dialog",onClick:p[4]||(p[4]=Pl(()=>{},["stop"]))},[_("div",Fc,[p[5]||(p[5]=_("h3",null,"用户登录",-1)),e.allowClose?(J(),Q("button",{key:0,class:"close-btn",onClick:p[0]||(p[0]=w=>a.$emit("close"))},"×")):ge("",!0)]),_("div",Rc,[_("div",$c,[p[6]||(p[6]=_("label",null,"用户名:",-1)),Pe(_("input",{type:"text","onUpdate:modelValue":p[1]||(p[1]=w=>r.value=w),placeholder:"请输入用户名"},null,512),[[rt,r.value]])]),_("div",Dc,[p[7]||(p[7]=_("label",null,"密码:",-1)),Pe(_("input",{type:"password","onUpdate:modelValue":p[2]||(p[2]=w=>i.value=w),placeholder:"请输入密码"},null,512),[[rt,i.value]])]),l.value?(J(),Q("div",Lc,z(l.value),1)):ge("",!0)]),_("div",Nc,[e.allowClose?(J(),Q("button",{key:0,class:"btn-secondary",onClick:p[3]||(p[3]=w=>a.$emit("close"))},"取消")):ge("",!0),_("button",{class:"btn-primary",onClick:d,disabled:o.value},z(o.value?"登录中...":"登录"),9,jc)])])]))}},Vc=ys(Hc,[["__scopeId","data-v-123e9c29"]]),Uc={class:"app-container"},Kc={key:0,class:"login-overlay"},Bc={class:"main-interface"},Wc={class:"sidebar"},kc={class:"menu-items"},qc={class:"main-content"},Jc={__name:"App",setup(e){const t=Y("video"),s=Y(!1),n=Y(!1),r=()=>{const u=localStorage.getItem("isLoggedIn"),d=localStorage.getItem("loginTime");if(u==="true"&&d){const a=Date.now(),p=parseInt(d);(a-p)/(1e3*60*60)<24?n.value=!0:(localStorage.removeItem("isLoggedIn"),localStorage.removeItem("loginTime"),n.value=!1,s.value=!0)}else n.value=!1,s.value=!0},i=()=>{n.value=!0,s.value=!1,localStorage.setItem("isLoggedIn","true"),localStorage.setItem("loginTime",Date.now().toString())},o=()=>{n.value&&(s.value=!1)},l=()=>{confirm("确定要退出登录吗?")&&(n.value=!1,s.value=!0,localStorage.removeItem("isLoggedIn"),localStorage.removeItem("loginTime"))};return Rr(()=>{r()}),(u,d)=>(J(),Q("div",Uc,[n.value?ge("",!0):(J(),Q("div",Kc,[...d[2]||(d[2]=[_("div",{class:"login-message"},[_("h2",null,"请先登录"),_("p",null,"您需要登录后才能使用此应用")],-1)])])),Pe(_("div",Bc,[_("div",Wc,[_("div",kc,[_("button",{class:pt(["menu-item",{active:t.value==="video"}]),onClick:d[0]||(d[0]=a=>t.value="video")},[...d[3]||(d[3]=[_("span",{class:"icon"},"🎬",-1),_("span",null,"视频",-1)])],2),d[5]||(d[5]=_("div",{class:"separator"},null,-1)),_("button",{class:pt(["menu-item",{active:t.value==="extract"}]),onClick:d[1]||(d[1]=a=>t.value="extract")},[...d[4]||(d[4]=[_("span",{class:"icon"},"✂️",-1),_("span",null,"抽帧",-1)])],2)]),_("div",{class:"menu-bottom"},[_("button",{class:"menu-item",onClick:l},[...d[6]||(d[6]=[_("span",{class:"icon"},"👤",-1)])])])]),_("div",qc,[t.value==="video"?(J(),Xt(gc,{key:0})):ge("",!0),t.value==="extract"?(J(),Xt(Mc,{key:1})):ge("",!0)])],512),[[fl,n.value]]),s.value?(J(),Xt(Vc,{key:1,"allow-close":n.value,onClose:o,onLoginSuccess:i},null,8,["allow-close"])):ge("",!0)]))}},Gc=ys(Jc,[["__scopeId","data-v-75b1df04"]]);Ml(Gc).mount("#app");
+**/let qs;const jn=typeof window<"u"&&window.trustedTypes;if(jn)try{qs=jn.createPolicy("vue",{createHTML:e=>e})}catch{}const si=qs?e=>qs.createHTML(e):e=>e,il="http://www.w3.org/2000/svg",ol="http://www.w3.org/1998/Math/MathML",Re=typeof document<"u"?document:null,Hn=Re&&Re.createElement("template"),ll={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const r=t==="svg"?Re.createElementNS(il,e):t==="mathml"?Re.createElementNS(ol,e):s?Re.createElement(e,{is:s}):Re.createElement(e);return e==="select"&&n&&n.multiple!=null&&r.setAttribute("multiple",n.multiple),r},createText:e=>Re.createTextNode(e),createComment:e=>Re.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Re.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,r,i){const o=s?s.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),s),!(r===i||!(r=r.nextSibling)););else{Hn.innerHTML=si(n==="svg"?``:n==="mathml"?``:e);const l=Hn.content;if(n==="svg"||n==="mathml"){const u=l.firstChild;for(;u.firstChild;)l.appendChild(u.firstChild);l.removeChild(u)}t.insertBefore(l,s)}return[o?o.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},cl=Symbol("_vtc");function ul(e,t,s){const n=e[cl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const ls=Symbol("_vod"),ni=Symbol("_vsh"),fl={name:"show",beforeMount(e,{value:t},{transition:s}){e[ls]=e.style.display==="none"?"":e.style.display,s&&t?s.beforeEnter(e):Ct(e,t)},mounted(e,{value:t},{transition:s}){s&&t&&s.enter(e)},updated(e,{value:t,oldValue:s},{transition:n}){!t!=!s&&(n?t?(n.beforeEnter(e),Ct(e,!0),n.enter(e)):n.leave(e,()=>{Ct(e,!1)}):Ct(e,t))},beforeUnmount(e,{value:t}){Ct(e,t)}};function Ct(e,t){e.style.display=t?e[ls]:"none",e[ni]=!t}const al=Symbol(""),dl=/(?:^|;)\s*display\s*:/;function hl(e,t,s){const n=e.style,r=X(s);let i=!1;if(s&&!r){if(t)if(X(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();s[l]==null&&Qt(n,l,"")}else for(const o in t)s[o]==null&&Qt(n,o,"");for(const o in s)o==="display"&&(i=!0),Qt(n,o,s[o])}else if(r){if(t!==s){const o=n[al];o&&(s+=";"+o),n.cssText=s,i=dl.test(s)}}else t&&e.removeAttribute("style");ls in e&&(e[ls]=i?n.display:"",e[ni]&&(n.display="none"))}const Vn=/\s*!important$/;function Qt(e,t,s){if(R(s))s.forEach(n=>Qt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=pl(e,t);Vn.test(s)?e.setProperty(it(n),s.replace(Vn,""),"important"):e[n]=s}}const Un=["Webkit","Moz","ms"],Fs={};function pl(e,t){const s=Fs[t];if(s)return s;let n=Je(t);if(n!=="filter"&&n in e)return Fs[t]=n;n=nr(n);for(let r=0;rRs||(_l.then(()=>Rs=0),Rs=Date.now());function yl(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;Me(xl(n,s.value),t,5,[n])};return s.value=e,s.attached=bl(),s}function xl(e,t){if(R(t)){const s=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{s.call(e),e._stopped=!0},t.map(n=>r=>!r._stopped&&n&&n(r))}else return t}const Jn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Sl=(e,t,s,n,r,i)=>{const o=r==="svg";t==="class"?ul(e,n,o):t==="style"?hl(e,s,n):us(t)?Gs(t)||ml(e,t,s,n,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):wl(e,t,n,o))?(Wn(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Bn(e,t,n,o,i,t!=="value")):e._isVueCE&&(/[A-Z]/.test(t)||!X(n))?Wn(e,Je(t),n,i,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),Bn(e,t,n,o))};function wl(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&Jn(t)&&D(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return Jn(t)&&X(s)?!1:t in e}const cs=e=>{const t=e.props["onUpdate:modelValue"]||!1;return R(t)?s=>Yt(t,s):t};function Cl(e){e.target.composing=!0}function Gn(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const ht=Symbol("_assign");function Yn(e,t,s){return t&&(e=e.trim()),s&&(e=Xs(e)),e}const rt={created(e,{modifiers:{lazy:t,trim:s,number:n}},r){e[ht]=cs(r);const i=n||r.props&&r.props.type==="number";tt(e,t?"change":"input",o=>{o.target.composing||e[ht](Yn(e.value,s,i))}),(s||i)&&tt(e,"change",()=>{e.value=Yn(e.value,s,i)}),t||(tt(e,"compositionstart",Cl),tt(e,"compositionend",Gn),tt(e,"change",Gn))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:r,number:i}},o){if(e[ht]=cs(o),e.composing)return;const l=(i||e.type==="number")&&!/^0\d/.test(e.value)?Xs(e.value):e.value,u=t??"";l!==u&&(document.activeElement===e&&e.type!=="range"&&(n&&t===s||r&&e.value.trim()===u)||(e.value=u))}},zn={created(e,{value:t},s){e.checked=es(t,s.props.value),e[ht]=cs(s),tt(e,"change",()=>{e[ht](Tl(e))})},beforeUpdate(e,{value:t,oldValue:s},n){e[ht]=cs(n),t!==s&&(e.checked=es(t,n.props.value))}};function Tl(e){return"_value"in e?e._value:e.value}const El=["ctrl","shift","alt","meta"],Ol={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>El.some(s=>e[`${s}Key`]&&!t.includes(s))},Pl=(e,t)=>{const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=(r,...i)=>{for(let o=0;o{const t=Il().createApp(...e),{mount:s}=t;return t.mount=n=>{const r=Rl(n);if(!r)return;const i=t._component;!D(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=s(r,!1,Fl(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t};function Fl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Rl(e){return X(e)?document.querySelector(e):e}class dn{constructor(t={}){"videoPath"in t||(this.videoPath=""),"outputPath"in t||(this.outputPath=""),"success"in t||(this.success=!1),Object.assign(this,t)}static createFrom(t={}){let s=typeof t=="string"?JSON.parse(t):t;return new dn(s)}}class bs{constructor(t={}){"path"in t||(this.path=""),"name"in t||(this.name=""),"videoCount"in t||(this.videoCount=0),"videoPaths"in t||(this.videoPaths=[]),Object.assign(this,t)}static createFrom(t={}){const s=$l;let n=typeof t=="string"?JSON.parse(t):t;return"videoPaths"in n&&(n.videoPaths=s(n.videoPaths)),new bs(n)}}class hn{constructor(t={}){"Code"in t||(this.Code=0),"Msg"in t||(this.Msg=""),"Data"in t||(this.Data=null),Object.assign(this,t)}static createFrom(t={}){let s=typeof t=="string"?JSON.parse(t):t;return new hn(s)}}class pn{constructor(t={}){"index"in t||(this.index=0),"fileName"in t||(this.fileName=""),"filePath"in t||(this.filePath=""),"size"in t||(this.size=""),"seconds"in t||(this.seconds=0),"status"in t||(this.status=""),"progress"in t||(this.progress=""),Object.assign(this,t)}static createFrom(t={}){let s=typeof t=="string"?JSON.parse(t):t;return new pn(s)}}const $l=_e.Array(_e.Any),Dl=bs.createFrom;_e.Array(Dl);function Ll(e,t){return vt.ByID(2350837569,e,t).then(s=>jl(s))}const Nl=hn.createFrom,jl=_e.Nullable(Nl);function Hl(e){return vt.ByID(1728131056,e).then(t=>Kl(t))}function Vl(e){return vt.ByID(2398906893,e).then(t=>Bl(t))}const Ul=dn.createFrom,Kl=_e.Array(Ul),Bl=_e.Array(_e.Any);function ri(){return vt.ByID(23551676)}_e.Array(_e.Any);function Wl(e){return vt.ByID(2660164351,e).then(t=>Jl(t))}function kl(e){return vt.ByID(2209109868,e).then(t=>Yl(t))}const ql=pn.createFrom,Jl=_e.Array(ql),Gl=bs.createFrom,Yl=_e.Array(Gl),ys=(e,t)=>{const s=e.__vccOpts||e;for(const[n,r]of t)s[n]=r;return s},zl={class:"video-tab"},Xl={class:"form-group"},Zl={class:"input-group"},Ql={key:0,class:"form-group"},ec={class:"folder-list"},tc={class:"video-count"},sc={class:"form-group"},nc={class:"radio-group"},rc={class:"form-group"},ic=["max","disabled"],oc={class:"hint"},lc={class:"form-group"},cc={class:"input-group"},uc={class:"form-group"},fc=["disabled"],ac={key:1,class:"results"},dc={class:"progress-bar"},hc={class:"progress-text"},pc={__name:"VideoTab",setup(e){const t=Y(""),s=Y([]),n=Y(1),r=Y(1),i=Y(0),o=Y(""),l=Y(!1),u=Y([]),d=ct(()=>t.value&&r.value>0&&r.value<=i.value&&!l.value),a=async()=>{try{const P=await ri();if(P&&P.trim()){t.value=P.trim();try{s.value=await kl(t.value),w()}catch(g){alert("列出文件夹失败: "+g.message)}}}catch(P){console.error("选择文件夹失败:",P),alert("选择文件夹失败: "+(P.message||"未知错误"))}},p=()=>{const P=document.createElement("input");P.type="file",P.accept="image/*",P.onchange=g=>{const E=g.target.files[0];E&&(o.value=E.name)},P.click()},w=()=>{n.value===1?i.value=s.value.reduce((P,g)=>P*g.videoCount,1):i.value=s.value.length>0?s.value[0].videoCount:0},O=async()=>{if(d.value){l.value=!0,u.value=[];try{const P={folderPath:t.value,num:r.value,joinType:n.value,auditImagePath:o.value,folderInfos:s.value};try{const g=await Wl(P);g&&(u.value=g)}catch(g){alert("拼接视频失败: "+g.message)}}catch(P){alert("拼接失败: "+P.message)}finally{l.value=!1}}};return(P,g)=>(J(),Q("div",zl,[g[14]||(g[14]=_("h2",null,"视频拼接",-1)),_("div",Xl,[g[5]||(g[5]=_("label",null,"选择文件夹:",-1)),_("div",Zl,[Pe(_("input",{type:"text","onUpdate:modelValue":g[0]||(g[0]=E=>t.value=E),placeholder:"请选择包含视频文件夹的目录",readonly:""},null,512),[[rt,t.value]]),_("button",{onClick:a},"选择文件夹")])]),s.value.length>0?(J(),Q("div",Ql,[g[6]||(g[6]=_("label",null,"文件夹列表:",-1)),_("div",ec,[(J(!0),Q(de,null,Hs(s.value,(E,$)=>(J(),Q("div",{key:$,class:"folder-item"},[_("span",null,z(E.name),1),_("span",tc,z(E.videoCount)+" 个视频",1)]))),128))])])):ge("",!0),_("div",sc,[g[9]||(g[9]=_("label",null,"拼接模式:",-1)),_("div",nc,[_("label",null,[Pe(_("input",{type:"radio","onUpdate:modelValue":g[1]||(g[1]=E=>n.value=E),value:1},null,512),[[zn,n.value]]),g[7]||(g[7]=Ws(" 组合拼接(从每个文件夹随机选择) ",-1))]),_("label",null,[Pe(_("input",{type:"radio","onUpdate:modelValue":g[2]||(g[2]=E=>n.value=E),value:2},null,512),[[zn,n.value]]),g[8]||(g[8]=Ws(" 顺序拼接(按索引顺序) ",-1))])])]),_("div",rc,[g[10]||(g[10]=_("label",null,"生成数量:",-1)),Pe(_("input",{type:"number","onUpdate:modelValue":g[3]||(g[3]=E=>r.value=E),min:1,max:i.value,disabled:l.value},null,8,ic),[[rt,r.value,void 0,{number:!0}]]),_("span",oc,"最大可生成:"+z(i.value)+" 个",1)]),_("div",lc,[g[11]||(g[11]=_("label",null,"审核图片(可选):",-1)),_("div",cc,[Pe(_("input",{type:"text","onUpdate:modelValue":g[4]||(g[4]=E=>o.value=E),placeholder:"选择审核图片",readonly:""},null,512),[[rt,o.value]]),_("button",{onClick:p},"选择图片")])]),_("div",uc,[_("button",{class:"btn-primary",onClick:O,disabled:!d.value||l.value},z(l.value?"处理中...":"开始拼接"),9,fc)]),u.value.length>0?(J(),Q("div",ac,[g[13]||(g[13]=_("h3",null,"拼接结果",-1)),_("table",null,[g[12]||(g[12]=_("thead",null,[_("tr",null,[_("th",null,"序号"),_("th",null,"文件名"),_("th",null,"大小"),_("th",null,"时长"),_("th",null,"状态"),_("th",null,"进度")])],-1)),_("tbody",null,[(J(!0),Q(de,null,Hs(u.value,E=>(J(),Q("tr",{key:E.index},[_("td",null,z(E.index),1),_("td",null,z(E.fileName),1),_("td",null,z(E.size),1),_("td",null,z(E.seconds)+"秒",1),_("td",{class:pt(E.status==="拼接成功"?"success":"error")},z(E.status),3),_("td",null,[_("div",dc,[_("div",{class:"progress-fill",style:ds({width:E.progress})},null,4),_("span",hc,z(E.progress),1)])])]))),128))])])])):ge("",!0)]))}},gc=ys(pc,[["__scopeId","data-v-976c97bb"]]),mc={class:"extract-tab"},vc={class:"form-group"},_c={class:"input-group"},bc={key:0,class:"form-group"},yc={class:"video-list"},xc={class:"hint"},Sc={class:"form-group"},wc=["disabled"],Cc={class:"form-group"},Tc=["disabled"],Ec=["disabled"],Oc={key:1,class:"help-info"},Pc={key:2,class:"results"},Ac={class:"result-summary"},Ic={__name:"ExtractTab",setup(e){const t=Y(""),s=Y([]),n=Y(1),r=Y(!1),i=Y(""),o=Y([]),l=ct(()=>t.value&&s.value.length>0&&n.value>0&&!r.value),u=ct(()=>t.value&&s.value.length>0&&!r.value),d=ct(()=>o.value.filter(g=>g.success).length),a=ct(()=>o.value.filter(g=>!g.success).length),p=async()=>{try{const g=await ri();if(g&&g.trim()){t.value=g.trim();try{s.value=await Vl(t.value)}catch(E){alert("列出视频失败: "+E.message)}}}catch(g){console.error("选择文件夹失败:",g),alert("选择文件夹失败: "+(g.message||"未知错误"))}},w=g=>g.split("/").pop()||g.split("\\").pop()||g,O=async()=>{if(l.value){r.value=!0,o.value=[],i.value=`开始处理,每个视频将生成 ${n.value} 个抽帧视频...`;try{const g={folderPath:t.value,extractCount:n.value},E=s.value.length*n.value;i.value=`处理中... (0/${E})`;try{const $=await Hl(g);$&&(o.value=$,i.value=`全部完成! 共处理 ${E} 个任务,成功 ${d.value} 个,失败 ${a.value} 个`)}catch($){alert("抽帧失败: "+$.message),i.value="处理失败"}}catch(g){alert("抽帧失败: "+g.message),i.value="处理失败: "+g.message}finally{r.value=!1}}},P=async()=>{if(u.value){r.value=!0,o.value=[],i.value="开始修改元数据...";try{if(extractService&&extractService.ModifyVideosMetadata){const g=s.value.length;i.value=`处理中... (0/${g})`;const E=await extractService.ModifyVideosMetadata(t.value);E&&(o.value=E,i.value=`全部完成! 共处理 ${g} 个任务,成功 ${d.value} 个,失败 ${a.value} 个`)}}catch(g){alert("修改失败: "+g.message),i.value="处理失败: "+g.message}finally{r.value=!1}}};return(g,E)=>(J(),Q("div",mc,[E[6]||(E[6]=_("h2",null,"视频抽帧",-1)),_("div",vc,[E[2]||(E[2]=_("label",null,"选择文件夹:",-1)),_("div",_c,[Pe(_("input",{type:"text","onUpdate:modelValue":E[0]||(E[0]=$=>t.value=$),placeholder:"请选择包含视频文件的目录",readonly:""},null,512),[[rt,t.value]]),_("button",{onClick:p},"选择文件夹")])]),s.value.length>0?(J(),Q("div",bc,[E[3]||(E[3]=_("label",null,"视频文件:",-1)),_("div",yc,[(J(!0),Q(de,null,Hs(s.value,($,V)=>(J(),Q("div",{key:V,class:"video-item"},z(w($)),1))),128))]),_("p",xc,"共 "+z(s.value.length)+" 个视频文件",1)])):ge("",!0),_("div",Sc,[E[4]||(E[4]=_("label",null,"每个视频生成数量:",-1)),Pe(_("input",{type:"number","onUpdate:modelValue":E[1]||(E[1]=$=>n.value=$),min:1,disabled:r.value},null,8,wc),[[rt,n.value,void 0,{number:!0}]])]),_("div",Cc,[_("button",{class:"btn-primary",onClick:O,disabled:!l.value||r.value},z(r.value?"处理中...":"开始抽帧"),9,Tc),_("button",{class:"btn-secondary",onClick:P,disabled:!u.value||r.value,style:{"margin-left":"10px"}},z(r.value?"处理中...":"开始修改元数据"),9,Ec)]),i.value?(J(),Q("div",Oc,z(i.value),1)):ge("",!0),o.value.length>0?(J(),Q("div",Pc,[E[5]||(E[5]=_("h3",null,"处理结果",-1)),_("div",Ac,[_("p",null,"成功: "+z(d.value)+" 个",1),_("p",null,"失败: "+z(a.value)+" 个",1)])])):ge("",!0)]))}},Mc=ys(Ic,[["__scopeId","data-v-32ae0b7c"]]),Fc={class:"dialog-header"},Rc={class:"dialog-body"},$c={class:"form-group"},Dc={class:"form-group"},Lc={key:0,class:"error-message"},Nc={class:"dialog-footer"},jc=["disabled"],Hc={__name:"LoginDialog",props:{allowClose:{type:Boolean,default:!1}},emits:["close","login-success"],setup(e,{emit:t}){const s=e,n=t,r=Y(""),i=Y(""),o=Y(!1),l=Y(""),u=()=>{s.allowClose&&n("close")},d=async()=>{if(!r.value||!i.value){l.value="请输入用户名和密码";return}o.value=!0,l.value="";try{const a=await Ll(r.value,i.value);a&&a.Code===200?(n("login-success"),n("close")):l.value=a&&a.Msg||"登录失败"}catch(a){l.value="登录失败: "+(a.message||a)}finally{o.value=!1}};return(a,p)=>(J(),Q("div",{class:"dialog-overlay",onClick:u},[_("div",{class:"dialog",onClick:p[4]||(p[4]=Pl(()=>{},["stop"]))},[_("div",Fc,[p[5]||(p[5]=_("h3",null,"用户登录",-1)),e.allowClose?(J(),Q("button",{key:0,class:"close-btn",onClick:p[0]||(p[0]=w=>a.$emit("close"))},"×")):ge("",!0)]),_("div",Rc,[_("div",$c,[p[6]||(p[6]=_("label",null,"用户名:",-1)),Pe(_("input",{type:"text","onUpdate:modelValue":p[1]||(p[1]=w=>r.value=w),placeholder:"请输入用户名"},null,512),[[rt,r.value]])]),_("div",Dc,[p[7]||(p[7]=_("label",null,"密码:",-1)),Pe(_("input",{type:"password","onUpdate:modelValue":p[2]||(p[2]=w=>i.value=w),placeholder:"请输入密码"},null,512),[[rt,i.value]])]),l.value?(J(),Q("div",Lc,z(l.value),1)):ge("",!0)]),_("div",Nc,[e.allowClose?(J(),Q("button",{key:0,class:"btn-secondary",onClick:p[3]||(p[3]=w=>a.$emit("close"))},"取消")):ge("",!0),_("button",{class:"btn-primary",onClick:d,disabled:o.value},z(o.value?"登录中...":"登录"),9,jc)])])]))}},Vc=ys(Hc,[["__scopeId","data-v-f054741d"]]),Uc={class:"app-container"},Kc={key:0,class:"login-overlay"},Bc={class:"main-interface"},Wc={class:"sidebar"},kc={class:"menu-items"},qc={class:"main-content"},Jc={__name:"App",setup(e){const t=Y("video"),s=Y(!1),n=Y(!1),r=()=>{const u=localStorage.getItem("isLoggedIn"),d=localStorage.getItem("loginTime");if(u==="true"&&d){const a=Date.now(),p=parseInt(d);(a-p)/(1e3*60*60)<24?n.value=!0:(localStorage.removeItem("isLoggedIn"),localStorage.removeItem("loginTime"),n.value=!1,s.value=!0)}else n.value=!1,s.value=!0},i=()=>{n.value=!0,s.value=!1,localStorage.setItem("isLoggedIn","true"),localStorage.setItem("loginTime",Date.now().toString())},o=()=>{n.value&&(s.value=!1)},l=()=>{confirm("确定要退出登录吗?")&&(n.value=!1,s.value=!0,localStorage.removeItem("isLoggedIn"),localStorage.removeItem("loginTime"))};return Rr(()=>{r()}),(u,d)=>(J(),Q("div",Uc,[n.value?ge("",!0):(J(),Q("div",Kc,[...d[2]||(d[2]=[_("div",{class:"login-message"},[_("h2",null,"请先登录"),_("p",null,"您需要登录后才能使用此应用")],-1)])])),Pe(_("div",Bc,[_("div",Wc,[_("div",kc,[_("button",{class:pt(["menu-item",{active:t.value==="video"}]),onClick:d[0]||(d[0]=a=>t.value="video")},[...d[3]||(d[3]=[_("span",{class:"icon"},"🎬",-1),_("span",null,"视频",-1)])],2),d[5]||(d[5]=_("div",{class:"separator"},null,-1)),_("button",{class:pt(["menu-item",{active:t.value==="extract"}]),onClick:d[1]||(d[1]=a=>t.value="extract")},[...d[4]||(d[4]=[_("span",{class:"icon"},"✂️",-1),_("span",null,"抽帧",-1)])],2)]),_("div",{class:"menu-bottom"},[_("button",{class:"menu-item",onClick:l},[...d[6]||(d[6]=[_("span",{class:"icon"},"👤",-1)])])])]),_("div",qc,[t.value==="video"?(J(),Xt(gc,{key:0})):ge("",!0),t.value==="extract"?(J(),Xt(Mc,{key:1})):ge("",!0)])],512),[[fl,n.value]]),s.value?(J(),Xt(Vc,{key:1,"allow-close":n.value,onClose:o,onLoginSuccess:i},null,8,["allow-close"])):ge("",!0)]))}},Gc=ys(Jc,[["__scopeId","data-v-8a8ce913"]]);Ml(Gc).mount("#app");
diff --git a/wails/assets/index.html b/wails/assets/index.html
index 4cf238a..bfabf9b 100644
--- a/wails/assets/index.html
+++ b/wails/assets/index.html
@@ -1,9 +1,9 @@
-
-
-
-
-
- 视频拼接工具
+
+
+
+
+
+ 视频拼接工具
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/wails/ffmpeg_darwin.go b/wails/ffmpeg_darwin.go
new file mode 100644
index 0000000..7114051
--- /dev/null
+++ b/wails/ffmpeg_darwin.go
@@ -0,0 +1,14 @@
+//go:build darwin
+
+package main
+
+import (
+ "embed"
+)
+
+//go:embed resources/ffmpeg/darwin/*
+var embeddedFFmpeg embed.FS
+
+func getEmbeddedFFmpeg() embed.FS {
+ return embeddedFFmpeg
+}
diff --git a/wails/ffmpeg_default.go b/wails/ffmpeg_default.go
new file mode 100644
index 0000000..bb863d6
--- /dev/null
+++ b/wails/ffmpeg_default.go
@@ -0,0 +1,14 @@
+//go:build !darwin && !windows && !linux
+
+package main
+
+import (
+ "embed"
+)
+
+//go:embed resources/ffmpeg
+var embeddedFFmpeg embed.FS
+
+func getEmbeddedFFmpeg() embed.FS {
+ return embeddedFFmpeg
+}
diff --git a/wails/ffmpeg_linux.go b/wails/ffmpeg_linux.go
new file mode 100644
index 0000000..fab5f34
--- /dev/null
+++ b/wails/ffmpeg_linux.go
@@ -0,0 +1,14 @@
+//go:build linux
+
+package main
+
+import (
+ "embed"
+)
+
+//go:embed resources/ffmpeg/linux/*
+var embeddedFFmpeg embed.FS
+
+func getEmbeddedFFmpeg() embed.FS {
+ return embeddedFFmpeg
+}
diff --git a/wails/ffmpeg_windows.go b/wails/ffmpeg_windows.go
new file mode 100644
index 0000000..23f6af0
--- /dev/null
+++ b/wails/ffmpeg_windows.go
@@ -0,0 +1,14 @@
+//go:build windows
+
+package main
+
+import (
+ "embed"
+)
+
+//go:embed resources/ffmpeg/windows/*
+var embeddedFFmpeg embed.FS
+
+func getEmbeddedFFmpeg() embed.FS {
+ return embeddedFFmpeg
+}
diff --git a/wails/main.go b/wails/main.go
index d7812c8..df54c77 100644
--- a/wails/main.go
+++ b/wails/main.go
@@ -11,6 +11,10 @@ import (
//go:embed assets
var assets embed.FS
+// getEmbeddedFFmpeg 在平台特定文件中实现(ffmpeg_darwin.go, ffmpeg_windows.go, ffmpeg_linux.go, ffmpeg_default.go)
+// 根据编译时的 GOOS 自动选择对应的实现
+// 注意:此函数没有在此文件中声明,而是在平台特定文件中声明和实现
+
func main() {
// 检测开发模式并设置日志级别
if os.Getenv("DEV") == "true" {
@@ -19,6 +23,9 @@ func main() {
services.LogDebug("详细日志已启用")
}
+ // 初始化 FFmpeg 助手(传递嵌入的文件系统,根据编译平台自动选择)
+ services.InitFFmpegHelper(getEmbeddedFFmpeg())
+
// 创建服务
services.LogDebug("开始创建服务...")
videoService := services.NewVideoService()
diff --git a/wails/resources/ffmpeg/.gitignore b/wails/resources/ffmpeg/.gitignore
new file mode 100644
index 0000000..280f3a3
--- /dev/null
+++ b/wails/resources/ffmpeg/.gitignore
@@ -0,0 +1,26 @@
+# 忽略 FFmpeg 二进制文件(文件太大,通常不应提交到 Git)
+# 用户需要自行下载并放置二进制文件到编译时嵌入
+
+# 根目录的二进制文件(向后兼容)
+ffmpeg
+ffprobe
+ffmpeg.exe
+ffprobe.exe
+
+# 按平台组织的目录中的二进制文件
+darwin/ffmpeg
+darwin/ffprobe
+windows/ffmpeg.exe
+windows/ffprobe.exe
+linux/ffmpeg
+linux/ffprobe
+
+# 但保留 README.md、.gitkeep 和目录结构
+!README.md
+!.gitkeep
+!darwin/
+!windows/
+!linux/
+!darwin/.gitkeep
+!windows/.gitkeep
+!linux/.gitkeep
diff --git a/wails/resources/ffmpeg/.gitkeep b/wails/resources/ffmpeg/.gitkeep
new file mode 100644
index 0000000..164da23
--- /dev/null
+++ b/wails/resources/ffmpeg/.gitkeep
@@ -0,0 +1 @@
+# 保留此目录在 Git 中
diff --git a/wails/resources/ffmpeg/README.md b/wails/resources/ffmpeg/README.md
new file mode 100644
index 0000000..67167c8
--- /dev/null
+++ b/wails/resources/ffmpeg/README.md
@@ -0,0 +1,158 @@
+# FFmpeg 二进制文件放置说明
+
+此目录用于存放 FFmpeg 和 FFprobe 的二进制文件。这些文件会被**嵌入到 Go 程序**中,随应用一起分发。
+
+**重要**:二进制文件会被编译到最终的可执行文件中,因此文件大小会增加。建议使用静态构建版本以减小体积。
+
+## 目录结构
+
+### ⭐ 推荐:按平台组织(实现自动平台选择)
+
+```
+resources/
+└── ffmpeg/
+ ├── darwin/ # macOS 版本(编译 macOS 时自动嵌入)
+ │ ├── ffmpeg
+ │ └── ffprobe
+ ├── windows/ # Windows 版本(编译 Windows 时自动嵌入)
+ │ ├── ffmpeg.exe
+ │ └── ffprobe.exe
+ └── linux/ # Linux 版本(编译 Linux 时自动嵌入)
+ ├── ffmpeg
+ └── ffprobe
+```
+
+**优势**:
+- ✅ 每个平台只嵌入对应平台的二进制文件
+- ✅ 减小最终可执行文件大小
+- ✅ 避免跨平台打包时的混乱
+- ✅ 使用 Go 构建标签自动选择
+
+### 选项 2:所有平台通用(向后兼容)
+
+```
+resources/
+└── ffmpeg/
+ ├── ffmpeg # 当前平台的 ffmpeg(macOS/Linux)
+ ├── ffmpeg.exe # 或 Windows 版本
+ ├── ffprobe # 当前平台的 ffprobe(macOS/Linux)
+ └── ffprobe.exe # 或 Windows 版本
+```
+
+**注意**:这种方式会嵌入所有文件,增加不必要的体积。
+
+## 获取 FFmpeg 二进制文件
+
+### macOS
+
+```bash
+# 使用 Homebrew 安装
+brew install ffmpeg
+
+# 复制到资源目录
+cp /opt/homebrew/bin/ffmpeg resources/ffmpeg/ffmpeg
+cp /opt/homebrew/bin/ffprobe resources/ffmpeg/ffprobe
+
+# 或者如果是 Intel Mac
+cp /usr/local/bin/ffmpeg resources/ffmpeg/ffmpeg
+cp /usr/local/bin/ffprobe resources/ffmpeg/ffprobe
+```
+
+### Windows
+
+1. 下载 FFmpeg Windows 构建版本:
+ - 访问 https://www.gyan.dev/ffmpeg/builds/
+ - 下载 ffmpeg-release-essentials.zip
+ - 解压后找到 `bin` 目录下的 `ffmpeg.exe` 和 `ffprobe.exe`
+
+2. 复制到资源目录:
+ ```powershell
+ copy ffmpeg.exe resources\ffmpeg\ffmpeg.exe
+ copy ffprobe.exe resources\ffmpeg\ffprobe.exe
+ ```
+
+### Linux
+
+```bash
+# Ubuntu/Debian
+sudo apt-get install ffmpeg
+
+# 复制到资源目录
+cp /usr/bin/ffmpeg resources/ffmpeg/ffmpeg
+cp /usr/bin/ffprobe resources/ffmpeg/ffprobe
+```
+
+或者下载静态构建版本:
+```bash
+wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
+tar xf ffmpeg-release-amd64-static.tar.xz
+cp ffmpeg-*-amd64-static/ffmpeg resources/ffmpeg/ffmpeg
+cp ffmpeg-*-amd64-static/ffprobe resources/ffmpeg/ffprobe
+```
+
+## 注意事项
+
+1. **文件权限**:在 macOS/Linux 上,确保二进制文件有执行权限:
+ ```bash
+ chmod +x resources/ffmpeg/ffmpeg
+ chmod +x resources/ffmpeg/ffprobe
+ ```
+
+2. **架构匹配**:
+ - macOS:确保使用正确的架构(Intel 或 Apple Silicon)
+ - Linux:确保使用正确的架构(amd64、arm64 等)
+
+3. **依赖库**:静态构建版本通常更好,因为不需要外部依赖库。
+
+4. **打包时**:在打包应用时,确保将此目录的内容包含到应用包中。
+
+## 应用程序查找顺序
+
+1. **系统 PATH**:首先检查系统 PATH 中是否有 ffmpeg/ffprobe
+2. **嵌入资源**:如果未找到,从嵌入的 Go 资源中提取并解压到临时目录
+3. **文件系统资源**:最后尝试从打包的资源目录查找(如果嵌入资源未找到)
+
+## 嵌入和使用方式
+
+### 工作原理
+
+1. **编译时**:
+ - 使用 Go 构建标签(build tags)根据目标平台自动选择嵌入文件
+ - macOS 构建时:只嵌入 `resources/ffmpeg/darwin/*`
+ - Windows 构建时:只嵌入 `resources/ffmpeg/windows/*`
+ - Linux 构建时:只嵌入 `resources/ffmpeg/linux/*`
+
+2. **运行时**:首次使用时,从嵌入的文件系统中提取二进制文件到临时目录
+
+3. **缓存**:提取的文件会缓存在临时目录中,避免重复提取
+
+### 构建标签说明
+
+项目使用以下文件实现平台特定嵌入:
+
+- `ffmpeg_darwin.go` - macOS 平台(使用 `//go:build darwin`)
+- `ffmpeg_windows.go` - Windows 平台(使用 `//go:build windows`)
+- `ffmpeg_linux.go` - Linux 平台(使用 `//go:build linux`)
+- `ffmpeg_default.go` - 其他平台(向后兼容)
+
+编译时,Go 会自动只编译匹配当前平台的文件,因此:
+- 在 macOS 上编译 → 只嵌入 darwin 目录的文件
+- 在 Windows 上编译 → 只嵌入 windows 目录的文件
+- 在 Linux 上编译 → 只嵌入 linux 目录的文件
+
+### 注意事项
+
+- **文件大小**:二进制文件会被嵌入到最终可执行文件中,会增加程序大小
+- **首次运行**:首次运行时需要提取二进制文件,可能会有短暂的延迟
+- **临时目录**:提取的文件保存在 `{临时目录}/videoconcat-ffmpeg/{进程ID}/` 中
+- **权限**:提取的文件会自动设置执行权限(Unix 系统)
+- **清理**:临时文件在程序退出后通常会被系统清理,但进程异常退出时可能需要手动清理
+
+## Git 提交注意事项
+
+默认情况下,`.gitignore` 已配置忽略二进制文件。如果需要将二进制文件提交到 Git:
+
+1. 移除 `.gitignore` 中的相关规则,或
+2. 使用 `git add -f` 强制添加
+
+**注意**:二进制文件通常很大(几十到几百 MB),可能不适合提交到 Git。建议使用构建脚本在编译时自动下载。
diff --git a/wails/resources/ffmpeg/darwin/.gitkeep b/wails/resources/ffmpeg/darwin/.gitkeep
new file mode 100644
index 0000000..c507e5e
--- /dev/null
+++ b/wails/resources/ffmpeg/darwin/.gitkeep
@@ -0,0 +1,5 @@
+# macOS 平台的 FFmpeg 二进制文件放置目录
+#
+# 将 macOS 版本的 ffmpeg 和 ffprobe 放在此目录下:
+# - ffmpeg
+# - ffprobe
diff --git a/wails/resources/ffmpeg/linux/.gitkeep b/wails/resources/ffmpeg/linux/.gitkeep
new file mode 100644
index 0000000..c059a5a
--- /dev/null
+++ b/wails/resources/ffmpeg/linux/.gitkeep
@@ -0,0 +1,5 @@
+# Linux 平台的 FFmpeg 二进制文件放置目录
+#
+# 将 Linux 版本的 ffmpeg 和 ffprobe 放在此目录下:
+# - ffmpeg
+# - ffprobe
diff --git a/wails/resources/ffmpeg/windows/.gitkeep b/wails/resources/ffmpeg/windows/.gitkeep
new file mode 100644
index 0000000..2482af8
--- /dev/null
+++ b/wails/resources/ffmpeg/windows/.gitkeep
@@ -0,0 +1,5 @@
+# Windows 平台的 FFmpeg 二进制文件放置目录
+#
+# 将 Windows 版本的 ffmpeg.exe 和 ffprobe.exe 放在此目录下:
+# - ffmpeg.exe
+# - ffprobe.exe
diff --git a/wails/resources/ffmpeg/使用说明.md b/wails/resources/ffmpeg/使用说明.md
new file mode 100644
index 0000000..2a022dc
--- /dev/null
+++ b/wails/resources/ffmpeg/使用说明.md
@@ -0,0 +1,220 @@
+# FFmpeg 嵌入使用说明
+
+## 快速开始
+
+### 1. 下载 FFmpeg 二进制文件
+
+根据你的目标平台下载对应的 FFmpeg 静态构建版本,并放置到对应的平台目录下:
+
+**重要**:使用平台特定目录组织,编译时会自动只嵌入对应平台的版本。
+
+#### macOS (Apple Silicon 或 Intel)
+```bash
+# 使用 Homebrew 安装(推荐)
+brew install ffmpeg
+
+# 复制到 macOS 专用目录
+cp /opt/homebrew/bin/ffmpeg wails/resources/ffmpeg/darwin/ffmpeg
+cp /opt/homebrew/bin/ffprobe wails/resources/ffmpeg/darwin/ffprobe
+
+# 或者如果是 Intel Mac
+cp /usr/local/bin/ffmpeg wails/resources/ffmpeg/darwin/ffmpeg
+cp /usr/local/bin/ffprobe wails/resources/ffmpeg/darwin/ffprobe
+
+# 设置权限
+chmod +x wails/resources/ffmpeg/darwin/ffmpeg
+chmod +x wails/resources/ffmpeg/darwin/ffprobe
+```
+
+**或者下载静态构建版本:**
+```bash
+# Apple Silicon
+wget https://evermeet.cx/ffmpeg/ffmpeg-6.1.zip
+unzip ffmpeg-6.1.zip
+cp ffmpeg wails/resources/ffmpeg/darwin/ffmpeg
+cp ffprobe wails/resources/ffmpeg/darwin/ffprobe
+chmod +x wails/resources/ffmpeg/darwin/ffmpeg wails/resources/ffmpeg/darwin/ffprobe
+```
+
+#### Windows
+```powershell
+# 下载
+# 访问 https://www.gyan.dev/ffmpeg/builds/
+# 下载 ffmpeg-release-essentials.zip
+# 解压后复制 bin 目录下的文件到 windows 目录
+
+copy ffmpeg.exe wails\resources\ffmpeg\windows\ffmpeg.exe
+copy ffprobe.exe wails\resources\ffmpeg\windows\ffprobe.exe
+```
+
+#### Linux
+```bash
+# 下载静态构建版本
+wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
+tar xf ffmpeg-release-amd64-static.tar.xz
+cd ffmpeg-*-amd64-static
+
+# 复制到 Linux 专用目录
+cp ffmpeg ../../wails/resources/ffmpeg/linux/ffmpeg
+cp ffprobe ../../wails/resources/ffmpeg/linux/ffprobe
+chmod +x ../../wails/resources/ffmpeg/linux/ffmpeg
+chmod +x ../../wails/resources/ffmpeg/linux/ffprobe
+```
+
+### 2. 编译应用
+
+二进制文件会在编译时根据目标平台自动选择并嵌入:
+
+```bash
+cd wails
+
+# 在 macOS 上编译 macOS 版本(只嵌入 darwin/ 目录的文件)
+go build -o VideoConcat
+
+# 交叉编译 Windows 版本(只嵌入 windows/ 目录的文件)
+GOOS=windows GOARCH=amd64 go build -o VideoConcat.exe
+
+# 交叉编译 Linux 版本(只嵌入 linux/ 目录的文件)
+GOOS=linux GOARCH=amd64 go build -o VideoConcat
+```
+
+**优势**:
+- ✅ macOS 版本只包含 macOS 的 FFmpeg,体积更小
+- ✅ Windows 版本只包含 Windows 的 FFmpeg,体积更小
+- ✅ 交叉编译时不会混入其他平台的二进制文件
+
+### 3. 运行应用
+
+应用启动时会自动:
+1. 检查系统 PATH 中的 ffmpeg
+2. 如果未找到,从嵌入的资源中提取到临时目录
+3. 使用提取的二进制文件
+
+## 工作原理
+
+### 嵌入过程
+
+```go
+// main.go
+//go:embed resources/ffmpeg
+var embeddedFFmpeg embed.FS
+```
+
+这行代码会在编译时将所有 `resources/ffmpeg/` 目录下的文件嵌入到程序中。
+
+### 运行时提取
+
+应用首次启动时:
+1. 检查系统是否有 ffmpeg
+2. 如果没有,从嵌入的文件系统中查找匹配的二进制文件
+3. 提取到临时目录:`{临时目录}/videoconcat-ffmpeg/{进程ID}/`
+4. 设置执行权限
+5. 使用提取的文件
+
+### 文件查找顺序
+
+1. `resources/ffmpeg/ffmpeg` 或 `resources/ffmpeg/ffmpeg.exe`
+2. `resources/ffmpeg/{GOOS}/ffmpeg` 或 `resources/ffmpeg/{GOOS}/ffmpeg.exe`
+3. `resources/ffmpeg/{GOOS}_{GOARCH}/ffmpeg` 等
+
+## 目录结构示例
+
+### ⭐ 推荐:按平台组织(自动平台选择)
+
+```
+wails/resources/ffmpeg/
+├── darwin/ # macOS 版本
+│ ├── ffmpeg
+│ └── ffprobe
+├── windows/ # Windows 版本
+│ ├── ffmpeg.exe
+│ └── ffprobe.exe
+├── linux/ # Linux 版本
+│ ├── ffmpeg
+│ └── ffprobe
+└── README.md
+```
+
+**编译行为**:
+- 在 macOS 上编译 → 只嵌入 `darwin/` 目录
+- 在 Windows 上编译 → 只嵌入 `windows/` 目录
+- 在 Linux 上编译 → 只嵌入 `linux/` 目录
+
+### 向后兼容:单目录结构
+
+```
+wails/resources/ffmpeg/
+├── ffmpeg # 当前平台的 ffmpeg(不推荐)
+├── ffprobe # 当前平台的 ffprobe
+└── README.md
+```
+
+**注意**:这种方式会嵌入所有文件,不推荐使用。
+
+## 注意事项
+
+### 文件大小
+
+- FFmpeg 静态构建版本通常 50-200 MB
+- 嵌入后会增加最终可执行文件的大小
+- 考虑使用 LZMA 压缩或分平台构建来减小体积
+
+### Git 提交
+
+默认情况下,二进制文件被 `.gitignore` 忽略。如果需要提交:
+
+```bash
+# 方法1:强制添加
+git add -f wails/resources/ffmpeg/ffmpeg
+
+# 方法2:修改 .gitignore(不推荐,文件太大)
+```
+
+### 权限问题
+
+在 macOS/Linux 上,确保文件有执行权限:
+```bash
+chmod +x wails/resources/ffmpeg/ffmpeg
+chmod +x wails/resources/ffmpeg/ffprobe
+```
+
+### 临时文件清理
+
+提取的文件保存在临时目录中。如果进程异常退出,可能需要手动清理:
+
+```bash
+# macOS/Linux
+rm -rf /tmp/videoconcat-ffmpeg
+
+# Windows
+rd /s /q %TEMP%\videoconcat-ffmpeg
+```
+
+## 故障排查
+
+### 问题:找不到 ffmpeg
+
+**检查清单:**
+1. 确认二进制文件已放置在 `wails/resources/ffmpeg/` 目录
+2. 检查文件名是否正确(Windows 需要 `.exe` 后缀)
+3. 检查文件权限(macOS/Linux)
+4. 查看应用日志,查找详细的错误信息
+
+### 问题:权限被拒绝
+
+**解决方案:**
+```bash
+chmod +x wails/resources/ffmpeg/ffmpeg
+chmod +x wails/resources/ffmpeg/ffprobe
+```
+
+### 问题:提取失败
+
+**可能原因:**
+- 临时目录不可写
+- 磁盘空间不足
+- 文件损坏
+
+**检查:**
+- 查看应用日志
+- 检查临时目录权限和空间
diff --git a/wails/resources/ffmpeg/平台特定嵌入说明.md b/wails/resources/ffmpeg/平台特定嵌入说明.md
new file mode 100644
index 0000000..f509bd4
--- /dev/null
+++ b/wails/resources/ffmpeg/平台特定嵌入说明.md
@@ -0,0 +1,149 @@
+# 平台特定 FFmpeg 嵌入说明
+
+## 概述
+
+项目使用 **Go 构建标签(Build Tags)** 实现平台特定的 FFmpeg 嵌入,确保每个平台的构建只包含对应平台的二进制文件。
+
+## 工作原理
+
+### 构建标签文件
+
+项目包含以下平台特定文件:
+
+- `ffmpeg_darwin.go` - macOS 平台(`//go:build darwin`)
+- `ffmpeg_windows.go` - Windows 平台(`//go:build windows`)
+- `ffmpeg_linux.go` - Linux 平台(`//go:build linux`)
+- `ffmpeg_default.go` - 其他平台(向后兼容)
+
+### 编译时的行为
+
+Go 编译器会根据当前编译目标平台(`GOOS`)自动选择匹配的文件:
+
+| 编译平台 | 使用文件 | 嵌入目录 | 结果 |
+|---------|---------|---------|------|
+| `GOOS=darwin` | `ffmpeg_darwin.go` | `resources/ffmpeg/darwin/*` | 只包含 macOS 版本 |
+| `GOOS=windows` | `ffmpeg_windows.go` | `resources/ffmpeg/windows/*` | 只包含 Windows 版本 |
+| `GOOS=linux` | `ffmpeg_linux.go` | `resources/ffmpeg/linux/*` | 只包含 Linux 版本 |
+
+## 目录结构
+
+```
+wails/
+├── main.go # 主文件(声明 getEmbeddedFFmpeg 函数)
+├── ffmpeg_darwin.go # macOS 实现
+├── ffmpeg_windows.go # Windows 实现
+├── ffmpeg_linux.go # Linux 实现
+├── ffmpeg_default.go # 默认实现(向后兼容)
+└── resources/
+ └── ffmpeg/
+ ├── darwin/ # macOS 二进制文件
+ │ ├── ffmpeg
+ │ └── ffprobe
+ ├── windows/ # Windows 二进制文件
+ │ ├── ffmpeg.exe
+ │ └── ffprobe.exe
+ └── linux/ # Linux 二进制文件
+ ├── ffmpeg
+ └── ffprobe
+```
+
+## 使用方法
+
+### 1. 准备二进制文件
+
+将各平台的 FFmpeg 二进制文件放置到对应目录:
+
+```bash
+# macOS
+cp /opt/homebrew/bin/ffmpeg wails/resources/ffmpeg/darwin/ffmpeg
+cp /opt/homebrew/bin/ffprobe wails/resources/ffmpeg/darwin/ffprobe
+
+# Windows
+copy ffmpeg.exe wails\resources\ffmpeg\windows\ffmpeg.exe
+copy ffprobe.exe wails\resources\ffmpeg\windows\ffprobe.exe
+
+# Linux
+cp ffmpeg wails/resources/ffmpeg/linux/ffmpeg
+cp ffprobe wails/resources/ffmpeg/linux/ffprobe
+```
+
+### 2. 编译不同平台
+
+```bash
+# macOS 版本(只嵌入 darwin/ 目录)
+GOOS=darwin go build -o VideoConcat-mac
+
+# Windows 版本(只嵌入 windows/ 目录)
+GOOS=windows GOARCH=amd64 go build -o VideoConcat.exe
+
+# Linux 版本(只嵌入 linux/ 目录)
+GOOS=linux GOARCH=amd64 go build -o VideoConcat-linux
+```
+
+### 3. 验证嵌入内容
+
+编译后,可以使用以下方法验证:
+
+```bash
+# macOS/Linux
+strings VideoConcat | grep -i ffmpeg | head -5
+
+# Windows (使用 PowerShell)
+Select-String -Path VideoConcat.exe -Pattern "ffmpeg" | Select-Object -First 5
+```
+
+## 优势
+
+### 1. 减小文件体积
+
+- 只嵌入当前平台的二进制文件
+- 减少不必要的文件大小增加
+- 示例:如果每个平台 FFmpeg 是 50MB,使用平台特定嵌入后:
+ - 之前:所有平台 = 150MB(三个平台都包含)
+ - 现在:每个平台 = 50MB(只包含对应平台)
+
+### 2. 避免交叉编译问题
+
+- 编译 Windows 版本时不会误嵌入 macOS 版本
+- 编译 Linux 版本时不会误嵌入 Windows 版本
+- 避免运行时找不到正确文件的问题
+
+### 3. 清晰的目录组织
+
+- 各平台文件分开管理
+- 易于维护和更新
+- 符合 Go 的最佳实践
+
+## 故障排查
+
+### 问题:编译时找不到文件
+
+**原因**:对应平台的目录中没有二进制文件
+
+**解决**:
+```bash
+# 检查文件是否存在
+ls -la wails/resources/ffmpeg/darwin/
+ls -la wails/resources/ffmpeg/windows/
+ls -la wails/resources/ffmpeg/linux/
+```
+
+### 问题:交叉编译时仍嵌入所有文件
+
+**原因**:使用了旧的目录结构(根目录下的文件)
+
+**解决**:确保使用平台特定目录(`darwin/`, `windows/`, `linux/`)
+
+### 问题:运行时找不到 FFmpeg
+
+**检查**:
+1. 查看应用日志,确认 FFmpeg 查找路径
+2. 检查临时目录中是否成功提取
+3. 验证二进制文件是否有执行权限
+
+## 向后兼容
+
+如果不想使用平台特定目录,仍然可以使用根目录放置文件(`resources/ffmpeg/ffmpeg`),但这样会:
+- 嵌入所有平台的二进制文件
+- 增加不必要的文件大小
+- 不推荐使用
diff --git a/wails/services/extract_service.go b/wails/services/extract_service.go
index 3f86865..3269e6e 100644
--- a/wails/services/extract_service.go
+++ b/wails/services/extract_service.go
@@ -5,7 +5,6 @@ import (
"fmt"
"math/rand"
"os"
- "os/exec"
"path/filepath"
"strings"
"sync"
@@ -54,7 +53,11 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
os.MkdirAll(tempDir, 0755)
// 获取视频信息
- cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath)
+ helper := GetFFmpegHelper()
+ if !helper.IsProbeAvailable() {
+ return fmt.Errorf("ffprobe 不可用,请确保已安装 ffmpeg")
+ }
+ cmd := helper.ProbeCommand("-v", "error", "-show_entries", "format=duration:stream=codec_name,r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", inputPath)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("获取视频信息失败: %v", err)
@@ -94,8 +97,11 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
// 如果是 HEVC,先转换为 H.264
if codecName == "hevc" {
+ if !helper.IsAvailable() {
+ return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
+ }
videoConvert := filepath.Join(tempDir, "convert.mp4")
- cmd := exec.Command("ffmpeg", "-i", inputPath, "-c:v", "libx264", "-y", videoConvert)
+ cmd := helper.Command("-i", inputPath, "-c:v", "libx264", "-y", videoConvert)
if err := cmd.Run(); err != nil {
return fmt.Errorf("转换HEVC失败: %v", err)
}
@@ -116,13 +122,13 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
videoPart2 := filepath.Join(tempDir, "part2.mp4")
// 第一部分:0 到 randomFrame - 0.016
- cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", "0", "-t", fmt.Sprintf("%.6f", randomFrame-0.016), "-c", "copy", "-y", videoPart1)
+ cmd = helper.Command("-i", inputPath, "-ss", "0", "-t", fmt.Sprintf("%.6f", randomFrame-0.016), "-c", "copy", "-y", videoPart1)
if err := cmd.Run(); err != nil {
return fmt.Errorf("裁剪第一部分失败: %v", err)
}
// 第二部分:randomFrame 到结束
- cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", fmt.Sprintf("%.6f", randomFrame), "-c", "copy", "-y", videoPart2)
+ cmd = helper.Command("-i", inputPath, "-ss", fmt.Sprintf("%.6f", randomFrame), "-c", "copy", "-y", videoPart2)
if err := cmd.Run(); err != nil {
return fmt.Errorf("裁剪第二部分失败: %v", err)
}
@@ -137,7 +143,7 @@ func (s *ExtractService) RemoveFrameRandom(ctx context.Context, inputPath string
file.WriteString(fmt.Sprintf("file '%s'\n", strings.ReplaceAll(videoPart2, "\\", "/")))
file.Close()
- cmd = exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath)
+ cmd = helper.Command("-f", "concat", "-safe", "0", "-i", concatFile, "-c", "copy", "-y", outputPath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("合并视频失败: %v", err)
}
@@ -247,8 +253,12 @@ func (s *ExtractService) ExtractFrames(ctx context.Context, req ExtractFrameRequ
// ModifyByMetadata 通过修改元数据改变文件 MD5
func (s *ExtractService) ModifyByMetadata(ctx context.Context, inputPath string, outputPath string) error {
+ helper := GetFFmpegHelper()
+ if !helper.IsAvailable() {
+ return fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
+ }
comment := fmt.Sprintf("JSY_%s", time.Now().Format("20060102150405"))
- cmd := exec.Command("ffmpeg", "-i", inputPath,
+ cmd := helper.Command("-i", inputPath,
"-c", "copy",
"-metadata", fmt.Sprintf("comment=%s", comment),
"-y", outputPath)
diff --git a/wails/services/ffmpeg_helper.go b/wails/services/ffmpeg_helper.go
new file mode 100644
index 0000000..646dd90
--- /dev/null
+++ b/wails/services/ffmpeg_helper.go
@@ -0,0 +1,383 @@
+package services
+
+import (
+ "embed"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+)
+
+// FFmpegHelper FFmpeg 工具助手
+type FFmpegHelper struct {
+ ffmpegPath string
+ ffprobePath string
+ embeddedFS *embed.FS // 使用指针以便检查 nil
+ extractedPath string // 已提取的临时目录路径
+}
+
+var ffmpegHelper *FFmpegHelper
+
+// InitFFmpegHelper 初始化 FFmpeg 助手(传入嵌入的文件系统)
+func InitFFmpegHelper(embeddedFS embed.FS) {
+ if ffmpegHelper == nil {
+ ffmpegHelper = &FFmpegHelper{
+ embeddedFS: &embeddedFS,
+ }
+ ffmpegHelper.init()
+ }
+}
+
+// GetFFmpegHelper 获取 FFmpeg 助手实例(单例模式)
+func GetFFmpegHelper() *FFmpegHelper {
+ if ffmpegHelper == nil {
+ // 如果没有初始化,创建一个不包含嵌入文件系统的实例
+ ffmpegHelper = &FFmpegHelper{}
+ ffmpegHelper.init()
+ }
+ return ffmpegHelper
+}
+
+// init 初始化 FFmpeg 路径
+func (h *FFmpegHelper) init() {
+ // 先尝试从系统 PATH 查找
+ h.ffmpegPath = h.findExecutable("ffmpeg")
+ h.ffprobePath = h.findExecutable("ffprobe")
+
+ // 如果系统 PATH 中没有,尝试从嵌入的资源中提取
+ if h.ffmpegPath == "" && h.hasEmbeddedFS() {
+ h.ffmpegPath = h.extractEmbeddedBinary("ffmpeg")
+ }
+ if h.ffprobePath == "" && h.hasEmbeddedFS() {
+ h.ffprobePath = h.extractEmbeddedBinary("ffprobe")
+ }
+
+ // 如果嵌入的资源也没有,尝试从打包的资源目录中查找
+ if h.ffmpegPath == "" {
+ h.ffmpegPath = h.findBundledBinary("ffmpeg")
+ }
+ if h.ffprobePath == "" {
+ h.ffprobePath = h.findBundledBinary("ffprobe")
+ }
+
+ // 记录日志
+ if h.ffmpegPath != "" {
+ LogInfof("找到 ffmpeg: %s", h.ffmpegPath)
+ } else {
+ LogWarn("未找到 ffmpeg,视频处理功能可能无法使用")
+ LogWarn("请确保 ffmpeg 已安装并在系统 PATH 中,或将其嵌入到 resources/ffmpeg/ 目录下")
+ }
+
+ if h.ffprobePath != "" {
+ LogInfof("找到 ffprobe: %s", h.ffprobePath)
+ } else {
+ LogWarn("未找到 ffprobe,视频信息获取功能可能无法使用")
+ }
+}
+
+// findExecutable 从系统 PATH 中查找可执行文件
+func (h *FFmpegHelper) findExecutable(name string) string {
+ path, err := exec.LookPath(name)
+ if err == nil {
+ // 验证文件是否可执行
+ if info, err := os.Stat(path); err == nil {
+ if runtime.GOOS != "windows" {
+ // Unix 系统检查执行权限
+ if info.Mode().Perm()&0111 != 0 {
+ return path
+ }
+ } else {
+ // Windows 系统直接返回
+ return path
+ }
+ }
+ }
+ return ""
+}
+
+// findBundledBinary 从打包的资源中查找二进制文件
+func (h *FFmpegHelper) findBundledBinary(name string) string {
+ // 获取可执行文件所在目录
+ exePath, err := os.Executable()
+ if err != nil {
+ return ""
+ }
+
+ exeDir := filepath.Dir(exePath)
+
+ // 根据操作系统确定二进制文件名
+ binaryName := name
+ if runtime.GOOS == "windows" {
+ binaryName = name + ".exe"
+ }
+
+ // 可能的资源目录路径(按优先级排序)
+ possiblePaths := []string{
+ // macOS app bundle 中的路径
+ filepath.Join(exeDir, "..", "Resources", "ffmpeg", binaryName),
+ // Windows/Linux 相对路径
+ filepath.Join(exeDir, "resources", "ffmpeg", binaryName),
+ // 开发环境的相对路径
+ filepath.Join(exeDir, "..", "resources", "ffmpeg", binaryName),
+ // 当前目录
+ filepath.Join(".", "resources", "ffmpeg", binaryName),
+ // 直接在可执行文件目录
+ filepath.Join(exeDir, binaryName),
+ }
+
+ for _, path := range possiblePaths {
+ // 转换为绝对路径
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ continue
+ }
+
+ // 检查文件是否存在
+ if info, err := os.Stat(absPath); err == nil && !info.IsDir() {
+ // 在首次使用时,可能需要提取到临时目录并设置执行权限
+ if runtime.GOOS != "windows" {
+ // 确保文件有执行权限
+ os.Chmod(absPath, 0755)
+ }
+ return absPath
+ }
+ }
+
+ return ""
+}
+
+// GetFFmpegPath 获取 ffmpeg 路径
+func (h *FFmpegHelper) GetFFmpegPath() string {
+ return h.ffmpegPath
+}
+
+// GetFFprobePath 获取 ffprobe 路径
+func (h *FFmpegHelper) GetFFprobePath() string {
+ return h.ffprobePath
+}
+
+// Command 创建 FFmpeg 命令
+func (h *FFmpegHelper) Command(args ...string) *exec.Cmd {
+ if h.ffmpegPath == "" {
+ return nil
+ }
+ return exec.Command(h.ffmpegPath, args...)
+}
+
+// ProbeCommand 创建 FFprobe 命令
+func (h *FFmpegHelper) ProbeCommand(args ...string) *exec.Cmd {
+ if h.ffprobePath == "" {
+ return nil
+ }
+ return exec.Command(h.ffprobePath, args...)
+}
+
+// IsAvailable 检查 FFmpeg 是否可用
+func (h *FFmpegHelper) IsAvailable() bool {
+ return h.ffmpegPath != ""
+}
+
+// IsProbeAvailable 检查 FFprobe 是否可用
+func (h *FFmpegHelper) IsProbeAvailable() bool {
+ return h.ffprobePath != ""
+}
+
+// hasEmbeddedFS 检查是否有嵌入的文件系统
+func (h *FFmpegHelper) hasEmbeddedFS() bool {
+ return h.embeddedFS != nil
+}
+
+// extractEmbeddedBinary 从嵌入的文件系统中提取二进制文件
+func (h *FFmpegHelper) extractEmbeddedBinary(name string) string {
+ if !h.hasEmbeddedFS() {
+ return ""
+ }
+
+ // 确定二进制文件名(根据当前平台)
+ binaryName := name
+ if runtime.GOOS == "windows" {
+ binaryName = name + ".exe"
+ }
+
+ // 在嵌入的文件系统中查找文件
+ // 根据不同的嵌入方式,可能的路径有所不同:
+ // - 如果使用平台特定目录:resources/ffmpeg/darwin/ffmpeg
+ // - 如果使用通用目录:resources/ffmpeg/ffmpeg
+ possiblePaths := []string{
+ // 平台特定目录(推荐,用于分离打包)
+ fmt.Sprintf("resources/ffmpeg/%s/%s", runtime.GOOS, binaryName),
+ fmt.Sprintf("resources/ffmpeg/%s/%s", runtime.GOOS, name),
+ fmt.Sprintf("resources/ffmpeg/%s_%s/%s", runtime.GOOS, runtime.GOARCH, binaryName),
+ // 通用路径(向后兼容)
+ fmt.Sprintf("resources/ffmpeg/%s", binaryName),
+ fmt.Sprintf("resources/ffmpeg/%s", name),
+ }
+
+ var binaryData []byte
+ var err error
+
+ for _, path := range possiblePaths {
+ binaryData, err = h.embeddedFS.ReadFile(path)
+ if err == nil {
+ LogDebugf("从嵌入资源中找到二进制文件: %s", path)
+ break
+ }
+ }
+
+ if err != nil {
+ LogDebugf("未在嵌入资源中找到 %s,尝试遍历目录", name)
+ // 如果直接路径找不到,尝试遍历目录查找
+ binaryData, _ = h.findBinaryInEmbeddedFS(name)
+ if binaryData == nil {
+ return ""
+ }
+ }
+
+ // 提取到临时目录
+ path, err := h.extractToTemp(name, binaryData)
+ if err != nil {
+ LogErrorf("提取嵌入的二进制文件失败: %v", err)
+ return ""
+ }
+
+ return path
+}
+
+// findBinaryInEmbeddedFS 在嵌入的文件系统中查找二进制文件
+func (h *FFmpegHelper) findBinaryInEmbeddedFS(name string) ([]byte, string) {
+ if !h.hasEmbeddedFS() {
+ return nil, ""
+ }
+
+ binaryName := name
+ if runtime.GOOS == "windows" {
+ binaryName = name + ".exe"
+ }
+
+ // 使用结构体存储结果
+ type result struct {
+ data []byte
+ path string
+ }
+ var found *result
+
+ // 遍历 resources/ffmpeg 目录
+ err := fs.WalkDir(h.embeddedFS, "resources/ffmpeg", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil // 忽略错误,继续遍历
+ }
+
+ // 检查文件名是否匹配
+ if !d.IsDir() && (d.Name() == name || d.Name() == binaryName) {
+ data, readErr := h.embeddedFS.ReadFile(path)
+ if readErr == nil {
+ // 找到匹配的文件
+ found = &result{
+ data: data,
+ path: path,
+ }
+ return fs.SkipAll // 找到后停止遍历
+ }
+ }
+ return nil
+ })
+
+ if err == nil && found != nil {
+ return found.data, found.path
+ }
+
+ return nil, ""
+}
+
+// extractToTemp 提取二进制文件到临时目录
+func (h *FFmpegHelper) extractToTemp(name string, data []byte) (string, error) {
+ // 创建临时目录(只创建一次)
+ if h.extractedPath == "" {
+ tempDir := filepath.Join(os.TempDir(), "videoconcat-ffmpeg", fmt.Sprintf("%d", os.Getpid()))
+ if err := os.MkdirAll(tempDir, 0755); err != nil {
+ return "", fmt.Errorf("创建临时目录失败: %v", err)
+ }
+ h.extractedPath = tempDir
+ }
+
+ // 确定二进制文件名
+ binaryName := name
+ if runtime.GOOS == "windows" {
+ binaryName = name + ".exe"
+ }
+
+ // 目标路径
+ targetPath := filepath.Join(h.extractedPath, binaryName)
+
+ // 如果文件已存在且大小一致,直接返回
+ if info, err := os.Stat(targetPath); err == nil {
+ if info.Size() == int64(len(data)) {
+ return targetPath, nil
+ }
+ // 文件大小不一致,删除重新写入
+ os.Remove(targetPath)
+ }
+
+ // 写入文件
+ file, err := os.Create(targetPath)
+ if err != nil {
+ return "", fmt.Errorf("创建文件失败: %v", err)
+ }
+ defer file.Close()
+
+ if _, err := file.Write(data); err != nil {
+ return "", fmt.Errorf("写入文件失败: %v", err)
+ }
+
+ // 设置执行权限(Unix 系统)
+ if runtime.GOOS != "windows" {
+ if err := os.Chmod(targetPath, 0755); err != nil {
+ return "", fmt.Errorf("设置执行权限失败: %v", err)
+ }
+ }
+
+ // 验证文件
+ if _, err := os.Stat(targetPath); err != nil {
+ return "", fmt.Errorf("验证文件失败: %v", err)
+ }
+
+ LogInfof("已提取嵌入的二进制文件到: %s", targetPath)
+ return targetPath, nil
+}
+
+// ExtractBundledBinary 从嵌入的资源中提取二进制文件到临时目录(兼容旧接口)
+func (h *FFmpegHelper) ExtractBundledBinary(name string, data []byte) (string, error) {
+ return h.extractToTemp(name, data)
+}
+
+// CopyFile 复制文件(用于从嵌入资源复制到目标位置)
+func (h *FFmpegHelper) CopyFile(src, dst string) error {
+ sourceFile, err := os.Open(src)
+ if err != nil {
+ return fmt.Errorf("打开源文件失败: %v", err)
+ }
+ defer sourceFile.Close()
+
+ destFile, err := os.Create(dst)
+ if err != nil {
+ return fmt.Errorf("创建目标文件失败: %v", err)
+ }
+ defer destFile.Close()
+
+ _, err = io.Copy(destFile, sourceFile)
+ if err != nil {
+ return fmt.Errorf("复制文件失败: %v", err)
+ }
+
+ // 设置执行权限(Unix 系统)
+ if runtime.GOOS != "windows" {
+ if err := os.Chmod(dst, 0755); err != nil {
+ return fmt.Errorf("设置执行权限失败: %v", err)
+ }
+ }
+
+ return nil
+}
diff --git a/wails/services/video_service.go b/wails/services/video_service.go
index c4cc0c8..e954103 100644
--- a/wails/services/video_service.go
+++ b/wails/services/video_service.go
@@ -8,7 +8,6 @@ import (
"io"
"math/rand"
"os"
- "os/exec"
"path/filepath"
"strings"
"sync"
@@ -129,7 +128,11 @@ func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) {
}
// 使用 FFmpeg 转换
- cmd := exec.Command("ffmpeg", "-i", videoPath,
+ helper := GetFFmpegHelper()
+ if !helper.IsAvailable() {
+ return "", fmt.Errorf("ffmpeg 不可用,请确保已安装 ffmpeg")
+ }
+ cmd := helper.Command("-i", videoPath,
"-c:v", "libx264",
"-c:a", "aac",
"-ar", "44100",
@@ -143,7 +146,7 @@ func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) {
output, err := cmd.CombinedOutput()
if err != nil {
// 尝试备用方案
- cmd2 := exec.Command("ffmpeg", "-i", videoPath,
+ cmd2 := helper.Command("-i", videoPath,
"-c", "copy",
"-f", "mpegts",
"-y", tsPath)
@@ -151,6 +154,7 @@ func (s *VideoService) ConvertVideoToTS(videoPath string) (string, error) {
if err2 != nil {
return "", fmt.Errorf("视频转换失败: %v, 输出: %s, 备用方案失败: %v, 输出: %s", err, string(output), err2, string(output2))
}
+ // 备用方案成功,继续执行
}
return tsPath, nil
@@ -343,7 +347,17 @@ func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) (
"-y", outputPath,
}
- cmd := exec.Command("ffmpeg", args...)
+ helper := GetFFmpegHelper()
+ if !helper.IsAvailable() {
+ LogError("ffmpeg 不可用")
+ result.Status = "拼接失败"
+ result.Progress = "失败"
+ mu.Lock()
+ results = append(results, result)
+ mu.Unlock()
+ return
+ }
+ cmd := helper.Command(args...)
err = cmd.Run()
if err != nil {
LogError(fmt.Sprintf("拼接视频失败: %v", err))
@@ -366,7 +380,17 @@ func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) (
"-y", outputImgPath,
}
- cmd := exec.Command("ffmpeg", args...)
+ helper := GetFFmpegHelper()
+ if !helper.IsAvailable() {
+ LogError("ffmpeg 不可用")
+ result.Status = "拼接失败"
+ result.Progress = "失败"
+ mu.Lock()
+ results = append(results, result)
+ mu.Unlock()
+ return
+ }
+ cmd := helper.Command(args...)
cmd.Run() // 忽略错误,水印是可选的
}
@@ -383,12 +407,18 @@ func (s *VideoService) JoinVideos(ctx context.Context, req VideoConcatRequest) (
// 获取视频时长(使用 ffprobe)
seconds := 0
- cmd = exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath)
- output, err := cmd.Output()
- if err == nil {
- var duration float64
- fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration)
- seconds = int(duration)
+ if helper.IsProbeAvailable() {
+ cmd = helper.ProbeCommand("-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", outputPath)
+ } else {
+ cmd = nil
+ }
+ if cmd != nil {
+ output, err := cmd.Output()
+ if err == nil {
+ var duration float64
+ fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration)
+ seconds = int(duration)
+ }
}
sizeMB := fileInfo.Size() / 1024 / 1024