diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index f33f9823..05376543 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -22,8 +22,11 @@ video_item/ | Redis | >= 6 | 可选,推荐用于限流/验证码/Celery | | FFmpeg | 任意 | 可选,用于视频封面截帧 | | alipay-sdk-python | >=3.7.1160 | 可选,用于支付 | +| wechatpayv3 | >=2.0.2 | 可选,用于支付 | +| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 | | ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) | + --- ## 二、后端部署 (video-gen-api) @@ -58,8 +61,13 @@ pip install -e ".[pg,redis,celery]" #安装阿里支付sdk pip install -e ".[pg,redis,celery,alipay]" +# 安装微信支付sdk +pip install -e ".[pg,redis,celery,alipay,wechatpayv3]" + #安装火山sdk -pip install -e ".[pg,redis,celery,alipay,volc]" +pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]" + + ``` ### 2. 配置环境变量 diff --git a/video-gen-admin/dist/assets/index-BBz1j0L-.js b/video-gen-admin/dist/assets/index-BBz1j0L-.js deleted file mode 100644 index 80342d54..00000000 --- a/video-gen-admin/dist/assets/index-BBz1j0L-.js +++ /dev/null @@ -1,329 +0,0 @@ -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&j(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&j(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var k=new MessageChannel,A=k.port2;k.port1.onmessage=D,O=function(){A.postMessage(null)}}else O=function(){_(D,0)};function j(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,j(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),u=o(((e,t)=>{t.exports=l()})),d=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function D(e,t){return E(e.type,t,e.props)}function O(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function k(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var A=/\/+/g;function j(e,t){return typeof e==`object`&&e&&e.key!=null?k(``+e.key):t.toString(36)}function M(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function N(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,N(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+j(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(A,`$&/`)+`/`),N(o,r,i,``,function(e){return e})):o!=null&&(O(o)&&(o=D(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(A,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=d()})),p=o((e=>{var t=f();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=u(),n=f(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1B||(e.current=z[B],z[B]=null,B--)}function U(e,t){B++,z[B]=e.current,e.current=t}var W=V(null),ee=V(null),te=V(null),ne=V(null);function re(e,t){switch(U(te,t),U(ee,e),U(W,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Gd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Gd(t),e=Kd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}H(W),U(W,e)}function G(){H(W),H(ee),H(te)}function ie(e){e.memoizedState!==null&&U(ne,e);var t=W.current,n=Kd(t,e.type);t!==n&&(U(ee,e),U(W,n))}function ae(e){ee.current===e&&(H(W),H(ee)),ne.current===e&&(H(ne),np._currentValue=R)}var oe,K;function se(e){if(oe===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);oe=t&&t[1]||``,K=-1)`:-1i||c[r]!==l[i]){var u=` -`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{ce=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?se(n):``}function ue(e,t){switch(e.tag){case 26:case 27:case 5:return se(e.type);case 16:return se(`Lazy`);case 13:return e.child!==t&&t!==null?se(`Suspense Fallback`):se(`Suspense`);case 19:return se(`SuspenseList`);case 0:case 15:return le(e.type,!1);case 11:return le(e.type.render,!1);case 1:return le(e.type,!0);case 31:return se(`Activity`);default:return``}}function de(e){try{var t=``,n=null;do t+=ue(e,n),n=e,e=e.return;while(e);return t}catch(e){return` -Error generating stack: `+e.message+` -`+e.stack}}var fe=Object.prototype.hasOwnProperty,pe=t.unstable_scheduleCallback,me=t.unstable_cancelCallback,he=t.unstable_shouldYield,ge=t.unstable_requestPaint,_e=t.unstable_now,ve=t.unstable_getCurrentPriorityLevel,ye=t.unstable_ImmediatePriority,be=t.unstable_UserBlockingPriority,xe=t.unstable_NormalPriority,Se=t.unstable_LowPriority,Ce=t.unstable_IdlePriority,we=t.log,Te=t.unstable_setDisableYieldValue,Ee=null,De=null;function Oe(e){if(typeof we==`function`&&Te(e),De&&typeof De.setStrictMode==`function`)try{De.setStrictMode(Ee,e)}catch{}}var ke=Math.clz32?Math.clz32:Me,Ae=Math.log,je=Math.LN2;function Me(e){return e>>>=0,e===0?32:31-(Ae(e)/je|0)|0}var Ne=256,Pe=262144,Fe=4194304;function Ie(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Le(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ie(n))):i=Ie(o):i=Ie(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ie(n))):i=Ie(o)):i=Ie(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Re(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function ze(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Be(){var e=Fe;return Fe<<=1,!(Fe&62914560)&&(Fe=4194304),e}function Ve(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function He(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Ue(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),tn=!1;if(en)try{var nn={};Object.defineProperty(nn,`passive`,{get:function(){tn=!0}}),window.addEventListener(`test`,nn,nn),window.removeEventListener(`test`,nn,nn)}catch{tn=!1}var rn=null,an=null,on=null;function sn(){if(on)return on;var e,t=an,n=t.length,r,i=`value`in rn?rn.value:rn.textContent,a=i.length;for(e=0;e=zn),Hn=` `,Un=!1;function Wn(e,t){switch(e){case`keyup`:return Ln.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Gn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Kn=!1;function qn(e,t){switch(e){case`compositionend`:return Gn(t);case`keypress`:return t.which===32?(Un=!0,Hn):null;case`textInput`:return e=t.data,e===Hn&&Un?null:e;default:return null}}function Jn(e,t){if(Kn)return e===`compositionend`||!Rn&&Wn(e,t)?(e=sn(),on=an=rn=null,Kn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=gr(n)}}function vr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?vr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function yr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Ot(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ot(e.document)}return t}function br(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var xr=en&&`documentMode`in document&&11>=document.documentMode,Sr=null,Cr=null,wr=null,Tr=!1;function Er(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Tr||Sr==null||Sr!==Ot(r)||(r=Sr,`selectionStart`in r&&br(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),wr&&hr(wr,r)||(wr=r,r=kd(Cr,`onSelect`),0>=o,i-=o,_i=1<<32-ke(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),Ei&&yi(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),Ei&&yi(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return Ei&&yi(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),Ei&&yi(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===O&&ya(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ea(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=ii(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=ri(o.type,o.key,o.props,null,e.mode,c),Ea(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=si(o,e.mode,c),c.return=e,e=c}return s(e);case O:return o=ya(o),b(e,r,o,c)}if(F(o))return h(e,r,o,c);if(M(o)){if(l=M(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ta(o),c);if(o.$$typeof===C)return b(e,r,qi(e,o),c);Da(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=ai(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{wa=0;var i=b(e,t,n,r);return Ca=null,i}catch(t){if(t===pa||t===ha)throw t;var a=$r(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var ka=Oa(!0),Aa=Oa(!1),ja=!1;function Ma(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Na(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Pa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Fa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Fl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=Xr(e),Yr(e,null,n),t}return Kr(e,r,t,n),Xr(e)}function Ia(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Ge(e,n)}}function La(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ra=!1;function za(){if(Ra){var e=ia;if(e!==null)throw e}}function Ba(e,t,n,r){Ra=!1;var i=e.updateQueue;ja=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(Rl&f)===f:(r&f)===f){f!==0&&f===ra&&(Ra=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:ja=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Kl|=o,e.lanes=o,e.memoizedState=d}}function Va(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Ha(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=I.T,s={};I.T=s,ks(e,!1,t,n);try{var c=i(),l=I.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Os(e,t,sa(c,r),mu(e)):Os(e,t,r,mu(e))}catch(n){Os(e,t,{then:function(){},status:`rejected`,reason:n},mu())}finally{L.p=a,o!==null&&s.types!==null&&(o.types=s.types),I.T=o}}function vs(){}function ys(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=bs(e).queue;_s(e,a,t,R,n===null?vs:function(){return xs(e),n(r)})}function bs(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:R,baseState:R,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ao,lastRenderedState:R},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ao,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function xs(e){var t=bs(e);t.next===null&&(t=e.alternate.memoizedState),Os(e,t.next.queue,{},mu())}function Ss(){return J(np)}function Cs(){return To().memoizedState}function ws(){return To().memoizedState}function Ts(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=mu();e=Pa(n);var r=Fa(t,e,n);r!==null&&(gu(r,t,n),Ia(r,t,n)),t={cache:$i()},e.payload=t;return}t=t.return}}function Es(e,t,n){var r=mu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},As(e)?js(t,n):(n=qr(e,t,n,r),n!==null&&(gu(n,e,r),Ms(n,t,r)))}function Ds(e,t,n){Os(e,t,n,mu())}function Os(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(As(e))js(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,mr(s,o))return Kr(e,t,i,0),Il===null&&Gr(),!1}catch{}if(n=qr(e,t,i,r),n!==null)return gu(n,e,r),Ms(n,t,r),!0}return!1}function ks(e,t,n,r){if(r={lane:2,revertLane:pd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},As(e)){if(t)throw Error(i(479))}else t=qr(e,n,r,2),t!==null&&gu(t,e,2)}function As(e){var t=e.alternate;return e===io||t!==null&&t===io}function js(e,t){co=so=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ms(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Ge(e,n)}}var Ns={readContext:J,use:Oo,useCallback:ho,useContext:ho,useEffect:ho,useImperativeHandle:ho,useLayoutEffect:ho,useInsertionEffect:ho,useMemo:ho,useReducer:ho,useRef:ho,useState:ho,useDebugValue:ho,useDeferredValue:ho,useTransition:ho,useSyncExternalStore:ho,useId:ho,useHostTransitionStatus:ho,useFormState:ho,useActionState:ho,useOptimistic:ho,useMemoCache:ho,useCacheRefresh:ho};Ns.useEffectEvent=ho;var Ps={readContext:J,use:Oo,useCallback:function(e,t){return wo().memoizedState=[e,t===void 0?null:t],e},useContext:J,useEffect:is,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),ns(4194308,4,us.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ns(4194308,4,e,t)},useInsertionEffect:function(e,t){ns(4,2,e,t)},useMemo:function(e,t){var n=wo();t=t===void 0?null:t;var r=e();if(lo){Oe(!0);try{e()}finally{Oe(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=wo();if(n!==void 0){var i=n(t);if(lo){Oe(!0);try{n(t)}finally{Oe(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Es.bind(null,io,e),[r.memoizedState,e]},useRef:function(e){var t=wo();return e={current:e},t.memoizedState=e},useState:function(e){e=Bo(e);var t=e.queue,n=Ds.bind(null,io,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:fs,useDeferredValue:function(e,t){return hs(wo(),e,t)},useTransition:function(){var e=Bo(!1);return e=_s.bind(null,io,e.queue,!0,!1),wo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=io,a=wo();if(Ei){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),Il===null)throw Error(i(349));Rl&127||Fo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,is(Lo.bind(null,r,o,e),[e]),r.flags|=2048,es(9,{destroy:void 0},Io.bind(null,r,o,n,t),null),n},useId:function(){var e=wo(),t=Il.identifierPrefix;if(Ei){var n=vi,r=_i;n=(r&~(1<<32-ke(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=uo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[Qe]=t,o[$e]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Rd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Oc(t)}}return Nc(t),kc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Oc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=te.current,Ni(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=wi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[Qe]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Fd(e.nodeValue,n)),e||Ai(t,!0)}else e=Wd(e).createTextNode(r),e[Qe]=t,t.stateNode=e}return Nc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ni(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[Qe]=t}else Pi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Nc(t),e=!1}else n=Fi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(eo(t),t):(eo(t),null);if(t.flags&128)throw Error(i(558))}return Nc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ni(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[Qe]=t}else Pi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Nc(t),a=!1}else a=Fi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(eo(t),t):(eo(t),null)}return eo(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),jc(t,t.updateQueue),Nc(t),null);case 4:return G(),e===null&&Td(t.stateNode.containerInfo),Nc(t),null;case 10:return Vi(t.type),Nc(t),null;case 19:if(H(to),r=t.memoizedState,r===null)return Nc(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Mc(r,!1);else{if(Gl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=no(e),o!==null){for(t.flags|=128,Mc(r,!1),e=o.updateQueue,t.updateQueue=e,jc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)ni(n,e),n=n.sibling;return U(to,to.current&1|2),Ei&&yi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&_e()>nu&&(t.flags|=128,a=!0,Mc(r,!1),t.lanes=4194304)}else{if(!a)if(e=no(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,jc(t,e),Mc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!Ei)return Nc(t),null}else 2*_e()-r.renderingStartTime>nu&&n!==536870912&&(t.flags|=128,a=!0,Mc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Nc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=_e(),e.sibling=null,n=to.current,U(to,a?n&1|2:n&1),Ei&&yi(t,r.treeForkCount),e);case 22:case 23:return eo(t),qa(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Nc(t),t.subtreeFlags&6&&(t.flags|=8192)):Nc(t),n=t.updateQueue,n!==null&&jc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&H(la),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Vi(Qi),Nc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Fc(e,t){switch(Si(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Vi(Qi),G(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return ae(t),null;case 31:if(t.memoizedState!==null){if(eo(t),t.alternate===null)throw Error(i(340));Pi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(eo(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Pi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return H(to),null;case 4:return G(),null;case 10:return Vi(t.type),null;case 22:case 23:return eo(t),qa(),e!==null&&H(la),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Vi(Qi),null;case 25:return null;default:return null}}function Ic(e,t){switch(Si(t),t.tag){case 3:Vi(Qi),G();break;case 26:case 27:case 5:ae(t);break;case 4:G();break;case 31:t.memoizedState!==null&&eo(t);break;case 13:eo(t);break;case 19:H(to);break;case 10:Vi(t.type);break;case 22:case 23:eo(t),qa(),e!==null&&H(la);break;case 24:Vi(Qi)}}function Lc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Ku(t,t.return,e)}}function Rc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Ku(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Ku(t,t.return,e)}}function zc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ha(t,n)}catch(t){Ku(e,e.return,t)}}}function Bc(e,t,n){n.props=Vs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Ku(e,t,n)}}function Vc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Ku(e,t,n)}}function Hc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Ku(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Ku(e,t,n)}else n.current=null}function Uc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Ku(e,e.return,t)}}function Wc(e,t,n){try{var r=e.stateNode;zd(r,e.type,n,t),r[$e]=t}catch(t){Ku(e,e.return,t)}}function Gc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&tf(e.type)||e.tag===4}function Kc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Gc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&tf(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function qc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Gt));else if(r!==4&&(r===27&&tf(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(qc(e,t,n),e=e.sibling;e!==null;)qc(e,t,n),e=e.sibling}function Jc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&tf(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Jc(e,t,n),e=e.sibling;e!==null;)Jc(e,t,n),e=e.sibling}function Yc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Rd(t,r,n),t[Qe]=e,t[$e]=n}catch(t){Ku(e,e.return,t)}}var Xc=!1,Zc=!1,Qc=!1,$c=typeof WeakSet==`function`?WeakSet:Set,el=null;function tl(e,t){if(e=e.containerInfo,Hd=dp,e=yr(e),br(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Ud={focusedElem:e,selectionRange:n},dp=!1,el=t;el!==null;)if(t=el,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,el=e;else for(;el!==null;){switch(t=el,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Rd(o,r,n),o[Qe]=e,dt(o),r=o;break a;case`link`:var s=Gf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=_r(s,h),v=_r(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,I.T=null,n=uu,uu=null;var o=ou,s=cu;if(au=0,su=ou=null,cu=0,Fl&6)throw Error(i(331));var c=Fl;if(Fl|=4,Al(o.current),Cl(o,o.current,s,n),Fl=c,od(0,!1),De&&typeof De.onPostCommitFiberRoot==`function`)try{De.onPostCommitFiberRoot(Ee,o)}catch{}return!0}finally{L.p=a,I.T=r,Hu(e,t)}}function Gu(e,t,n){t=li(n,t),t=qs(e.stateNode,t,2),e=Fa(e,t,2),e!==null&&(He(e,2),ad(e))}function Ku(e,t,n){if(e.tag===3)Gu(e,e,n);else for(;t!==null;){if(t.tag===3){Gu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(iu===null||!iu.has(r))){e=li(n,e),n=Js(2),r=Fa(t,n,2),r!==null&&(Ys(n,r,t,e),He(r,2),ad(r));break}}t=t.return}}function qu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Pl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Ul=!0,i.add(n),e=Ju.bind(null,e,t,n),t.then(e,e))}function Ju(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Il===e&&(Rl&n)===n&&(Gl===4||Gl===3&&(Rl&62914560)===Rl&&300>_e()-eu?!(Fl&2)&&Cu(e,0):Jl|=n,Xl===Rl&&(Xl=0)),ad(e)}function Yu(e,t){t===0&&(t=Be()),e=Jr(e,t),e!==null&&(He(e,t),ad(e))}function Xu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Yu(e,n)}function Zu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),Yu(e,n)}function Qu(e,t){return pe(e,t)}var $u=null,ed=null,td=!1,nd=!1,rd=!1,id=0;function ad(e){e!==ed&&e.next===null&&(ed===null?$u=ed=e:ed=ed.next=e),nd=!0,td||(td=!0,fd())}function od(e,t){if(!rd&&nd){rd=!0;do for(var n=!1,r=$u;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-ke(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,dd(r,a))}else a=Rl,a=Le(r,r===Il?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Re(r,a)||(n=!0,dd(r,a));r=r.next}while(n);rd=!1}}function sd(){cd()}function cd(){nd=td=!1;var e=0;id!==0&&Yd()&&(e=id);for(var t=_e(),n=null,r=$u;r!==null;){var i=r.next,a=ld(r,t);a===0?(r.next=null,n===null?$u=i:n.next=i,i===null&&(ed=n)):(n=r,(e!==0||a&3)&&(nd=!0)),r=i}au!==0&&au!==5||od(e,!1),id!==0&&(id=0)}function ld(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Bd(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function Tf(e,t,n){var r=wf;if(r&&typeof t==`string`&&t){var i=At(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),yf.has(i)||(yf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Rd(t,`link`,e),dt(t),r.head.appendChild(t)))}}function Ef(e){xf.D(e),Tf(`dns-prefetch`,e,null)}function Df(e,t){xf.C(e,t),Tf(`preconnect`,e,t)}function Of(e,t,n){xf.L(e,t,n);var r=wf;if(r&&e&&t){var i=`link[rel="preload"][as="`+At(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+At(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+At(n.imageSizes)+`"]`)):i+=`[href="`+At(e)+`"]`;var a=i;switch(t){case`style`:a=Pf(e);break;case`script`:a=Rf(e)}vf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),vf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Ff(a))||t===`script`&&r.querySelector(zf(a))||(t=r.createElement(`link`),Rd(t,`link`,e),dt(t),r.head.appendChild(t)))}}function kf(e,t){xf.m(e,t);var n=wf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+At(r)+`"][href="`+At(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Rf(e)}if(!vf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),vf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(zf(a)))return}r=n.createElement(`link`),Rd(r,`link`,e),dt(r),n.head.appendChild(r)}}}function Af(e,t,n){xf.S(e,t,n);var r=wf;if(r&&e){var i=ut(r).hoistableStyles,a=Pf(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Ff(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=vf.get(a))&&Hf(e,n);var c=o=r.createElement(`link`);dt(c),Rd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Vf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function jf(e,t){xf.X(e,t);var n=wf;if(n&&e){var r=ut(n).hoistableScripts,i=Rf(e),a=r.get(i);a||(a=n.querySelector(zf(i)),a||(e=h({src:e,async:!0},t),(t=vf.get(i))&&Uf(e,t),a=n.createElement(`script`),dt(a),Rd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Mf(e,t){xf.M(e,t);var n=wf;if(n&&e){var r=ut(n).hoistableScripts,i=Rf(e),a=r.get(i);a||(a=n.querySelector(zf(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=vf.get(i))&&Uf(e,t),a=n.createElement(`script`),dt(a),Rd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Nf(e,t,n,r){var a=(a=te.current)?bf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Pf(n.href),n=ut(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Pf(n.href);var o=ut(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(Ff(e)))&&!o._p&&(s.instance=o,s.state.loading=5),vf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},vf.set(e,n),o||Lf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Rf(n),n=ut(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Pf(e){return`href="`+At(e)+`"`}function Ff(e){return`link[rel="stylesheet"][`+e+`]`}function If(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Lf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Rd(t,`link`,n),dt(t),e.head.appendChild(t))}function Rf(e){return`[src="`+At(e)+`"]`}function zf(e){return`script[async]`+e}function Bf(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+At(n.href)+`"]`);if(r)return t.instance=r,dt(r),r;var a=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),dt(r),Rd(r,`style`,a),Vf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Pf(n.href);var o=e.querySelector(Ff(a));if(o)return t.state.loading|=4,t.instance=o,dt(o),o;r=If(n),(a=vf.get(a))&&Hf(r,a),o=(e.ownerDocument||e).createElement(`link`),dt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Rd(o,`link`,r),t.state.loading|=4,Vf(o,n.precedence,e),t.instance=o;case`script`:return o=Rf(n.src),(a=e.querySelector(zf(o)))?(t.instance=a,dt(a),a):(r=n,(a=vf.get(o))&&(r=h({},n),Uf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),dt(a),Rd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Vf(r,n.precedence,e));return t.instance}function Vf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function qf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Jf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Yf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Pf(r.href),a=t.querySelector(Ff(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Qf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,dt(a);return}a=t.ownerDocument||t,r=If(r),(i=vf.get(i))&&Hf(r,i),a=a.createElement(`link`),dt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Rd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Qf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Xf=0;function Zf(e,t){return e.stylesheets&&e.count===0&&ep(e,e.stylesheets),0Xf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Qf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)ep(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var $f=null;function ep(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,$f=new Map,t.forEach(tp,e),$f=null,Qf.call(e))}function tp(e,t){if(!(t.state.loading&4)){var n=$f.get(e);if(n)var r=n.get(null);else{n=new Map,$f.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=`modulepreload`,v=function(e){return`/`+e},y={},b=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=v(t,n),t in y)return;y[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:_,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},x=c(f(),1),S=`popstate`;function C(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function w(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return k(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:A(t)}return M(t,n,null,e)}function T(e,t){if(e===!1||e==null)throw Error(t)}function E(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function D(){return Math.random().toString(36).substring(2,10)}function O(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function k(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?j(t):t,state:n,key:t&&t.key||r||D(),mask:i}}function A({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function j(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function M(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=C(e)?e:k(h.location,e,t);n&&n(r,e),l=u()+1;let d=O(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=C(e)?e:k(h.location,e,t);n&&n(r,e),l=u();let i=O(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return N(e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(S,d),c=e,()=>{i.removeEventListener(S,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function N(e,t=!1){let n=`http://localhost`;typeof window<`u`&&(n=window.location.origin===`null`?window.location.href:window.location.origin),T(n,`No window.location.(origin|href) available to create URL`);let r=typeof e==`string`?e:A(e);return r=r.replace(/ $/,`%20`),!t&&r.startsWith(`//`)&&(r=n+r),new URL(r,n)}function P(e,t,n=`/`){return F(e,t,n,!1)}function F(e,t,n,r,i){let a=se((typeof t==`string`?j(t):t).pathname||`/`,n);if(a==null)return null;let o=i??L(e),s=null,c=K(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;T(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=ge([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(T(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),R(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:re(l,e.index),routesMeta:u})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of z(e.path))a(e,t,!0,n)}),t}function z(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=z(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function B(e){e.sort((e,t)=>e.score===t.score?G(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var V=/^:[\w-]+$/,H=3,U=2,W=1,ee=10,te=-2,ne=e=>e===`*`;function re(e,t){let n=e.split(`/`),r=n.length;return n.some(ne)&&(r+=te),t&&(r+=U),n.filter(e=>!ne(e)).reduce((e,t)=>e+(V.test(t)?H:t===``?W:ee),r)}function G(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function ie(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function oe(e,t=!1,n=!0){E(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function K(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return E(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function se(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}var ce=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function le(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?j(e):e,a;return n?(n=he(n),a=n.startsWith(`/`)?ue(n.substring(1),`/`):ue(n,t)):a=t,{pathname:a,search:ye(r),hash:be(i)}}function ue(e,t){let n=_e(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function de(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function fe(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function pe(e){let t=fe(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function me(e,t,n,r=!1){let i;typeof e==`string`?i=j(e):(i={...e},T(!i.pathname||!i.pathname.includes(`?`),de(`?`,`pathname`,`search`,i)),T(!i.pathname||!i.pathname.includes(`#`),de(`#`,`pathname`,`hash`,i)),T(!i.search||!i.search.includes(`#`),de(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=le(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var he=e=>e.replace(/\/\/+/g,`/`),ge=e=>he(e.join(`/`)),_e=e=>e.replace(/\/+$/,``),ve=e=>_e(e).replace(/^\/*/,`/`),ye=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,be=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,xe=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function Se(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Ce(e){return ge(e.map(e=>e.route.path).filter(Boolean))||`/`}var we=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function Te(e,t){let n=e;if(typeof n!=`string`||!ce.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(we)try{let e=new URL(window.location.href),r=n.startsWith(`//`)?new URL(e.protocol+n):new URL(n),a=se(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{E(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var Ee=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Ee);var De=[`GET`,...Ee];new Set(De);var Oe=x.createContext(null);Oe.displayName=`DataRouter`;var ke=x.createContext(null);ke.displayName=`DataRouterState`;var Ae=x.createContext(!1);function je(){return x.useContext(Ae)}var Me=x.createContext({isTransitioning:!1});Me.displayName=`ViewTransition`;var Ne=x.createContext(new Map);Ne.displayName=`Fetchers`;var Pe=x.createContext(null);Pe.displayName=`Await`;var Fe=x.createContext(null);Fe.displayName=`Navigation`;var Ie=x.createContext(null);Ie.displayName=`Location`;var Le=x.createContext({outlet:null,matches:[],isDataRoute:!1});Le.displayName=`Route`;var Re=x.createContext(null);Re.displayName=`RouteError`;var ze=`REACT_ROUTER_ERROR`,Be=`REDIRECT`,Ve=`ROUTE_ERROR_RESPONSE`;function He(e){if(e.startsWith(`${ze}:${Be}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function Ue(e){if(e.startsWith(`${ze}:${Ve}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new xe(t.status,t.statusText,t.data)}catch{}}function We(e,{relative:t}={}){T(Ge(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=x.useContext(Fe),{hash:i,pathname:a,search:o}=$e(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:ge([n,a])),r.createHref({pathname:s,search:o,hash:i})}function Ge(){return x.useContext(Ie)!=null}function Ke(){return T(Ge(),`useLocation() may be used only in the context of a component.`),x.useContext(Ie).location}var qe=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function Je(e){x.useContext(Fe).static||x.useLayoutEffect(e)}function Ye(){let{isDataRoute:e}=x.useContext(Le);return e?vt():Xe()}function Xe(){T(Ge(),`useNavigate() may be used only in the context of a component.`);let e=x.useContext(Oe),{basename:t,navigator:n}=x.useContext(Fe),{matches:r}=x.useContext(Le),{pathname:i}=Ke(),a=JSON.stringify(pe(r)),o=x.useRef(!1);return Je(()=>{o.current=!0}),x.useCallback((r,s={})=>{if(E(o.current,qe),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=me(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:ge([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var Ze=x.createContext(null);function Qe(e){let t=x.useContext(Le).outlet;return x.useMemo(()=>t&&x.createElement(Ze.Provider,{value:e},t),[t,e])}function $e(e,{relative:t}={}){let{matches:n}=x.useContext(Le),{pathname:r}=Ke(),i=JSON.stringify(pe(n));return x.useMemo(()=>me(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function et(e,t){return tt(e,t)}function tt(e,t,n){T(Ge(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=x.useContext(Fe),{matches:i}=x.useContext(Le),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;bt(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. - -Please change the parent to .`)}let u=Ke(),d;if(t){let e=typeof t==`string`?j(t):t;T(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):P(e,{pathname:p});E(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),E(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=ct(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:ge([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:ge([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?x.createElement(Ie.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function nt(){let e=_t(),t=Se(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=x.createElement(x.Fragment,null,x.createElement(`p`,null,`💿 Hey developer 👋`),x.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,x.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,x.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),x.createElement(x.Fragment,null,x.createElement(`h2`,null,`Unexpected Application Error!`),x.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?x.createElement(`pre`,{style:i},n):null,o)}var rt=x.createElement(nt,null),it=class extends x.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=Ue(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:x.createElement(Le.Provider,{value:this.props.routeContext},x.createElement(Re.Provider,{value:e,children:this.props.component}));return this.context?x.createElement(ot,{error:e},t):t}};it.contextType=Ae;var at=new WeakMap;function ot({children:e,error:t}){let{basename:n}=x.useContext(Fe);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=He(t.digest);if(e){let r=at.get(t);if(r)throw r;let i=Te(e.location,n);if(we&&!at.get(t))if(i.isExternal||e.reloadDocument)window.location.href=i.absoluteURL||i.to;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw at.set(t,n),n}return x.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${i.absoluteURL||i.to}`})}}return e}function st({routeContext:e,match:t,children:n}){let r=x.useContext(Oe);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),x.createElement(Le.Provider,{value:e},n)}function ct(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);T(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Ce(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||rt,o&&(s<0&&c===0?(bt(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?x.createElement(n.route.Component,null):n.route.element?n.route.element:e,x.createElement(st,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?x.createElement(it,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function lt(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function ut(e){let t=x.useContext(Oe);return T(t,lt(e)),t}function dt(e){let t=x.useContext(ke);return T(t,lt(e)),t}function ft(e){let t=x.useContext(Le);return T(t,lt(e)),t}function pt(e){let t=ft(e),n=t.matches[t.matches.length-1];return T(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function mt(){return pt(`useRouteId`)}function ht(){return dt(`useNavigation`).navigation}function gt(){let{matches:e,loaderData:t}=dt(`useMatches`);return x.useMemo(()=>e.map(e=>I(e,t)),[e,t])}function _t(){let e=x.useContext(Re),t=dt(`useRouteError`),n=pt(`useRouteError`);return e===void 0?t.errors?.[n]:e}function vt(){let{router:e}=ut(`useNavigate`),t=pt(`useNavigate`),n=x.useRef(!1);return Je(()=>{n.current=!0}),x.useCallback(async(r,i={})=>{E(n.current,qe),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var yt={};function bt(e,t,n){!t&&!yt[e]&&(yt[e]=!0,E(!1,n))}x.memo(xt);function xt({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return tt(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function St({to:e,replace:t,state:n,relative:r}){T(Ge(),` may be used only in the context of a component.`);let{static:i}=x.useContext(Fe);E(!i,` must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.`);let{matches:a}=x.useContext(Le),{pathname:o}=Ke(),s=Ye(),c=me(e,pe(a),o,r===`path`),l=JSON.stringify(c);return x.useEffect(()=>{s(JSON.parse(l),{replace:t,state:n,relative:r})},[s,l,r,t,n]),null}function Ct(e){return Qe(e.context)}function wt(e){T(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function Tt({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){T(!Ge(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=x.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=j(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=x.useMemo(()=>{let e=se(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return E(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:x.createElement(Fe.Provider,{value:c},x.createElement(Ie.Provider,{children:t,value:h}))}function Et({children:e,location:t}){return et(Dt(e),t)}x.Component;function Dt(e,t=[]){let n=[];return x.Children.forEach(e,(e,r)=>{if(!x.isValidElement(e))return;let i=[...t,r];if(e.type===x.Fragment){n.push.apply(n,Dt(e.props.children,i));return}T(e.type===wt,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),T(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=Dt(e.props.children,i)),n.push(a)}),n}var Ot=`get`,kt=`application/x-www-form-urlencoded`;function At(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function jt(e){return At(e)&&e.tagName.toLowerCase()===`button`}function Mt(e){return At(e)&&e.tagName.toLowerCase()===`form`}function Nt(e){return At(e)&&e.tagName.toLowerCase()===`input`}function Pt(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Ft(e,t){return e.button===0&&(!t||t===`_self`)&&!Pt(e)}var It=null;function Lt(){if(It===null)try{new FormData(document.createElement(`form`),0),It=!1}catch{It=!0}return It}var Rt=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function zt(e){return e!=null&&!Rt.has(e)?(E(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${kt}"`),null):e}function Bt(e,t){let n,r,i,a,o;if(Mt(e)){let o=e.getAttribute(`action`);r=o?se(o,t):null,n=e.getAttribute(`method`)||Ot,i=zt(e.getAttribute(`enctype`))||kt,a=new FormData(e)}else if(jt(e)||Nt(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a + + + + ), + }, + ]; + + return ( +
+ +
+ + + 授权应用列表 + + + + + +
+ `共 ${t} 条记录`, + onChange: (p) => { setPage(p); load(p); }, + }} + scroll={{ x: 800 }} + /> + + + { + setCreateModalVisible(false); + form.resetFields(); + }} + okText="创建" + cancelText="取消" + width={600} + > + + + + + + + + + + + + + + + + + + + + + + + { + setDetailModalVisible(false); + setCurrentApp(null); + }} + okText="关闭" + cancelText="取消" + width={600} + > + {currentApp && ( +
+

ID: {currentApp.id}

+

应用ID: {currentApp.appId}

+

应用密钥: {currentApp.secret}

+

开户方式: {(() => { + const typeMap: Record = { + 1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商', + 6: '巨量星图', 7: '巨量服务单', 8: '腾讯服务单', 9: '腾讯营销K2', 10: '腾讯营销K3' + }; + return typeMap[currentApp.openType] || currentApp.openType; + })()}

+

归属公司: {currentApp.company || '-'}

+

授权次数: {currentApp.count}

+

状态: {currentApp.status === 1 ? '正常' : '禁用'}

+

授权URL: {currentApp.authUrl || '-'}

+

创建人: {currentApp.createBy}

+

创建时间: {formatDate(currentApp.createdAt)}

+

更新时间: {formatDate(currentApp.updatedAt)}

+
+ )} +
+ + { + setUpdateModalVisible(false); + updateForm.resetFields(); + setCurrentApp(null); + }} + okText="更新" + cancelText="取消" + width={600} + > +
+ + + + + + + + + + + + + + + + + + + +
+ + ); +}; + +export default AdminOauthAppList; \ No newline at end of file diff --git a/video-gen-admin/src/pages/AdminPaymentConfig.tsx b/video-gen-admin/src/pages/AdminPaymentConfig.tsx index c125f1c1..279aeb69 100644 --- a/video-gen-admin/src/pages/AdminPaymentConfig.tsx +++ b/video-gen-admin/src/pages/AdminPaymentConfig.tsx @@ -22,8 +22,10 @@ const AdminPaymentConfig: React.FC = () => { data.forEach((c: any) => { map[c.key] = c.value; }); form.setFieldsValue({ wechat_mch_id: map['payment_wechat_mch_id'] || '', - wechat_api_key: map['payment_wechat_api_key'] || '', - wechat_cert_path: map['payment_wechat_cert_path'] || '', + wechat_private_key: map['payment_wechat_private_key'] || '', + wechat_cert_serial_no: map['payment_wechat_cert_serial_no'] || '', + wechat_api_v3_key: map['payment_wechat_api_v3_key'] || '', + wechat_gateway: map['payment_wechat_gateway'] || '', wechat_notify_url: map['payment_wechat_notify_url'] || '', alipay_app_id: map['payment_alipay_app_id'] || '', alipay_private_key: map['payment_alipay_private_key'] || '', @@ -51,8 +53,10 @@ const AdminPaymentConfig: React.FC = () => { payment_mock: String(mockMode), payment_wechat_enabled: String(wechatEnabled), payment_wechat_mch_id: values.wechat_mch_id || '', - payment_wechat_api_key: values.wechat_api_key || '', - payment_wechat_cert_path: values.wechat_cert_path || '', + payment_wechat_private_key: values.wechat_private_key || '', + payment_wechat_cert_serial_no: values.wechat_cert_serial_no || '', + payment_wechat_api_v3_key: values.wechat_api_v3_key || '', + payment_wechat_gateway: values.wechat_gateway || '', payment_wechat_notify_url: values.wechat_notify_url || '', payment_alipay_enabled: String(alipayEnabled), payment_alipay_app_id: values.alipay_app_id || '', @@ -150,13 +154,19 @@ const AdminPaymentConfig: React.FC = () => { - - + + - - + + - + + + + + + + diff --git a/video-gen-admin/tsconfig.tsbuildinfo b/video-gen-admin/tsconfig.tsbuildinfo index 6d63e8b8..6698c958 100644 --- a/video-gen-admin/tsconfig.tsbuildinfo +++ b/video-gen-admin/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminsettings.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminsettings.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"} \ No newline at end of file diff --git a/video-gen-api/alembic/versions/780226f22449_merge_changes_from_remote.py b/video-gen-api/alembic/versions/780226f22449_merge_changes_from_remote.py new file mode 100644 index 00000000..eb56371b --- /dev/null +++ b/video-gen-api/alembic/versions/780226f22449_merge_changes_from_remote.py @@ -0,0 +1,25 @@ +"""merge changes from remote + +Revision ID: 780226f22449 +Revises: 8922eafcd8b0, dbb11c0b5a0a +Create Date: 2026-06-11 18:00:02.841793 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '780226f22449' +down_revision: Union[str, None] = ('8922eafcd8b0', 'dbb11c0b5a0a') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/video-gen-api/alembic/versions/dbb11c0b5a0a_add_shot_replicate_tables.py b/video-gen-api/alembic/versions/dbb11c0b5a0a_add_shot_replicate_tables.py new file mode 100644 index 00000000..8ef2b4b3 --- /dev/null +++ b/video-gen-api/alembic/versions/dbb11c0b5a0a_add_shot_replicate_tables.py @@ -0,0 +1,161 @@ +"""add shot replicate tables + +Revision ID: dbb11c0b5a0a +Revises: 9ac2212e1b8e +Create Date: 2026-06-11 14:59:26.871902 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'dbb11c0b5a0a' +down_revision: Union[str, None] = '9ac2212e1b8e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('shot_replicate_task_sets', + sa.Column('id', sa.String(length=32), nullable=False), + sa.Column('user_id', sa.String(length=32), nullable=False), + sa.Column('title', sa.String(length=160), nullable=True), + sa.Column('video_url', sa.String(length=512), nullable=False), + sa.Column('video_path', sa.String(length=512), nullable=False), + sa.Column('video_duration_seconds', sa.Float(), nullable=False), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('analysis_status', sa.String(length=32), nullable=False), + sa.Column('split_status', sa.String(length=32), nullable=False), + sa.Column('original_video_content', sa.Text(), nullable=True), + sa.Column('original_video_category', sa.String(length=160), nullable=True), + sa.Column('original_video_audience', sa.Text(), nullable=True), + sa.Column('ai_suggestion_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True), + sa.Column('analysis_raw_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True), + sa.Column('analysis_result_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True), + sa.Column('segment_count', sa.Integer(), nullable=False), + sa.Column('completed_segment_count', sa.Integer(), nullable=False), + sa.Column('failed_segment_count', sa.Integer(), nullable=False), + sa.Column('analysis_error_message', sa.Text(), nullable=True), + sa.Column('split_error_message', sa.Text(), nullable=True), + sa.Column('idempotency_key', sa.String(length=64), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_shot_replicate_task_sets_analysis_status', 'shot_replicate_task_sets', ['analysis_status'], unique=False) + op.create_index('idx_shot_replicate_task_sets_split_status', 'shot_replicate_task_sets', ['split_status'], unique=False) + op.create_index('idx_shot_replicate_task_sets_status', 'shot_replicate_task_sets', ['status'], unique=False) + op.create_index('idx_shot_replicate_task_sets_user_created', 'shot_replicate_task_sets', ['user_id', 'created_at'], unique=False) + op.create_index(op.f('ix_shot_replicate_task_sets_analysis_status'), 'shot_replicate_task_sets', ['analysis_status'], unique=False) + op.create_index(op.f('ix_shot_replicate_task_sets_deleted_at'), 'shot_replicate_task_sets', ['deleted_at'], unique=False) + op.create_index(op.f('ix_shot_replicate_task_sets_idempotency_key'), 'shot_replicate_task_sets', ['idempotency_key'], unique=False) + op.create_index(op.f('ix_shot_replicate_task_sets_split_status'), 'shot_replicate_task_sets', ['split_status'], unique=False) + op.create_index(op.f('ix_shot_replicate_task_sets_status'), 'shot_replicate_task_sets', ['status'], unique=False) + op.create_index(op.f('ix_shot_replicate_task_sets_user_id'), 'shot_replicate_task_sets', ['user_id'], unique=False) + op.create_index('uq_shot_replicate_task_sets_user_idempotency', 'shot_replicate_task_sets', ['user_id', 'idempotency_key'], unique=True, postgresql_where=sa.text('deleted_at IS NULL AND idempotency_key IS NOT NULL')) + op.create_table('shot_replicate_segments', + sa.Column('id', sa.String(length=32), nullable=False), + sa.Column('task_set_id', sa.String(length=32), nullable=False), + sa.Column('user_id', sa.String(length=32), nullable=False), + sa.Column('segment_index', sa.Integer(), nullable=False), + sa.Column('source_mode', sa.String(length=32), nullable=False), + sa.Column('start_second', sa.Float(), nullable=False), + sa.Column('end_second', sa.Float(), nullable=False), + sa.Column('duration_seconds', sa.Float(), nullable=False), + sa.Column('time_node', sa.String(length=64), nullable=False), + sa.Column('split_status', sa.String(length=32), nullable=False), + sa.Column('analysis_status', sa.String(length=32), nullable=False), + sa.Column('replicate_status', sa.String(length=32), nullable=False), + sa.Column('segment_video_url', sa.String(length=512), nullable=True), + sa.Column('segment_video_path', sa.String(length=512), nullable=True), + sa.Column('original_video_content', sa.Text(), nullable=True), + sa.Column('original_video_category', sa.String(length=160), nullable=True), + sa.Column('original_video_audience', sa.Text(), nullable=True), + sa.Column('segment_content', sa.Text(), nullable=True), + sa.Column('segment_category', sa.String(length=160), nullable=True), + sa.Column('segment_audience', sa.Text(), nullable=True), + sa.Column('analysis_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True), + sa.Column('ai_suggestion_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True), + sa.Column('module_project_id', sa.String(length=32), nullable=True), + sa.Column('split_celery_task_id', sa.String(length=160), nullable=True), + sa.Column('split_enqueued_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('split_started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('split_lease_until', sa.DateTime(timezone=True), nullable=True), + sa.Column('split_next_retry_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('split_retry_count', sa.Integer(), nullable=False), + sa.Column('split_last_error', sa.Text(), nullable=True), + sa.Column('split_completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('analysis_error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['task_set_id'], ['shot_replicate_task_sets.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_shot_replicate_segments_analysis_status', 'shot_replicate_segments', ['analysis_status'], unique=False) + op.create_index('idx_shot_replicate_segments_project', 'shot_replicate_segments', ['module_project_id'], unique=False) + op.create_index('idx_shot_replicate_segments_replicate_status', 'shot_replicate_segments', ['replicate_status'], unique=False) + op.create_index('idx_shot_replicate_segments_source_mode', 'shot_replicate_segments', ['source_mode'], unique=False) + op.create_index('idx_shot_replicate_segments_split_status', 'shot_replicate_segments', ['split_status'], unique=False) + op.create_index('idx_shot_replicate_segments_task_set', 'shot_replicate_segments', ['task_set_id', 'segment_index'], unique=False) + op.create_index('idx_shot_replicate_segments_user_created', 'shot_replicate_segments', ['user_id', 'created_at'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_analysis_status'), 'shot_replicate_segments', ['analysis_status'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_deleted_at'), 'shot_replicate_segments', ['deleted_at'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_module_project_id'), 'shot_replicate_segments', ['module_project_id'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_replicate_status'), 'shot_replicate_segments', ['replicate_status'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_segment_index'), 'shot_replicate_segments', ['segment_index'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_source_mode'), 'shot_replicate_segments', ['source_mode'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_split_celery_task_id'), 'shot_replicate_segments', ['split_celery_task_id'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_split_lease_until'), 'shot_replicate_segments', ['split_lease_until'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_split_next_retry_at'), 'shot_replicate_segments', ['split_next_retry_at'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_split_status'), 'shot_replicate_segments', ['split_status'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_task_set_id'), 'shot_replicate_segments', ['task_set_id'], unique=False) + op.create_index(op.f('ix_shot_replicate_segments_user_id'), 'shot_replicate_segments', ['user_id'], unique=False) + op.create_index('uq_shot_replicate_segments_task_set_index_active', 'shot_replicate_segments', ['task_set_id', 'segment_index'], unique=True, postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_column('user_oauth_app', 'count') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_oauth_app', sa.Column('count', sa.BIGINT(), autoincrement=False, nullable=False, comment='应用最大可以授权多少个用户')) + op.drop_index('uq_shot_replicate_segments_task_set_index_active', table_name='shot_replicate_segments', postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_index(op.f('ix_shot_replicate_segments_user_id'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_task_set_id'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_split_status'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_split_next_retry_at'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_split_lease_until'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_split_celery_task_id'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_source_mode'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_segment_index'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_replicate_status'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_module_project_id'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_deleted_at'), table_name='shot_replicate_segments') + op.drop_index(op.f('ix_shot_replicate_segments_analysis_status'), table_name='shot_replicate_segments') + op.drop_index('idx_shot_replicate_segments_user_created', table_name='shot_replicate_segments') + op.drop_index('idx_shot_replicate_segments_task_set', table_name='shot_replicate_segments') + op.drop_index('idx_shot_replicate_segments_split_status', table_name='shot_replicate_segments') + op.drop_index('idx_shot_replicate_segments_source_mode', table_name='shot_replicate_segments') + op.drop_index('idx_shot_replicate_segments_replicate_status', table_name='shot_replicate_segments') + op.drop_index('idx_shot_replicate_segments_project', table_name='shot_replicate_segments') + op.drop_index('idx_shot_replicate_segments_analysis_status', table_name='shot_replicate_segments') + op.drop_table('shot_replicate_segments') + op.drop_index('uq_shot_replicate_task_sets_user_idempotency', table_name='shot_replicate_task_sets', postgresql_where=sa.text('deleted_at IS NULL AND idempotency_key IS NOT NULL')) + op.drop_index(op.f('ix_shot_replicate_task_sets_user_id'), table_name='shot_replicate_task_sets') + op.drop_index(op.f('ix_shot_replicate_task_sets_status'), table_name='shot_replicate_task_sets') + op.drop_index(op.f('ix_shot_replicate_task_sets_split_status'), table_name='shot_replicate_task_sets') + op.drop_index(op.f('ix_shot_replicate_task_sets_idempotency_key'), table_name='shot_replicate_task_sets') + op.drop_index(op.f('ix_shot_replicate_task_sets_deleted_at'), table_name='shot_replicate_task_sets') + op.drop_index(op.f('ix_shot_replicate_task_sets_analysis_status'), table_name='shot_replicate_task_sets') + op.drop_index('idx_shot_replicate_task_sets_user_created', table_name='shot_replicate_task_sets') + op.drop_index('idx_shot_replicate_task_sets_status', table_name='shot_replicate_task_sets') + op.drop_index('idx_shot_replicate_task_sets_split_status', table_name='shot_replicate_task_sets') + op.drop_index('idx_shot_replicate_task_sets_analysis_status', table_name='shot_replicate_task_sets') + op.drop_table('shot_replicate_task_sets') + # ### end Alembic commands ### diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py index e80ee5b1..a46e5c52 100644 --- a/video-gen-api/app/api/v1/__init__.py +++ b/video-gen-api/app/api/v1/__init__.py @@ -16,6 +16,7 @@ from app.api.v1.video_engines import router as video_engines_router from app.api.v1.image_engines import router as image_engines_router from app.api.v1.generation_ai import router as generation_ai_router from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router +from app.api.v1.shot_replicate import router as shot_replicate_router from app.api.v1.test import router as test_router from app.api.v1.user_oauth import router as user_oauth_router from app.api.v1.user_oauth_app import router as user_oauth_app_router @@ -37,6 +38,7 @@ api_router.include_router(video_engines_router) api_router.include_router(image_engines_router) api_router.include_router(generation_ai_router) api_router.include_router(hot_opening_replicate_router) +api_router.include_router(shot_replicate_router) api_router.include_router(test_router) api_router.include_router(user_oauth_router) api_router.include_router(user_oauth_app_router) diff --git a/video-gen-api/app/api/v1/hot_opening_replicate.py b/video-gen-api/app/api/v1/hot_opening_replicate.py index e53bc8d4..5704d42d 100644 --- a/video-gen-api/app/api/v1/hot_opening_replicate.py +++ b/video-gen-api/app/api/v1/hot_opening_replicate.py @@ -1,10 +1,14 @@ from __future__ import annotations +from types import SimpleNamespace + from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query +from sqlalchemy import inspect as sa_inspect from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db from app.models.user import User +from app.enums.hot_opening_replicate import ModuleCodeEnum from app.schemas.hot_opening_replicate import ( HotOpeningActionOut, HotOpeningDeleteOut, @@ -35,14 +39,94 @@ from app.services.hot_opening_replicate_service import ( update_hot_opening_material_input, update_hot_opening_video_prompt_schema, ) +from app.services.module_generation_log_service import log_module_error from app.tasks.celery_app import celery_app +MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value + + + +def _safe_user_id(user: object | None) -> str | None: + """从 ORM 对象中安全取用户ID,避免 rollback/commit 后访问过期属性触发 MissingGreenlet。""" + if user is None: + return None + try: + value = getattr(user, "__dict__", {}).get("id") + if value is not None: + return str(value) + except Exception: + pass + try: + identity = sa_inspect(user).identity + if identity: + return str(identity[0]) + except Exception: + pass + return None + + +def _safe_user_is_admin(user: object | None) -> bool: + """安全判断管理员身份;如果对象属性已过期,保守按普通用户处理。""" + if user is None: + return False + try: + data = getattr(user, "__dict__", {}) + if "is_admin" in data: + return bool(data.get("is_admin")) + except Exception: + pass + return False + + +def _user_context(user: object | None) -> SimpleNamespace: + return SimpleNamespace(id=_safe_user_id(user), is_admin=_safe_user_is_admin(user)) + router = APIRouter( prefix="/hot-opening-replications", tags=["hot-opening-replications"], ) + + +def _log_api_error( + *, + event_type: str, + current_user: User | None = None, + project_id: str | None = None, + step_id: str | None = None, + message: str | None = None, + exc: BaseException | None = None, + detail: dict | None = None, +) -> None: + log_module_error( + module=MODULE, + event_type=event_type, + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message=message, + detail=detail, + exc=exc, + ) + + +def _log_api_exception_from_locals(exc: BaseException, local_values: dict, message: str) -> None: + current_user = local_values.get("current_user") + project_id = local_values.get("project_id_value") or local_values.get("project_id") + step_id = local_values.get("step_id_value") or local_values.get("step_id") + req = local_values.get("req") + detail = {"request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None} + _log_api_error( + event_type="API_REQUEST_FAILED", + current_user=current_user if isinstance(current_user, User) else None, + project_id=str(project_id) if project_id else None, + step_id=str(step_id) if step_id else None, + message=message, + detail=detail, + exc=exc, + ) + async def _reload_project_detail( db: AsyncSession, current_user: User, @@ -52,7 +136,7 @@ async def _reload_project_detail( project = await _get_project_for_user( db, project_id=project_id, - user=current_user, + user=_user_context(current_user), for_update=False, populate_existing=True, ) @@ -72,14 +156,33 @@ async def _mark_dispatch_failed_and_raise( try: await mark_hot_opening_step_dispatch_failed( db, - current_user=current_user, + current_user=_user_context(current_user), project_id=project_id, step_id=step_id, error_message=message, ) await db.commit() - except Exception: + except Exception as exc: await db.rollback() + _log_api_error( + event_type="CELERY_DISPATCH_MARK_FAILED", + current_user=current_user, + project_id=project_id, + step_id=step_id, + message="Celery 投递失败后标记步骤失败也失败", + detail={"dispatch_error": message}, + exc=exc, + ) + log_module_error( + module=MODULE, + event_type="CELERY_DISPATCH_FAILED", + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message=message, + detail={"reason": "celery_dispatch_failed"}, + error=message, + ) raise HTTPException(status_code=503, detail=message) @@ -118,6 +221,7 @@ async def create_task( raise except Exception as exc: await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"创建爆款开头复刻项目失败: {exc}") raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}") return await _reload_project_detail(db, current_user, project_id_value) @@ -185,6 +289,7 @@ async def update_material( raise except Exception as exc: await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"修改素材输入失败: {exc}") raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}") return HotOpeningActionOut( @@ -228,6 +333,7 @@ async def update_image_prompt( raise except Exception as exc: await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"修改图片 AI 提词失败: {exc}") raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}") return HotOpeningActionOut( @@ -300,6 +406,14 @@ async def generate_image_prompt( ): _ = req if celery_app is None: + _log_api_error( + event_type="CELERY_DISABLED", + current_user=current_user, + project_id=project_id, + step_id=step_id, + message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker", + detail={"api": "hot_opening_replicate"}, + ) raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") try: @@ -312,6 +426,7 @@ async def generate_image_prompt( raise except Exception as exc: await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"图片提词任务创建失败: {exc}") raise HTTPException(status_code=500, detail=f"图片提词任务创建失败: {exc}") from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize @@ -354,6 +469,14 @@ async def generate_image( db: AsyncSession = Depends(get_db), ): if celery_app is None: + _log_api_error( + event_type="CELERY_DISABLED", + current_user=current_user, + project_id=project_id, + step_id=step_id, + message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker", + detail={"api": "hot_opening_replicate"}, + ) raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") try: @@ -369,6 +492,7 @@ async def generate_image( raise except Exception as exc: await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"图片生成任务创建失败: {exc}") raise HTTPException(status_code=500, detail=f"图片生成任务创建失败: {exc}") from app.tasks.generation_create_tasks import chatapi_create_generation_task @@ -411,6 +535,14 @@ async def generate_video_prompt( db: AsyncSession = Depends(get_db), ): if celery_app is None: + _log_api_error( + event_type="CELERY_DISABLED", + current_user=current_user, + project_id=project_id, + step_id=step_id, + message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker", + detail={"api": "hot_opening_replicate"}, + ) raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") try: @@ -423,6 +555,7 @@ async def generate_video_prompt( raise except Exception as exc: await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"视频提词任务创建失败: {exc}") raise HTTPException(status_code=500, detail=f"视频提词任务创建失败: {exc}") from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize @@ -466,6 +599,14 @@ async def generate_video( db: AsyncSession = Depends(get_db), ): if celery_app is None: + _log_api_error( + event_type="CELERY_DISABLED", + current_user=current_user, + project_id=project_id, + step_id=step_id, + message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker", + detail={"api": "hot_opening_replicate"}, + ) raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") try: @@ -481,6 +622,7 @@ async def generate_video( raise except Exception as exc: await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"视频生成任务创建失败: {exc}") raise HTTPException(status_code=500, detail=f"视频生成任务创建失败: {exc}") from app.tasks.generation_create_tasks import chatapi_create_generation_task diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py index 0390ea7c..44edb008 100644 --- a/video-gen-api/app/api/v1/payments.py +++ b/video-gen-api/app/api/v1/payments.py @@ -83,13 +83,87 @@ async def recharge( @router.post("/wechat/callback") async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)): - data = await request.json() - if not await verify_wechat_callback(data, db): - raise HTTPException(status_code=400, detail="签名验证失败") - order_no = data.get("out_trade_no") - if order_no: - await process_payment_success_by_order_no(db, order_no) - return {"code": "SUCCESS", "message": "OK"} + # 读取微信支付回调数据 + body_bytes = await request.body() + body_str = body_bytes.decode("utf-8") + + # 获取配置 + from app.services.payment import _get_payment_configs, _is_mock_mode, _get_wechat_client + db_configs = await _get_payment_configs(db) + + # 检查 mock 模式 + if _is_mock_mode(db_configs): + try: + import json + data = json.loads(body_str) if body_str else {} + order_no = data.get("out_trade_no") + if order_no: + await process_payment_success_by_order_no(db, order_no) + logger.info(f"Mock WeChat callback processed: order_no={order_no}") + return {"code": "SUCCESS", "message": "OK"} + except Exception as e: + logger.exception(f"Mock WeChat callback error: {e}") + return {"code": "SUCCESS", "message": "OK"} # 微信要求即使处理失败也返回成功 + + # 真实模式:使用 wechatpayv3 SDK 验证回调并解析数据 + try: + mch_id = db_configs.get("payment_wechat_mch_id", "") + private_key = db_configs.get("payment_wechat_private_key", "") + cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") + api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") + gateway = db_configs.get("payment_wechat_gateway", "") + + client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, gateway) + if not client: + logger.error("WeChat client not initialized for callback") + return {"code": "SUCCESS", "message": "OK"} + + # 从请求头获取必要信息 + headers = dict(request.headers) + timestamp = headers.get("Wechatpay-Timestamp", "") + nonce = headers.get("Wechatpay-Nonce", "") + signature = headers.get("Wechatpay-Signature", "") + serial_no = headers.get("Wechatpay-Serial", "") + + # 验证签名 + is_verified = client.verify( + timestamp=timestamp, + nonce=nonce, + body=body_str, + signature=signature, + serial_no=serial_no + ) + + if not is_verified: + logger.warning("WeChat callback signature verification failed") + raise HTTPException(status_code=400, detail="签名验证失败") + + # 解密回调数据 + decrypted_data = client.decrypt(body_str) + if not decrypted_data: + logger.error("WeChat callback decryption failed") + raise HTTPException(status_code=400, detail="数据解密失败") + + # 处理支付成功回调 + if decrypted_data.get("event_type") == "TRANSACTION.SUCCESS": + resource = decrypted_data.get("resource", {}) + order_no = resource.get("out_trade_no", "") + transaction_id = resource.get("transaction_id", "") + amount_info = resource.get("amount", {}) + total_amount = amount_info.get("total", 0) / 100 # 转换为元 + + if order_no: + await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount) + logger.info( + f"WeChat callback processed: order_no={order_no}, " + f"transaction_id={transaction_id}, amount={total_amount}" + ) + + return {"code": "SUCCESS", "message": "OK"} + except Exception as e: + logger.exception(f"WeChat callback processing error: {e}") + # 微信支付要求即使处理失败也返回成功,避免重复回调 + return {"code": "SUCCESS", "message": "OK"} @router.post("/alipay/callback") @@ -183,13 +257,19 @@ async def cancel_order( if order.status != "pending": raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消") - # If it's an Alipay order, call close API first + # If it's an Alipay or WeChat order, call close API first + db_configs = await _get_payment_configs(db) if order.payment_method == "alipay": - db_configs = await _get_payment_configs(db) try: await _close_alipay_order(db, order, db_configs) except Exception as e: logger.exception(f"Failed to close Alipay order {order_no}: {e}") + elif order.payment_method == "wechat": + try: + from app.services.payment import _close_wechat_order + await _close_wechat_order(db, order, db_configs) + except Exception as e: + logger.exception(f"Failed to close WeChat order {order_no}: {e}") order.status = "cancelled" await db.flush() diff --git a/video-gen-api/app/api/v1/shot_replicate.py b/video-gen-api/app/api/v1/shot_replicate.py new file mode 100644 index 00000000..f54095ed --- /dev/null +++ b/video-gen-api/app/api/v1/shot_replicate.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query +from sqlalchemy import inspect as sa_inspect +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_current_user, get_db +from app.models.user import User +from app.enums.shot_replicate import ModuleCodeEnum +from app.schemas.shot_replicate import ( + ShotReplicateActionOut, + ShotReplicateDeleteOut, + ShotReplicateGenerateImagePromptRequest, + ShotReplicateGenerateImageRequest, + ShotReplicateGenerateVideoPromptRequest, + ShotReplicateGenerateVideoRequest, + ShotReplicateImagePromptUpdateRequest, + ShotReplicateMaterialUpdateRequest, + ShotReplicateSpecOut, + ShotReplicateTaskDetailOut, + ShotReplicateVideoPromptSchemaUpdateRequest, + ShotSegmentDetailOut, + ShotSegmentListOut, + ShotSegmentReplicationCreateRequest, + ShotSplitByAIOut, + ShotSplitByAIRequest, + ShotSplitCustomOut, + ShotSplitCustomRequest, + ShotTaskSetCreate, + ShotTaskSetDetailOut, + ShotTaskSetListOut, +) +from app.services.shot_replicate_flow_service import ( + _get_project_for_user, + create_shot_replicate_project_from_segment, + delete_shot_replicate_project, + generate_image_from_prompt, + generate_video_from_prompt, + mark_shot_replicate_step_dispatch_failed, + project_to_detail_out, + submit_image_prompt_optimize, + submit_video_prompt_optimize, + update_shot_replicate_image_prompt, + update_shot_replicate_material_input, + update_shot_replicate_video_prompt_schema, +) +from app.services.shot_replicate_taskset_service import ( + create_custom_segment, + create_segments_by_ai, + create_task_set, + get_segment_for_user, + list_segments, + list_task_sets, + segment_detail, + task_set_detail, +) +from app.services.module_generation_log_service import log_module_error, log_module_event_file +from app.tasks.celery_app import celery_app + +MODULE = ModuleCodeEnum.SHOT_REPLICATE.value + + + +def _safe_user_id(user: object | None) -> str | None: + """从 ORM 对象中安全取用户ID,避免 rollback/commit 后访问过期属性触发 MissingGreenlet。""" + if user is None: + return None + try: + value = getattr(user, "__dict__", {}).get("id") + if value is not None: + return str(value) + except Exception: + pass + try: + identity = sa_inspect(user).identity + if identity: + return str(identity[0]) + except Exception: + pass + return None + + +def _safe_user_is_admin(user: object | None) -> bool: + """安全判断管理员身份;如果对象属性已过期,保守按普通用户处理。""" + if user is None: + return False + try: + data = getattr(user, "__dict__", {}) + if "is_admin" in data: + return bool(data.get("is_admin")) + except Exception: + pass + return False + + +def _user_context(user: object | None) -> SimpleNamespace: + return SimpleNamespace(id=_safe_user_id(user), is_admin=_safe_user_is_admin(user)) + +router = APIRouter(prefix="/shot-replications", tags=["shot-replications"]) + + + + +def _log_api_error( + *, + event_type: str, + current_user: User | None = None, + project_id: str | None = None, + step_id: str | None = None, + message: str | None = None, + exc: BaseException | None = None, + detail: dict | None = None, +) -> None: + log_module_error( + module=MODULE, + event_type=event_type, + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message=message, + detail=detail, + exc=exc, + ) + + +def _log_api_exception_from_locals(exc: BaseException, local_values: dict, message: str) -> None: + current_user = local_values.get("current_user") + project_id = local_values.get("project_id_value") or local_values.get("project_id") or local_values.get("task_set_id") or local_values.get("task_set_id_value") + step_id = local_values.get("step_id_value") or local_values.get("step_id") or local_values.get("segment_id") or local_values.get("segment_id_value") + req = local_values.get("req") + detail = {"api": local_values.get("__name__"), "request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None} + _log_api_error( + event_type="API_REQUEST_FAILED", + current_user=current_user if isinstance(current_user, User) else None, + project_id=str(project_id) if project_id else None, + step_id=str(step_id) if step_id else None, + message=message, + detail=detail, + exc=exc, + ) + +async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut: + project = await _get_project_for_user( + db, + project_id=project_id, + user=_user_context(current_user), + for_update=False, + populate_existing=True, + ) + return await project_to_detail_out(db, project) + + +async def _mark_dispatch_failed_and_raise( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str | None, + message: str, +) -> None: + if step_id: + try: + await mark_shot_replicate_step_dispatch_failed( + db, + current_user=_user_context(current_user), + project_id=project_id, + step_id=step_id, + error_message=message, + ) + await db.commit() + except Exception as exc: + await db.rollback() + _log_api_error( + event_type="CELERY_DISPATCH_MARK_FAILED", + current_user=current_user, + project_id=project_id, + step_id=step_id, + message="Celery 投递失败后标记步骤失败也失败", + detail={"dispatch_error": message}, + exc=exc, + ) + log_module_error( + module=MODULE, + event_type="CELERY_DISPATCH_FAILED", + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message=message, + detail={"reason": "celery_dispatch_failed"}, + error=message, + ) + raise HTTPException(status_code=503, detail=message) + + +@router.get( + "/spec", + response_model=ShotReplicateSpecOut, + summary="查询拆镜复刻模块状态枚举和步骤 JSON 结构说明", +) +async def get_spec(): + return ShotReplicateSpecOut() + + +@router.post( + "/task-sets", + response_model=ShotTaskSetDetailOut, + summary="创建拆镜总任务集并异步分析原视频", +) +async def create_shot_task_set( + req: ShotTaskSetCreate = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + task_set = await create_task_set(db, current_user=current_user, req=req) + task_set_id = task_set.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}") + raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}") + + if celery_app: + try: + from app.tasks.shot_replicate_tasks import analyze_original_video + + analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0) + except Exception as exc: + # 分析任务投递失败时保留总任务,前端可稍后通过恢复/重试处理。 + _log_api_error( + event_type="CELERY_DISPATCH_FAILED", + current_user=current_user, + project_id=task_set_id, + message=f"拆镜分析任务投递失败: {exc}", + detail={"task_set_id": task_set_id, "task": "analyze_original_video"}, + exc=exc, + ) + raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}") + + return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id) + + +@router.get( + "/task-sets", + response_model=ShotTaskSetListOut, + summary="查询拆镜总任务集列表", +) +async def list_shot_task_sets( + status: str | None = Query(None, description="总任务状态,见 ShotTaskSetStatusEnum"), + analysis_status: str | None = Query(None, description="分析状态,见 ShotAnalysisStatusEnum"), + split_status: str | None = Query(None, description="拆镜状态,见 ShotSplitStatusEnum"), + keyword: str | None = Query(None, description="标题/内容关键词"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await list_task_sets( + db, + current_user=current_user, + status=status, + analysis_status=analysis_status, + split_status=split_status, + keyword=keyword, + page=page, + page_size=page_size, + ) + + +@router.get( + "/task-sets/{task_set_id}", + response_model=ShotTaskSetDetailOut, + summary="获取拆镜总任务集详情", +) +async def get_shot_task_set( + task_set_id: str = Path(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id) + + +@router.post( + "/task-sets/{task_set_id}/split-by-ai", + response_model=ShotSplitByAIOut, + summary="按 AI 建议方案异步拆镜", +) +async def split_by_ai( + task_set_id: str = Path(...), + req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + out = await create_segments_by_ai(db, current_user=current_user, task_set_id=task_set_id, req=req) + segment_ids = [item.id for item in out.segments] + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"按 AI 建议拆镜失败: {exc}") + raise HTTPException(status_code=500, detail=f"按 AI 建议拆镜失败: {exc}") + + if celery_app: + from app.tasks.shot_replicate_tasks import split_one_segment + + for segment_id in segment_ids: + split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0) + return out + + +@router.post( + "/task-sets/{task_set_id}/split-custom", + response_model=ShotSplitCustomOut, + summary="按用户自定义开始/结束秒异步拆单条片段", +) +async def split_custom( + task_set_id: str = Path(...), + req: ShotSplitCustomRequest = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + out = await create_custom_segment(db, current_user=current_user, task_set_id=task_set_id, req=req) + segment_id = out.segment.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"自定义拆镜失败: {exc}") + raise HTTPException(status_code=500, detail=f"自定义拆镜失败: {exc}") + + if celery_app: + from app.tasks.shot_replicate_tasks import split_one_segment + + split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0) + return out + + +@router.get( + "/task-sets/{task_set_id}/segments", + response_model=ShotSegmentListOut, + summary="查询拆镜片段列表", +) +async def list_task_set_segments( + task_set_id: str = Path(...), + source_mode: str | None = Query(None, description="ai_suggestion/custom"), + split_status: str | None = Query(None, description="拆镜状态"), + analysis_status: str | None = Query(None, description="片段分析状态"), + replicate_status: str | None = Query(None, description="复刻状态"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await list_segments( + db, + current_user=current_user, + task_set_id=task_set_id, + source_mode=source_mode, + split_status=split_status, + analysis_status=analysis_status, + replicate_status=replicate_status, + page=page, + page_size=page_size, + ) + + +@router.get( + "/segments/{segment_id}", + response_model=ShotSegmentDetailOut, + summary="获取拆镜片段详情", +) +async def get_segment( + segment_id: str = Path(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await segment_detail(db, current_user=current_user, segment_id=segment_id) + + +@router.post( + "/segments/{segment_id}/replication-projects", + response_model=ShotReplicateActionOut, + summary="将拆镜片段创建为拆镜复刻项目", +) +async def create_replication_project_from_segment( + segment_id: str = Path(...), + req: ShotSegmentReplicationCreateRequest = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True) + project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req) + project_id = project.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"创建拆镜复刻项目失败: {exc}") + raise HTTPException(status_code=500, detail=f"创建拆镜复刻项目失败: {exc}") + + return ShotReplicateActionOut( + message="已从拆镜片段创建复刻项目,素材视频已锁定", + project_id=project_id, + step_id=None, + detail=await _reload_project_detail(db, current_user, project_id), + ) + + +@router.get( + "/projects/{project_id}", + response_model=ShotReplicateTaskDetailOut, + summary="获取拆镜复刻项目详情", +) +async def get_project( + project_id: str = Path(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await _reload_project_detail(db, current_user, project_id) + + +@router.put( + "/projects/{project_id}/material", + response_model=ShotReplicateActionOut, + summary="修改拆镜复刻素材信息,素材视频不允许修改", +) +async def update_material( + project_id: str = Path(...), + req: ShotReplicateMaterialUpdateRequest = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project_id_value, step_id_value = await update_shot_replicate_material_input(db, current_user=current_user, project_id=project_id, req=req) + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"修改素材输入失败: {exc}") + raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}") + return ShotReplicateActionOut(message="素材输入已修改,素材视频保持锁定", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) + + +@router.put( + "/projects/{project_id}/steps/{step_id}/image-prompt", + response_model=ShotReplicateActionOut, + summary="直接修改图片 AI 优化提词", +) +async def update_image_prompt( + project_id: str = Path(...), + step_id: str = Path(...), + req: ShotReplicateImagePromptUpdateRequest = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project, step = await update_shot_replicate_image_prompt(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req) + project_id_value, step_id_value = project.id, step.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"修改图片 AI 提词失败: {exc}") + raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}") + return ShotReplicateActionOut(message="图片 AI 提词已修改,后续步骤已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) + + +@router.put( + "/projects/{project_id}/steps/{step_id}/video-prompt-schema", + response_model=ShotReplicateActionOut, + summary="修改视频 AI 提词 JSON schema", +) +async def update_video_prompt_schema( + project_id: str = Path(...), + step_id: str = Path(...), + req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project, step = await update_shot_replicate_video_prompt_schema(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req) + project_id_value, step_id_value = project.id, step.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"修改视频 AI 提词失败: {exc}") + raise HTTPException(status_code=500, detail=f"修改视频 AI 提词失败: {exc}") + return ShotReplicateActionOut(message="视频 AI 提词 schema 已修改,第5步视频生成已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) + + +@router.post( + "/projects/{project_id}/generate-image-prompt", + response_model=ShotReplicateActionOut, + summary="生成图片 AI 提词", +) +async def generate_image_prompt( + project_id: str = Path(...), + req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req) + project_id_value, step_id_value = project.id, step.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}") + raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}") + + try: + if celery_app: + from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize + + start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0) + except Exception as exc: + await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}") + + return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) + + +@router.post( + "/projects/{project_id}/generate-image", + response_model=ShotReplicateActionOut, + summary="根据图片 AI 提词生成图片", +) +async def generate_image( + project_id: str = Path(...), + req: ShotReplicateGenerateImageRequest = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, req=req) + project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"提交图片生成失败: {exc}") + raise HTTPException(status_code=500, detail=f"提交图片生成失败: {exc}") + + try: + if celery_app: + from app.tasks.generation_create_tasks import chatapi_create_generation_task + + chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0) + except Exception as exc: + await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片生成任务投递失败: {exc}") + + return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) + + +@router.post( + "/projects/{project_id}/generate-video-prompt", + response_model=ShotReplicateActionOut, + summary="生成视频 AI 提词 JSON schema", +) +async def generate_video_prompt( + project_id: str = Path(...), + req: ShotReplicateGenerateVideoPromptRequest = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req) + project_id_value, step_id_value = project.id, step.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}") + raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}") + + try: + if celery_app: + from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize + + start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0) + except Exception as exc: + await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}") + + return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) + + +@router.post( + "/projects/{project_id}/generate-video", + response_model=ShotReplicateActionOut, + summary="根据视频 AI 提词生成视频", +) +async def generate_video( + project_id: str = Path(...), + req: ShotReplicateGenerateVideoRequest = Body(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, req=req) + project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"提交视频生成失败: {exc}") + raise HTTPException(status_code=500, detail=f"提交视频生成失败: {exc}") + + try: + if celery_app: + from app.tasks.generation_create_tasks import chatapi_create_generation_task + + chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0) + except Exception as exc: + await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频生成任务投递失败: {exc}") + + return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) + + +@router.delete( + "/projects/{project_id}", + response_model=ShotReplicateDeleteOut, + summary="软删除拆镜复刻项目", +) +async def delete_project( + project_id: str = Path(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + out = await delete_shot_replicate_project(db, current_user=current_user, project_id=project_id) + await db.commit() + return out + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + _log_api_exception_from_locals(exc, locals(), f"删除拆镜复刻项目失败: {exc}") + raise HTTPException(status_code=500, detail=f"删除拆镜复刻项目失败: {exc}") diff --git a/video-gen-api/app/config.py b/video-gen-api/app/config.py index 334175a3..066c5528 100644 --- a/video-gen-api/app/config.py +++ b/video-gen-api/app/config.py @@ -87,6 +87,13 @@ class Settings(BaseSettings): # ChatAPI async generation pipeline settings CELERY_BROKER_URL: str = "" CELERY_RESULT_BACKEND: str = "" + # Celery async 兼容配置。 + # single_loop:每个 Celery 子进程一个专用 event loop,推荐线上/本地统一使用。 + # direct:旧版线程本地 loop 降级模式,建议配合 CELERY_DB_USE_NULLPOOL=true。 + CELERY_ASYNC_RUNNER_MODE: str = "single_loop" + CELERY_DB_USE_NULLPOOL: bool = False + CELERY_STARTUP_RECOVERY_ENABLED: bool = True + CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 180 CHATAPI_VIDEO_FPS: float = 0.5 CHATAPI_ASYNC_MAX_RETRIES: int = 3 @@ -123,6 +130,22 @@ class Settings(BaseSettings): DOWNLOAD_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:download:active" DOWNLOAD_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:download:active_index" + # Celery 生成链路 / provider poll 容灾配置。 + # 说明: + # - 不新增 Celery worker;恢复任务仍投递到 gen_result_download。 + # - worker_ready 每个 worker 都会尝试抢启动恢复锁,只有抢到锁的 worker 投递恢复任务。 + # - poll active 使用独立 Redis key,避免影响稳定的下载 active 注册表。 + GENERATION_RECOVERY_BATCH_SIZE: int = 100 + GENERATION_RECOVERY_MAX_ROUNDS: int = 5 + POLL_RECOVERY_BATCH_SIZE: int = 100 + POLL_TASK_LEASE_SECONDS: int = 5 * 60 + POLL_TASK_QUEUE_TIMEOUT_SECONDS: int = 2 * 60 + POLL_ACTIVE_REDIS_HASH_KEY: str = "vg:celery:poll:active" + POLL_ACTIVE_REDIS_ZSET_KEY: str = "vg:celery:poll:active_index" + CELERY_STARTUP_RECOVERY_LOCK_KEY: str = "vg:celery:startup_recovery_lock" + CELERY_STARTUP_RECOVERY_LOCK_TTL_SECONDS: int = 120 + + RESOURCE_SIGN_SECRET: str = "resource-signature-secret-key-for-API-authentication" RESOURCE_SIGN_EXPIRE_SECONDS: int = 60 RESOURCE_SIGN_ARG_EXPIRE: str = "exp" @@ -134,5 +157,38 @@ class Settings(BaseSettings): HOT_OPENING_DEFAULT_VIDEO_RESOLUTION: str = "480p" HOT_OPENING_DEFAULT_TARGET_PLATFORM: str = "抖音" + # 拆镜复刻配置。 + # 原始上传视频和拆镜片段都属于 uploads 素材域;只有 generate 生成结果走 token 验签。 + SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 180 + SHOT_ANALYSIS_TEMPERATURE: float = 0.1 + SHOT_ANALYSIS_MAX_TOKENS: int = 5000 + SHOT_ANALYSIS_VIDEO_FPS: float = 1.0 + SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB: int = 45 + + SHOT_SEGMENT_LOCAL_PATH: str = "./storage/uploads/shot_segments" + SHOT_SEGMENT_URL_PREFIX: str = "/uploads/shot_segments" + SHOT_SPLIT_MIN_SECONDS: float = 2 + SHOT_SPLIT_MAX_SECONDS: float = 15 + SHOT_SPLIT_END_TOLERANCE_SECONDS: float = 0.5 + SHOT_DURATION_TOLERANCE_SECONDS: float = 1.0 + SHOT_FFMPEG_TIMEOUT_SECONDS: int = 120 + SHOT_FFPROBE_TIMEOUT_SECONDS: int = 20 + FFPROBE_BIN: str = "" + + # 继续复用 gen_result_download 队列,但限制 ffmpeg 并发,避免拖慢 Chat 下载。 + SHOT_SPLIT_MAX_CONCURRENT: int = 1 + SHOT_SPLIT_MAX_RETRY_COUNT: int = 3 + SHOT_SPLIT_RETRY_BACKOFF_SECONDS: int = 30 + SHOT_SPLIT_LEASE_SECONDS: int = 10 * 60 + SHOT_SPLIT_PENDING_TIMEOUT_SECONDS: int = 5 * 60 + SHOT_SPLIT_RECOVERY_BATCH_SIZE: int = 50 + SHOT_SPLIT_LOCK_KEY_PREFIX: str = "vg:shot_replicate:split:lock" + SHOT_SPLIT_SEMAPHORE_KEY_PREFIX: str = "vg:shot_replicate:split:semaphore" + + SHOT_REPLICATE_DEFAULT_VIDEO_DURATION: int = 4 + SHOT_REPLICATE_DEFAULT_VIDEO_RATIO: str = "9:16" + SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION: str = "480p" + SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM: str = "抖音" + settings = Settings() diff --git a/video-gen-api/app/enums/__init__.py b/video-gen-api/app/enums/__init__.py index a27058d0..c26dc623 100644 --- a/video-gen-api/app/enums/__init__.py +++ b/video-gen-api/app/enums/__init__.py @@ -1,3 +1,4 @@ from app.enums.common import * from app.enums.hot_opening_replicate import * from app.enums.video_prompt_schema import * +from app.enums.shot_replicate import * diff --git a/video-gen-api/app/enums/shot_replicate.py b/video-gen-api/app/enums/shot_replicate.py new file mode 100644 index 00000000..2c48914c --- /dev/null +++ b/video-gen-api/app/enums/shot_replicate.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from enum import StrEnum + + +class ModuleCodeEnum(StrEnum): + """拆镜复刻模块编码。""" + + SHOT_REPLICATE = "shot_replicate" + + +class ShotReplicateStepCodeEnum(StrEnum): + """拆镜复刻复用通用模块步骤编码。""" + + MATERIAL_INPUT = "material_input" + IMAGE_PROMPT_OPTIMIZE = "image_prompt_optimize" + IMAGE_GENERATE = "image_generate" + VIDEO_PROMPT_OPTIMIZE = "video_prompt_optimize" + VIDEO_GENERATE = "video_generate" + + +class ShotReplicateGenerationModeEnum(StrEnum): + """复用 ChatGenerationTask 时使用的 generation_mode。""" + + SHOT_REPLICATE = "shot_replicate" + + +class ShotReplicateStepIOSchemaVersionEnum(StrEnum): + """拆镜复刻子任务 input_json/output_json 结构版本。""" + + V1 = "shot_replicate_step_io_v1" + + +class ShotTaskSetStatusEnum(StrEnum): + """拆镜总任务集状态。""" + + PENDING_ANALYSIS = "pending_analysis" + ANALYZING = "analyzing" + ANALYSIS_COMPLETED = "analysis_completed" + ANALYSIS_FAILED = "analysis_failed" + SPLITTING = "splitting" + SPLIT_COMPLETED = "split_completed" + PARTIAL_FAILED = "partial_failed" + FAILED = "failed" + DELETED = "deleted" + + +class ShotAnalysisStatusEnum(StrEnum): + """原视频/片段视频分析状态。""" + + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class ShotSplitStatusEnum(StrEnum): + """ffmpeg 拆镜状态。""" + + NONE = "none" + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + RETRY_WAITING = "retry_waiting" + + +class ShotSegmentSourceModeEnum(StrEnum): + """拆镜片段来源。""" + + AI_SUGGESTION = "ai_suggestion" + CUSTOM = "custom" + + +class ShotSegmentAnalysisStatusEnum(StrEnum): + """拆镜片段分析状态。""" + + NOT_REQUIRED = "not_required" + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class ShotSegmentReplicateStatusEnum(StrEnum): + """拆镜片段进入复刻流程后的状态。""" + + NOT_STARTED = "not_started" + PROJECT_CREATED = "project_created" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py index 82311bb8..948616db 100644 --- a/video-gen-api/app/models/__init__.py +++ b/video-gen-api/app/models/__init__.py @@ -23,6 +23,8 @@ from app.models.user_resource_month_stat import UserResourceMonthStat from app.models.user_resource_total_stat import UserResourceTotalStat from app.models.module_generation_project import ModuleGenerationProject from app.models.module_generation_step import ModuleGenerationStep +from app.models.shot_replicate_task_set import ShotReplicateTaskSet +from app.models.shot_replicate_segment import ShotReplicateSegment from app.models.user_oauth import UserOAuth from app.models.user_oauth_account import UserOAuthAccount from app.models.user_oauth_app import UserOAuthApp @@ -37,5 +39,6 @@ __all__ = [ "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat", "ModuleGenerationProject", "ModuleGenerationStep", + "ShotReplicateTaskSet", "ShotReplicateSegment", "UserOAuth", "UserOAuthAccount", "UserOAuthApp", ] diff --git a/video-gen-api/app/models/base.py b/video-gen-api/app/models/base.py index cad97a0d..c4728b4f 100644 --- a/video-gen-api/app/models/base.py +++ b/video-gen-api/app/models/base.py @@ -4,6 +4,7 @@ import os from sqlalchemy import DateTime, func from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.pool import NullPool from app.config import settings @@ -18,14 +19,18 @@ engine_kwargs = { "pool_pre_ping": True, } -# SQLite 本地调试时不要乱塞 pool_size/max_overflow,PostgreSQL/asyncpg 才建议配置 +# SQLite 本地调试时不要乱塞 pool_size/max_overflow,PostgreSQL/asyncpg 才建议配置。 +# 默认保持 Celery 连接池复用;只有显式开启 CELERY_DB_USE_NULLPOOL=true 时才降级 NullPool。 if _is_celery_process() and not settings.DATABASE_URL.startswith("sqlite"): - engine_kwargs.update( - pool_size=settings.CELERY_DB_POOL_SIZE, - max_overflow=settings.CELERY_DB_MAX_OVERFLOW, - pool_timeout=settings.CELERY_DB_POOL_TIMEOUT, - pool_recycle=settings.CELERY_DB_POOL_RECYCLE, - ) + if bool(getattr(settings, "CELERY_DB_USE_NULLPOOL", False)): + engine_kwargs.update(poolclass=NullPool) + else: + engine_kwargs.update( + pool_size=settings.CELERY_DB_POOL_SIZE, + max_overflow=settings.CELERY_DB_MAX_OVERFLOW, + pool_timeout=settings.CELERY_DB_POOL_TIMEOUT, + pool_recycle=settings.CELERY_DB_POOL_RECYCLE, + ) engine = create_async_engine(settings.DATABASE_URL, **engine_kwargs) @@ -63,4 +68,4 @@ async def init_database() -> None: async def close_database() -> None: - await engine.dispose() \ No newline at end of file + await engine.dispose() diff --git a/video-gen-api/app/models/shot_replicate_segment.py b/video-gen-api/app/models/shot_replicate_segment.py new file mode 100644 index 00000000..7773788d --- /dev/null +++ b/video-gen-api/app/models/shot_replicate_segment.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, SoftDeleteMixin, TimestampMixin + +_JSON_TYPE = JSON().with_variant(JSONB, "postgresql") + + +class ShotReplicateSegment(Base, TimestampMixin, SoftDeleteMixin): + """拆镜复刻片段表。""" + + __tablename__ = "shot_replicate_segments" + __table_args__ = ( + Index("idx_shot_replicate_segments_task_set", "task_set_id", "segment_index"), + Index("idx_shot_replicate_segments_user_created", "user_id", "created_at"), + Index("idx_shot_replicate_segments_source_mode", "source_mode"), + Index("idx_shot_replicate_segments_split_status", "split_status"), + Index("idx_shot_replicate_segments_analysis_status", "analysis_status"), + Index("idx_shot_replicate_segments_replicate_status", "replicate_status"), + Index("idx_shot_replicate_segments_project", "module_project_id"), + Index( + "uq_shot_replicate_segments_task_set_index_active", + "task_set_id", + "segment_index", + unique=True, + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + task_set_id: Mapped[str] = mapped_column( + String(32), ForeignKey("shot_replicate_task_sets.id", ondelete="CASCADE"), index=True, nullable=False + ) + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + + segment_index: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + source_mode: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + + start_second: Mapped[float] = mapped_column(Float, nullable=False) + end_second: Mapped[float] = mapped_column(Float, nullable=False) + duration_seconds: Mapped[float] = mapped_column(Float, nullable=False) + time_node: Mapped[str] = mapped_column(String(64), nullable=False) + + split_status: Mapped[str] = mapped_column(String(32), default="pending", index=True, nullable=False) + analysis_status: Mapped[str] = mapped_column(String(32), default="pending", index=True, nullable=False) + replicate_status: Mapped[str] = mapped_column(String(32), default="not_started", index=True, nullable=False) + + segment_video_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + segment_video_path: Mapped[str | None] = mapped_column(String(512), nullable=True) + + original_video_content: Mapped[str | None] = mapped_column(Text, nullable=True) + original_video_category: Mapped[str | None] = mapped_column(String(160), nullable=True) + original_video_audience: Mapped[str | None] = mapped_column(Text, nullable=True) + + segment_content: Mapped[str | None] = mapped_column(Text, nullable=True) + segment_category: Mapped[str | None] = mapped_column(String(160), nullable=True) + segment_audience: Mapped[str | None] = mapped_column(Text, nullable=True) + + analysis_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_JSON_TYPE, nullable=True) + ai_suggestion_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_JSON_TYPE, nullable=True) + module_project_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + + split_celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True) + split_enqueued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + split_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + split_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + split_next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + split_retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + split_last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + split_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + analysis_error_message: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/video-gen-api/app/models/shot_replicate_task_set.py b/video-gen-api/app/models/shot_replicate_task_set.py new file mode 100644 index 00000000..93e1a3e9 --- /dev/null +++ b/video-gen-api/app/models/shot_replicate_task_set.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import Float, ForeignKey, Index, Integer, JSON, String, Text, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, SoftDeleteMixin, TimestampMixin + +_JSON_TYPE = JSON().with_variant(JSONB, "postgresql") + + +class ShotReplicateTaskSet(Base, TimestampMixin, SoftDeleteMixin): + """拆镜复刻总任务集。""" + + __tablename__ = "shot_replicate_task_sets" + __table_args__ = ( + Index("idx_shot_replicate_task_sets_user_created", "user_id", "created_at"), + Index("idx_shot_replicate_task_sets_status", "status"), + Index("idx_shot_replicate_task_sets_analysis_status", "analysis_status"), + Index("idx_shot_replicate_task_sets_split_status", "split_status"), + Index( + "uq_shot_replicate_task_sets_user_idempotency", + "user_id", + "idempotency_key", + unique=True, + postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"), + ), + ) + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + title: Mapped[str | None] = mapped_column(String(160), nullable=True) + + video_url: Mapped[str] = mapped_column(String(512), nullable=False) + video_path: Mapped[str] = mapped_column(String(512), nullable=False) + video_duration_seconds: Mapped[float] = mapped_column(Float, nullable=False) + + status: Mapped[str] = mapped_column(String(32), default="pending_analysis", index=True, nullable=False) + analysis_status: Mapped[str] = mapped_column(String(32), default="pending", index=True, nullable=False) + split_status: Mapped[str] = mapped_column(String(32), default="none", index=True, nullable=False) + + original_video_content: Mapped[str | None] = mapped_column(Text, nullable=True) + original_video_category: Mapped[str | None] = mapped_column(String(160), nullable=True) + original_video_audience: Mapped[str | None] = mapped_column(Text, nullable=True) + + ai_suggestion_json: Mapped[list[Any] | dict[str, Any] | None] = mapped_column(_JSON_TYPE, nullable=True) + analysis_raw_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_JSON_TYPE, nullable=True) + analysis_result_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_JSON_TYPE, nullable=True) + + segment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + completed_segment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + failed_segment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + analysis_error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + split_error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) diff --git a/video-gen-api/app/schemas/shot_replicate.py b/video-gen-api/app/schemas/shot_replicate.py new file mode 100644 index 00000000..a018d673 --- /dev/null +++ b/video-gen-api/app/schemas/shot_replicate.py @@ -0,0 +1,734 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.schemas.common import NaiveDatetimeOptional + +SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS: dict[str, str] = { + "pending": "已创建但未进入流程", + "waiting_user": "等待用户手动触发下一步", + "processing": "当前有步骤处理中", + "completed": "总任务完成", + "failed": "总任务失败", + "cancelled": "总任务取消", +} + +SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS: dict[str, str] = { + "pending": "子任务待处理", + "waiting_user": "等待用户确认或触发", + "processing": "子任务处理中", + "completed": "子任务完成", + "failed": "子任务失败", + "cancelled": "子任务取消", +} + +SHOT_REPLICATE_STEP_DESCRIPTIONS: list[dict[str, Any]] = [ + {"step_index": 1, "step_code": "material_input", "name": "素材输入"}, + {"step_index": 2, "step_code": "image_prompt_optimize", "name": "图片 AI 提词"}, + {"step_index": 3, "step_code": "image_generate", "name": "图片生成"}, + {"step_index": 4, "step_code": "video_prompt_optimize", "name": "视频 AI 提词 JSON schema"}, + {"step_index": 5, "step_code": "video_generate", "name": "视频生成"}, +] + +SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION = "shot_replicate_step_io_v1" + +SHOT_REPLICATE_STEP_IO_EXAMPLES: dict[str, dict[str, Any]] = { + "material_input": { + "input_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "material_input", + "source": {"source_step_id": None, "parent_step_id": None}, + "payload": { + "material_video_url": "https://example.com/source.mp4", + "material_image_url": "https://example.com/product.png", + "source_project_name": "参考素材项目名称", + "target_project_name": "新项目名称", + "core_content_point": "50字以内核心内容点", + }, + "context": {}, + }, + "output_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "material_input", + "status": "completed", + "payload": {}, + "result": {"accepted": True, "message": "素材输入已提交", "next_step_code": "image_prompt_optimize"}, + "usage": {}, + "error": {}, + }, + }, + "image_prompt_optimize": { + "input_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "image_prompt_optimize", + "source": {"source_step_id": "第1步素材输入ID", "parent_step_id": "第1步素材输入ID"}, + "payload": {"source_step_id": "第1步素材输入ID"}, + "context": {}, + }, + "output_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "image_prompt_optimize", + "status": "completed", + "payload": { + "optimized_prompt": "图片生成提示词", + "prompt": "兼容字段,同 optimized_prompt", + "original_prompt": "后端拼接的图片提词原始需求", + "references": [{"type": "video|image", "url": "...", "name": "..."}], + }, + "result": {}, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "text_credits_cost": 0, + "credit_biz_key": "module_generation_step:{step_id}:attempt:1:text_prompt:charge", + }, + "error": {}, + }, + }, + "image_generate": { + "input_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "image_generate", + "source": {"source_step_id": "第2步图片提词ID", "parent_step_id": "第2步图片提词ID"}, + "payload": { + "engine_id": "图片引擎ID", + "params": {"image_size": "2K", "image_proportion": "1:1", "image_px": "2048x2048"}, + "prompt": "图片生成提示词", + "media_references": [{"type": "image", "url": "新产品图片", "name": "新产品图片"}], + }, + "context": {}, + }, + "output_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "image_generate", + "status": "completed", + "payload": {}, + "result": {"result_image_url": "/generate/images/xxx.png", "chat_task_id": "ChatGenerationTask ID"}, + "usage": {}, + "error": {}, + }, + }, + "video_prompt_optimize": { + "input_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "video_prompt_optimize", + "source": {"source_step_id": "第3步图片生成ID", "parent_step_id": "第3步图片生成ID"}, + "payload": { + "source_step_id": "第3步图片生成ID", + "video_config": {"engine_id": "视频引擎ID", "duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"}, + "target_platform": "抖音", + }, + "context": {}, + }, + "output_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "video_prompt_optimize", + "status": "completed", + "payload": { + "prompt_schema": {"任务基础信息": {}, "最终提示词": {}}, + "final_prompt": "展示用最终视频提示词", + "params_used_for_prompt": {"duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"}, + "target_platform": "抖音", + }, + "result": {}, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "text_credits_cost": 0, + "credit_biz_key": "module_generation_step:{step_id}:attempt:1:text_prompt:charge", + }, + "error": {}, + }, + }, + "video_generate": { + "input_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "video_generate", + "source": {"source_step_id": "第4步视频提词ID", "parent_step_id": "第4步视频提词ID"}, + "payload": { + "engine_id": "视频引擎ID", + "params": {"duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"}, + "prompt_schema": {"任务基础信息": {}, "最终提示词": {}}, + "final_prompt": "展示用最终提示词", + "media_references": [{"type": "image", "url": "第3步生成图片", "name": "新项目图片"}], + }, + "context": {}, + }, + "output_json": { + "schema_version": SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, + "step_code": "video_generate", + "status": "completed", + "payload": {}, + "result": { + "result_video_url": "/generate/videos/xxx.mp4", + "result_video_cover_url": "/generate/covers/xxx.jpg", + "chat_task_id": "ChatGenerationTask ID", + }, + "usage": {}, + "error": {}, + }, + }, +} + + +class ShotReplicateTaskCreate(BaseModel): + """创建拆镜复刻总任务项目请求体。""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "material_video_url": "https://example.com/source.mp4", + "material_image_url": "https://example.com/product.png", + "source_project_name": "参考素材项目名称", + "target_project_name": "新项目名称", + "core_content_point": "突出产品能帮助用户认识附近新朋友", + "idempotency_key": "frontend-submit-uuid-001", + } + } + ) + + material_video_url: str = Field(..., min_length=1, description="素材视频链接,参考素材,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验") + material_image_url: str = Field(..., min_length=1, description="素材图片链接,新产品图片,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验") + source_project_name: str = Field(..., min_length=1, max_length=20, description="视频素材内容项目名称") + target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称") + core_content_point: str = Field(..., min_length=1, max_length=50, description="生成的项目核心内容点,最多50字") + idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键。只用于 module_generation_projects,不用于 ChatGenerationTask") + + @field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point") + @classmethod + def _strip_required(cls, value: str) -> str: + value = str(value or "").strip() + if not value: + raise ValueError("字段不能为空") + return value + + +class ShotReplicateMaterialUpdateRequest(BaseModel): + """修改拆镜复刻第1步素材输入请求体。""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "material_video_url": "https://example.com/new-source.mp4", + "material_image_url": "https://example.com/new-product.png", + "source_project_name": "新的参考素材项目名称", + "target_project_name": "新的生成项目名称", + "core_content_point": "新的50字以内核心内容点", + } + } + ) + + material_video_url: str | None = Field(None, min_length=1, description="素材视频链接,未传则沿用旧值") + material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值") + source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值") + target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值") + core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值") + + @field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", mode="before") + @classmethod + def _strip_optional(cls, value: str | None) -> str | None: + if value is None: + return None + value = str(value).strip() + if not value: + raise ValueError("字段不能为空字符串") + return value + + @model_validator(mode="after") + def _require_at_least_one(self) -> "ShotReplicateMaterialUpdateRequest": + if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")): + raise ValueError("至少需要传入一个需要修改的字段") + return self + + +class ShotReplicateStepUpdate(BaseModel): + """修改拆镜复刻子任务请求体。""" + + material_video_url: str | None = Field(None, description="修改第1步素材视频链接") + material_image_url: str | None = Field(None, description="修改第1步素材图片链接") + source_project_name: str | None = Field(None, max_length=20, description="修改第1步视频素材内容项目名称") + target_project_name: str | None = Field(None, max_length=20, description="修改第1步生成项目名称") + core_content_point: str | None = Field(None, max_length=50, description="修改第1步生成项目核心内容点,最多50字") + prompt: str | None = Field(None, description="修改第2步图片提词或第4步视频最终提词") + prompt_schema: dict[str, Any] | None = Field(None, description="修改第4步视频提词 JSON schema。只对视频提词步骤有意义") + input_json: dict[str, Any] | None = Field(None, description="高级用法:合并修改当前步骤 input_json.payload") + output_json: dict[str, Any] | None = Field(None, description="高级用法:合并修改当前步骤 output_json.payload") + + +class ShotReplicateImagePromptUpdateRequest(BaseModel): + """直接修改第2步图片 AI 优化提词请求体。 + + 本接口不调用 AI、不扣积分;保存后会软删除第3、4、5步当前有效任务。 + """ + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={"example": {"prompt": "用户手动修改后的图片生成提示词"}}, + ) + + prompt: str = Field(..., min_length=1, description="用户手动修改后的图片生成提示词,不能为空") + + @field_validator("prompt", mode="before") + @classmethod + def _strip_prompt(cls, value: str) -> str: + value = str(value or "").strip() + if not value: + raise ValueError("图片提示词不能为空") + return value + + +class ShotReplicateVideoPromptSchemaUpdateRequest(BaseModel): + """修改第4步视频 AI 提词 JSON schema 请求体。 + + 前端提交的 prompt_schema 只作为 patch:服务端会锁定视频时长、比例、清晰度、帧率、推荐分辨率、 + 动作/镜头/动态时间规划数组长度和时间段、输出规格限制、质量控制、合规控制、schema_version、schema_usage。 + 最终提示词允许修改,但保存前会清洗视频参数。 + """ + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={ + "example": { + "prompt_schema": { + "业务属性": {"产品名称": "脱单交友APP", "行动引导": "立即下载"}, + "最终提示词": {"主提示词": "脱单交友APP推广短视频,突出认识附近新朋友和高效匹配"}, + } + } + }, + ) + + prompt_schema: dict[str, Any] = Field(..., description="前端修改后的视频提词 JSON schema。后端只按白名单回填允许修改字段") + + @field_validator("prompt_schema") + @classmethod + def _validate_schema(cls, value: dict[str, Any]) -> dict[str, Any]: + if not isinstance(value, dict) or not value: + raise ValueError("prompt_schema 必须是非空 JSON 对象") + return value + + +class ShotReplicateGenerateImagePromptRequest(BaseModel): + """手动生成第2步图片 AI 提词请求体。当前无需请求参数。""" + + model_config = ConfigDict(extra="ignore") + + +class ShotReplicateGenerateImageRequest(BaseModel): + """根据图片提词生成新项目图片请求体。 + + ChatGenerationTask.idempotency_key 由后端自动生成,接口不再接收前端幂等键。 + """ + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={"example": {"engine_id": "image_engine_xxx", "image_size": "2K", "image_proportion": "1:1", "image_px": "2048x2048"}}, + ) + + engine_id: str | None = Field(None, description="图片生成引擎ID。为空则使用当前启用且优先级最高的图片引擎") + image_size: str | None = Field(None, description="图片分辨率档位,例如 1K、2K。为空使用引擎默认值") + image_proportion: str | None = Field(None, description="图片比例,例如 1:1、16:9、9:16。为空使用默认值") + image_px: str | None = Field(None, description="图片像素尺寸,例如 2048x2048。为空时按引擎支持尺寸自动匹配") + + +class ShotReplicateGenerateVideoPromptRequest(BaseModel): + """手动生成第4步视频 AI 提词请求体。 + + 视频时长、比例、分辨率集中在本步骤确定;第5步生成视频只选择视频引擎。 + """ + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={"example": {"engine_id": "video_engine_xxx", "duration": 8, "aspect_ratio": "9:16", "resolution": "1080p", "target_platform": "抖音"}}, + ) + + engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎") + duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_DURATION") + aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RATIO") + resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION") + target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM") + + +class ShotReplicateGenerateVideoRequest(BaseModel): + """根据视频提词生成最终视频请求体。 + + 只选择视频生成引擎。duration / aspect_ratio / resolution 从第4步视频提词优化结果读取。 + ChatGenerationTask.original_prompt / optimized_prompt 都写入第4步生成的 prompt_schema JSON 字符串。 + """ + + model_config = ConfigDict(extra="ignore", json_schema_extra={"example": {"engine_id": "video_engine_xxx"}}) + + engine_id: str | None = Field(None, description="视频生成引擎ID。为空优先使用第4步视频提词时选择的 engine_id,再为空使用最高优先级启用视频引擎") + + +class ShotReplicateStepOut(BaseModel): + id: str = Field(..., description="子任务ID") + project_id: str = Field(..., description="总任务项目ID,即 module_generation_projects.id") + module: str = Field(..., description="模块标识,例如 shot_replicate") + step_index: int = Field(..., description="步骤序号:1素材输入、2图片提词、3图片生成、4视频提词、5视频生成") + step_code: str = Field(..., description="步骤编码:material_input/image_prompt_optimize/image_generate/video_prompt_optimize/video_generate") + status: str = Field(..., description="步骤状态:pending/waiting_user/processing/completed/failed/cancelled") + version: int = Field(..., description="步骤版本号。重新生成或修改上游步骤后 version+1") + is_current: bool = Field(..., description="是否当前有效步骤。旧步骤会软删除且 is_current=false") + parent_step_id: str | None = Field(None, description="上一个步骤ID") + source_step_id: str | None = Field(None, description="当前步骤基于哪个上游步骤生成") + chat_task_id: str | None = Field(None, description="关联的 ChatGenerationTask ID。第3步图片生成、第5步视频生成有值") + input: dict[str, Any] | None = Field(None, description=f"步骤输入 JSON,统一 schema_version={SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION}") + output: dict[str, Any] | None = Field(None, description=f"步骤输出 JSON,统一 schema_version={SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION}") + error_message: str | None = Field(None, description="步骤错误信息") + created_at: NaiveDatetimeOptional = Field(None, description="创建时间") + updated_at: NaiveDatetimeOptional = Field(None, description="更新时间") + completed_at: NaiveDatetimeOptional = Field(None, description="完成时间") + + +class ShotReplicateMaterialOut(BaseModel): + material_step_id: str | None = Field(None, description="第1步素材输入子任务ID") + material_video_url: str | None = Field(None, description="素材视频链接") + material_image_url: str | None = Field(None, description="素材图片链接") + source_project_name: str | None = Field(None, description="视频素材内容项目名称") + target_project_name: str | None = Field(None, description="生成项目名称") + core_content_point: str | None = Field(None, description="生成项目核心内容点") + + +class ShotReplicateImageGenerationOut(BaseModel): + prompt_step_id: str | None = Field(None, description="第2步图片 AI 提词子任务ID") + generate_step_id: str | None = Field(None, description="第3步图片生成子任务ID") + prompt: str | None = Field(None, description="图片优化提词") + engine_id: str | None = Field(None, description="图片生成引擎ID") + engine_name: str | None = Field(None, description="图片生成引擎名称") + params: dict[str, Any] | None = Field(None, description="图片生成参数") + chat_task_id: str | None = Field(None, description="图片生成 ChatGenerationTask ID") + status: str | None = Field(None, description="图片生成状态") + result_image_url: str | None = Field(None, description="新项目图片 URL") + error_message: str | None = Field(None, description="图片生成错误信息") + + +class ShotReplicateVideoGenerationOut(BaseModel): + prompt_step_id: str | None = Field(None, description="第4步视频 AI 提词子任务ID") + generate_step_id: str | None = Field(None, description="第5步视频生成子任务ID") + prompt_schema: dict[str, Any] | None = Field(None, description="视频提词 JSON schema。第5步 ChatGenerationTask 原始提词会使用该 JSON 字符串") + final_prompt: str | None = Field(None, description="视频最终提词,仅用于前端展示") + prompt_params: dict[str, Any] | None = Field(None, description="第4步生成视频提词时使用的视频配置,例如 duration、aspect_ratio、resolution") + engine_id: str | None = Field(None, description="视频生成引擎ID") + engine_name: str | None = Field(None, description="视频生成引擎名称") + params: dict[str, Any] | None = Field(None, description="视频生成实际参数。第5步只传 engine_id,其它参数继承第4步") + chat_task_id: str | None = Field(None, description="视频生成 ChatGenerationTask ID") + status: str | None = Field(None, description="视频生成状态") + result_video_url: str | None = Field(None, description="最终视频 URL") + result_video_cover_url: str | None = Field(None, description="最终视频封面 URL") + error_message: str | None = Field(None, description="视频生成错误信息") + + +class ShotReplicateTaskDetailOut(BaseModel): + id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID") + project_id: str = Field(..., description="兼容前端命名,等同于 id") + module: str = Field(..., description="模块标识,拆镜复刻固定为 shot_replicate") + title: str | None = Field(None, description="项目标题,默认取生成项目名称") + status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled") + current_step_code: str | None = Field(None, description="当前所处步骤编码") + final_image_url: str | None = Field(None, description="最终新项目图片 URL") + final_video_url: str | None = Field(None, description="最终视频 URL") + final_video_cover_url: str | None = Field(None, description="最终视频封面 URL") + error_message: str | None = Field(None, description="总任务错误信息") + material: ShotReplicateMaterialOut = Field(default_factory=ShotReplicateMaterialOut, description="素材和项目描述信息") + image_generation: ShotReplicateImageGenerationOut = Field(default_factory=ShotReplicateImageGenerationOut, description="图片提词、图片引擎参数和图片结果") + video_generation: ShotReplicateVideoGenerationOut = Field(default_factory=ShotReplicateVideoGenerationOut, description="视频提词、视频引擎参数和视频结果") + steps: list[ShotReplicateStepOut] = Field(default_factory=list, description="当前有效子任务列表") + created_at: NaiveDatetimeOptional = Field(None, description="创建时间") + updated_at: NaiveDatetimeOptional = Field(None, description="更新时间") + completed_at: NaiveDatetimeOptional = Field(None, description="完成时间") + + +class ShotReplicateTaskListItemOut(BaseModel): + id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID") + project_id: str = Field(..., description="兼容前端命名,等同于 id") + module: str = Field(..., description="模块标识") + title: str | None = Field(None, description="项目标题") + status: str = Field(..., description="总任务状态") + current_step_code: str | None = Field(None, description="当前步骤") + target_project_name: str | None = Field(None, description="生成项目名称,来源于第1步素材输入") + final_image_url: str | None = Field(None, description="最终图片 URL") + final_video_url: str | None = Field(None, description="最终视频 URL") + error_message: str | None = Field(None, description="错误信息") + created_at: NaiveDatetimeOptional = Field(None, description="创建时间") + updated_at: NaiveDatetimeOptional = Field(None, description="更新时间") + completed_at: NaiveDatetimeOptional = Field(None, description="完成时间") + + +class ShotReplicateTaskListOut(BaseModel): + total: int = Field(..., description="总数量") + items: list[ShotReplicateTaskListItemOut] = Field(default_factory=list, description="列表数据") + + +class ShotReplicateActionOut(BaseModel): + message: str = Field(..., description="操作结果提示") + project_id: str = Field(..., description="总任务项目ID") + step_id: str | None = Field(None, description="本次创建或修改的子任务ID") + next_step_id: str | None = Field(None, description="兼容字段:当前接口不自动生成下下个任务,一般为空") + detail: ShotReplicateTaskDetailOut | None = Field(None, description="操作后的总任务详情") + + +class ShotReplicateDeleteOut(BaseModel): + message: str = Field(..., description="删除结果提示") + project_id: str = Field(..., description="被软删除的总任务项目ID") + deleted: bool = Field(..., description="是否已软删除") + + +class ShotReplicateSpecOut(BaseModel): + project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS, description="总任务状态说明") + step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS, description="子任务状态说明") + steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS, description="5个固定步骤说明") + step_io_schema_version: str = Field(default=SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, description="步骤 input_json/output_json 结构版本") + step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES, description="每个步骤 input_json/output_json 示例") + + +# ======================== +# 拆镜总任务集 / 片段 API Schema +# ======================== + +SHOT_TASK_SET_STATUS_DESCRIPTIONS: dict[str, str] = { + "pending_analysis": "已创建,等待原视频分析", + "analyzing": "原视频分析中", + "analysis_completed": "原视频分析完成", + "analysis_failed": "原视频分析失败", + "splitting": "拆镜处理中", + "split_completed": "拆镜全部完成", + "partial_failed": "部分片段失败", + "failed": "总任务失败", + "deleted": "已软删", +} + +SHOT_ANALYSIS_STATUS_DESCRIPTIONS: dict[str, str] = { + "pending": "待分析", + "processing": "分析中", + "completed": "分析完成", + "failed": "分析失败", +} + +SHOT_SPLIT_STATUS_DESCRIPTIONS: dict[str, str] = { + "none": "尚未拆镜", + "pending": "待拆镜", + "processing": "拆镜中", + "completed": "拆镜完成", + "failed": "拆镜失败", + "retry_waiting": "等待恢复重试", +} + +SHOT_SEGMENT_SOURCE_MODE_DESCRIPTIONS: dict[str, str] = { + "ai_suggestion": "AI 建议拆镜", + "custom": "用户自定义拆镜", +} + +SHOT_SEGMENT_ANALYSIS_STATUS_DESCRIPTIONS: dict[str, str] = { + "not_required": "不需要单独分析,通常用于 AI 建议拆镜", + "pending": "等待片段分析", + "processing": "片段分析中", + "completed": "片段分析完成", + "failed": "片段分析失败", +} + +SHOT_SEGMENT_REPLICATE_STATUS_DESCRIPTIONS: dict[str, str] = { + "not_started": "未进入复刻流程", + "project_created": "已创建复刻项目", + "processing": "复刻流程处理中", + "completed": "复刻流程完成", + "failed": "复刻流程失败", +} + + +class ShotAISuggestionOut(BaseModel): + index: int = Field(..., description="AI 建议序号,从1开始") + start_second: float = Field(..., description="拆镜开始秒") + end_second: float = Field(..., description="拆镜结束秒") + duration_seconds: float = Field(..., description="片段时长") + time_node: str = Field(..., description="拆镜时间节点,例如 0-15秒") + content: str = Field(..., description="对应时间节点内的内容") + category: str = Field(..., description="片段分类") + audience: str = Field(..., description="片段受众人群") + + +class ShotTaskSetCreate(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": { + "video_url": "/uploads/2026/06/11/demo.mp4", + "video_duration_seconds": 31.42, + "title": "游戏视频拆镜", + "idempotency_key": "frontend-shot-task-001", + } + } + ) + + video_url: str = Field(..., min_length=1, description="已有上传接口返回的视频链接。必须能反解到 storage/uploads 下文件") + video_duration_seconds: float = Field(..., gt=0, description="前端获取的视频时长秒数,允许浮点;后端会用 ffprobe 校验并以后端真实时长为准") + title: str | None = Field(None, max_length=160, description="拆镜总任务标题") + idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键") + + @field_validator("video_url", "title", "idempotency_key", mode="before") + @classmethod + def _strip_optional_text(cls, value: str | None) -> str | None: + if value is None: + return None + value = str(value).strip() + if not value: + return None + return value + + +class ShotTaskSetListQuery(BaseModel): + status: str | None = Field(None, description="总任务状态筛选,见 ShotTaskSetStatusEnum") + analysis_status: str | None = Field(None, description="分析状态筛选,见 ShotAnalysisStatusEnum") + split_status: str | None = Field(None, description="拆镜状态筛选,见 ShotSplitStatusEnum") + keyword: str | None = Field(None, description="标题/内容关键词") + page: int = Field(1, ge=1, description="页码") + page_size: int = Field(20, ge=1, le=100, description="每页数量") + + +class ShotTaskSetOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + title: str | None = None + video_url: str + video_duration_seconds: float + status: str + analysis_status: str + split_status: str + original_video_content: str | None = None + original_video_category: str | None = None + original_video_audience: str | None = None + segment_count: int = 0 + completed_segment_count: int = 0 + failed_segment_count: int = 0 + analysis_error_message: str | None = None + split_error_message: str | None = None + created_at: NaiveDatetimeOptional = None + updated_at: NaiveDatetimeOptional = None + + +class ShotTaskSetListOut(BaseModel): + total: int + page: int + page_size: int + items: list[ShotTaskSetOut] + + +class ShotTaskSetDetailOut(ShotTaskSetOut): + ai_suggestions: list[ShotAISuggestionOut] = Field(default_factory=list) + analysis_result_json: dict[str, Any] | list[Any] | None = None + + +class ShotSplitByAIRequest(BaseModel): + selected_indices: list[int] | None = Field(None, description="指定 AI 建议序号。不传则全部拆") + replace_existing: bool = Field(False, description="是否软删旧 AI 建议片段后重新拆") + + +class ShotSplitCustomRequest(BaseModel): + start_second: float = Field(..., ge=0, description="自定义拆镜开始秒,允许浮点") + end_second: float = Field(..., gt=0, description="自定义拆镜结束秒,允许浮点,必须大于 start_second") + + @model_validator(mode="after") + def _check_range(self) -> "ShotSplitCustomRequest": + if self.end_second <= self.start_second: + raise ValueError("end_second 必须大于 start_second") + return self + + +class ShotSegmentOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + task_set_id: str + segment_index: int + segment_name: str | None = None + source_mode: str + start_second: float + end_second: float + duration_seconds: float + time_node: str + split_status: str + analysis_status: str + replicate_status: str + segment_video_url: str | None = None + original_video_content: str | None = None + original_video_category: str | None = None + original_video_audience: str | None = None + segment_content: str | None = None + segment_category: str | None = None + segment_audience: str | None = None + split_retry_count: int = 0 + split_last_error: str | None = None + analysis_error_message: str | None = None + module_project_id: str | None = None + created_at: NaiveDatetimeOptional = None + updated_at: NaiveDatetimeOptional = None + + +class ShotSegmentDetailOut(ShotSegmentOut): + analysis_json: dict[str, Any] | list[Any] | None = None + ai_suggestion_json: dict[str, Any] | list[Any] | None = None + + +class ShotSegmentListOut(BaseModel): + total: int + page: int + page_size: int + items: list[ShotSegmentOut] + + +class ShotSplitByAIOut(BaseModel): + task_set_id: str + status: str + split_status: str + created_segment_count: int + segments: list[ShotSegmentOut] + + +class ShotSplitCustomOut(BaseModel): + task_set_id: str + segment: ShotSegmentOut + + +class ShotSegmentReplicationCreateRequest(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": { + "target_project_name": "新产品推广视频", + "core_content_point": "突出产品附近交友和快速脱单", + "material_image_url": "/uploads/2026/06/11/product.png", + "idempotency_key": "optional-key", + } + } + ) + + target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称") + core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字") + material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口") + idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键") + + @field_validator("target_project_name", "core_content_point", "material_image_url", "idempotency_key", mode="before") + @classmethod + def _strip_text(cls, value: str | None) -> str | None: + if value is None: + return None + value = str(value).strip() + if not value: + return None + return value + + +class ShotReplicateSpecOut(BaseModel): + project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS) + step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS) + steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS) + step_io_schema_version: str = SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION + step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES) + task_set_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_TASK_SET_STATUS_DESCRIPTIONS) + analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_ANALYSIS_STATUS_DESCRIPTIONS) + split_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SPLIT_STATUS_DESCRIPTIONS) + segment_source_modes: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_SOURCE_MODE_DESCRIPTIONS) + segment_analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_ANALYSIS_STATUS_DESCRIPTIONS) + segment_replicate_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_REPLICATE_STATUS_DESCRIPTIONS) diff --git a/video-gen-api/app/services/celery_download_recovery_service.py b/video-gen-api/app/services/celery_download_recovery_service.py index a74b5f28..2ee7e877 100644 --- a/video-gen-api/app/services/celery_download_recovery_service.py +++ b/video-gen-api/app/services/celery_download_recovery_service.py @@ -1,103 +1,28 @@ # app/services/celery_download_recovery_service.py from __future__ import annotations -import inspect -import json -import logging -from datetime import datetime, timezone +from datetime import datetime from typing import Any, Dict, Iterable, List, Optional, Union from app.config import settings - -try: - from redis.exceptions import RedisError -except ImportError: - RedisError = RuntimeError # type: ignore[assignment] +from app.services.redis_registry_service import ( + close_registry_redis, + datetime_to_epoch, + ensure_aware_utc, + get_registry_redis, + redis_get_due_registry_ids, + redis_get_registry_payloads, + redis_postpone_registry_item, + redis_remove_registry_item, + redis_upsert_registry_item, + utc_now, +) -logger = logging.getLogger("video_gen") - -_redis_client: Optional[Any] = None - - -def utc_now() -> datetime: - return datetime.now(timezone.utc) - - -def ensure_aware_utc(value: Optional[datetime]) -> Optional[datetime]: - if value is None: - return None - - if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - - return value.astimezone(timezone.utc) - - -def datetime_to_epoch(value: Optional[datetime]) -> int: - checked_value = ensure_aware_utc(value) or utc_now() - return int(checked_value.timestamp()) - - -def _registry_redis_url() -> str: - return settings.CELERY_BROKER_URL or settings.REDIS_URL or "" - - -async def get_registry_redis() -> Optional[Any]: - global _redis_client - - if _redis_client is not None: - return _redis_client - - redis_url = _registry_redis_url() - if not redis_url: - return None - - try: - from redis.asyncio import Redis - except ImportError as exc: - logger.warning( - "下载容灾 Redis 注册表不可用,redis 依赖未安装。error=%s", - exc, - ) - return None - - try: - redis_client = Redis.from_url(redis_url, decode_responses=True) - await redis_client.ping() - _redis_client = redis_client - return _redis_client - except (RedisError, OSError, RuntimeError) as exc: - logger.warning( - "下载容灾 Redis 注册表不可用,降级为仅 DB 容灾。error=%s", - exc, - ) - _redis_client = None - return None - - -async def close_registry_redis() -> None: - global _redis_client - - client = _redis_client - _redis_client = None - - if client is None: - return - - try: - close_method = getattr(client, "close", None) - if close_method is None: - return - - close_result = close_method() - if inspect.isawaitable(close_result): - await close_result - except (RedisError, OSError, RuntimeError) as exc: - logger.debug( - "关闭下载容灾 Redis 注册表连接失败。error=%s", - exc, - ) +# 说明: +# - 本文件保留旧函数名,作为下载容灾兼容层。 +# - 底层 Redis Hash/ZSet 操作已迁移到 redis_registry_service.py。 +# - 下载 active key、Redis URL 选择逻辑不变,避免影响已稳定下载模块。 def build_download_active_payload( @@ -131,32 +56,12 @@ def build_download_active_payload( "attempt": int(attempt or 0), "queue": queue, "priority": priority, - "enqueue_at": ( - datetime_to_epoch(checked_enqueue_at) - if checked_enqueue_at - else None - ), - "started_at": ( - datetime_to_epoch(checked_started_at) - if checked_started_at - else None - ), + "enqueue_at": datetime_to_epoch(checked_enqueue_at) if checked_enqueue_at else None, + "started_at": datetime_to_epoch(checked_started_at) if checked_started_at else None, "updated_at": datetime_to_epoch(checked_updated_at), - "lease_until": ( - datetime_to_epoch(checked_lease_until) - if checked_lease_until - else None - ), - "next_retry_at": ( - datetime_to_epoch(checked_next_retry_at) - if checked_next_retry_at - else None - ), - "check_at": ( - datetime_to_epoch(checked_check_at) - if checked_check_at - else None - ), + "lease_until": datetime_to_epoch(checked_lease_until) if checked_lease_until else None, + "next_retry_at": datetime_to_epoch(checked_next_retry_at) if checked_next_retry_at else None, + "check_at": datetime_to_epoch(checked_check_at) if checked_check_at else None, "reason": reason, } @@ -167,56 +72,23 @@ async def upsert_download_active( payload: Dict[str, Any], check_at: Optional[Union[datetime, int, float]], ) -> None: - redis = await get_registry_redis() - if redis is None: - return - - if isinstance(check_at, datetime): - score = datetime_to_epoch(check_at) - elif check_at is None: - score = datetime_to_epoch(utc_now()) - else: - score = int(float(check_at)) - - updated_payload = dict(payload) - updated_payload["check_at"] = score - - try: - pipe: Any = redis.pipeline(transaction=True) - pipe.hset( - settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, - record_id, - json.dumps(updated_payload, ensure_ascii=False, default=str), - ) - pipe.zadd( - settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, - {record_id: score}, - ) - await pipe.execute() - except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: - logger.warning( - "写入下载容灾 Redis 注册表失败。record_id=%s, error=%s", - record_id, - exc, - ) + await redis_upsert_registry_item( + hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, + zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, + item_id=record_id, + payload=payload, + check_at=check_at, + log_context="download_active", + ) async def remove_download_active(record_id: str) -> None: - redis = await get_registry_redis() - if redis is None: - return - - try: - pipe: Any = redis.pipeline(transaction=True) - pipe.hdel(settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, record_id) - pipe.zrem(settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, record_id) - await pipe.execute() - except (RedisError, OSError, RuntimeError) as exc: - logger.warning( - "删除下载容灾 Redis 注册表失败。record_id=%s, error=%s", - record_id, - exc, - ) + await redis_remove_registry_item( + hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, + zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, + item_id=record_id, + log_context="download_active", + ) async def get_due_download_record_ids( @@ -224,68 +96,22 @@ async def get_due_download_record_ids( limit: Optional[int] = None, now: Optional[datetime] = None, ) -> List[str]: - redis = await get_registry_redis() - if redis is None: - return [] - - batch_limit = int(limit or settings.DOWNLOAD_RECOVERY_BATCH_SIZE or 100) - score = datetime_to_epoch(now or utc_now()) - - try: - result = await redis.zrangebyscore( - settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, - min="-inf", - max=score, - start=0, - num=batch_limit, - ) - return [str(item) for item in result] - except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: - logger.warning( - "扫描下载容灾 Redis ZSet 失败。error=%s", - exc, - ) - return [] + return await redis_get_due_registry_ids( + zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, + limit=limit or int(settings.DOWNLOAD_RECOVERY_BATCH_SIZE or 100), + now=now, + log_context="download_active", + ) async def get_download_active_payloads( record_ids: Iterable[str], ) -> Dict[str, Dict[str, Any]]: - cleaned_record_ids = [str(item) for item in record_ids if item] - if not cleaned_record_ids: - return {} - - redis = await get_registry_redis() - if redis is None: - return {} - - try: - raw_values = await redis.hmget( - settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, - cleaned_record_ids, - ) - except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: - logger.warning( - "读取下载容灾 Redis Hash 失败。error=%s", - exc, - ) - return {} - - result: Dict[str, Dict[str, Any]] = {} - - for record_id, raw in zip(cleaned_record_ids, raw_values): - if not raw: - continue - - try: - value = json.loads(raw) - except (TypeError, ValueError, json.JSONDecodeError): - continue - - if isinstance(value, dict): - result[record_id] = value - - return result + return await redis_get_registry_payloads( + hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, + item_ids=record_ids, + log_context="download_active", + ) async def postpone_download_active_check( @@ -294,45 +120,14 @@ async def postpone_download_active_check( payload: Optional[Dict[str, Any]] = None, check_at: Optional[Union[datetime, int, float]] = None, ) -> None: - redis = await get_registry_redis() - if redis is None: - return + if check_at is None: + check_at = utc_now().timestamp() + int(settings.DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS or 300) - if isinstance(check_at, datetime): - score = datetime_to_epoch(check_at) - elif check_at is None: - score = datetime_to_epoch(utc_now()) + int( - settings.DOWNLOAD_TASK_QUEUE_TIMEOUT_SECONDS or 300 - ) - else: - score = int(float(check_at)) - - try: - pipe: Any = redis.pipeline(transaction=True) - pipe.zadd( - settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, - {record_id: score}, - ) - - if payload is not None: - updated_payload = dict(payload) - updated_payload["check_at"] = score - updated_payload["updated_at"] = datetime_to_epoch(utc_now()) - - pipe.hset( - settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, - record_id, - json.dumps( - updated_payload, - ensure_ascii=False, - default=str, - ), - ) - - await pipe.execute() - except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: - logger.warning( - "刷新下载容灾 Redis 检查时间失败。record_id=%s, error=%s", - record_id, - exc, - ) + await redis_postpone_registry_item( + hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY, + zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY, + item_id=record_id, + payload=payload, + check_at=check_at, + log_context="download_active", + ) diff --git a/video-gen-api/app/services/generation_module_hook_service.py b/video-gen-api/app/services/generation_module_hook_service.py index ebf3f03c..9a518725 100644 --- a/video-gen-api/app/services/generation_module_hook_service.py +++ b/video-gen-api/app/services/generation_module_hook_service.py @@ -23,3 +23,15 @@ async def notify_chat_generation_task_finished(db: AsyncSession, task: ChatGener await handle_chat_generation_task_completed(db, task) elif task.status == "failed": await handle_chat_generation_task_failed(db, task) + return + + if task.generation_mode == "shot_replicate": + from app.services.shot_replicate_flow_service import ( + handle_chat_generation_task_completed, + handle_chat_generation_task_failed, + ) + if task.status == "completed": + await handle_chat_generation_task_completed(db, task) + elif task.status == "failed": + await handle_chat_generation_task_failed(db, task) + return diff --git a/video-gen-api/app/services/generation_recovery_service.py b/video-gen-api/app/services/generation_recovery_service.py index 33ffd7a3..ea9ebe18 100644 --- a/video-gen-api/app/services/generation_recovery_service.py +++ b/video-gen-api/app/services/generation_recovery_service.py @@ -1,10 +1,11 @@ from __future__ import annotations +import json import logging from datetime import datetime, timedelta, timezone -from typing import Any, Dict +from typing import Any -from sqlalchemy import or_, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -16,13 +17,21 @@ from app.services.celery_download_recovery_service import ( postpone_download_active_check, remove_download_active, ) -from app.services.generation_log_service import log_task_event +from app.services.generation_log_service import log_provider_call, log_task_event from app.services.generation_module_hook_service import notify_chat_generation_task_finished +from app.services.generation_provider_service import poll_provider_task from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once +from app.services.redis_registry_service import ( + redis_get_due_registry_ids, + redis_get_registry_payloads, + redis_postpone_registry_item, + redis_remove_registry_item, +) logger = logging.getLogger("video_gen") -ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"} +ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"} +POLL_QUEUE = "gen_provider_poll" def _now() -> datetime: @@ -58,6 +67,52 @@ def _is_final_task_state(task: ChatGenerationTask) -> bool: ) +def _is_success(status: str | None) -> bool: + return str(status or "").lower() in ("succeeded", "success", "completed", "done") + + +def _is_failed(status: str | None) -> bool: + return str(status or "").lower() in ("failed", "error", "canceled", "cancelled") + + +def _engine_snapshot(task: ChatGenerationTask) -> dict[str, Any]: + try: + value = json.loads(task.engine_snapshot_json or "{}") + return value if isinstance(value, dict) else {} + except Exception: + return {} + + +def _poll_queue_timeout_at(now: datetime | None = None) -> datetime: + current_time = now or _now() + return current_time + timedelta(seconds=int(settings.POLL_TASK_QUEUE_TIMEOUT_SECONDS or 120)) + + +async def _remove_poll_active(task_id: str) -> None: + await redis_remove_registry_item( + hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY, + zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY, + item_id=task_id, + log_context="poll_active", + ) + + +async def _postpone_poll_active( + *, + task_id: str, + payload: dict[str, Any] | None = None, + check_at: datetime | int | float | None = None, +) -> None: + await redis_postpone_registry_item( + hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY, + zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY, + item_id=task_id, + payload=payload, + check_at=check_at or _poll_queue_timeout_at(), + log_context="poll_active", + ) + + async def recover_one_download_task( db: AsyncSession, task: ChatGenerationTask, @@ -221,7 +276,7 @@ async def recover_download_tasks_once(db: AsyncSession) -> dict[str, Any]: select(ChatGenerationTask) .where( ChatGenerationTask.deleted_at.is_(None), - ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]), + ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]), ChatGenerationTask.status == "generating", ChatGenerationTask.remote_result_url.is_not(None), ChatGenerationTask.pipeline_stage.in_( @@ -249,134 +304,363 @@ async def recover_download_tasks_once(db: AsyncSession) -> dict[str, Any]: return {"checked": len(checked_ids), "results": results} -async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]: - """启动时生成链路容灾扫描。 +async def _mark_timeout( + db: AsyncSession, + task: ChatGenerationTask, + *, + error_message: str = "任务超时", +) -> str: + await mark_chat_generation_task_failed_and_refund_once( + db, + task=task, + error_message=error_message, + pipeline_stage="timeout", + ) + await notify_chat_generation_task_finished(db, task) + await db.commit() + await _remove_poll_active(task.id) + await log_task_event( + task, + event_type="TASK_TIMEOUT", + to_status="failed", + to_stage="timeout", + ) + return "mark_timeout" - 只在 Celery worker 启动时跑一次,不引入 beat,不新增第四条启动命令。 - 用于把 queued/creating/waiting_remote/polling/result_ready 等中间态重新投递到现有三个队列。 + +async def _mark_failed( + db: AsyncSession, + task: ChatGenerationTask, + *, + error_message: str, + event_type: str = "POLL_FAILED", + detail: Any = None, +) -> str: + await mark_chat_generation_task_failed_and_refund_once( + db, + task=task, + error_message=error_message, + pipeline_stage="failed", + ) + await notify_chat_generation_task_finished(db, task) + await db.commit() + await _remove_poll_active(task.id) + await log_task_event(task, event_type=event_type, message=task.error_message, detail=detail) + return "mark_failed" + + +async def _try_final_poll_before_timeout(db: AsyncSession, task: ChatGenerationTask) -> str: + """超时前最后查一次供应商,避免 Celery 中断导致本地假超时。 + + 如果供应商已经成功,继续进入下载;如果仍 running 或查询失败,再按超时处理。 """ + from app.tasks.generation_download_tasks import enqueue_download_task + + if not (task.provider_task_id or task.seedance_task_id): + return await _mark_timeout(db, task) + + try: + poll_result = await poll_provider_task(db, task) + status = poll_result.get("status") + response_data = poll_result.get("response_data") + except Exception as exc: + await log_task_event( + task, + event_type="FINAL_POLL_BEFORE_TIMEOUT_ERROR", + message=str(exc), + ) + return await _mark_timeout(db, task) + + try: + provider_response = json.loads(response_data or "{}") + except Exception: + provider_response = {"raw": response_data} + + snapshot = _engine_snapshot(task) + await log_provider_call( + task, + provider=snapshot.get("provider") or "ark", + api_type=f"{task.gen_type}_final_poll_before_timeout", + model=snapshot.get("model_name"), + engine_id=task.engine_id, + status="success", + provider_task_id=task.seedance_task_id or task.provider_task_id, + response_data=provider_response, + ) + + if _is_success(status): + if task.gen_type == "image": + task.remote_result_url = poll_result.get("image_url") + task.image_tokens_used = poll_result.get("image_tokens", 0) or 0 + else: + task.remote_result_url = poll_result.get("video_url") + task.video_tokens_used = poll_result.get("video_tokens", 0) or 0 + + task.provider_response_json = response_data + if not task.remote_result_url: + return await _mark_failed( + db, + task, + error_message="供应商任务成功但未返回结果URL", + detail=poll_result, + ) + + task.pipeline_stage = "result_ready" + task.retry_count = 0 + await db.commit() + await _remove_poll_active(task.id) + await log_task_event( + task, + event_type="POLL_SUCCESS_AFTER_TIMEOUT_RECOVERY", + to_stage="result_ready", + detail=poll_result, + ) + await enqueue_download_task(db, task, recover=True, reason="final_poll_before_timeout_success") + return "recover_timeout_success_to_download" + + if _is_failed(status): + task.provider_response_json = response_data + return await _mark_failed( + db, + task, + error_message=poll_result.get("error") or f"供应商任务失败: {status}", + detail=poll_result, + ) + + await log_task_event( + task, + event_type="FINAL_POLL_BEFORE_TIMEOUT_PENDING", + message=f"status={status}", + detail=poll_result, + ) + return await _mark_timeout(db, task) + + +async def recover_one_generation_task( + db: AsyncSession, + task: ChatGenerationTask, + *, + payload: dict[str, Any] | None = None, + source: str = "startup_db", +) -> str: from app.tasks.generation_create_tasks import chatapi_create_generation_task from app.tasks.generation_download_tasks import enqueue_download_task - from app.tasks.generation_poll_tasks import poll_generation_task + from app.tasks.generation_poll_tasks import poll_generation_task, register_poll_active current_time = _now() - results: dict[str, int] = {} + redis_payload = payload or {} - query_result = await db.execute( - select(ChatGenerationTask) - .where( - ChatGenerationTask.deleted_at.is_(None), - ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]), - ChatGenerationTask.status == "generating", - ChatGenerationTask.pipeline_stage.in_( - [ - "queued", - "preparing", - "creating_provider_task", - "waiting_remote", - "polling", - "result_ready", - ] - ), - ) - .order_by(ChatGenerationTask.updated_at.asc()) - .limit(int(settings.DOWNLOAD_RECOVERY_BATCH_SIZE or 100)) - .with_for_update(skip_locked=True) - ) - tasks = query_result.scalars().all() + if not task: + return "skip_missing_task" + if task.generation_mode not in ALLOWED_GENERATION_MODES: + await _remove_poll_active(task.id) + return "clean_invalid_mode" + if _is_final_task_state(task): + await _remove_poll_active(task.id) + return "clean_final_state" + if task.status != "generating": + await _remove_poll_active(task.id) + return "clean_not_generating" - for task in tasks: - if task.deadline_at and _is_expired(task.deadline_at, current_time): - await mark_chat_generation_task_failed_and_refund_once( - db, - task=task, - error_message="任务超时", - pipeline_stage="timeout", - ) - await notify_chat_generation_task_finished(db, task) + if task.deadline_at and _is_expired(task.deadline_at, current_time): + if task.pipeline_stage in ("waiting_remote", "polling"): + return await _try_final_poll_before_timeout(db, task) + return await _mark_timeout(db, task) + + if task.pipeline_stage in ("queued", "preparing", "creating_provider_task"): + if task.provider_task_id or task.seedance_task_id: + task.pipeline_stage = "waiting_remote" await db.commit() await log_task_event( task, - event_type="TASK_TIMEOUT", - to_status="failed", - to_stage="timeout", + event_type="GENERATION_RECOVERY_ENQUEUE", + message=f"{source} 发现创建阶段已存在供应商任务ID,恢复投递轮询队列", + detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload}, ) - action = "mark_timeout" + poll_generation_task.apply_async(args=[task.id], queue=POLL_QUEUE, countdown=0) + await register_poll_active( + task, + check_at=_poll_queue_timeout_at(), + reason=f"{source}_create_stage_has_provider_id", + ) + return "recover_poll_from_create_stage" - elif task.pipeline_stage in ("queued", "preparing", "creating_provider_task"): + await log_task_event( + task, + event_type="GENERATION_RECOVERY_ENQUEUE", + message=f"{source} 发现创建阶段任务未完成,恢复投递创建队列", + detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload}, + ) + chatapi_create_generation_task.apply_async( + args=[task.id], + queue="gen_chatapi_create", + countdown=0, + ) + return "recover_create" + + if task.pipeline_stage in ("waiting_remote", "polling"): + if task.remote_result_url: + await _remove_poll_active(task.id) + await enqueue_download_task( + db, + task, + recover=True, + reason=f"{source}_waiting_remote_has_result", + ) + return "recover_waiting_has_result" + + if task.provider_task_id or task.seedance_task_id: await log_task_event( task, event_type="GENERATION_RECOVERY_ENQUEUE", - message="启动时发现创建阶段任务未完成,恢复投递创建队列", - detail={"pipeline_stage": task.pipeline_stage}, + message=f"{source} 发现远程等待/轮询阶段任务未完成,恢复投递轮询队列", + detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload}, ) - chatapi_create_generation_task.apply_async( + task.pipeline_stage = "waiting_remote" + await db.commit() + poll_generation_task.apply_async( args=[task.id], - queue="gen_chatapi_create", + queue=POLL_QUEUE, countdown=0, ) - action = "recover_create" + await register_poll_active( + task, + check_at=_poll_queue_timeout_at(), + reason=f"{source}_recover_poll", + ) + return "recover_poll" - elif task.pipeline_stage in ("waiting_remote", "polling"): - if task.remote_result_url: - await enqueue_download_task( - db, - task, - recover=True, - reason="startup_waiting_remote_has_result", - ) - action = "recover_waiting_has_result" - elif task.provider_task_id or task.seedance_task_id: - await log_task_event( - task, - event_type="GENERATION_RECOVERY_ENQUEUE", - message="启动时发现远程等待/轮询阶段任务未完成,恢复投递轮询队列", - detail={"pipeline_stage": task.pipeline_stage}, - ) - task.pipeline_stage = "waiting_remote" - await db.commit() - poll_generation_task.apply_async( - args=[task.id], - queue="gen_provider_poll", - countdown=0, - ) - action = "recover_poll" - else: - await log_task_event( - task, - event_type="GENERATION_RECOVERY_ENQUEUE", - message="启动时发现任务缺少供应商任务ID,恢复投递创建队列", - detail={"pipeline_stage": task.pipeline_stage}, - ) - task.pipeline_stage = "queued" - await db.commit() - chatapi_create_generation_task.apply_async( - args=[task.id], - queue="gen_chatapi_create", - countdown=0, - ) - action = "recover_create_missing_provider_id" + await log_task_event( + task, + event_type="GENERATION_RECOVERY_ENQUEUE", + message=f"{source} 发现任务缺少供应商任务ID,恢复投递创建队列", + detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload}, + ) + task.pipeline_stage = "queued" + await db.commit() + await _remove_poll_active(task.id) + chatapi_create_generation_task.apply_async( + args=[task.id], + queue="gen_chatapi_create", + countdown=0, + ) + return "recover_create_missing_provider_id" - elif task.pipeline_stage == "result_ready": - if task.remote_result_url: - await enqueue_download_task( - db, - task, - recover=True, - reason="startup_generation_result_ready", - ) - action = "recover_result_ready" - else: - action = "skip_result_ready_no_url" + if task.pipeline_stage == "result_ready": + await _remove_poll_active(task.id) + if task.remote_result_url: + await enqueue_download_task( + db, + task, + recover=True, + reason=f"{source}_generation_result_ready", + ) + return "recover_result_ready" + return "skip_result_ready_no_url" + + return f"skip_stage_{task.pipeline_stage}" + + +async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]: + """启动时生成链路容灾扫描。 + + 不新增 Celery beat,不新增 worker 命令;worker 启动时由 Redis 锁保证只投递一次。 + 恢复顺序: + 1. Redis poll active_index 到期任务; + 2. DB fallback 扫描 queued/creating/waiting_remote/polling/result_ready; + 3. 下载阶段仍由 recover_download_tasks_once 兜底。 + """ + checked_ids: set[str] = set() + results: dict[str, int] = {} + + due_poll_ids = await redis_get_due_registry_ids( + zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY, + limit=int(settings.POLL_RECOVERY_BATCH_SIZE or settings.GENERATION_RECOVERY_BATCH_SIZE or 100), + log_context="poll_active", + ) + poll_payloads = await redis_get_registry_payloads( + hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY, + item_ids=due_poll_ids, + log_context="poll_active", + ) + + for task_id in due_poll_ids: + result = await db.execute( + select(ChatGenerationTask) + .where( + ChatGenerationTask.id == task_id, + ChatGenerationTask.deleted_at.is_(None), + ) + .with_for_update() + .limit(1) + ) + task = result.scalar_one_or_none() + if task is None: + await _remove_poll_active(task_id) + action = "clean_missing_poll_task" else: - action = f"skip_stage_{task.pipeline_stage}" - + checked_ids.add(task.id) + action = await recover_one_generation_task( + db, + task, + payload=poll_payloads.get(task_id), + source="startup_poll_redis", + ) results[action] = results.get(action, 0) + 1 + batch_size = int(settings.GENERATION_RECOVERY_BATCH_SIZE or settings.DOWNLOAD_RECOVERY_BATCH_SIZE or 100) + max_rounds = max(1, int(settings.GENERATION_RECOVERY_MAX_ROUNDS or 1)) + total_db_checked = 0 + + for _round in range(max_rounds): + query_result = await db.execute( + select(ChatGenerationTask) + .where( + ChatGenerationTask.deleted_at.is_(None), + ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]), + ChatGenerationTask.status == "generating", + ChatGenerationTask.pipeline_stage.in_( + [ + "queued", + "preparing", + "creating_provider_task", + "waiting_remote", + "polling", + "result_ready", + ] + ), + ) + .order_by(ChatGenerationTask.updated_at.asc()) + .limit(batch_size) + .with_for_update(skip_locked=True) + ) + tasks = query_result.scalars().all() + if not tasks: + break + + progressed_this_round = 0 + for task in tasks: + if task.id in checked_ids: + continue + action = await recover_one_generation_task( + db, + task, + payload=None, + source="startup_db", + ) + results[action] = results.get(action, 0) + 1 + checked_ids.add(task.id) + total_db_checked += 1 + progressed_this_round += 1 + + if len(tasks) < batch_size or progressed_this_round <= 0: + break + # 下载阶段单独跑 DB fallback。 download_result = await recover_download_tasks_once(db) return { - "checked": len(tasks), + "checked": len(checked_ids), + "db_checked": total_db_checked, "results": results, "download_recovery": download_result, } diff --git a/video-gen-api/app/services/generation_refund_service.py b/video-gen-api/app/services/generation_refund_service.py index 0b13b644..dcb0752a 100644 --- a/video-gen-api/app/services/generation_refund_service.py +++ b/video-gen-api/app/services/generation_refund_service.py @@ -180,7 +180,7 @@ async def mark_chat_generation_task_failed_and_refund_once( select(ChatGenerationTask) .where( ChatGenerationTask.id == task_id, - ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]), + ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]), ChatGenerationTask.deleted_at.is_(None), ) .with_for_update() diff --git a/video-gen-api/app/services/hot_opening_replicate_service.py b/video-gen-api/app/services/hot_opening_replicate_service.py index 040dea7b..9ece9061 100644 --- a/video-gen-api/app/services/hot_opening_replicate_service.py +++ b/video-gen-api/app/services/hot_opening_replicate_service.py @@ -44,7 +44,7 @@ from app.services.generation_billing_service import charge_module_prompt_usage from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_task_factory_service import create_chat_generation_task_for_module from app.services.hot_opening_video_prompt_service import build_final_video_prompt, optimize_hot_opening_video_prompt, patch_video_prompt_schema_from_client -from app.services.module_generation_log_service import log_module_event_file, log_module_prompt_event +from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event from app.services.llm import optimize_prompt from app.services.resource_accounting_service import soft_delete_chat_task_resources from app.services.resource_signed_url_service import build_resource_signed_url @@ -206,6 +206,28 @@ async def log_module_event( ) + +def _log_project_error( + *, + project: ModuleGenerationProject | None, + event_type: str, + message: str, + exc: BaseException | None = None, + step: ModuleGenerationStep | None = None, + detail: dict[str, Any] | None = None, +) -> None: + log_module_error( + module=(project.module if project else MODULE), + event_type=event_type, + project_id=(project.id if project else None), + step_id=(step.id if step else None), + user_id=(project.user_id if project else None), + message=message, + detail=detail, + exc=exc, + ) + + async def _get_project_for_user( db: AsyncSession, *, @@ -1039,6 +1061,17 @@ async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_i step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = f"图片 AI 提词生成失败: {exc}" + log_module_prompt_event( + event_type="module_prompt_error", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.IMAGE_PROMPT.value, + request=locals().get("request_log", {}), + error=str(exc), + ) + _log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message) return step @@ -1317,6 +1350,17 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = f"视频 AI 提词生成失败: {exc}" + log_module_prompt_event( + event_type="module_prompt_error", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value, + request=locals().get("request_log", {}), + error=str(exc), + ) + _log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message) return step @@ -1517,6 +1561,16 @@ async def mark_hot_opening_step_dispatch_failed( step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = error_message + log_module_error( + module=project.module, + event_type="CELERY_DISPATCH_FAILED", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + message=error_message, + detail={"reason": "celery_dispatch_failed", "chat_task_id": step.chat_task_id}, + error=error_message, + ) await log_module_event( db, project=project, diff --git a/video-gen-api/app/services/hot_opening_video_prompt_service.py b/video-gen-api/app/services/hot_opening_video_prompt_service.py index d6b2858f..218a0921 100644 --- a/video-gen-api/app/services/hot_opening_video_prompt_service.py +++ b/video-gen-api/app/services/hot_opening_video_prompt_service.py @@ -628,7 +628,7 @@ async def optimize_hot_opening_video_prompt( ) -> tuple[dict[str, Any], str, dict[str, Any]]: duration = int(video_config["duration"]) references = [ - {"type": "video", "url": material_video_url}, + {"type": "video", "url": _build_file_url_or_data_uri(material_video_url)}, {"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)}, ] client_schema = build_dynamic_schema(video_config) diff --git a/video-gen-api/app/services/module_generation_log_service.py b/video-gen-api/app/services/module_generation_log_service.py index 1acb3b00..bb415a70 100644 --- a/video-gen-api/app/services/module_generation_log_service.py +++ b/video-gen-api/app/services/module_generation_log_service.py @@ -3,12 +3,14 @@ from __future__ import annotations import json import os import re +import traceback from datetime import datetime from typing import Any from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled MAX_LOG_FIELD_LENGTH = 20000 +MAX_TRACEBACK_LENGTH = 12000 MODULE_LOG_ROOT = os.path.join(os.path.dirname(LOG_DIR), "ModuleGeneration") @@ -33,6 +35,23 @@ def _safe_dump_value(value: Any) -> Any: return value +def build_exception_detail(exc: BaseException | None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + """构造统一异常日志 detail。日志方法必须吞异常,业务不能被日志影响。""" + detail: dict[str, Any] = dict(extra or {}) + if exc is not None: + tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + if len(tb) > MAX_TRACEBACK_LENGTH: + tb = tb[:MAX_TRACEBACK_LENGTH] + f"..." + detail.update( + { + "exception_type": type(exc).__name__, + "exception_message": str(exc), + "traceback": tb, + } + ) + return detail + + def _append_module_log(module: str, entry: dict[str, Any]) -> None: if not is_enabled(): return @@ -92,10 +111,7 @@ def log_module_prompt_event( token_usage: dict[str, Any] | None = None, error: str | None = None, ) -> None: - """记录模块 AI 提词请求/响应到 JSONL 文件。 - - 与模块事件共用同一个服务,但按 module 分目录,方便按模块排查。 - """ + """记录模块 AI 提词/分析请求和响应到 JSONL 文件。""" entry = { "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "log_type": "module_prompt", @@ -123,8 +139,15 @@ def log_module_error( message: str | None = None, detail: dict[str, Any] | None = None, error: str | None = None, + exc: BaseException | None = None, ) -> None: - """记录模块异常日志。""" + """记录模块异常日志。 + + - 兼容原有 detail/error 参数。 + - 新增 exc 后自动记录 exception_type、message、traceback。 + - 日志写入失败会被底层吞掉,不影响主流程。 + """ + merged_detail = build_exception_detail(exc, detail) entry = { "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "log_type": "module_error", @@ -134,7 +157,7 @@ def log_module_error( "step_id": step_id, "user_id": user_id, "message": message, - "detail": _safe_dump_value(detail or {}), - "error": error, + "detail": _safe_dump_value(merged_detail), + "error": error if error is not None else (str(exc) if exc is not None else None), } _append_module_log(module, entry) diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py index db4ddbb6..5f0133f0 100644 --- a/video-gen-api/app/services/payment.py +++ b/video-gen-api/app/services/payment.py @@ -2,6 +2,14 @@ import logging import os from datetime import datetime, timedelta +# 尝试设置 SSL 证书路径 +try: + import certifi + os.environ["SSL_CERT_FILE"] = certifi.where() + os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() +except ImportError: + pass + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -135,12 +143,17 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} " f"amount={order.amount} created_at={order.created_at.isoformat()}" ) - # Also call Alipay close API if it was an Alipay order + # Also call close API if it was an Alipay or WeChat order if order.payment_method == "alipay": try: await _close_alipay_order(db, order, db_configs) except Exception as e: logger.exception(f"Failed to close Alipay order {order.order_no}: {e}") + elif order.payment_method == "wechat": + try: + await _close_wechat_order(db, order, db_configs) + except Exception as e: + logger.exception(f"Failed to close WeChat order {order.order_no}: {e}") return True return False @@ -166,12 +179,17 @@ async def expire_all_pending_orders(db: AsyncSession) -> int: logger.info( f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}" ) - # Also call Alipay close API if it was an Alipay order + # Also call close API if it was an Alipay or WeChat order if o.payment_method == "alipay": try: await _close_alipay_order(db, o, db_configs) except Exception as e: logger.exception(f"Failed to close Alipay order {o.order_no}: {e}") + elif o.payment_method == "wechat": + try: + await _close_wechat_order(db, o, db_configs) + except Exception as e: + logger.exception(f"Failed to close WeChat order {o.order_no}: {e}") if orders: await db.flush() return expired_count @@ -261,8 +279,15 @@ async def create_recharge_order( if not db_configs.get("payment_alipay_app_id") or not db_configs.get("payment_alipay_private_key"): raise ValueError("支付宝支付未完成配置,请联系管理员") elif method == "wechat": - if not db_configs.get("payment_wechat_mch_id") or not db_configs.get("payment_wechat_api_key"): - raise ValueError("微信支付未完成配置,请联系管理员") + required_configs = [ + "payment_wechat_mch_id", + "payment_wechat_private_key", + "payment_wechat_cert_serial_no", + "payment_wechat_api_v3_key" + ] + missing_configs = [c for c in required_configs if not db_configs.get(c)] + if missing_configs: + raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员") total_credits = credits + bonus_credits order = PaymentOrder( @@ -299,7 +324,13 @@ async def create_recharge_order( else: # Real payment: delegate to WeChat or Alipay if method == "wechat": - _create_wechat_order(order, db_configs) + qr_code_content = _create_wechat_order(order, db_configs) + if qr_code_content: + # Attach QR code content to the order instance (transient, not persisted) + order.qr_url = qr_code_content # type: ignore[attr-defined] + else: + # Precreate failed — do not leave a pending order that can never be paid + raise ValueError("微信支付预下单失败,请检查配置或稍后重试") elif method == "alipay": qr_url = _create_alipay_order(order, db_configs) if qr_url: @@ -313,21 +344,231 @@ async def create_recharge_order( # --------------------------------------------------------------------------- -# WeChat (stub) +# WeChat Pay client (lazy singleton, recreated when config changes) # --------------------------------------------------------------------------- +_wechat_client = None +_wechat_mch_id = None -def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> None: - """Create a WeChat Pay order. Stub for real integration.""" +def _get_wechat_client( + mch_id: str, + private_key: str, + cert_serial_no: str, + api_v3_key: str, + gateway: str = "" +): + """Get or create a WeChat Pay client. Recreated if config changes.""" + global _wechat_client, _wechat_mch_id + + if _wechat_client is not None and _wechat_mch_id == mch_id: + return _wechat_client + + try: + from wechatpayv3 import WeChatPay, WeChatPayType + except ImportError: + logger.error( + "wechatpayv3 is not installed. " + "Install it with: pip install wechatpayv3" + ) + return None + + try: + # 处理私钥格式,确保是正确的 PEM 格式 + private_key_str = private_key.strip() + if not private_key_str.startswith("-----BEGIN"): + private_key_str = "-----BEGIN PRIVATE KEY-----\n" + private_key_str + "\n-----END PRIVATE KEY-----" + + # 初始化微信支付客户端 + _wechat_client = WeChatPay( + wechatpay_type=WeChatPayType.NATIVE, + mchid=mch_id, + private_key=private_key_str, + cert_serial_no=cert_serial_no, + apiv3_private_key=api_v3_key, + gateway=gateway or "https://api.mch.weixin.qq.com", + ) + _wechat_mch_id = mch_id + logger.info("WeChat Pay client initialized successfully") + return _wechat_client + except Exception as e: + logger.exception(f"Failed to initialize WeChat Pay client: {e}") + _wechat_client = None + _wechat_mch_id = None + return None + + +def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None: + """Create a WeChat Pay Native order. Returns QR code content (code_url). + + Reads all WeChat config from the database (admin panel). + Returns the ``code_url`` on success, or ``None`` on failure. + """ mch_id = db_configs.get("payment_wechat_mch_id", "") - api_key = db_configs.get("payment_wechat_api_key", "") - if not mch_id or not api_key: + private_key = db_configs.get("payment_wechat_private_key", "") + cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") + api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") + gateway = db_configs.get("payment_wechat_gateway", "") + notify_url = db_configs.get("payment_wechat_notify_url", "") + + if not all([mch_id, private_key, cert_serial_no, api_v3_key]): logger.warning("WeChat payment config missing in database") - return - logger.info( - f"WeChat order created: mch_id={mch_id}, " - f"order_no={order.order_no}, amount={order.amount}" - ) + return None + + client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, gateway) + if client is None: + return None + + try: + # 调用微信支付 Native 下单接口 + result = client.pay( + description=f"充值订单 {order.order_no}", + out_trade_no=order.order_no, + amount={ + "total": int(order.amount * 100), # 微信支付以分为单位 + "currency": "CNY" + }, + notify_url=notify_url, + scene_info={ + "payer_client_ip": "127.0.0.1", + } + ) + + if result.get('code_url'): + # 注意:微信返回的 code_url 可能需要进一步处理成二维码图片地址 + logger.info(f"WeChat order created successfully: order_no={order.order_no}") + return result.get('code_url') + else: + logger.error( + f"WeChat pay failed: order_no={order.order_no}, " + f"result={result}" + ) + return None + except Exception as e: + logger.exception(f"WeChat pay exception: order_no={order.order_no}") + return None + + +async def _close_wechat_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> bool: + """Call WeChat Pay close API to close an unpaid order. + Returns True if the order was closed successfully. + """ + mch_id = db_configs.get("payment_wechat_mch_id", "") + private_key = db_configs.get("payment_wechat_private_key", "") + cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") + api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") + gateway = db_configs.get("payment_wechat_gateway", "") + + client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, gateway) + if client is None: + return False + + mock_mode = _is_mock_mode(db_configs) + if mock_mode: + logger.info(f"Mock mode: skipping close_wechat_order for {order.order_no}") + return True + + try: + result = client.close(out_trade_no=order.order_no) + if result: + logger.info(f"WeChat order closed: order_no={order.order_no}") + return True + else: + logger.error(f"WeChat close failed: order_no={order.order_no}") + return False + except Exception as e: + logger.exception(f"WeChat close exception: order_no={order.order_no}") + return False + + +async def _query_wechat_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> dict | None: + """Call WeChat Pay query API to check order status. + Returns the response data if successful, None otherwise. + """ + mch_id = db_configs.get("payment_wechat_mch_id", "") + private_key = db_configs.get("payment_wechat_private_key", "") + cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") + api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") + gateway = db_configs.get("payment_wechat_gateway", "") + + client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, gateway) + if client is None: + return None + + mock_mode = _is_mock_mode(db_configs) + if mock_mode: + logger.info(f"Mock mode: skipping query_wechat_order for {order.order_no}") + return {"trade_state": "SUCCESS"} + + try: + result = client.query(out_trade_no=order.order_no) + if result.get('trade_state'): + logger.info( + f"WeChat query succeeded: order_no={order.order_no}, " + f"trade_state={result.get('trade_state')}" + ) + return result + else: + logger.error( + f"WeChat query failed: order_no={order.order_no}, " + f"result={result}" + ) + return None + except Exception as e: + logger.exception(f"WeChat query exception: order_no={order.order_no}") + return None + + +async def _refund_wechat_order( + db: AsyncSession, + order: PaymentOrder, + refund_amount: float, + refund_reason: str, + db_configs: dict[str, str] +) -> dict: + """Call WeChat Pay refund API.""" + mch_id = db_configs.get("payment_wechat_mch_id", "") + private_key = db_configs.get("payment_wechat_private_key", "") + cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") + api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") + gateway = db_configs.get("payment_wechat_gateway", "") + + client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, gateway) + if client is None: + return {"success": False, "message": "微信支付客户端初始化失败"} + + mock_mode = _is_mock_mode(db_configs) + if mock_mode: + logger.info(f"Mock mode: skipping wechat refund for {order.order_no}") + return {"success": True} + + try: + out_refund_no = f"{order.order_no}_refund_{int(datetime.now().timestamp())}" + result = client.refund( + out_trade_no=order.order_no, + out_refund_no=out_refund_no, + amount={ + "total": int(order.amount * 100), # 订单总金额 + "refund": int(refund_amount * 100), # 退款金额 + "currency": "CNY" + }, + reason=refund_reason + ) + + if result.get('status') == 'SUCCESS': + logger.info(f"WeChat refund succeeded: order_no={order.order_no}") + return {"success": True, "refund_id": result.get('refund_id')} + else: + logger.error( + f"WeChat refund failed: order_no={order.order_no}, " + f"result={result}" + ) + return { + "success": False, + "message": f"微信退款失败: {result.get('code', '')}" + } + except Exception as e: + logger.exception(f"WeChat refund exception: order_no={order.order_no}") + return {"success": False, "message": f"微信退款异常: {str(e)}"} # --------------------------------------------------------------------------- @@ -554,7 +795,7 @@ async def _query_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: async def sync_pending_orders(db: AsyncSession) -> int: - """Check pending orders via Alipay query and update status. + """Check pending orders via Alipay/WeChat query and update status. Returns the number of orders updated. """ result = await db.execute( @@ -568,23 +809,38 @@ async def sync_pending_orders(db: AsyncSession) -> int: db_configs = await _get_payment_configs(db) for order in orders: - if order.payment_method != "alipay": - continue - try: - data = await _query_alipay_order(db, order, db_configs) - if data: - trade_status = data.get("trade_status") - if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"): - # Order was paid but we missed the callback - trade_no = data.get("trade_no", "") - await process_payment_success_by_order_no(db, order.order_no, trade_no) - updated_count += 1 - elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"): - # Order was closed on Alipay side - order.status = "cancelled" - await db.flush() - updated_count += 1 + if order.payment_method == "alipay": + data = await _query_alipay_order(db, order, db_configs) + if data: + trade_status = data.get("trade_status") + if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"): + # Order was paid but we missed the callback + trade_no = data.get("trade_no", "") + total_amount_str = data.get("total_amount", "") + total_amount = float(total_amount_str) if total_amount_str else None + await process_payment_success_by_order_no(db, order.order_no, trade_no, total_amount) + updated_count += 1 + elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"): + # Order was closed on Alipay side + order.status = "cancelled" + await db.flush() + updated_count += 1 + elif order.payment_method == "wechat": + data = await _query_wechat_order(db, order, db_configs) + if data: + trade_state = data.get("trade_state") + if trade_state == "SUCCESS": + # Order was paid but we missed the callback + transaction_id = data.get("transaction_id", "") + total_amount = float(data.get("amount", {}).get("total", 0)) / 100 + await process_payment_success_by_order_no(db, order.order_no, transaction_id, total_amount) + updated_count += 1 + elif trade_state in ("CLOSED", "REVOKED"): + # Order was closed on WeChat side + order.status = "cancelled" + await db.flush() + updated_count += 1 except Exception as e: logger.exception(f"Failed to sync order {order.order_no}: {e}") @@ -739,12 +995,21 @@ def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool: - """Verify WeChat payment callback signature.""" + """Verify WeChat payment callback signature. + + Note: Since we're using wechatpayv3 SDK which handles verification internally, + in the callback handler we'll verify with the SDK. This function is kept for + interface consistency and mock mode support. + """ db_configs = await _get_payment_configs(db) mock_mode = _is_mock_mode(db_configs) if mock_mode: + logger.info("Mock mode: skipping WeChat callback verification") return True - logger.info("WeChat callback verification (real mode not implemented)") + + # 对于真实模式,我们在回调路由处理器中直接使用 SDK 验证 + # 这里我们返回 True 以保持接口一致性 + logger.info("WeChat callback verification (delegated to SDK in router)") return True @@ -873,7 +1138,7 @@ async def process_refund( if refund_amount > order.amount: return {"success": False, "message": "退款金额超过订单金额"} - # 如果是支付宝订单,调用支付宝退款API + # 根据支付方式调用相应的退款API db_configs = await _get_payment_configs(db) if order.payment_method == "alipay": refund_result = await _refund_alipay_order( @@ -881,6 +1146,12 @@ async def process_refund( ) if not refund_result.get("success"): return refund_result + elif order.payment_method == "wechat": + refund_result = await _refund_wechat_order( + db, order, refund_amount, refund_reason, db_configs + ) + if not refund_result.get("success"): + return refund_result # 扣除积分 try: diff --git a/video-gen-api/app/services/redis_registry_service.py b/video-gen-api/app/services/redis_registry_service.py new file mode 100644 index 00000000..bc55102a --- /dev/null +++ b/video-gen-api/app/services/redis_registry_service.py @@ -0,0 +1,354 @@ +# app/services/redis_registry_service.py +from __future__ import annotations + +import asyncio +import inspect +import json +import logging +import os +import threading +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional, Union + +from app.config import settings + +try: + from redis.exceptions import RedisError +except ImportError: # pragma: no cover - redis 未安装时降级 + RedisError = RuntimeError # type: ignore[assignment] + + +logger = logging.getLogger("video_gen") + +_redis_clients: Dict[tuple[int, int, int], Any] = {} + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def ensure_aware_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def datetime_to_epoch(value: Optional[datetime]) -> int: + checked_value = ensure_aware_utc(value) or utc_now() + return int(checked_value.timestamp()) + + +def normalize_registry_score(value: Optional[Union[datetime, int, float]]) -> int: + if isinstance(value, datetime): + return datetime_to_epoch(value) + if value is None: + return datetime_to_epoch(utc_now()) + return int(float(value)) + + +def registry_redis_url() -> str: + """Celery 容灾注册表统一使用 Celery broker Redis。 + + 不能改成只读 settings.REDIS_URL,否则线上 CELERY_BROKER_URL 使用独立 + Redis DB 时,旧下载 active 注册表会被写到另一个库,导致恢复扫描失效。 + """ + return settings.CELERY_BROKER_URL or settings.REDIS_URL or "" + + +def _is_supported_redis_url(redis_url: str) -> bool: + if not redis_url: + return False + lowered = redis_url.lower() + return lowered.startswith(("redis://", "rediss://", "unix://")) + + +async def get_registry_redis() -> Optional[Any]: + """获取 Celery 容灾 Redis 连接。 + + 重点:redis.asyncio 的连接/连接池绑定 event loop,不能跨 loop 复用。 + Celery -P threads 或 worker_ready + task 线程混用时,如果使用单个全局 + Redis 客户端,会触发 got Future attached to a different loop。 + + 因此这里按 pid + thread_id + event_loop_id 缓存客户端,确保同一个客户端 + 只在创建它的事件循环里使用。Redis 不可用时返回 None,调用方降级为 + DB fallback,不能影响生成主链路。 + """ + redis_url = registry_redis_url() + if not _is_supported_redis_url(redis_url): + if redis_url: + logger.warning( + "Celery 容灾 Redis 注册表仅支持 redis/rediss/unix URL,当前 broker 不是 Redis,降级为 DB 容灾。url=%s", + redis_url, + ) + return None + + try: + from redis.asyncio import Redis + except ImportError as exc: + logger.warning( + "Celery 容灾 Redis 注册表不可用,redis 依赖未安装。error=%s", + exc, + ) + return None + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return None + + client_key = (os.getpid(), threading.get_ident(), id(loop)) + cached = _redis_clients.get(client_key) + if cached is not None: + return cached + + try: + redis_client = Redis.from_url(redis_url, decode_responses=True) + await redis_client.ping() + _redis_clients[client_key] = redis_client + return redis_client + except (RedisError, OSError, RuntimeError) as exc: + logger.warning( + "Celery 容灾 Redis 注册表不可用,降级为仅 DB 容灾。error=%s", + exc, + ) + _redis_clients.pop(client_key, None) + return None + + +async def close_registry_redis() -> None: + """关闭当前进程内已缓存的 Redis 注册表连接。 + + 关闭动作尽量只关闭当前 event loop 对应的客户端;如果调用方处于进程 + 退出阶段,则逐个尝试关闭,失败忽略,避免影响 worker 退出。 + """ + if not _redis_clients: + return + + try: + loop = asyncio.get_running_loop() + current_key = (os.getpid(), threading.get_ident(), id(loop)) + items = [(current_key, _redis_clients.pop(current_key, None))] + except RuntimeError: + items = list(_redis_clients.items()) + _redis_clients.clear() + + for _, client in items: + if client is None: + continue + try: + close_method = getattr(client, "close", None) or getattr(client, "aclose", None) + if close_method is None: + continue + close_result = close_method() + if inspect.isawaitable(close_result): + await close_result + except (RedisError, OSError, RuntimeError) as exc: + logger.debug("关闭 Celery 容灾 Redis 注册表连接失败。error=%s", exc) + + +async def redis_upsert_registry_item( + *, + hash_key: str, + zset_key: str, + item_id: str, + payload: Dict[str, Any], + check_at: Optional[Union[datetime, int, float]], + log_context: str = "registry", +) -> None: + redis = await get_registry_redis() + if redis is None: + return + + score = normalize_registry_score(check_at) + updated_payload = dict(payload) + updated_payload["check_at"] = score + updated_payload["updated_at"] = updated_payload.get("updated_at") or datetime_to_epoch(utc_now()) + + try: + pipe: Any = redis.pipeline(transaction=True) + pipe.hset(hash_key, item_id, json.dumps(updated_payload, ensure_ascii=False, default=str)) + pipe.zadd(zset_key, {item_id: score}) + await pipe.execute() + except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: + logger.warning( + "写入 Redis 注册表失败。context=%s, item_id=%s, error=%s", + log_context, + item_id, + exc, + ) + + +async def redis_remove_registry_item( + *, + hash_key: str, + zset_key: str, + item_id: str, + log_context: str = "registry", +) -> None: + redis = await get_registry_redis() + if redis is None: + return + + try: + pipe: Any = redis.pipeline(transaction=True) + pipe.hdel(hash_key, item_id) + pipe.zrem(zset_key, item_id) + await pipe.execute() + except (RedisError, OSError, RuntimeError) as exc: + logger.warning( + "删除 Redis 注册表失败。context=%s, item_id=%s, error=%s", + log_context, + item_id, + exc, + ) + + +async def redis_get_due_registry_ids( + *, + zset_key: str, + limit: Optional[int] = None, + now: Optional[datetime] = None, + log_context: str = "registry", +) -> List[str]: + redis = await get_registry_redis() + if redis is None: + return [] + + batch_limit = int(limit or 100) + score = datetime_to_epoch(now or utc_now()) + + try: + result = await redis.zrangebyscore( + zset_key, + min="-inf", + max=score, + start=0, + num=batch_limit, + ) + return [str(item) for item in result] + except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: + logger.warning("扫描 Redis ZSet 失败。context=%s, error=%s", log_context, exc) + return [] + + +async def redis_get_registry_payloads( + *, + hash_key: str, + item_ids: Iterable[str], + log_context: str = "registry", +) -> Dict[str, Dict[str, Any]]: + cleaned_item_ids = [str(item) for item in item_ids if item] + if not cleaned_item_ids: + return {} + + redis = await get_registry_redis() + if redis is None: + return {} + + try: + raw_values = await redis.hmget(hash_key, cleaned_item_ids) + except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: + logger.warning("读取 Redis Hash 失败。context=%s, error=%s", log_context, exc) + return {} + + result: Dict[str, Dict[str, Any]] = {} + for item_id, raw in zip(cleaned_item_ids, raw_values): + if not raw: + continue + try: + value = json.loads(raw) + except (TypeError, ValueError, json.JSONDecodeError): + continue + if isinstance(value, dict): + result[item_id] = value + return result + + +async def redis_postpone_registry_item( + *, + hash_key: str, + zset_key: str, + item_id: str, + payload: Optional[Dict[str, Any]] = None, + check_at: Optional[Union[datetime, int, float]] = None, + log_context: str = "registry", +) -> None: + redis = await get_registry_redis() + if redis is None: + return + + score = normalize_registry_score(check_at) + + try: + pipe: Any = redis.pipeline(transaction=True) + pipe.zadd(zset_key, {item_id: score}) + + if payload is not None: + updated_payload = dict(payload) + updated_payload["check_at"] = score + updated_payload["updated_at"] = datetime_to_epoch(utc_now()) + pipe.hset(hash_key, item_id, json.dumps(updated_payload, ensure_ascii=False, default=str)) + + await pipe.execute() + except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: + logger.warning( + "刷新 Redis 注册表检查时间失败。context=%s, item_id=%s, error=%s", + log_context, + item_id, + exc, + ) + + +async def redis_acquire_lock( + *, + lock_key: str, + ttl_seconds: int, + token: Optional[str] = None, + log_context: str = "lock", +) -> Optional[str]: + """尝试获取 Redis 分布式锁。 + + 返回 token 表示抢锁成功;返回 None 表示 Redis 不可用或锁已被其他 worker 持有。 + """ + redis = await get_registry_redis() + if redis is None: + return None + + lock_token = token or uuid.uuid4().hex + ttl = max(1, int(ttl_seconds or 60)) + + try: + acquired = await redis.set(lock_key, lock_token, nx=True, ex=ttl) + return lock_token if acquired else None + except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: + logger.warning("获取 Redis 锁失败。context=%s, lock_key=%s, error=%s", log_context, lock_key, exc) + return None + + +async def redis_release_lock( + *, + lock_key: str, + token: str, + log_context: str = "lock", +) -> bool: + """只释放 token 匹配的锁,避免误删其他 worker 新抢到的锁。""" + redis = await get_registry_redis() + if redis is None: + return False + + script = """ + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end + """ + try: + released = await redis.eval(script, 1, lock_key, token) + return bool(released) + except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc: + logger.warning("释放 Redis 锁失败。context=%s, lock_key=%s, error=%s", log_context, lock_key, exc) + return False diff --git a/video-gen-api/app/services/shot_replicate_flow_service.py b/video-gen-api/app/services/shot_replicate_flow_service.py new file mode 100644 index 00000000..22343d57 --- /dev/null +++ b/video-gen-api/app/services/shot_replicate_flow_service.py @@ -0,0 +1,1747 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum +from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum +from app.models.chat_generation_task import ChatGenerationTask +from app.models.module_generation_project import ModuleGenerationProject +from app.models.module_generation_step import ModuleGenerationStep +from app.models.user import User +from app.schemas.shot_replicate import ( + ShotReplicateDeleteOut, + ShotReplicateGenerateImageRequest, + ShotReplicateGenerateVideoPromptRequest, + ShotReplicateGenerateVideoRequest, + ShotReplicateImageGenerationOut, + ShotReplicateImagePromptUpdateRequest, + ShotReplicateMaterialOut, + ShotReplicateMaterialUpdateRequest, + ShotReplicateStepOut, + ShotReplicateStepUpdate, + ShotReplicateTaskCreate, + ShotReplicateTaskDetailOut, + ShotReplicateTaskListItemOut, + ShotReplicateTaskListOut, + ShotReplicateVideoGenerationOut, + ShotReplicateVideoPromptSchemaUpdateRequest, +) +from app.services.generation_ai_service import ( + VIDEO_DEFAULT_DURATION, + VIDEO_DEFAULT_RATIO, + VIDEO_DEFAULT_RESOLUTION, + _get_video_engine, + _parse_list, +) +from app.services.generation_billing_service import charge_module_prompt_usage +from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once +from app.services.generation_task_factory_service import create_chat_generation_task_for_module +from app.services.hot_opening_video_prompt_service import ( + build_final_video_prompt, + optimize_hot_opening_video_prompt as optimize_shot_replicate_video_prompt, + patch_video_prompt_schema_from_client, +) +from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event +from app.services.llm import optimize_prompt +from app.services.resource_accounting_service import soft_delete_chat_task_resources +from app.services.resource_signed_url_service import build_resource_signed_url +from app.utils.id_gen import generate_id +from app.models.shot_replicate_segment import ShotReplicateSegment +from app.enums.shot_replicate import ShotSegmentReplicateStatusEnum, ShotSplitStatusEnum +from app.schemas.shot_replicate import ShotSegmentReplicationCreateRequest + +MODULE = ModuleCodeEnum.SHOT_REPLICATE.value +GENERATION_MODE = ShotReplicateGenerationModeEnum.SHOT_REPLICATE.value + +STEP_INDEX_MAP = { + ShotReplicateStepCodeEnum.MATERIAL_INPUT.value: 1, + ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: 2, + ShotReplicateStepCodeEnum.IMAGE_GENERATE.value: 3, + ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: 4, + ShotReplicateStepCodeEnum.VIDEO_GENERATE.value: 5, +} + +STEP_IO_SCHEMA_VERSION = "shot_replicate_step_io_v1" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _json(data: Any) -> str | None: + if data is None: + return None + return json.dumps(data, ensure_ascii=False, default=str) + + +def _parse_json(value: Any, fallback: Any = None) -> Any: + if value is None or value == "": + return fallback + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except Exception: + return fallback + return fallback + + +def _step_input( + *, + step_code: str, + payload: dict[str, Any] | None = None, + source_step_id: str | None = None, + parent_step_id: str | None = None, + context: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "schema_version": STEP_IO_SCHEMA_VERSION, + "step_code": step_code, + "source": { + "source_step_id": source_step_id, + "parent_step_id": parent_step_id, + }, + "payload": payload or {}, + "context": context or {}, + } + + +def _step_output( + *, + step_code: str, + status: str, + payload: dict[str, Any] | None = None, + result: dict[str, Any] | None = None, + usage: dict[str, Any] | None = None, + error: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "schema_version": STEP_IO_SCHEMA_VERSION, + "step_code": step_code, + "status": status, + "payload": payload or {}, + "result": result or {}, + "usage": usage or {}, + "error": error or {}, + } + + +def _is_wrapped_step_io(value: Any) -> bool: + return isinstance(value, dict) and value.get("schema_version") == STEP_IO_SCHEMA_VERSION + + +def _step_payload(value: Any) -> dict[str, Any]: + data = _parse_json(value, {}) or {} + if _is_wrapped_step_io(data): + payload = data.get("payload") + return payload if isinstance(payload, dict) else {} + return data if isinstance(data, dict) else {} + + +def _step_result(value: Any) -> dict[str, Any]: + data = _parse_json(value, {}) or {} + if _is_wrapped_step_io(data): + result = data.get("result") + if isinstance(result, dict) and result: + return result + payload = data.get("payload") + return payload if isinstance(payload, dict) else {} + return data if isinstance(data, dict) else {} + + +def _step_usage(value: Any) -> dict[str, Any]: + data = _parse_json(value, {}) or {} + if _is_wrapped_step_io(data): + usage = data.get("usage") + return usage if isinstance(usage, dict) else {} + usage = data.get("token_usage") if isinstance(data, dict) else {} + return usage if isinstance(usage, dict) else {} + + +def _unwrap_step_output(value: Any) -> dict[str, Any]: + data = _parse_json(value, {}) or {} + if not _is_wrapped_step_io(data): + return data if isinstance(data, dict) else {} + merged: dict[str, Any] = {} + payload = data.get("payload") + result = data.get("result") + usage = data.get("usage") + if isinstance(payload, dict): + merged.update(payload) + if isinstance(result, dict): + merged.update(result) + if isinstance(usage, dict) and usage: + merged["token_usage"] = usage + return merged + + +def _merge_dict(old: dict[str, Any] | None, new: dict[str, Any] | None) -> dict[str, Any]: + merged = dict(old or {}) + for key, value in (new or {}).items(): + if value is not None: + merged[key] = value + return merged + + +async def log_module_event( + db: AsyncSession, + *, + project: ModuleGenerationProject, + event_type: str, + step: ModuleGenerationStep | None = None, + message: str | None = None, + detail: dict[str, Any] | None = None, +) -> None: + """模块事件日志只落盘,不再写 module_generation_events 表。""" + _ = db + log_module_event_file( + module=project.module, + event_type=event_type, + project_id=project.id, + step_id=step.id if step else None, + user_id=project.user_id, + message=message, + detail=detail, + ) + + + +def _log_project_error( + *, + project: ModuleGenerationProject | None, + event_type: str, + message: str, + exc: BaseException | None = None, + step: ModuleGenerationStep | None = None, + detail: dict[str, Any] | None = None, +) -> None: + log_module_error( + module=(project.module if project else MODULE), + event_type=event_type, + project_id=(project.id if project else None), + step_id=(step.id if step else None), + user_id=(project.user_id if project else None), + message=message, + detail=detail, + exc=exc, + ) + + +async def _get_project_for_user( + db: AsyncSession, + *, + project_id: str, + user: User, + for_update: bool = False, + populate_existing: bool = False, +) -> ModuleGenerationProject: + query = select(ModuleGenerationProject).where( + ModuleGenerationProject.id == project_id, + ModuleGenerationProject.module == MODULE, + ModuleGenerationProject.deleted_at.is_(None), + ) + if not user.is_admin: + query = query.where(ModuleGenerationProject.user_id == user.id) + if populate_existing: + query = query.execution_options(populate_existing=True) + if for_update: + query = query.with_for_update() + result = await db.execute(query.limit(1)) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="拆镜复刻项目不存在") + return project + + +async def _get_step_for_user( + db: AsyncSession, + *, + project_id: str, + step_id: str, + user: User, + for_update: bool = False, +) -> ModuleGenerationStep: + await _get_project_for_user(db, project_id=project_id, user=user, for_update=for_update) + query = select(ModuleGenerationStep).where( + ModuleGenerationStep.id == step_id, + ModuleGenerationStep.project_id == project_id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + if not user.is_admin: + query = query.where(ModuleGenerationStep.user_id == user.id) + if for_update: + query = query.with_for_update() + result = await db.execute(query.limit(1)) + step = result.scalar_one_or_none() + if not step: + raise HTTPException(status_code=404, detail="子任务不存在") + return step + + +async def _get_current_steps(db: AsyncSession, project_id: str) -> list[ModuleGenerationStep]: + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.project_id == project_id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + .order_by(ModuleGenerationStep.step_index.asc(), ModuleGenerationStep.created_at.asc()) + ) + return list(result.scalars().all()) + + +async def _get_current_step_by_code(db: AsyncSession, project_id: str, step_code: str) -> ModuleGenerationStep | None: + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.project_id == project_id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.step_code == step_code, + ModuleGenerationStep.is_current == True, + ModuleGenerationStep.deleted_at.is_(None), + ) + .order_by(ModuleGenerationStep.version.desc(), ModuleGenerationStep.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def _next_version(db: AsyncSession, project_id: str, step_code: str) -> int: + result = await db.execute( + select(func.max(ModuleGenerationStep.version)).where( + ModuleGenerationStep.project_id == project_id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.step_code == step_code, + ) + ) + return int(result.scalar_one_or_none() or 0) + 1 + + +async def _create_step( + db: AsyncSession, + *, + project: ModuleGenerationProject, + step_code: str, + status: str = ModuleStepStatusEnum.PENDING.value, + parent_step_id: str | None = None, + source_step_id: str | None = None, + chat_task_id: str | None = None, + input_data: dict[str, Any] | None = None, + output_data: dict[str, Any] | None = None, +) -> ModuleGenerationStep: + version = await _next_version(db, project.id, step_code) + step = ModuleGenerationStep( + id=generate_id(), + project_id=project.id, + user_id=project.user_id, + module=project.module, + step_index=STEP_INDEX_MAP[step_code], + step_code=step_code, + status=status, + version=version, + is_current=True, + parent_step_id=parent_step_id, + source_step_id=source_step_id, + chat_task_id=chat_task_id, + input_json=_step_input( + step_code=step_code, + payload=input_data, + source_step_id=source_step_id, + parent_step_id=parent_step_id, + ) if input_data is not None else None, + output_json=_step_output( + step_code=step_code, + status=status, + result=output_data, + ) if output_data is not None else None, + started_at=_now() if status == ModuleStepStatusEnum.PROCESSING.value else None, + completed_at=_now() if status == ModuleStepStatusEnum.COMPLETED.value else None, + ) + db.add(step) + project.current_step_code = step_code + await db.flush() + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_CREATED.value, detail={"step_code": step_code, "version": version}) + return step + + +async def _soft_delete_steps_from_index( + db: AsyncSession, + *, + project: ModuleGenerationProject, + start_index: int, + deleted_at: datetime | None = None, +) -> None: + deleted_at = deleted_at or _now() + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.is_current == True, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.step_index >= start_index, + ) + .with_for_update() + ) + steps = list(result.scalars().all()) + for step in steps: + step.is_current = False + step.deleted_at = deleted_at + if step.chat_task_id: + chat_result = await db.execute( + select(ChatGenerationTask) + .where(ChatGenerationTask.id == step.chat_task_id, ChatGenerationTask.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + chat_task = chat_result.scalar_one_or_none() + if chat_task: + if chat_task.status == "completed": + await soft_delete_chat_task_resources(db, chat_task.id, deleted_at=deleted_at) + elif chat_task.status != "failed": + await mark_chat_generation_task_failed_and_refund_once( + db, + task=chat_task, + error_message="拆镜复刻步骤被重新生成或删除,旧生成任务已取消", + pipeline_stage="failed", + ) + chat_task.deleted_at = deleted_at + if steps: + await log_module_event( + db, + project=project, + event_type=ModuleEventTypeEnum.SOFT_DELETE_STEPS.value, + message=f"软删除第 {start_index} 步及之后的旧子任务", + detail={"step_ids": [step.id for step in steps]}, + ) + + +def _step_to_out(step: ModuleGenerationStep) -> ShotReplicateStepOut: + return ShotReplicateStepOut( + id=step.id, + project_id=step.project_id, + module=step.module, + step_index=step.step_index, + step_code=step.step_code, + status=step.status, + version=step.version, + is_current=step.is_current, + parent_step_id=step.parent_step_id, + source_step_id=step.source_step_id, + chat_task_id=step.chat_task_id, + input=_parse_json(step.input_json, {}), + output=_parse_json(step.output_json, {}), + error_message=step.error_message, + created_at=step.created_at, + updated_at=step.updated_at, + completed_at=step.completed_at, + ) + + +def _snapshot_from_chat(chat_task: ChatGenerationTask | None) -> dict[str, Any]: + if not chat_task: + return {} + return _parse_json(chat_task.engine_snapshot_json, {}) or {} + + +async def _chat_tasks_by_id(db: AsyncSession, steps: list[ModuleGenerationStep]) -> dict[str, ChatGenerationTask]: + ids = [step.chat_task_id for step in steps if step.chat_task_id] + if not ids: + return {} + result = await db.execute(select(ChatGenerationTask).where(ChatGenerationTask.id.in_(ids))) + return {task.id: task for task in result.scalars().all()} + + +async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProject) -> ShotReplicateTaskDetailOut: + steps = await _get_current_steps(db, project.id) + by_code = {step.step_code: step for step in steps} + chats = await _chat_tasks_by_id(db, steps) + + material_step = by_code.get(ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) + image_prompt_step = by_code.get(ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value) + image_generate_step = by_code.get(ShotReplicateStepCodeEnum.IMAGE_GENERATE.value) + video_prompt_step = by_code.get(ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value) + video_generate_step = by_code.get(ShotReplicateStepCodeEnum.VIDEO_GENERATE.value) + + material_input = _step_payload(material_step.input_json if material_step else None) + image_prompt_output = _unwrap_step_output(image_prompt_step.output_json if image_prompt_step else None) + image_generate_input = _step_payload(image_generate_step.input_json if image_generate_step else None) + image_generate_output = _unwrap_step_output(image_generate_step.output_json if image_generate_step else None) + video_prompt_input = _step_payload(video_prompt_step.input_json if video_prompt_step else None) + video_prompt_output = _unwrap_step_output(video_prompt_step.output_json if video_prompt_step else None) + video_generate_input = _step_payload(video_generate_step.input_json if video_generate_step else None) + video_generate_output = _unwrap_step_output(video_generate_step.output_json if video_generate_step else None) + + image_chat = chats.get(image_generate_step.chat_task_id) if image_generate_step and image_generate_step.chat_task_id else None + video_chat = chats.get(video_generate_step.chat_task_id) if video_generate_step and video_generate_step.chat_task_id else None + image_snapshot = _snapshot_from_chat(image_chat) + video_snapshot = _snapshot_from_chat(video_chat) + + image_url = image_generate_output.get("result_image_url") or (image_chat.image_url if image_chat else None) or project.final_image_url + video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url + cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url + + return ShotReplicateTaskDetailOut( + id=project.id, + project_id=project.id, + module=project.module, + title=project.title, + status=project.status, + current_step_code=project.current_step_code, + final_image_url=build_resource_signed_url(project.final_image_url) if project.final_image_url else None, + final_video_url=build_resource_signed_url(project.final_video_url) if project.final_video_url else None, + final_video_cover_url=build_resource_signed_url(project.final_video_cover_url) if project.final_video_cover_url else None, + error_message=project.error_message, + material=ShotReplicateMaterialOut( + material_step_id=material_step.id if material_step else None, + material_video_url=material_input.get("material_video_url"), + material_image_url=material_input.get("material_image_url"), + source_project_name=material_input.get("source_project_name"), + target_project_name=material_input.get("target_project_name"), + core_content_point=material_input.get("core_content_point"), + ), + image_generation=ShotReplicateImageGenerationOut( + prompt_step_id=image_prompt_step.id if image_prompt_step else None, + generate_step_id=image_generate_step.id if image_generate_step else None, + prompt=image_prompt_output.get("optimized_prompt") or image_prompt_output.get("prompt"), + engine_id=image_snapshot.get("id") or image_generate_input.get("engine_id"), + engine_name=image_snapshot.get("name") or image_generate_input.get("engine_name"), + params=image_generate_input.get("params") or image_generate_input, + chat_task_id=image_generate_step.chat_task_id if image_generate_step else None, + status=image_chat.status if image_chat else (image_generate_step.status if image_generate_step else None), + result_image_url=build_resource_signed_url(image_url) if image_url else None, + error_message=image_chat.error_message if image_chat else (image_generate_step.error_message if image_generate_step else None), + ), + video_generation=ShotReplicateVideoGenerationOut( + prompt_step_id=video_prompt_step.id if video_prompt_step else None, + generate_step_id=video_generate_step.id if video_generate_step else None, + prompt_schema=video_prompt_output.get("prompt_schema"), + final_prompt=video_prompt_output.get("final_prompt"), + prompt_params=video_prompt_output.get("params_used_for_prompt") or video_prompt_input.get("video_config"), + engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id"), + engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name"), + params=video_generate_input.get("params") or video_generate_input, + chat_task_id=video_generate_step.chat_task_id if video_generate_step else None, + status=video_chat.status if video_chat else (video_generate_step.status if video_generate_step else None), + result_video_url=build_resource_signed_url(video_url) if video_url else None, + result_video_cover_url=build_resource_signed_url(cover_url) if cover_url else None, + error_message=video_chat.error_message if video_chat else (video_generate_step.error_message if video_generate_step else None), + ), + steps=[_step_to_out(step) for step in steps], + created_at=project.created_at, + updated_at=project.updated_at, + completed_at=project.completed_at, + ) + + +async def create_shot_replicate_project(db: AsyncSession, current_user: User, req: ShotReplicateTaskCreate) -> ModuleGenerationProject: + if req.idempotency_key: + result = await db.execute( + select(ModuleGenerationProject) + .where( + ModuleGenerationProject.user_id == current_user.id, + ModuleGenerationProject.module == MODULE, + ModuleGenerationProject.idempotency_key == req.idempotency_key, + ModuleGenerationProject.deleted_at.is_(None), + ) + .order_by(ModuleGenerationProject.created_at.desc()) + .limit(1) + ) + existing = result.scalar_one_or_none() + if existing: + return existing + + project = ModuleGenerationProject( + id=generate_id(), + user_id=current_user.id, + module=MODULE, + title=req.target_project_name, + status=ModuleProjectStatusEnum.WAITING_USER.value, + current_step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value, + idempotency_key=req.idempotency_key, + ) + db.add(project) + await db.flush() + + await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value, + status=ModuleStepStatusEnum.COMPLETED.value, + input_data={ + "material_video_url": req.material_video_url, + "material_image_url": req.material_image_url, + "source_project_name": req.source_project_name, + "target_project_name": req.target_project_name, + "core_content_point": req.core_content_point, + }, + output_data={"message": "素材输入已提交,后端不做素材文件校验。下一步请手动生成图片AI提词。"}, + ) + await log_module_event(db, project=project, event_type=ModuleEventTypeEnum.PROJECT_CREATED.value, message="创建拆镜复刻项目") + return project + + +async def list_shot_replicate_projects( + db: AsyncSession, + *, + current_user: User, + status: str | None, + page: int, + page_size: int, +) -> ShotReplicateTaskListOut: + query = select(ModuleGenerationProject).where( + ModuleGenerationProject.module == MODULE, + ModuleGenerationProject.deleted_at.is_(None), + ) + if not current_user.is_admin: + query = query.where(ModuleGenerationProject.user_id == current_user.id) + if status: + query = query.where(ModuleGenerationProject.status == status) + + total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar_one() + result = await db.execute(query.order_by(ModuleGenerationProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size)) + projects = list(result.scalars().all()) + + items: list[ShotReplicateTaskListItemOut] = [] + for project in projects: + material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) + material = _step_payload(material_step.input_json if material_step else None) + items.append( + ShotReplicateTaskListItemOut( + id=project.id, + project_id=project.id, + module=project.module, + title=project.title, + status=project.status, + current_step_code=project.current_step_code, + target_project_name=material.get("target_project_name"), + final_image_url=build_resource_signed_url(project.final_image_url) if project.final_image_url else None, + final_video_url=build_resource_signed_url(project.final_video_url) if project.final_video_url else None, + error_message=project.error_message, + created_at=project.created_at, + updated_at=project.updated_at, + completed_at=project.completed_at, + ) + ) + return ShotReplicateTaskListOut(total=total, items=items) + + +async def update_shot_replicate_step( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + req: ShotReplicateStepUpdate, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True) + if step.status == ModuleStepStatusEnum.PROCESSING.value: + raise HTTPException(status_code=400, detail="当前子任务正在处理中,暂不能修改") + + input_data = _step_payload(step.input_json) + output_data = _unwrap_step_output(step.output_json) + + if step.step_code == ShotReplicateStepCodeEnum.MATERIAL_INPUT.value: + input_data = _merge_dict( + input_data, + { + "material_video_url": req.material_video_url, + "material_image_url": req.material_image_url, + "source_project_name": req.source_project_name, + "target_project_name": req.target_project_name, + "core_content_point": req.core_content_point, + }, + ) + if req.target_project_name: + project.title = req.target_project_name + elif step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: + if req.prompt is not None: + output_data["optimized_prompt"] = req.prompt + output_data["prompt"] = req.prompt + elif step.step_code == ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: + if req.prompt_schema is not None: + output_data["prompt_schema"] = req.prompt_schema + if req.prompt is not None: + output_data["final_prompt"] = req.prompt + else: + if req.input_json: + input_data = _merge_dict(input_data, req.input_json) + if req.output_json: + output_data = _merge_dict(output_data, req.output_json) + + if req.input_json: + input_data = _merge_dict(input_data, req.input_json) + if req.output_json: + output_data = _merge_dict(output_data, req.output_json) + + step.input_json = _step_input(step_code=step.step_code, payload=input_data, source_step_id=step.source_step_id, parent_step_id=step.parent_step_id) + step.output_json = _step_output(step_code=step.step_code, status=ModuleStepStatusEnum.COMPLETED.value, payload=output_data) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.error_message = None + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = step.step_code + project.error_message = None + + await _soft_delete_steps_from_index(db, project=project, start_index=step.step_index + 1) + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_UPDATED.value, message="用户修改子任务内容") + return project, step + + +async def update_shot_replicate_material_input( + db: AsyncSession, + *, + current_user: User, + project_id: str, + req: ShotReplicateMaterialUpdateRequest, +) -> tuple[str, str]: + """修改第1步素材输入。 + + 采用方案 B:软删除旧第1步及之后的当前有效步骤,然后新建第1步 version+1。 + 未传字段沿用旧第1步素材输入,避免前端只改一个字段时丢失其它素材信息。 + """ + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + old_material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) + old_material = _step_payload(old_material_step.input_json if old_material_step else None) + + material = { + "material_video_url": req.material_video_url if req.material_video_url is not None else old_material.get("material_video_url"), + "material_image_url": req.material_image_url if req.material_image_url is not None else old_material.get("material_image_url"), + "source_project_name": req.source_project_name if req.source_project_name is not None else old_material.get("source_project_name"), + "target_project_name": req.target_project_name if req.target_project_name is not None else old_material.get("target_project_name"), + "core_content_point": req.core_content_point if req.core_content_point is not None else old_material.get("core_content_point"), + } + + missing_fields = [key for key, value in material.items() if value is None or str(value).strip() == ""] + if missing_fields: + raise HTTPException(status_code=400, detail=f"素材输入缺少必要字段: {', '.join(missing_fields)}") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.MATERIAL_INPUT.value]) + + project.title = str(material["target_project_name"]) + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = ShotReplicateStepCodeEnum.MATERIAL_INPUT.value + project.final_image_url = None + project.final_video_url = None + project.final_video_cover_url = None + project.error_message = None + project.completed_at = None + + new_step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value, + status=ModuleStepStatusEnum.COMPLETED.value, + input_data=material, + output_data={"message": "素材输入已修改,旧步骤已软删除。下一步请重新生成图片AI提词。"}, + ) + await log_module_event( + db, + project=project, + step=new_step, + event_type=ModuleEventTypeEnum.STEP_UPDATED.value, + message="用户修改素材输入并重建第1步新版本", + detail={ + "old_material_step_id": old_material_step.id if old_material_step else None, + "new_material_step_id": new_step.id, + "version": new_step.version, + }, + ) + return project.id, new_step.id + + +async def update_shot_replicate_image_prompt( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + req: ShotReplicateImagePromptUpdateRequest, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + """直接修改第2步图片 AI 优化提词,不调用 AI、不扣积分。 + + 修改后软删除第3、4、5步当前有效任务,让用户从图片生成开始重新执行。 + """ + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True) + if step.step_code != ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: + raise HTTPException(status_code=400, detail="只能修改第2步图片 AI 提词子任务") + if step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="图片 AI 提词未完成,不能直接修改") + + output_data = _step_payload(step.output_json) + usage = _step_usage(step.output_json) + new_prompt = req.prompt.strip() + output_data["optimized_prompt"] = new_prompt + output_data["prompt"] = new_prompt + output_data["manual_edited"] = True + output_data["manual_edited_at"] = _now().isoformat() + + step.output_json = _step_output( + step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + payload=output_data, + usage=usage, + ) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.error_message = None + step.completed_at = _now() + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.IMAGE_GENERATE.value]) + + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + project.final_image_url = None + project.final_video_url = None + project.final_video_cover_url = None + project.completed_at = None + project.error_message = None + + await log_module_event( + db, + project=project, + step=step, + event_type=ModuleEventTypeEnum.STEP_UPDATED.value, + message="用户直接修改图片 AI 优化提词,已软删除后续步骤", + detail={"start_deleted_step_index": STEP_INDEX_MAP[ShotReplicateStepCodeEnum.IMAGE_GENERATE.value]}, + ) + return project, step + + +async def update_shot_replicate_video_prompt_schema( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + req: ShotReplicateVideoPromptSchemaUpdateRequest, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + """以前端 schema 为 patch 修改第4步视频 AI 提词,不调用 AI、不扣积分。 + + 服务端已有 schema 为基准:视频规格、数组长度、时间段、合规控制、质量控制、协议字段均锁定。 + 最终提示词允许修改,但保存前会清洗视频时长、比例、分辨率、帧率等参数。 + """ + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True) + if step.step_code != ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: + raise HTTPException(status_code=400, detail="只能修改第4步视频 AI 提词 JSON schema 子任务") + if step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="视频 AI 提词未完成,不能直接修改") + + output_data = _step_payload(step.output_json) + usage = _step_usage(step.output_json) + input_data = _step_payload(step.input_json) + server_schema = output_data.get("prompt_schema") if isinstance(output_data.get("prompt_schema"), dict) else {} + video_config = output_data.get("params_used_for_prompt") or input_data.get("video_config") or {} + if not isinstance(video_config, dict) or not video_config.get("duration") or not video_config.get("aspect_ratio") or not video_config.get("resolution"): + raise HTTPException(status_code=400, detail="缺少第4步视频参数快照,不能安全修改视频 schema") + + patched_schema = patch_video_prompt_schema_from_client( + server_schema=server_schema, + client_schema=req.prompt_schema, + video_config=video_config, + ) + final_prompt = build_final_video_prompt(patched_schema) + + output_data["prompt_schema"] = patched_schema + output_data["final_prompt"] = final_prompt + output_data["params_used_for_prompt"] = video_config + output_data["manual_edited"] = True + output_data["manual_edited_at"] = _now().isoformat() + + step.output_json = _step_output( + step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + payload=output_data, + usage=usage, + ) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.error_message = None + step.completed_at = _now() + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.VIDEO_GENERATE.value]) + + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value + project.final_video_url = None + project.final_video_cover_url = None + project.completed_at = None + project.error_message = None + + await log_module_event( + db, + project=project, + step=step, + event_type=ModuleEventTypeEnum.STEP_UPDATED.value, + message="用户修改视频 AI 提词 schema,已软删除视频生成步骤", + detail={ + "start_deleted_step_index": STEP_INDEX_MAP[ShotReplicateStepCodeEnum.VIDEO_GENERATE.value], + "locked_fields": [ + "schema_version", + "schema_usage", + "画面属性.视频时长", + "画面属性.视频比例", + "画面属性.清晰度", + "画面属性.帧率", + "画面属性.推荐分辨率", + "动作流程[*].时间段", + "镜头流程[*].时间段", + "动态时间规划", + "输出规格限制", + "质量控制", + "合规控制", + ], + }, + ) + return project, step + + +async def submit_image_prompt_optimize( + db: AsyncSession, + *, + current_user: User, + project_id: str, + material_step_id: str | None = None, + req: Any | None = None, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + _ = req + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + if material_step_id: + material_step = await _get_step_for_user(db, project_id=project_id, step_id=material_step_id, user=current_user, for_update=True) + else: + material_step = await _get_current_step_by_code(db, project_id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) + if not material_step: + raise HTTPException(status_code=400, detail="缺少第1步素材输入子任务") + if material_step.step_code != ShotReplicateStepCodeEnum.MATERIAL_INPUT.value: + raise HTTPException(status_code=400, detail="请基于第1步素材输入子任务生成图片 AI 提词") + if material_step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="素材输入子任务未完成,不能生成图片 AI 提词") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value]) + step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=material_step.id, + source_step_id=material_step.id, + input_data={"source_step_id": material_step.id}, + ) + project.status = ModuleProjectStatusEnum.PROCESSING.value + project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + project.error_message = None + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交") + return project, step + + +async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_id: str | None = None) -> ModuleGenerationStep | None: + project_result = await db.execute( + select(ModuleGenerationProject) + .where(ModuleGenerationProject.id == project_id, ModuleGenerationProject.module == MODULE, ModuleGenerationProject.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + project = project_result.scalar_one_or_none() + if not project: + return None + + material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) + if not material_step: + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = "缺少素材输入子任务" + return None + + if step_id: + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.id == step_id, + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + else: + step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value) + if not step: + if step_id: + # 用户重复提交后,旧 Celery 消息对应的 step 可能已被软删。 + # 指定 step_id 查不到时必须静默忽略,不能重新创建步骤导致旧任务复活。 + return None + step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=material_step.id, + source_step_id=material_step.id, + input_data={"source_step_id": material_step.id}, + ) + else: + step.status = ModuleStepStatusEnum.PROCESSING.value + step.started_at = _now() + step.error_message = None + + material = _step_payload(material_step.input_json) + prompt_text = ( + "请基于参考素材复刻拆镜视觉风格,用于生成新项目图片。\n" + f"视频素材内容项目名称:{material.get('source_project_name')}\n" + f"生成项目名称:{material.get('target_project_name')}\n" + f"生成项目核心内容点:{material.get('core_content_point')}\n" + "要求:参考素材视频的开头构图、主体位置、节奏和风格;结合新产品图片生成新项目推广图片;不要照抄原素材品牌、文字、水印;适合作为后续图生视频首帧。" + ) + references = [ + {"type": "video", "url": material.get("material_video_url"), "name": "参考素材视频"}, + {"type": "image", "url": material.get("material_image_url"), "name": "新产品图片"}, + ] + try: + request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"} + log_module_prompt_event( + event_type="module_prompt_request", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.IMAGE_PROMPT.value, + request=request_log, + ) + optimized, token_usage = await optimize_prompt( + db, + original_prompt=prompt_text, + user_id=project.user_id, + references=references, + gen_type="image", + ) + billing = await charge_module_prompt_usage( + db, + user_id=project.user_id, + step_id=step.id, + usage=token_usage, + description="拆镜复刻-图片AI提词优化", + ) + usage = dict(token_usage or {}) + usage.update({ + "text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged), + "credit_biz_key": billing.items[0].biz_key if billing.items else None, + }) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.completed_at = _now() + step.output_json = _step_output( + step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + payload={ + "optimized_prompt": optimized, + "prompt": optimized, + "original_prompt": prompt_text, + "references": references, + }, + usage=usage, + ) + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + project.error_message = None + log_module_prompt_event( + event_type="module_prompt_response", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.IMAGE_PROMPT.value, + request=request_log, + response={"optimized_prompt": optimized}, + token_usage=usage, + ) + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUCCESS.value, message="图片 AI 提词生成成功") + except Exception as exc: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = str(exc) + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = f"图片 AI 提词生成失败: {exc}" + log_module_prompt_event( + event_type="module_prompt_error", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.IMAGE_PROMPT.value, + request=locals().get("request_log", {}), + error=str(exc), + ) + _log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc) + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message) + return step + + +async def generate_image_from_prompt( + db: AsyncSession, + *, + current_user: User, + project_id: str, + req: ShotReplicateGenerateImageRequest, + prompt_step_id: str | None = None, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep, ChatGenerationTask]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + if prompt_step_id: + prompt_step = await _get_step_for_user(db, project_id=project_id, step_id=prompt_step_id, user=current_user, for_update=True) + else: + prompt_step = await _get_current_step_by_code(db, project_id, ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value) + if not prompt_step: + raise HTTPException(status_code=400, detail="缺少第2步图片 AI 提词子任务") + if prompt_step.step_code != ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: + raise HTTPException(status_code=400, detail="请基于第2步图片 AI 提词子任务生成图片") + if prompt_step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="图片 AI 提词未完成,不能生成图片") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.IMAGE_GENERATE.value]) + + material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) + material = _step_payload(material_step.input_json if material_step else None) + prompt_output = _unwrap_step_output(prompt_step.output_json) + optimized_prompt = prompt_output.get("optimized_prompt") or prompt_output.get("prompt") or "" + refs = [ + {"type": "image", "url": material.get("material_image_url"), "name": "新产品图片"}, + ] + + chat_task = await create_chat_generation_task_for_module( + db, + current_user=current_user, + generation_mode=GENERATION_MODE, + gen_type="image", + original_prompt=prompt_output.get("original_prompt") or optimized_prompt, + optimized_prompt=optimized_prompt, + engine_id=req.engine_id, + media_references=refs, + image_size=req.image_size, + image_proportion=req.image_proportion, + image_px=req.image_px, + billing_project_name=project.title or "拆镜复刻", + billing_description_prefix="拆镜复刻图片生成", + ) + step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=prompt_step.id, + source_step_id=prompt_step.id, + chat_task_id=chat_task.id, + input_data={ + "engine_id": chat_task.engine_id, + "params": {"image_size": chat_task.image_size, "image_proportion": chat_task.image_proportion, "image_px": chat_task.image_px}, + "prompt": optimized_prompt, + "media_references": refs, + }, + ) + project.status = ModuleProjectStatusEnum.PROCESSING.value + project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_GENERATE.value + project.error_message = None + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_GENERATE_SUBMITTED.value, message="图片生成任务已提交", detail={"chat_task_id": chat_task.id}) + return project, step, chat_task + + +async def _resolve_video_prompt_config(db: AsyncSession, req: ShotReplicateGenerateVideoPromptRequest) -> dict[str, Any]: + engine = await _get_video_engine(db, req.engine_id) + supported_ratios = _parse_list(engine.supported_ratios, []) + supported_resolutions = _parse_list(engine.supported_resolutions, []) + supported_durations = _parse_list(engine.supported_durations, []) + + default_ratio = getattr(settings, "SHOT_REPLICATE_DEFAULT_VIDEO_RATIO", None) or VIDEO_DEFAULT_RATIO + default_resolution = getattr(settings, "SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION", None) or VIDEO_DEFAULT_RESOLUTION + default_duration = int(getattr(settings, "SHOT_REPLICATE_DEFAULT_VIDEO_DURATION", None) or VIDEO_DEFAULT_DURATION) + + selected_ratio = req.aspect_ratio or (default_ratio if not supported_ratios or default_ratio in supported_ratios else supported_ratios[0]) + selected_resolution = req.resolution or (default_resolution if not supported_resolutions or default_resolution in supported_resolutions else supported_resolutions[0]) + selected_duration = req.duration or (default_duration if not supported_durations or default_duration in supported_durations else supported_durations[0]) + + if supported_ratios and selected_ratio not in supported_ratios: + raise HTTPException(status_code=400, detail=f"视频比例不支持: {selected_ratio}") + if supported_resolutions and selected_resolution not in supported_resolutions: + raise HTTPException(status_code=400, detail=f"视频分辨率不支持: {selected_resolution}") + if supported_durations and selected_duration not in supported_durations: + raise HTTPException(status_code=400, detail=f"视频时长不支持: {selected_duration}") + if engine.max_duration and int(selected_duration) > int(engine.max_duration): + raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration} 秒") + + return { + "engine_id": engine.id, + "engine_name": engine.name, + "duration": int(selected_duration), + "aspect_ratio": selected_ratio, + "resolution": selected_resolution, + "supported_ratios": supported_ratios, + "supported_resolutions": supported_resolutions, + "supported_durations": supported_durations, + "max_duration": engine.max_duration, + "frame_rate": "30fps", + "reference_video_fps": max(1, int(settings.CHATAPI_VIDEO_FPS or 1)), + } + + +async def submit_video_prompt_optimize( + db: AsyncSession, + *, + current_user: User, + project_id: str, + req: ShotReplicateGenerateVideoPromptRequest, + image_step_id: str | None = None, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + if image_step_id: + image_step = await _get_step_for_user(db, project_id=project_id, step_id=image_step_id, user=current_user, for_update=True) + else: + image_step = await _get_current_step_by_code(db, project_id, ShotReplicateStepCodeEnum.IMAGE_GENERATE.value) + if not image_step: + raise HTTPException(status_code=400, detail="缺少第3步图片生成子任务") + if image_step.step_code != ShotReplicateStepCodeEnum.IMAGE_GENERATE.value: + raise HTTPException(status_code=400, detail="请基于第3步图片生成子任务生成视频 AI 提词") + if image_step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="图片生成子任务未完成,不能生成视频 AI 提词") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value]) + video_config = await _resolve_video_prompt_config(db, req) + step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=image_step.id, + source_step_id=image_step.id, + input_data={ + "source_step_id": image_step.id, + "video_config": video_config, + "target_platform": req.target_platform or getattr(settings, "SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM", "抖音") or "抖音", + }, + ) + project.status = ModuleProjectStatusEnum.PROCESSING.value + project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value + project.error_message = None + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交") + return project, step + + +async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_id: str | None = None) -> ModuleGenerationStep | None: + project_result = await db.execute( + select(ModuleGenerationProject) + .where(ModuleGenerationProject.id == project_id, ModuleGenerationProject.module == MODULE, ModuleGenerationProject.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + project = project_result.scalar_one_or_none() + if not project: + return None + + material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) + image_prompt_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value) + image_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.IMAGE_GENERATE.value) + if not material_step or not image_step: + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = "生成视频提词失败:缺少素材输入或图片生成结果" + return None + + if step_id: + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.id == step_id, + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.step_code == ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + else: + step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value) + if not step: + if step_id: + # 用户重复提交后,旧 Celery 消息对应的 step 可能已被软删。 + # 指定 step_id 查不到时必须静默忽略,不能把当前项目标记失败。 + return None + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = "缺少视频 AI 提词子任务,请先手动提交视频提词生成" + return None + + step.status = ModuleStepStatusEnum.PROCESSING.value + step.started_at = _now() + step.error_message = None + + material = _step_payload(material_step.input_json) + image_output = _unwrap_step_output(image_step.output_json) + step_input = _step_payload(step.input_json) + video_config = step_input.get("video_config") or {} + target_platform = step_input.get("target_platform") or getattr(settings, "SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM", "抖音") or "抖音" + generated_image_url = image_output.get("result_image_url") or project.final_image_url + if not generated_image_url: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = "缺少新项目图片结果,不能生成视频提词" + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + return step + + try: + request_log = { + "source_project_name": material.get("source_project_name") or "无", + "target_project_name": material.get("target_project_name") or "无", + "core_content_point": material.get("core_content_point") or "无", + "material_video_url": material.get("material_video_url") or "", + "generated_image_url": generated_image_url, + "video_config": video_config, + "target_platform": target_platform, + } + log_module_prompt_event( + event_type="module_prompt_request", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value, + request=request_log, + ) + prompt_schema, final_prompt, token_usage = await optimize_shot_replicate_video_prompt( + db, + user_id=project.user_id, + source_project_name=request_log["source_project_name"], + target_project_name=request_log["target_project_name"], + core_content_point=request_log["core_content_point"], + material_video_url=request_log["material_video_url"], + generated_image_url=generated_image_url, + video_config=video_config, + target_platform=target_platform, + ) + billing = await charge_module_prompt_usage( + db, + user_id=project.user_id, + step_id=step.id, + usage=token_usage, + description="拆镜复刻-视频AI提词优化", + ) + usage = dict(token_usage or {}) + usage.update({ + "text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged), + "credit_biz_key": billing.items[0].biz_key if billing.items else None, + }) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.completed_at = _now() + step.output_json = _step_output( + step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + payload={ + "prompt_schema": prompt_schema, + "final_prompt": final_prompt, + "params_used_for_prompt": video_config, + "target_platform": target_platform, + }, + usage=usage, + ) + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value + project.error_message = None + log_module_prompt_event( + event_type="module_prompt_response", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value, + request=request_log, + response={"prompt_schema": prompt_schema, "final_prompt": final_prompt}, + token_usage=usage, + ) + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUCCESS.value, message="视频 AI 提词生成成功") + except Exception as exc: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = str(exc) + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = f"视频 AI 提词生成失败: {exc}" + log_module_prompt_event( + event_type="module_prompt_error", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value, + request=locals().get("request_log", {}), + error=str(exc), + ) + _log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc) + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message) + return step + + +async def generate_video_from_prompt( + db: AsyncSession, + *, + current_user: User, + project_id: str, + req: ShotReplicateGenerateVideoRequest, + prompt_step_id: str | None = None, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep, ChatGenerationTask]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + if prompt_step_id: + prompt_step = await _get_step_for_user(db, project_id=project_id, step_id=prompt_step_id, user=current_user, for_update=True) + else: + prompt_step = await _get_current_step_by_code(db, project_id, ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value) + if not prompt_step: + raise HTTPException(status_code=400, detail="缺少第4步视频 AI 提词子任务") + if prompt_step.step_code != ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: + raise HTTPException(status_code=400, detail="请基于第4步视频 AI 提词子任务生成视频") + if prompt_step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="视频 AI 提词未完成,不能生成视频") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[ShotReplicateStepCodeEnum.VIDEO_GENERATE.value]) + + image_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.IMAGE_GENERATE.value) + image_output = _unwrap_step_output(image_step.output_json if image_step else None) + prompt_output = _unwrap_step_output(prompt_step.output_json) + final_prompt = prompt_output.get("final_prompt") or "" + prompt_schema = prompt_output.get("prompt_schema") or {} + prompt_schema_str = json.dumps(prompt_schema, ensure_ascii=False, default=str) if prompt_schema else "" + prompt_input = _step_payload(prompt_step.input_json) + prompt_params = prompt_output.get("params_used_for_prompt") or prompt_input.get("video_config") or {} + duration = int(prompt_params.get("duration") or settings.SHOT_REPLICATE_DEFAULT_VIDEO_DURATION or 4) + aspect_ratio = prompt_params.get("aspect_ratio") or settings.SHOT_REPLICATE_DEFAULT_VIDEO_RATIO or "9:16" + resolution = prompt_params.get("resolution") or settings.SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION or "480p" + generated_image_url = image_output.get("result_image_url") or project.final_image_url + if not generated_image_url: + raise HTTPException(status_code=400, detail="缺少新项目图片结果,不能生成视频") + + refs = [ + {"type": "image", "url": _build_file_url_or_data_uri(generated_image_url), "name": "新项目图片"}, + ] + + chat_task = await create_chat_generation_task_for_module( + db, + current_user=current_user, + generation_mode=GENERATION_MODE, + gen_type="video", + original_prompt=prompt_schema_str or final_prompt, + optimized_prompt=prompt_schema_str or final_prompt, + engine_id=req.engine_id or prompt_params.get("engine_id"), + media_references=refs, + duration=duration, + aspect_ratio=aspect_ratio, + resolution=resolution, + billing_project_name=project.title or "拆镜复刻", + billing_description_prefix="拆镜复刻视频生成", + ) + step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=prompt_step.id, + source_step_id=prompt_step.id, + chat_task_id=chat_task.id, + input_data={ + "engine_id": chat_task.engine_id, + "params": { + "duration": chat_task.duration, + "aspect_ratio": chat_task.aspect_ratio, + "resolution": chat_task.resolution, + "image_size": chat_task.image_size, + "image_proportion": chat_task.image_proportion, + "image_px": chat_task.image_px, + }, + "prompt_schema": prompt_schema, + "final_prompt": final_prompt, + "media_references": refs, + }, + ) + project.status = ModuleProjectStatusEnum.PROCESSING.value + project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_GENERATE.value + project.error_message = None + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_GENERATE_SUBMITTED.value, message="视频生成任务已提交", detail={"chat_task_id": chat_task.id}) + return project, step, chat_task + + +async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGenerationTask) -> None: + if not task or task.generation_mode != GENERATION_MODE: + return + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.chat_task_id == task.id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.is_current == True, + ModuleGenerationStep.deleted_at.is_(None), + ) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + if not step: + return + project_result = await db.execute( + select(ModuleGenerationProject) + .where(ModuleGenerationProject.id == step.project_id, ModuleGenerationProject.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + project = project_result.scalar_one_or_none() + if not project: + return + + if step.step_code == ShotReplicateStepCodeEnum.IMAGE_GENERATE.value: + step.status = ModuleStepStatusEnum.COMPLETED.value + step.completed_at = _now() + step.output_json = _step_output( + step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + result={"result_image_url": task.image_url, "chat_task_id": task.id}, + ) + project.final_image_url = task.image_url + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_GENERATE.value + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_GENERATE_SUCCESS.value, message="图片生成完成,等待用户手动生成视频 AI 提词") + elif step.step_code == ShotReplicateStepCodeEnum.VIDEO_GENERATE.value: + step.status = ModuleStepStatusEnum.COMPLETED.value + step.completed_at = _now() + step.output_json = _step_output( + step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + result={"result_video_url": task.video_url, "result_video_cover_url": task.video_cover_url, "chat_task_id": task.id}, + ) + project.final_video_url = task.video_url + project.final_video_cover_url = task.video_cover_url + project.status = ModuleProjectStatusEnum.COMPLETED.value + project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_GENERATE.value + project.completed_at = _now() + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_GENERATE_SUCCESS.value, message="视频生成完成,总任务完成") + + +async def handle_chat_generation_task_failed(db: AsyncSession, task: ChatGenerationTask) -> None: + if not task or task.generation_mode != GENERATION_MODE: + return + result = await db.execute( + select(ModuleGenerationStep) + .where(ModuleGenerationStep.chat_task_id == task.id, ModuleGenerationStep.module == MODULE, ModuleGenerationStep.is_current == True, ModuleGenerationStep.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + if not step: + return + project_result = await db.execute(select(ModuleGenerationProject).where(ModuleGenerationProject.id == step.project_id).with_for_update().limit(1)) + project = project_result.scalar_one_or_none() + if not project: + return + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = task.error_message + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = task.error_message or "生成失败" + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, message=project.error_message, detail={"chat_task_id": task.id}) + + +async def mark_shot_replicate_step_dispatch_failed( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + error_message: str, +) -> None: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.id == step_id, + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + if not step: + return + if step.chat_task_id: + await mark_chat_generation_task_failed_and_refund_once( + db, + task_id=step.chat_task_id, + error_message=error_message, + pipeline_stage="failed", + ) + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = error_message + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = error_message + log_module_error( + module=project.module, + event_type="CELERY_DISPATCH_FAILED", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + message=error_message, + detail={"reason": "celery_dispatch_failed", "chat_task_id": step.chat_task_id}, + error=error_message, + ) + await log_module_event( + db, + project=project, + step=step, + event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, + message=error_message, + detail={"reason": "celery_dispatch_failed"}, + ) + + +async def delete_shot_replicate_project(db: AsyncSession, *, current_user: User, project_id: str) -> ShotReplicateDeleteOut: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + deleted_at = _now() + project.deleted_at = deleted_at + await _soft_delete_steps_from_index(db, project=project, start_index=1, deleted_at=deleted_at) + await log_module_event(db, project=project, event_type=ModuleEventTypeEnum.PROJECT_DELETED.value, message="软删除拆镜复刻项目") + return ShotReplicateDeleteOut(message="项目已删除", project_id=project.id, deleted=True) + + +async def create_shot_replicate_project_from_segment( + db: AsyncSession, + *, + current_user: User, + segment: ShotReplicateSegment, + req: ShotSegmentReplicationCreateRequest, +) -> ModuleGenerationProject: + """从拆镜片段创建拆镜复刻项目。 + + 素材视频固定取 segment.segment_video_url,不允许前端传入或后续修改。 + """ + if segment.user_id != current_user.id and not current_user.is_admin: + raise HTTPException(status_code=404, detail="拆镜片段不存在") + if segment.split_status != ShotSplitStatusEnum.COMPLETED.value or not segment.segment_video_url: + raise HTTPException(status_code=400, detail="拆镜片段视频未完成,不能进入复刻流程") + if segment.module_project_id: + existing = await _get_project_for_user( + db, + project_id=segment.module_project_id, + user=current_user, + for_update=False, + ) + return existing + + if req.idempotency_key: + existing_result = await db.execute( + select(ModuleGenerationProject).where( + ModuleGenerationProject.user_id == current_user.id, + ModuleGenerationProject.module == MODULE, + ModuleGenerationProject.idempotency_key == req.idempotency_key, + ModuleGenerationProject.deleted_at.is_(None), + ).limit(1) + ) + existing = existing_result.scalar_one_or_none() + if existing: + segment.module_project_id = existing.id + segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value + await db.flush() + return existing + + project = ModuleGenerationProject( + id=generate_id(), + user_id=current_user.id, + module=MODULE, + title=req.target_project_name, + status=ModuleProjectStatusEnum.WAITING_USER.value, + current_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + idempotency_key=req.idempotency_key, + ) + db.add(project) + await db.flush() + + payload = { + "material_video_url": segment.segment_video_url, + "material_video_locked": True, + "material_image_url": req.material_image_url, + "source_project_name": segment.segment_category or segment.original_video_category or "拆镜片段", + "target_project_name": req.target_project_name, + "core_content_point": req.core_content_point, + "source_shot_task_set_id": segment.task_set_id, + "source_segment_id": segment.id, + "source_segment_index": segment.segment_index, + "segment_time_node": segment.time_node, + } + context = { + "shot_segment": { + "id": segment.id, + "task_set_id": segment.task_set_id, + "segment_index": segment.segment_index, + "time_node": segment.time_node, + "start_second": segment.start_second, + "end_second": segment.end_second, + "source_mode": segment.source_mode, + }, + "analysis": { + "original_video_content": segment.original_video_content, + "original_video_category": segment.original_video_category, + "original_video_audience": segment.original_video_audience, + "segment_content": segment.segment_content, + "segment_category": segment.segment_category, + "segment_audience": segment.segment_audience, + }, + } + + step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value, + status=ModuleStepStatusEnum.COMPLETED.value, + input_data={**payload, "source_context": context}, + output_data={ + "accepted": True, + "message": "拆镜片段素材输入已提交,素材视频已锁定", + "next_step_code": ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + **context["analysis"], + }, + ) + project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + project.status = ModuleProjectStatusEnum.WAITING_USER.value + segment.module_project_id = project.id + segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value + await log_module_event( + db, + project=project, + step=step, + event_type=ModuleEventTypeEnum.PROJECT_CREATED.value, + message="从拆镜片段创建拆镜复刻项目", + detail={"segment_id": segment.id, "task_set_id": segment.task_set_id}, + ) + await db.flush() + return project + + +def _build_file_url_or_data_uri(file_url: str) -> str: + """构建模型可访问的素材 URL。 + + - /uploads 与 /uploads/shot_segments 属于上传/素材域,不走 generate token 签名。 + - /generate 下的 AI 生成资源继续走 token 签名。 + - 远程 URL / data URI 原样返回。 + """ + if file_url.startswith(("http://", "https://", "data:")): + return file_url + if file_url.startswith("/generate/"): + file_url = build_resource_signed_url(resource_url=file_url, expire_seconds=86400) + base_url = str(settings.BASE_URL or "").rstrip("/") + return f"{base_url}{file_url}" if base_url else file_url diff --git a/video-gen-api/app/services/shot_replicate_recovery_service.py b/video-gen-api/app/services/shot_replicate_recovery_service.py new file mode 100644 index 00000000..3470f89b --- /dev/null +++ b/video-gen-api/app/services/shot_replicate_recovery_service.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.enums.shot_replicate import ShotSplitStatusEnum +from app.models.shot_replicate_segment import ShotReplicateSegment +from app.models.shot_replicate_task_set import ShotReplicateTaskSet +from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary +from app.tasks.celery_app import celery_app + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _ensure_aware(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _expired(value: datetime | None, now: datetime | None = None) -> bool: + checked = _ensure_aware(value) + if checked is None: + return True + return checked <= (now or _now()) + + +def _queue_timeout(segment: ShotReplicateSegment, now: datetime | None = None) -> bool: + enqueued_at = _ensure_aware(segment.split_enqueued_at) + if enqueued_at is None: + return True + return enqueued_at + timedelta(seconds=int(settings.SHOT_SPLIT_PENDING_TIMEOUT_SECONDS or 300)) <= (now or _now()) + + +async def recover_one_split_segment(db: AsyncSession, segment: ShotReplicateSegment, *, source: str = "startup_db") -> str: + from app.tasks.shot_replicate_tasks import split_one_segment + + if not segment: + return "skip_missing_segment" + if segment.deleted_at is not None: + return "skip_deleted" + if segment.split_status == ShotSplitStatusEnum.COMPLETED.value: + return "skip_completed" + if segment.split_status == ShotSplitStatusEnum.FAILED.value: + return "skip_failed" + + current_time = _now() + should_recover = False + + if segment.split_status == ShotSplitStatusEnum.PENDING.value: + should_recover = _queue_timeout(segment, current_time) + elif segment.split_status == ShotSplitStatusEnum.PROCESSING.value: + should_recover = _expired(segment.split_lease_until, current_time) + elif segment.split_status == ShotSplitStatusEnum.RETRY_WAITING.value: + should_recover = _expired(segment.split_next_retry_at, current_time) + + if not should_recover: + return f"skip_{segment.split_status}_not_due" + + if int(segment.split_retry_count or 0) >= int(settings.SHOT_SPLIT_MAX_RETRY_COUNT or 3): + segment.split_status = ShotSplitStatusEnum.FAILED.value + segment.split_last_error = segment.split_last_error or f"{source} 恢复时超过最大重试次数" + segment.split_lease_until = None + segment.split_next_retry_at = None + await refresh_task_set_split_summary(db, segment.task_set_id) + await db.commit() + return "mark_failed_max_retry" + + segment.split_status = ShotSplitStatusEnum.PENDING.value + segment.split_enqueued_at = current_time + segment.split_lease_until = None + segment.split_next_retry_at = None + await refresh_task_set_split_summary(db, segment.task_set_id) + await db.commit() + + if celery_app: + split_one_segment.apply_async( + args=[segment.id], + queue="gen_result_download", + priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER, + countdown=0, + ) + return f"recover_{source}" + + +async def recover_shot_split_tasks_once(db: AsyncSession) -> dict[str, Any]: + """拆镜 ffmpeg 任务容灾恢复。独立扫描 shot_replicate_segments,不复用 Chat 下载 active registry。""" + batch_size = int(settings.SHOT_SPLIT_RECOVERY_BATCH_SIZE or 50) + result = await db.execute( + select(ShotReplicateSegment) + .where( + ShotReplicateSegment.deleted_at.is_(None), + ShotReplicateSegment.split_status.in_( + [ + ShotSplitStatusEnum.PENDING.value, + ShotSplitStatusEnum.PROCESSING.value, + ShotSplitStatusEnum.RETRY_WAITING.value, + ] + ), + ) + .order_by(ShotReplicateSegment.updated_at.asc()) + .limit(batch_size) + .with_for_update(skip_locked=True) + ) + segments = list(result.scalars().all()) + + checked = 0 + results: dict[str, int] = {} + touched_task_set_ids: set[str] = set() + for segment in segments: + action = await recover_one_split_segment(db, segment, source="startup_db") + checked += 1 + touched_task_set_ids.add(segment.task_set_id) + results[action] = results.get(action, 0) + 1 + + for task_set_id in touched_task_set_ids: + await refresh_task_set_split_summary(db, task_set_id) + await db.commit() + + return {"checked": checked, "results": results} diff --git a/video-gen-api/app/services/shot_replicate_taskset_service.py b/video-gen-api/app/services/shot_replicate_taskset_service.py new file mode 100644 index 00000000..2ad87bf4 --- /dev/null +++ b/video-gen-api/app/services/shot_replicate_taskset_service.py @@ -0,0 +1,529 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any + +from fastapi import HTTPException + +from app.config import settings +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.shot_replicate import ( + ModuleCodeEnum, + ShotAnalysisStatusEnum, + ShotSegmentAnalysisStatusEnum, + ShotSegmentReplicateStatusEnum, + ShotSegmentSourceModeEnum, + ShotSplitStatusEnum, + ShotTaskSetStatusEnum, +) +from app.models.shot_replicate_segment import ShotReplicateSegment +from app.models.shot_replicate_task_set import ShotReplicateTaskSet +from app.models.user import User +from app.schemas.shot_replicate import ( + ShotAISuggestionOut, + ShotSegmentDetailOut, + ShotSegmentListOut, + ShotSegmentOut, + ShotSplitByAIOut, + ShotSplitByAIRequest, + ShotSplitCustomOut, + ShotSplitCustomRequest, + ShotTaskSetCreate, + ShotTaskSetDetailOut, + ShotTaskSetListOut, + ShotTaskSetOut, +) +from app.services.module_generation_log_service import log_module_event_file +from app.services.upload_video_asset_service import ( + build_time_node, + validate_split_range, + validate_upload_video_asset, +) +from app.tasks.celery_app import celery_app +from app.utils.id_gen import generate_id + + +MODULE = ModuleCodeEnum.SHOT_REPLICATE.value + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _normalize_suggestions(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, Any]] = [] + for idx, item in enumerate(value, start=1): + if not isinstance(item, dict): + continue + start = item.get("拆镜开始秒") + end = item.get("拆镜结束秒") + try: + start_f = float(start) + end_f = float(end) + except Exception: + continue + if start_f < 0 or end_f <= start_f: + continue + normalized.append( + { + "index": idx, + "start_second": start_f, + "end_second": end_f, + "duration_seconds": round(end_f - start_f, 3), + "time_node": str(item.get("拆镜时间节点") or build_time_node(start_f, end_f)), + "content": str(item.get("对应时间节点内的内容") or "无"), + "category": str(item.get("分类") or "无"), + "audience": str(item.get("受众人群") or "无"), + "raw": item, + } + ) + return normalized + + +def _task_set_to_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetOut: + return ShotTaskSetOut.model_validate(task_set) + + +def _task_set_to_detail_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetDetailOut: + suggestions = [ShotAISuggestionOut(**{k: v for k, v in item.items() if k != "raw"}) for item in _normalize_suggestions(task_set.ai_suggestion_json)] + base = ShotTaskSetDetailOut.model_validate(task_set) + base.ai_suggestions = suggestions + return base + + +def _segment_to_out(segment: ShotReplicateSegment) -> ShotSegmentOut: + data = ShotSegmentOut.model_validate(segment) + data.segment_name = f"片段{segment.segment_index}" + return data + + +def _segment_to_detail_out(segment: ShotReplicateSegment) -> ShotSegmentDetailOut: + data = ShotSegmentDetailOut.model_validate(segment) + data.segment_name = f"片段{segment.segment_index}" + return data + + +async def get_task_set_for_user( + db: AsyncSession, + *, + task_set_id: str, + user: User, + for_update: bool = False, +) -> ShotReplicateTaskSet: + query = select(ShotReplicateTaskSet).where( + ShotReplicateTaskSet.id == task_set_id, + ShotReplicateTaskSet.deleted_at.is_(None), + ) + if not user.is_admin: + query = query.where(ShotReplicateTaskSet.user_id == user.id) + if for_update: + query = query.with_for_update() + result = await db.execute(query.limit(1)) + task_set = result.scalar_one_or_none() + if not task_set: + raise HTTPException(status_code=404, detail="拆镜总任务集不存在") + return task_set + + +async def get_segment_for_user( + db: AsyncSession, + *, + segment_id: str, + user: User, + for_update: bool = False, +) -> ShotReplicateSegment: + query = select(ShotReplicateSegment).where( + ShotReplicateSegment.id == segment_id, + ShotReplicateSegment.deleted_at.is_(None), + ) + if not user.is_admin: + query = query.where(ShotReplicateSegment.user_id == user.id) + if for_update: + query = query.with_for_update() + result = await db.execute(query.limit(1)) + segment = result.scalar_one_or_none() + if not segment: + raise HTTPException(status_code=404, detail="拆镜片段不存在") + return segment + + +async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTaskSetCreate) -> ShotReplicateTaskSet: + if req.idempotency_key: + existing_result = await db.execute( + select(ShotReplicateTaskSet).where( + ShotReplicateTaskSet.user_id == current_user.id, + ShotReplicateTaskSet.idempotency_key == req.idempotency_key, + ShotReplicateTaskSet.deleted_at.is_(None), + ).limit(1) + ) + existing = existing_result.scalar_one_or_none() + if existing: + return existing + + asset = validate_upload_video_asset(req.video_url, req.video_duration_seconds) + task_set = ShotReplicateTaskSet( + id=generate_id(), + user_id=current_user.id, + title=req.title or "拆镜复刻任务", + video_url=asset.url, + video_path=str(asset.path), + video_duration_seconds=asset.duration_seconds, + status=ShotTaskSetStatusEnum.PENDING_ANALYSIS.value, + analysis_status=ShotAnalysisStatusEnum.PENDING.value, + split_status=ShotSplitStatusEnum.NONE.value, + segment_count=0, + completed_segment_count=0, + failed_segment_count=0, + idempotency_key=req.idempotency_key, + ) + db.add(task_set) + await db.flush() + log_module_event_file( + module=MODULE, + event_type="SHOT_TASK_SET_CREATED", + project_id=task_set.id, + user_id=task_set.user_id, + message="创建拆镜总任务集", + detail={ + "task_set_id": task_set.id, + "title": task_set.title, + "video_url": task_set.video_url, + "video_path": task_set.video_path, + "video_duration_seconds": task_set.video_duration_seconds, + "idempotency_key": task_set.idempotency_key, + }, + ) + return task_set + + +async def list_task_sets( + db: AsyncSession, + *, + current_user: User, + status: str | None = None, + analysis_status: str | None = None, + split_status: str | None = None, + keyword: str | None = None, + page: int = 1, + page_size: int = 20, +) -> ShotTaskSetListOut: + query = select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.deleted_at.is_(None)) + if not current_user.is_admin: + query = query.where(ShotReplicateTaskSet.user_id == current_user.id) + if status: + query = query.where(ShotReplicateTaskSet.status == status) + if analysis_status: + query = query.where(ShotReplicateTaskSet.analysis_status == analysis_status) + if split_status: + query = query.where(ShotReplicateTaskSet.split_status == split_status) + if keyword: + like = f"%{keyword.strip()}%" + query = query.where( + (ShotReplicateTaskSet.title.ilike(like)) + | (ShotReplicateTaskSet.original_video_content.ilike(like)) + | (ShotReplicateTaskSet.original_video_category.ilike(like)) + ) + + total_result = await db.execute(select(func.count()).select_from(query.subquery())) + total = int(total_result.scalar() or 0) + rows = await db.execute( + query.order_by(ShotReplicateTaskSet.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return ShotTaskSetListOut(total=total, page=page, page_size=page_size, items=[_task_set_to_out(item) for item in rows.scalars().all()]) + + +async def task_set_detail(db: AsyncSession, *, current_user: User, task_set_id: str) -> ShotTaskSetDetailOut: + task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user) + return _task_set_to_detail_out(task_set) + + +async def _next_segment_index(db: AsyncSession, task_set_id: str) -> int: + result = await db.execute( + select(func.max(ShotReplicateSegment.segment_index)).where( + ShotReplicateSegment.task_set_id == task_set_id, + ShotReplicateSegment.deleted_at.is_(None), + ) + ) + return int(result.scalar() or 0) + 1 + + +async def refresh_task_set_split_summary(db: AsyncSession, task_set_id: str) -> None: + task_set_result = await db.execute(select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.id == task_set_id).with_for_update().limit(1)) + task_set = task_set_result.scalar_one_or_none() + if not task_set: + return + + result = await db.execute( + select(ShotReplicateSegment).where( + ShotReplicateSegment.task_set_id == task_set_id, + ShotReplicateSegment.deleted_at.is_(None), + ) + ) + segments = list(result.scalars().all()) + total = len(segments) + completed = len([s for s in segments if s.split_status == ShotSplitStatusEnum.COMPLETED.value]) + failed = len([s for s in segments if s.split_status == ShotSplitStatusEnum.FAILED.value]) + + task_set.segment_count = total + task_set.completed_segment_count = completed + task_set.failed_segment_count = failed + + if total <= 0: + task_set.split_status = ShotSplitStatusEnum.NONE.value + if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value: + task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value + return + + old_status = task_set.status + old_split_status = task_set.split_status + + if completed == total: + task_set.split_status = ShotSplitStatusEnum.COMPLETED.value + task_set.status = ShotTaskSetStatusEnum.SPLIT_COMPLETED.value + elif failed == total: + task_set.split_status = ShotSplitStatusEnum.FAILED.value + task_set.status = ShotTaskSetStatusEnum.FAILED.value + elif failed > 0: + task_set.split_status = ShotSplitStatusEnum.FAILED.value + task_set.status = ShotTaskSetStatusEnum.PARTIAL_FAILED.value + else: + task_set.split_status = ShotSplitStatusEnum.PROCESSING.value + task_set.status = ShotTaskSetStatusEnum.SPLITTING.value + + if old_status != task_set.status or old_split_status != task_set.split_status: + log_module_event_file( + module=MODULE, + event_type="SHOT_SPLIT_STATUS_CHANGED", + project_id=task_set.id, + user_id=task_set.user_id, + message="拆镜总任务集拆分状态变更", + detail={ + "task_set_id": task_set.id, + "from_status": old_status, + "to_status": task_set.status, + "from_split_status": old_split_status, + "to_split_status": task_set.split_status, + "segment_count": total, + "completed_segment_count": completed, + "failed_segment_count": failed, + }, + ) + + +async def create_segments_by_ai( + db: AsyncSession, + *, + current_user: User, + task_set_id: str, + req: ShotSplitByAIRequest, +) -> ShotSplitByAIOut: + task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True) + if task_set.analysis_status != ShotAnalysisStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="原视频分析未完成,不能按 AI 建议拆镜") + + suggestions = _normalize_suggestions(task_set.ai_suggestion_json) + if not suggestions: + raise HTTPException(status_code=400, detail="当前没有可用 AI 建议拆镜方案,请使用自定义拆镜") + + if req.selected_indices: + selected_set = {int(x) for x in req.selected_indices} + suggestions = [item for item in suggestions if int(item["index"]) in selected_set] + if not suggestions: + raise HTTPException(status_code=400, detail="selected_indices 没有匹配到可用 AI 建议") + + old_result = await db.execute( + select(ShotReplicateSegment).where( + ShotReplicateSegment.task_set_id == task_set.id, + ShotReplicateSegment.source_mode == ShotSegmentSourceModeEnum.AI_SUGGESTION.value, + ShotReplicateSegment.deleted_at.is_(None), + ) + ) + old_segments = list(old_result.scalars().all()) + if old_segments and not req.replace_existing: + raise HTTPException(status_code=409, detail="已存在 AI 建议拆镜片段,如需重拆请传 replace_existing=true") + if old_segments and req.replace_existing: + now = _now() + for segment in old_segments: + segment.deleted_at = now + + created: list[ShotReplicateSegment] = [] + next_index = await _next_segment_index(db, task_set.id) + for item in suggestions: + start, end, duration = validate_split_range( + start_second=item["start_second"], + end_second=item["end_second"], + video_duration_seconds=task_set.video_duration_seconds, + ) + segment = ShotReplicateSegment( + id=generate_id(), + task_set_id=task_set.id, + user_id=task_set.user_id, + segment_index=next_index, + source_mode=ShotSegmentSourceModeEnum.AI_SUGGESTION.value, + start_second=start, + end_second=end, + duration_seconds=duration, + time_node=build_time_node(start, end), + split_status=ShotSplitStatusEnum.PENDING.value, + analysis_status=ShotSegmentAnalysisStatusEnum.NOT_REQUIRED.value, + replicate_status=ShotSegmentReplicateStatusEnum.NOT_STARTED.value, + original_video_content=task_set.original_video_content, + original_video_category=task_set.original_video_category, + original_video_audience=task_set.original_video_audience, + segment_content=item.get("content"), + segment_category=item.get("category"), + segment_audience=item.get("audience"), + ai_suggestion_json=item.get("raw") or item, + split_enqueued_at=_now(), + split_celery_task_id=f"shot-split:{uuid.uuid4().hex}", + ) + db.add(segment) + created.append(segment) + next_index += 1 + + task_set.status = ShotTaskSetStatusEnum.SPLITTING.value + task_set.split_status = ShotSplitStatusEnum.PROCESSING.value + await db.flush() + await refresh_task_set_split_summary(db, task_set.id) + await db.flush() + + log_module_event_file( + module=MODULE, + event_type="SHOT_SPLIT_BY_AI_SUBMITTED", + project_id=task_set.id, + user_id=task_set.user_id, + message="按 AI 建议创建拆镜片段", + detail={ + "task_set_id": task_set.id, + "selected_indices": req.selected_indices, + "replace_existing": req.replace_existing, + "created_segment_count": len(created), + "segment_ids": [segment.id for segment in created], + }, + ) + + return ShotSplitByAIOut( + task_set_id=task_set.id, + status=task_set.status, + split_status=task_set.split_status, + created_segment_count=len(created), + segments=[_segment_to_out(segment) for segment in created], + ) + + +async def create_custom_segment( + db: AsyncSession, + *, + current_user: User, + task_set_id: str, + req: ShotSplitCustomRequest, +) -> ShotSplitCustomOut: + task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True) + start, end, duration = validate_split_range( + start_second=req.start_second, + end_second=req.end_second, + video_duration_seconds=task_set.video_duration_seconds, + ) + next_index = await _next_segment_index(db, task_set.id) + segment = ShotReplicateSegment( + id=generate_id(), + task_set_id=task_set.id, + user_id=task_set.user_id, + segment_index=next_index, + source_mode=ShotSegmentSourceModeEnum.CUSTOM.value, + start_second=start, + end_second=end, + duration_seconds=duration, + time_node=build_time_node(start, end), + split_status=ShotSplitStatusEnum.PENDING.value, + analysis_status=ShotSegmentAnalysisStatusEnum.PENDING.value, + replicate_status=ShotSegmentReplicateStatusEnum.NOT_STARTED.value, + split_enqueued_at=_now(), + split_celery_task_id=f"shot-split:{uuid.uuid4().hex}", + ) + db.add(segment) + task_set.status = ShotTaskSetStatusEnum.SPLITTING.value + task_set.split_status = ShotSplitStatusEnum.PROCESSING.value + await db.flush() + await refresh_task_set_split_summary(db, task_set.id) + await db.flush() + log_module_event_file( + module=MODULE, + event_type="SHOT_SPLIT_CUSTOM_SUBMITTED", + project_id=task_set.id, + step_id=segment.id, + user_id=task_set.user_id, + message="按用户自定义时间创建拆镜片段", + detail={ + "task_set_id": task_set.id, + "segment_id": segment.id, + "start_second": start, + "end_second": end, + "duration_seconds": duration, + "time_node": segment.time_node, + }, + ) + return ShotSplitCustomOut(task_set_id=task_set.id, segment=_segment_to_out(segment)) + + +async def enqueue_segment_split(segment_id: str, *, countdown: int | None = None, recover: bool = False) -> None: + if not celery_app: + return + from app.tasks.shot_replicate_tasks import split_one_segment + + split_one_segment.apply_async( + args=[segment_id], + queue="gen_result_download", + countdown=countdown, + priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER if recover else settings.DOWNLOAD_TASK_PRIORITY_NORMAL, + ) + + +async def list_segments( + db: AsyncSession, + *, + current_user: User, + task_set_id: str, + source_mode: str | None = None, + split_status: str | None = None, + analysis_status: str | None = None, + replicate_status: str | None = None, + page: int = 1, + page_size: int = 20, +) -> ShotSegmentListOut: + await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user) + query = select(ShotReplicateSegment).where( + ShotReplicateSegment.task_set_id == task_set_id, + ShotReplicateSegment.deleted_at.is_(None), + ) + if not current_user.is_admin: + query = query.where(ShotReplicateSegment.user_id == current_user.id) + if source_mode: + query = query.where(ShotReplicateSegment.source_mode == source_mode) + if split_status: + query = query.where(ShotReplicateSegment.split_status == split_status) + if analysis_status: + query = query.where(ShotReplicateSegment.analysis_status == analysis_status) + if replicate_status: + query = query.where(ShotReplicateSegment.replicate_status == replicate_status) + + total_result = await db.execute(select(func.count()).select_from(query.subquery())) + total = int(total_result.scalar() or 0) + rows = await db.execute( + query.order_by(ShotReplicateSegment.segment_index.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return ShotSegmentListOut(total=total, page=page, page_size=page_size, items=[_segment_to_out(item) for item in rows.scalars().all()]) + + +async def segment_detail(db: AsyncSession, *, current_user: User, segment_id: str) -> ShotSegmentDetailOut: + segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user) + return _segment_to_detail_out(segment) diff --git a/video-gen-api/app/services/shot_video_analysis_service.py b/video-gen-api/app/services/shot_video_analysis_service.py new file mode 100644 index 00000000..2da22640 --- /dev/null +++ b/video-gen-api/app/services/shot_video_analysis_service.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +import base64 +import json +import mimetypes +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.model_config import ModelConfig +from app.models.token_usage import TokenUsage +from app.services.upload_video_asset_service import resolve_upload_video_path +from app.services.resource_signed_url_service import build_resource_signed_url +from app.utils.id_gen import generate_id + +AnalysisMode = Literal["full_breakdown", "summary_only"] + + +@dataclass(slots=True) +class ShotVideoAnalysisResult: + result: dict[str, Any] + raw_response: dict[str, Any] + usage: dict[str, Any] + + +def _timeout_seconds() -> int: + return int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 180) or 180) + + +def _video_fps() -> float: + return float(getattr(settings, "SHOT_ANALYSIS_VIDEO_FPS", 1.0) or 1.0) + + +def _split_min_seconds() -> float: + return float(getattr(settings, "SHOT_SPLIT_MIN_SECONDS", 1) or 1) + + +def _split_max_seconds() -> float: + return float(getattr(settings, "SHOT_SPLIT_MAX_SECONDS", 120) or 120) + + +def _resolve_local_file_path(file_url: str) -> str: + if file_url.startswith("/uploads/") or file_url.startswith("uploads/"): + return str(resolve_upload_video_path(file_url)) + return file_url + + +def build_file_url_or_data_uri(file_url: str, fallback_mime: str = "video/mp4") -> str: + if file_url.startswith(("http://", "https://", "data:")): + return file_url + file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400) + return f"{settings.BASE_URL}{file_url_sign}" + + # file_path = _resolve_local_file_path(file_url) + # path = Path(file_path) + # if not path.exists(): + # raise FileNotFoundError(f"视频文件不存在: {file_path}") + # + # max_mb = float(getattr(settings, "SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB", 45) or 45) + # size_mb = path.stat().st_size / 1024 / 1024 + # if size_mb > max_mb: + # raise ValueError(f"本地视频文件过大: {size_mb:.2f} MB,当前限制 {max_mb:g} MB") + # + # mime = mimetypes.guess_type(str(path))[0] or fallback_mime + # with open(path, "rb") as f: + # b64 = base64.b64encode(f.read()).decode("utf-8") + # return f"data:{mime};base64,{b64}" + + +def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any], dict[str, Any]]: + real_url = build_file_url_or_data_uri(video_url) + content_parts = [ + { + "type": "video_url", + "video_url": { + "url": real_url, + "fps": _video_fps(), + }, + }, + {"type": "text", "text": user_text}, + ] + log_content_parts = [ + { + "type": "video_url", + "video_url": { + "url": video_url, + "fps": _video_fps(), + }, + }, + {"type": "text", "text": user_text}, + ] + return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts} + + +def build_video_analysis_system_prompt(*, mode: AnalysisMode) -> str: + if mode == "summary_only": + return """ +你是专业的短视频内容分析师、广告素材拆解师。 + +你的任务: +1. 根据用户提供的视频附件,分析这个视频片段的整体内容。 +2. 判断视频分类。 +3. 判断视频可能面向的受众人群。 +4. 必须输出严格 JSON 对象。 +5. 不输出 Markdown。 +6. 不输出解释文字。 +7. 不返回 null,未知内容填“无”。 + +顶级字段只能包含: +- 原视频内容 +- 原视频分类 +- 原视频受众人群 +- 拆镜内容剖析 + +summary_only 模式下“拆镜内容剖析”必须返回空数组。 + +安全规则: +1. 不要识别视频中人物身份。 +2. 不要猜测真实姓名、联系方式、账号身份。 +3. 如果视频是游戏录屏,只分析画面内容、玩法内容、玩家情绪表达、受众,不要编造不存在的剧情。 +4. 如果视频包含广告内容,可以分析广告品类、目标用户、转化意图,但不要编造品牌信息。 +""".strip() + + return f""" +你是专业的短视频内容分析师、广告素材拆解师、视频分镜分析师。 + +你的任务: +1. 根据用户提供的视频附件,分析原视频整体内容。 +2. 判断原视频分类。 +3. 判断原视频可能面向的受众人群。 +4. 对视频进行拆镜内容剖析。 +5. 必须输出严格 JSON 对象。 +6. 不输出 Markdown。 +7. 不输出解释文字。 +8. 不返回 null,未知内容填“无”。 + +顶级字段只能包含: +- 原视频内容 +- 原视频分类 +- 原视频受众人群 +- 拆镜内容剖析 + +拆镜内容剖析必须是数组。 + +每个拆镜片段必须包含: +- 拆镜开始秒 +- 拆镜结束秒 +- 拆镜时间节点 +- 对应时间节点内的内容 +- 分类 +- 受众人群 + +拆镜时间规则: +1. 拆镜开始秒必须是数字,例如 0、15、26。 +2. 拆镜结束秒必须是数字,例如 15、26、31。 +3. 拆镜时间节点必须由拆镜开始秒和拆镜结束秒组成,例如“0-15秒”。 +4. 禁止输出“-15秒”这种缺少开始秒的时间节点。 +5. 禁止输出“15-秒”这种缺少结束秒的时间节点。 +6. 每个拆镜片段时长不能低于 {_split_min_seconds():g} 秒。 +7. 每个拆镜片段时长不能高于 {_split_max_seconds():g} 秒。 +8. 如果某段内容不足 {_split_min_seconds():g} 秒,不要单独拆出来。 +9. 如果单个连续内容超过 {_split_max_seconds():g} 秒,需要按语义变化继续拆分。 +10. 如果没有明显镜头变化、场景变化、人物动作变化、剧情变化、字幕重点变化或语义变化,不要强行剖析。 +11. 如果无法可靠拆镜,则“拆镜内容剖析”返回空数组。 +12. 拆镜时间必须从 0 秒或视频中实际可识别的开始时间开始,不允许出现负数。 +13. 拆镜结束秒必须大于拆镜开始秒。 +14. 拆镜片段必须按时间顺序排列。 + +安全规则: +1. 不要识别视频中人物身份。 +2. 不要猜测真实姓名、联系方式、账号身份。 +3. 如果视频是游戏录屏,只分析画面内容、玩法内容、玩家情绪表达、受众,不要编造不存在的剧情。 +4. 如果视频包含广告内容,可以分析广告品类、目标用户、转化意图,但不要编造品牌信息。 +""".strip() + + +def build_video_analysis_user_text(*, mode: AnalysisMode) -> str: + if mode == "summary_only": + payload = { + "任务": "请根据上传的视频片段附件,返回这个视频片段的内容分析 JSON。", + "输出JSON格式": { + "原视频内容": "概括这个视频片段整体内容,描述主要画面、主体、场景、动作、剧情或信息点", + "原视频分类": "判断视频类型,例如:游戏视频、产品广告视频、剧情视频、口播讲解视频、教程视频、生活记录视频等", + "原视频受众人群": "判断该片段更适合的人群", + "拆镜内容剖析": [], + }, + "返回要求": ["只返回 JSON 对象", "不要返回 Markdown", "不要返回解释文字", "不要返回代码块", "不要返回 null,未知填无"], + } + return json.dumps(payload, ensure_ascii=False) + + payload = { + "任务": "请根据上传的视频附件,返回原视频内容分析和拆镜内容剖析 JSON。", + "输出JSON格式": { + "原视频内容": "概括原视频整体内容,描述主要画面、人物/主体、场景、动作、剧情或信息点", + "原视频分类": "判断视频类型,例如:游戏视频、产品广告视频、剧情视频、口播讲解视频、教程视频、生活记录视频、直播切片视频、图文快闪视频等", + "原视频受众人群": "判断该视频更适合的人群,例如:游戏玩家、年轻娱乐用户、潜在购买用户、同城社交用户等", + "拆镜内容剖析": [ + { + "拆镜开始秒": 0, + "拆镜结束秒": 15, + "拆镜时间节点": "0-15秒", + "对应时间节点内的内容": "描述这个时间片段内发生了什么", + "分类": "判断这个片段的内容分类,例如:开场吸引、冲突铺垫、玩法展示、卖点展示、情绪爆发、行动引导、结果展示等", + "受众人群": "判断这个片段主要吸引的人群", + } + ], + }, + "拆镜规则": [ + f"每个拆镜片段时长必须大于等于 {_split_min_seconds():g} 秒", + f"每个拆镜片段时长必须小于等于 {_split_max_seconds():g} 秒", + "拆镜开始秒必须是数字", + "拆镜结束秒必须是数字", + "拆镜开始秒不能是负数", + "拆镜结束秒必须大于拆镜开始秒", + "拆镜时间节点必须等于:拆镜开始秒-拆镜结束秒秒", + "禁止输出“-15秒”", + "禁止输出“15-秒”", + "如果无法判断拆镜节点,拆镜内容剖析返回空数组", + ], + "返回要求": ["只返回 JSON 对象", "不要返回 Markdown", "不要返回解释文字", "不要返回代码块", "不要返回 null,未知填无"], + } + return json.dumps(payload, ensure_ascii=False) + + +def strip_json_code_fence(text: str) -> str: + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip() + text = re.sub(r"```$", "", text).strip() + return text + + +def parse_model_json(content: str) -> dict[str, Any]: + cleaned = strip_json_code_fence(content) + data = json.loads(cleaned) + if not isinstance(data, dict): + raise ValueError(f"模型返回 JSON 不是对象类型: {type(data).__name__}") + return data + + +def get_message_content_or_raise(data: dict[str, Any]) -> str: + choices = data.get("choices") or [] + if not choices: + raise RuntimeError(f"模型响应没有 choices: {json.dumps(data, ensure_ascii=False)}") + choice = choices[0] + finish_reason = choice.get("finish_reason") + if finish_reason == "length": + usage = data.get("usage", {}) + raise RuntimeError(f"模型输出被长度限制截断,finish_reason={finish_reason}, usage={json.dumps(usage, ensure_ascii=False)}") + message = choice.get("message") or {} + content = message.get("content", "") + if not content: + raise RuntimeError(f"模型响应 content 为空: {json.dumps(data, ensure_ascii=False)}") + return content.strip() + + +def fill_none_with_wu(value: Any) -> Any: + if value is None: + return "无" + if isinstance(value, str): + return value if value.strip() else "无" + if isinstance(value, list): + return [fill_none_with_wu(item) for item in value] + if isinstance(value, dict): + return {k: fill_none_with_wu(v) for k, v in value.items()} + return value + + +def first_present(data: dict[str, Any], keys: list[str]) -> Any: + for key in keys: + if key in data: + return data.get(key) + return None + + +def parse_number(value: Any) -> float | None: + if isinstance(value, (int, float)): + return float(value) + text = str(value or "").strip() + if not text: + return None + m = re.search(r"-?\d+(?:\.\d+)?", text) + return float(m.group(0)) if m else None + + +def parse_time_value_to_seconds(value: str) -> float | None: + value = str(value or "").strip() + if not value: + return None + m = re.match(r"^(\d+(?:\.\d+)?)\s*(?:秒|s)?$", value, flags=re.IGNORECASE) + if m: + return float(m.group(1)) + parts = value.split(":") + if len(parts) in (2, 3) and all(re.match(r"^\d+(?:\.\d+)?$", p.strip()) for p in parts): + nums = [float(p.strip()) for p in parts] + if len(nums) == 2: + minute, second = nums + return minute * 60 + second + hour, minute, second = nums + return hour * 3600 + minute * 60 + second + return None + + +def parse_time_node_to_range(time_node: str) -> tuple[float, float] | None: + text = str(time_node or "").strip() + if not text: + return None + text = text.replace("—", "-").replace("–", "-").replace("-", "-") + text = text.replace("到", "-").replace("至", "-").replace("~", "-").replace("~", "-") + text = text.replace("第", "").replace("时间段", "").replace(":", ":") + m = re.search(r"(\d{1,2}:\d{1,2}(?::\d{1,2})?)\s*-\s*(\d{1,2}:\d{1,2}(?::\d{1,2})?)", text) + if m: + start = parse_time_value_to_seconds(m.group(1)) + end = parse_time_value_to_seconds(m.group(2)) + if start is not None and end is not None and end > start: + return start, end + m = re.search(r"(\d+(?:\.\d+)?)\s*(?:秒|s)?\s*-\s*(\d+(?:\.\d+)?)\s*(?:秒|s)?", text, flags=re.IGNORECASE) + if m: + start = float(m.group(1)) + end = float(m.group(2)) + if end > start: + return start, end + m = re.search(r"^\s*-\s*(\d+(?:\.\d+)?)\s*(?:秒|s)?\s*$", text, flags=re.IGNORECASE) + if m: + end = float(m.group(1)) + if end > 0: + return 0.0, end + return None + + +def format_second(value: float) -> int | float: + checked = float(value) + return int(checked) if checked.is_integer() else round(checked, 2) + + +def normalize_time_node_by_range(start: float, end: float) -> str: + return f"{format_second(start)}-{format_second(end)}秒" + + +def ensure_result_schema(result: dict[str, Any]) -> dict[str, Any]: + final_result = { + "原视频内容": result.get("原视频内容", "无"), + "原视频分类": result.get("原视频分类", "无"), + "原视频受众人群": result.get("原视频受众人群", "无"), + "拆镜内容剖析": result.get("拆镜内容剖析", []), + } + for key in ("原视频内容", "原视频分类", "原视频受众人群"): + if not isinstance(final_result[key], str): + final_result[key] = json.dumps(final_result[key], ensure_ascii=False) + if not isinstance(final_result["拆镜内容剖析"], list): + final_result["拆镜内容剖析"] = [] + return final_result + + +def filter_and_normalize_breakdown(result: dict[str, Any], *, mode: AnalysisMode = "full_breakdown") -> dict[str, Any]: + if mode == "summary_only": + result["拆镜内容剖析"] = [] + return result + + breakdown = result.get("拆镜内容剖析") + if not isinstance(breakdown, list): + result["拆镜内容剖析"] = [] + return result + + normalized_items: list[dict[str, Any]] = [] + for item in breakdown: + if not isinstance(item, dict): + continue + raw_start = first_present(item, ["拆镜开始秒", "开始秒", "起始秒", "开始时间", "起始时间", "start", "start_second", "start_seconds"]) + raw_end = first_present(item, ["拆镜结束秒", "结束秒", "结束时间", "end", "end_second", "end_seconds"]) + start = parse_number(raw_start) + end = parse_number(raw_end) + time_node = str(first_present(item, ["拆镜时间节点", "时间节点", "时间段", "镜头时间", "time_node", "time_range"]) or "").strip() + if start is None or end is None: + parsed_range = parse_time_node_to_range(time_node) + if parsed_range is None: + continue + start, end = parsed_range + if start is None or end is None or start < 0 or end < 0 or end <= start: + continue + duration = end - start + if duration < _split_min_seconds() or duration > _split_max_seconds(): + continue + content = first_present(item, ["对应时间节点内的内容", "内容", "画面内容", "片段内容", "镜头内容", "content"]) or "无" + category = first_present(item, ["分类", "片段分类", "内容分类", "镜头分类", "category"]) or "无" + audience = first_present(item, ["受众人群", "目标受众", "片段受众", "镜头受众", "audience"]) or "无" + normalized_items.append({ + "拆镜开始秒": format_second(start), + "拆镜结束秒": format_second(end), + "拆镜时间节点": normalize_time_node_by_range(start, end), + "对应时间节点内的内容": str(content or "无"), + "分类": str(category or "无"), + "受众人群": str(audience or "无"), + }) + normalized_items.sort(key=lambda x: float(x.get("拆镜开始秒", 0))) + result["拆镜内容剖析"] = normalized_items + return result + + +async def _select_model_config(db: AsyncSession) -> ModelConfig | None: + result = await db.execute( + select(ModelConfig) + .where(ModelConfig.is_active == True) + .order_by(ModelConfig.priority.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +def _int_usage(value: Any) -> int: + try: + return int(value or 0) + except Exception: + return 0 + +async def analyze_video_for_shot_split( + db: AsyncSession, + video_url: str, + *, + user_id: str | None = None, + mode: AnalysisMode = "full_breakdown", +) -> ShotVideoAnalysisResult: + """调用模型完成拆镜/片段分析。 + + 模型配置统一从 model_configs 表选择当前启用且 priority 最高的配置; + 不再读取 SHOT_ANALYSIS_API_BASE / SHOT_ANALYSIS_API_KEY / SHOT_ANALYSIS_MODEL_NAME, + 也不再 fallback 到 SEEDANCE_*,避免拆镜分析走错通道。 + """ + config = await _select_model_config(db) + if not config: + raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型") + if not str(config.api_key or "").strip(): + raise RuntimeError(f"拆镜分析模型 API Key 为空: model_config_id={config.id}") + if not str(config.api_base or "").strip(): + raise RuntimeError(f"拆镜分析模型 API Base 为空: model_config_id={config.id}") + if not str(config.model_name or "").strip(): + raise RuntimeError(f"拆镜分析模型名称为空: model_config_id={config.id}") + + system_prompt = build_video_analysis_system_prompt(mode=mode) + user_text = build_video_analysis_user_text(mode=mode) + user_message, log_user_message = build_user_message(user_text, video_url) + + request_data: dict[str, Any] = { + "model": config.model_name, + "messages": [ + {"role": "system", "content": system_prompt}, + user_message, + ], + "max_tokens": int(getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or getattr(config, "max_tokens", 5000) or 5000), + "temperature": float(getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or getattr(config, "temperature", 0.1) or 0.1), + "response_format": {"type": "json_object"}, + } + log_request_data: dict[str, Any] = { + **request_data, + "messages": [ + {"role": "system", "content": system_prompt}, + log_user_message, + ], + "model_config_id": config.id, + "model_config_name": config.name, + "provider": config.provider, + "analysis_mode": mode, + } + + url = f"{str(config.api_base).rstrip('/')}/chat/completions" + async with httpx.AsyncClient(timeout=_timeout_seconds()) as client: + response = await client.post( + url, + headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"}, + json=request_data, + ) + if response.status_code >= 400: + raise RuntimeError(f"视频拆镜分析 API 请求失败: HTTP {response.status_code}: {response.text}") + + raw = response.json() + content = get_message_content_or_raise(raw) + result = parse_model_json(content) + result = fill_none_with_wu(result) + result = ensure_result_schema(result) + result = filter_and_normalize_breakdown(result, mode=mode) + + usage = raw.get("usage") or {} + token_usage = { + "input_tokens": _int_usage(usage.get("prompt_tokens") or usage.get("input_tokens")), + "output_tokens": _int_usage(usage.get("completion_tokens") or usage.get("output_tokens")), + "total_tokens": _int_usage(usage.get("total_tokens")), + "finish_reason": ((raw.get("choices") or [{}])[0] or {}).get("finish_reason"), + "model_config_id": config.id, + "model_config_name": config.name, + "model": config.model_name, + "provider": config.provider, + "video_fps": _video_fps(), + "split_min_seconds": _split_min_seconds(), + "split_max_seconds": _split_max_seconds(), + "analysis_mode": mode, + "log_request": log_request_data, + } + if not token_usage["total_tokens"]: + token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"] + + db.add( + TokenUsage( + id=generate_id(), + model_config_id=config.id, + user_id=user_id, + input_tokens=token_usage["input_tokens"], + output_tokens=token_usage["output_tokens"], + total_tokens=token_usage["total_tokens"], + ) + ) + await db.flush() + + return ShotVideoAnalysisResult(result=result, raw_response=raw, usage=token_usage) + diff --git a/video-gen-api/app/services/shot_video_split_service.py b/video-gen-api/app/services/shot_video_split_service.py new file mode 100644 index 00000000..bb9edb65 --- /dev/null +++ b/video-gen-api/app/services/shot_video_split_service.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import asyncio +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from app.config import settings +from app.services.upload_video_asset_service import ( + build_upload_url_from_path, + ensure_shot_segment_dir, + get_ffmpeg_bin, +) + + +@dataclass(slots=True) +class ShotSplitResult: + url: str + path: str + file_size_bytes: int + + +def _date_dir_from_segment_id(segment_id: str) -> str: + # 由调用方更适合按 created_at 传入;这里兜底按当前日期。 + from datetime import datetime + + return datetime.now().strftime("%Y/%m/%d") + + +def _safe_unlink(path: Path) -> None: + try: + if path.exists(): + path.unlink() + except Exception: + pass + + +def split_video_segment( + *, + source_path: str | Path, + segment_id: str, + start_second: float, + end_second: float, + date_dir: str | None = None, +) -> ShotSplitResult: + """使用 ffmpeg 拆出单个视频片段,输出到 storage/uploads/shot_segments。""" + source_path = Path(source_path) + + if not source_path.exists(): + raise RuntimeError(f"ffmpeg 拆镜失败:源视频不存在 {source_path}") + + start = max(float(start_second), 0.0) + end = max(float(end_second), 0.0) + duration = end - start + + if duration <= 0: + raise RuntimeError( + f"ffmpeg 拆镜失败:非法时间范围 start_second={start_second}, end_second={end_second}" + ) + + date_dir = date_dir or _date_dir_from_segment_id(segment_id) + output_dir = ensure_shot_segment_dir(date_dir) + output_path = output_dir / f"{segment_id}.mp4" + + # 注意: + # 不能用 xxx.mp4.part,因为 ffmpeg 会按最后一个扩展名 .part 判断输出格式,导致: + # Unable to choose an output format + # 这里改为 xxx.part.mp4,让 ffmpeg 能识别 mp4 容器。 + part_path = output_dir / f"{segment_id}.part.mp4" + + _safe_unlink(part_path) + + timeout = int(getattr(settings, "SHOT_FFMPEG_TIMEOUT_SECONDS", 120) or 120) + + cmd = [ + get_ffmpeg_bin(), + "-y", + + # 先 seek 到起始秒,再按 duration 切割,避免 -to 在不同 ffmpeg 参数位置下语义不一致。 + "-ss", + f"{start:.3f}", + "-i", + str(source_path), + "-t", + f"{duration:.3f}", + + # 只取主视频流,音频可选,避免 map 0 把字幕/数据流带进去导致 mp4 封装失败。 + "-map", + "0:v:0", + "-map", + "0:a:0?", + + # 当前是拆镜片段,重编码更稳,避免关键帧不准导致片段首尾异常。 + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-pix_fmt", + "yuv420p", + + "-c:a", + "aac", + "-b:a", + "128k", + + "-movflags", + "+faststart", + + # 即使临时文件扩展名未来被改坏,也强制指定 mp4 muxer。 + "-f", + "mp4", + + str(part_path), + ] + + try: + completed = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + _safe_unlink(part_path) + raise RuntimeError( + f"ffmpeg 拆镜超时:timeout={timeout}s, start={start:.3f}, end={end:.3f}" + ) from exc + + if completed.returncode != 0: + _safe_unlink(part_path) + raise RuntimeError( + f"ffmpeg 拆镜失败: {completed.stderr.strip() or completed.stdout.strip()}" + ) + + if not part_path.exists() or part_path.stat().st_size <= 0: + _safe_unlink(part_path) + raise RuntimeError("ffmpeg 拆镜失败:输出文件为空") + + os.replace(part_path, output_path) + + return ShotSplitResult( + url=build_upload_url_from_path(output_path), + path=str(output_path), + file_size_bytes=output_path.stat().st_size, + ) + + +async def split_video_segment_async( + *, + source_path: str | Path, + segment_id: str, + start_second: float, + end_second: float, + date_dir: str | None = None, +) -> ShotSplitResult: + """异步拆镜入口。 + + ffmpeg 本身是同步阻塞命令,不能直接在 Celery 进程内唯一 event loop 中执行。 + 这里通过 asyncio.to_thread 跑同步拆镜函数,避免阻塞 asyncpg / Redis / HTTP 等异步任务。 + """ + return await asyncio.to_thread( + split_video_segment, + source_path=source_path, + segment_id=segment_id, + start_second=start_second, + end_second=end_second, + date_dir=date_dir, + ) \ No newline at end of file diff --git a/video-gen-api/app/services/upload_video_asset_service.py b/video-gen-api/app/services/upload_video_asset_service.py new file mode 100644 index 00000000..cc18ee83 --- /dev/null +++ b/video-gen-api/app/services/upload_video_asset_service.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + +from fastapi import HTTPException + +from app.config import settings + +VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"} + + +@dataclass(slots=True) +class UploadVideoAsset: + url: str + path: Path + duration_seconds: float + + +def _project_root() -> Path: + return Path.cwd() + + +def _abs_path(value: str | Path) -> Path: + path = Path(value) + if not path.is_absolute(): + path = _project_root() / path + return path.resolve() + + +def upload_root() -> Path: + return _abs_path(settings.UPLOAD_LOCAL_PATH) + + +def shot_segment_root() -> Path: + return _abs_path(getattr(settings, "SHOT_SEGMENT_LOCAL_PATH", "./storage/uploads/shot_segments")) + + +def _strip_base_url(url: str) -> str: + text = str(url or "").strip() + if not text: + return text + + base_url = str(getattr(settings, "BASE_URL", "") or "").strip().rstrip("/") + if base_url and text.startswith(base_url + "/"): + return text[len(base_url):] + + parsed = urlparse(text) + if parsed.scheme in ("http", "https"): + # 只接受本系统 BASE_URL 下的上传资源;外部 URL 不允许 ffmpeg 本地切片。 + raise HTTPException(status_code=400, detail="拆镜源视频必须来自本系统上传接口,不能传外部 http/https URL") + + return text + + +def _safe_relative_from_upload_url(url: str) -> str: + value = _strip_base_url(url) + value = value.split("?", 1)[0].split("#", 1)[0] + + if value.startswith("/uploads/"): + rel = value.replace("/uploads/", "", 1) + elif value.startswith("uploads/"): + rel = value.replace("uploads/", "", 1) + else: + raise HTTPException(status_code=400, detail="拆镜源视频链接必须是 /uploads/ 下的上传资源") + + rel = rel.lstrip("/") + if not rel or ".." in Path(rel).parts: + raise HTTPException(status_code=400, detail="上传视频路径非法") + return rel + + +def resolve_upload_video_path(video_url: str) -> Path: + rel = _safe_relative_from_upload_url(video_url) + root = upload_root() + path = (root / rel).resolve() + + try: + path.relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=400, detail="上传视频路径越界") from exc + + if path.suffix.lower() not in VIDEO_EXTENSIONS: + raise HTTPException(status_code=400, detail="上传资源不是支持的视频格式") + if not path.exists() or not path.is_file(): + raise HTTPException(status_code=404, detail=f"上传视频文件不存在: {video_url}") + return path + + +def build_upload_url_from_path(path: str | Path) -> str: + root = upload_root() + checked_path = _abs_path(path) + try: + rel = checked_path.relative_to(root).as_posix() + except ValueError as exc: + raise HTTPException(status_code=500, detail="生成上传资源 URL 失败:路径不在 uploads 目录下") from exc + return f"/uploads/{rel}" + + +def get_ffmpeg_bin() -> str: + return str(getattr(settings, "FFMPEG_BIN", "") or "ffmpeg") + + +def get_ffprobe_bin() -> str: + configured = str(getattr(settings, "FFPROBE_BIN", "") or "").strip() + if configured: + return configured + ffmpeg_bin = get_ffmpeg_bin() + if ffmpeg_bin.endswith("ffmpeg.exe"): + return ffmpeg_bin[:-10] + "ffprobe.exe" + if ffmpeg_bin.endswith("ffmpeg"): + return ffmpeg_bin[:-6] + "ffprobe" + return "ffprobe" + + +def probe_video_duration_seconds(video_path: str | Path) -> float: + path = _abs_path(video_path) + timeout = int(getattr(settings, "SHOT_FFPROBE_TIMEOUT_SECONDS", 20) or 20) + cmd = [ + get_ffprobe_bin(), + "-v", "error", + "-show_entries", "format=duration", + "-of", "json", + str(path), + ] + try: + completed = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"ffprobe 获取视频时长失败: {exc}") from exc + + if completed.returncode != 0: + raise HTTPException(status_code=400, detail=f"ffprobe 获取视频时长失败: {completed.stderr.strip()}") + + try: + data = json.loads(completed.stdout or "{}") + duration = float((data.get("format") or {}).get("duration") or 0) + except Exception as exc: + raise HTTPException(status_code=400, detail="ffprobe 返回的视频时长无法解析") from exc + + if duration <= 0: + raise HTTPException(status_code=400, detail="视频时长无效") + return round(duration, 3) + + +def validate_upload_video_asset(video_url: str, frontend_duration_seconds: float | None = None) -> UploadVideoAsset: + path = resolve_upload_video_path(video_url) + real_duration = probe_video_duration_seconds(path) + + if frontend_duration_seconds is not None and frontend_duration_seconds > 0: + tolerance = float(getattr(settings, "SHOT_DURATION_TOLERANCE_SECONDS", 1.0) or 1.0) + # 超出误差时以后端 ffprobe 为准,不拒绝,避免前端浮点或浏览器 metadata 偏差导致创建失败。 + if abs(float(frontend_duration_seconds) - real_duration) <= tolerance: + real_duration = round(float(frontend_duration_seconds), 3) + + return UploadVideoAsset(url=_strip_base_url(video_url), path=path, duration_seconds=real_duration) + + +def validate_split_range(*, start_second: float, end_second: float, video_duration_seconds: float) -> tuple[float, float, float]: + start = round(float(start_second), 3) + end = round(float(end_second), 3) + + if start < 0: + raise HTTPException(status_code=400, detail="开始秒不能小于0") + if end <= start: + raise HTTPException(status_code=400, detail="结束秒必须大于开始秒") + + tolerance = float(getattr(settings, "SHOT_SPLIT_END_TOLERANCE_SECONDS", 0.5) or 0.5) + if end > float(video_duration_seconds) + tolerance: + raise HTTPException(status_code=400, detail="结束秒不能超过视频总时长") + + duration = round(end - start, 3) + min_seconds = float(getattr(settings, "SHOT_SPLIT_MIN_SECONDS", 1) or 1) + max_seconds = float(getattr(settings, "SHOT_SPLIT_MAX_SECONDS", 120) or 120) + if duration < min_seconds: + raise HTTPException(status_code=400, detail=f"拆镜片段不能低于 {min_seconds:g} 秒") + if duration > max_seconds: + raise HTTPException(status_code=400, detail=f"拆镜片段不能超过 {max_seconds:g} 秒") + return start, end, duration + + +def format_second(value: float) -> int | float: + checked = float(value) + if checked.is_integer(): + return int(checked) + return round(checked, 2) + + +def build_time_node(start_second: float, end_second: float) -> str: + return f"{format_second(start_second)}-{format_second(end_second)}秒" + + +def ensure_shot_segment_dir(date_dir: str) -> Path: + root = shot_segment_root() + output_dir = (root / date_dir).resolve() + try: + output_dir.relative_to(root) + except ValueError as exc: + raise RuntimeError("拆镜输出目录越界") from exc + os.makedirs(output_dir, exist_ok=True) + return output_dir diff --git a/video-gen-api/app/tasks/__init__.py b/video-gen-api/app/tasks/__init__.py index 1685b2f7..03e34c2c 100644 --- a/video-gen-api/app/tasks/__init__.py +++ b/video-gen-api/app/tasks/__init__.py @@ -10,7 +10,9 @@ try: generation_poll_tasks, generation_download_tasks, generation_recovery_tasks, - hot_opening_replicate_tasks + hot_opening_replicate_tasks, + shot_replicate_tasks, + shot_replicate_flow_tasks ) except Exception: pass diff --git a/video-gen-api/app/tasks/async_runner.py b/video-gen-api/app/tasks/async_runner.py index 7d6f1df8..24964dad 100644 --- a/video-gen-api/app/tasks/async_runner.py +++ b/video-gen-api/app/tasks/async_runner.py @@ -1,23 +1,35 @@ +from __future__ import annotations + import asyncio import os import threading +from concurrent.futures import Future from typing import Awaitable, TypeVar +from app.config import settings + T = TypeVar("T") _thread_local = threading.local() +_single_loop_lock = threading.RLock() +_single_loop: asyncio.AbstractEventLoop | None = None +_single_loop_thread: threading.Thread | None = None +_single_loop_pid: int | None = None +_single_loop_ready: threading.Event | None = None -def _get_or_create_loop() -> asyncio.AbstractEventLoop: - """ - 给当前进程/线程维护一个长期 event loop。 +def _runner_mode() -> str: + mode = str(getattr(settings, "CELERY_ASYNC_RUNNER_MODE", "single_loop") or "single_loop").strip().lower() + if mode not in {"single_loop", "direct"}: + return "single_loop" + return mode - Linux prefork: - 每个 Celery 子进程通常单线程跑任务,这里相当于每个子进程一个长期 loop。 - Windows -P threads: - 每个线程一个 loop,但注意 asyncpg pool 仍不适合跨线程共享; - Windows threads 模式建议继续用 NullPool 或只做本地调试。 +def _get_or_create_thread_local_loop() -> asyncio.AbstractEventLoop: + """兼容旧方案:当前线程持有一个长期 event loop。 + + 仅作为降级模式使用。长期推荐 single_loop,避免 Windows threads 下 + 多线程 event loop 复用 asyncpg / redis.asyncio 连接对象。 """ pid = os.getpid() loop = getattr(_thread_local, "loop", None) @@ -31,18 +43,104 @@ def _get_or_create_loop() -> asyncio.AbstractEventLoop: return loop +def _single_loop_worker(loop: asyncio.AbstractEventLoop, ready: threading.Event) -> None: + asyncio.set_event_loop(loop) + ready.set() + loop.run_forever() + + pending = [task for task in asyncio.all_tasks(loop) if not task.done()] + if pending: + for task in pending: + task.cancel() + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + +def _get_or_create_single_loop() -> asyncio.AbstractEventLoop: + """获取当前 Celery 进程内唯一 async event loop。 + + Linux prefork:每个 Celery 子进程各自一个 loop。 + Windows threads:同一 worker 进程内所有任务线程共享同一个 loop。 + """ + global _single_loop, _single_loop_thread, _single_loop_pid, _single_loop_ready + + pid = os.getpid() + with _single_loop_lock: + if ( + _single_loop is not None + and not _single_loop.is_closed() + and _single_loop_thread is not None + and _single_loop_thread.is_alive() + and _single_loop_pid == pid + ): + return _single_loop + + # fork 后 pid 变化,必须丢弃父进程状态,重新创建子进程自己的 loop。 + _single_loop = asyncio.new_event_loop() + _single_loop_pid = pid + _single_loop_ready = threading.Event() + _single_loop_thread = threading.Thread( + target=_single_loop_worker, + args=(_single_loop, _single_loop_ready), + name=f"celery-async-runner-{pid}", + daemon=True, + ) + _single_loop_thread.start() + _single_loop_ready.wait(timeout=5) + return _single_loop + + def run_async(coro: Awaitable[T]) -> T: + """Celery 同步 task 调用异步协程的统一入口。 + + 默认 single_loop 模式: + - 一个 Celery 子进程只有一个专用 event loop; + - 所有 asyncpg / redis.asyncio 操作都在这个 loop 内创建和使用; + - 避免 got Future attached to a different loop。 + + 降级 direct 模式: + - 兼容旧的线程本地 loop 方案; + - 如果使用 direct,建议同时开启 CELERY_DB_USE_NULLPOOL=true。 """ - Celery 同步 task 调用异步协程的统一入口。 - 不使用 asyncio.run(),避免每个 task 结束时关闭 event loop。 - """ - loop = _get_or_create_loop() - return loop.run_until_complete(coro) + if _runner_mode() == "direct": + loop = _get_or_create_thread_local_loop() + return loop.run_until_complete(coro) + + loop = _get_or_create_single_loop() + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + + if running_loop is loop: + raise RuntimeError("run_async() 不能在 Celery async_runner 的事件循环内部被同步调用") + + future: Future[T] = asyncio.run_coroutine_threadsafe(coro, loop) + return future.result() def close_loop() -> None: + """关闭当前进程内 async runner loop。""" + global _single_loop, _single_loop_thread, _single_loop_pid, _single_loop_ready + + # 关闭 single_loop。 + with _single_loop_lock: + loop = _single_loop + thread = _single_loop_thread + if loop is not None and not loop.is_closed() and thread is not None and thread.is_alive(): + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) + + _single_loop = None + _single_loop_thread = None + _single_loop_pid = None + _single_loop_ready = None + + # 关闭 direct 降级模式的线程本地 loop。 loop = getattr(_thread_local, "loop", None) if loop is not None and not loop.is_closed(): loop.close() _thread_local.loop = None - _thread_local.pid = None \ No newline at end of file + _thread_local.pid = None diff --git a/video-gen-api/app/tasks/celery_app.py b/video-gen-api/app/tasks/celery_app.py index ed4b567d..2f4e0f38 100644 --- a/video-gen-api/app/tasks/celery_app.py +++ b/video-gen-api/app/tasks/celery_app.py @@ -51,6 +51,12 @@ if broker_url: "generation.download_generation_result_task": {"queue": "gen_result_download"}, "hot_opening.start_image_prompt_optimize": {"queue": "gen_chatapi_create"}, "hot_opening.start_video_prompt_optimize": {"queue": "gen_chatapi_create"}, + "shot_replicate.analyze_original_video": {"queue": "gen_chatapi_create"}, + "shot_replicate.analyze_custom_segment_video": {"queue": "gen_chatapi_create"}, + "shot_replicate.split_one_segment": {"queue": "gen_result_download"}, + "shot_replicate.start_image_prompt_optimize": {"queue": "gen_chatapi_create"}, + "shot_replicate.start_video_prompt_optimize": {"queue": "gen_chatapi_create"}, + "shot_replicate.recover_split_tasks_once": {"queue": "gen_result_download"}, "generation.recover_download_tasks_once": {"queue": "gen_result_download"}, "generation.recover_generation_tasks_once": {"queue": "gen_result_download"}, "app.tasks.cleanup.*": {"queue": "default"}, @@ -61,6 +67,18 @@ else: celery_app = None +async def _try_acquire_startup_recovery_lock() -> bool: + """任意 worker 启动时都可尝试抢恢复锁,避免依赖 hostname 命名。""" + from app.services.redis_registry_service import redis_acquire_lock + + token = await redis_acquire_lock( + lock_key=settings.CELERY_STARTUP_RECOVERY_LOCK_KEY, + ttl_seconds=int(settings.CELERY_STARTUP_RECOVERY_LOCK_TTL_SECONDS or 120), + log_context="celery_startup_recovery", + ) + return bool(token) + + @worker_ready.connect def on_worker_ready(sender=None, **kwargs): """Celery worker 启动时做一次容灾恢复。 @@ -68,13 +86,21 @@ def on_worker_ready(sender=None, **kwargs): 注意: - 不启用 Celery beat。 - 不要求新增第四条启动命令。 - - 只让 gen_result_download worker 投递恢复任务,避免三个 worker 同时重复扫描。 + - 不再依赖 worker hostname 是否包含 gen_result_download。 + - 所有 worker 都尝试抢 Redis 锁,只有抢到锁的 worker 投递恢复任务。 """ if celery_app is None: return + if not bool(getattr(settings, "CELERY_STARTUP_RECOVERY_ENABLED", True)): + logger.info("启动容灾恢复已关闭。CELERY_STARTUP_RECOVERY_ENABLED=false") + return - hostname = str(getattr(sender, "hostname", "") or "") - if "gen_result_download" not in hostname: + try: + if not run_async(_try_acquire_startup_recovery_lock()): + return + except Exception: + # Redis 不可用时不阻塞 worker 启动,避免影响稳定生成链路。 + logger.exception("启动容灾恢复锁获取失败,已跳过本次自动恢复投递") return try: @@ -82,6 +108,7 @@ def on_worker_ready(sender=None, **kwargs): recover_download_tasks_once, recover_generation_tasks_once, ) + from app.tasks.shot_replicate_tasks import recover_split_tasks_once countdown = max(0, int(settings.DOWNLOAD_RECOVERY_STARTUP_DELAY_SECONDS or 0)) @@ -95,6 +122,13 @@ def on_worker_ready(sender=None, **kwargs): queue="gen_result_download", priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER, ) + recover_split_tasks_once.apply_async( + countdown=countdown + 10, + queue="gen_result_download", + priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER, + ) + + logger.info("启动容灾恢复任务已投递。countdown=%s", countdown) except Exception: logger.exception("启动容灾恢复任务投递失败") @@ -110,14 +144,14 @@ def on_worker_process_init(**kwargs): @worker_process_shutdown.connect def on_worker_process_shutdown(**kwargs): - """子进程退出前关闭连接池和 event loop。""" + """子进程退出前关闭连接池、Redis 注册表连接和 event loop。""" try: run_async(engine.dispose()) except Exception: pass try: - from app.services.celery_download_recovery_service import close_registry_redis + from app.services.redis_registry_service import close_registry_redis run_async(close_registry_redis()) except Exception: diff --git a/video-gen-api/app/tasks/generation_create_tasks.py b/video-gen-api/app/tasks/generation_create_tasks.py index 29a5d44e..08eea6e7 100644 --- a/video-gen-api/app/tasks/generation_create_tasks.py +++ b/video-gen-api/app/tasks/generation_create_tasks.py @@ -11,9 +11,10 @@ from app.services.error_codes import extract_error_message from app.services.generation_log_service import log_task_event from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_provider_service import create_provider_task +from app.services.redis_registry_service import ensure_aware_utc from app.tasks.celery_app import celery_app -ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"} +ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"} def _get_first_value(obj: Any, *field_names: str) -> Optional[Any]: @@ -69,7 +70,7 @@ def _build_optimized_prompt_by_params(task: ChatGenerationTask) -> str: # 爆款开头复刻第5步的视频生成,original_prompt 已经是视频提词 JSON schema。 # 不能再追加“时长/比例/分辨率”中文参数,否则会污染 schema。 - if generation_mode == "hot_opening_replicate" and gen_type == "video": + if generation_mode in {"hot_opening_replicate", "shot_replicate"} and gen_type == "video": stripped = base_prompt.strip() if stripped.startswith("{") or stripped.startswith("["): return base_prompt @@ -129,7 +130,8 @@ async def _run(task_id: str): if task.status != "generating": return - if task.deadline_at and datetime.now(timezone.utc) > task.deadline_at: + deadline_at = ensure_aware_utc(task.deadline_at) + if deadline_at and datetime.now(timezone.utc) > deadline_at: await mark_chat_generation_task_failed_and_refund_once( db, task=task, @@ -242,7 +244,11 @@ async def _run(task_id: str): else: from app.tasks.generation_poll_tasks import poll_generation_task - poll_generation_task.delay(task.id) + poll_generation_task.apply_async( + args=[task.id], + queue="gen_provider_poll", + countdown=0, + ) except Exception as exc: try: diff --git a/video-gen-api/app/tasks/generation_download_tasks.py b/video-gen-api/app/tasks/generation_download_tasks.py index 3a27f659..df9a9c54 100644 --- a/video-gen-api/app/tasks/generation_download_tasks.py +++ b/video-gen-api/app/tasks/generation_download_tasks.py @@ -21,7 +21,7 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai from app.services.resource_accounting_service import record_chat_task_generated_resource from app.tasks.celery_app import celery_app -ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"} +ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"} DOWNLOAD_QUEUE = "gen_result_download" DOWNLOAD_STAGE_QUEUED = "download_queued" diff --git a/video-gen-api/app/tasks/generation_poll_tasks.py b/video-gen-api/app/tasks/generation_poll_tasks.py index 08835b8b..ffc585f2 100644 --- a/video-gen-api/app/tasks/generation_poll_tasks.py +++ b/video-gen-api/app/tasks/generation_poll_tasks.py @@ -1,6 +1,7 @@ from app.tasks.async_runner import run_async import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from typing import Any from sqlalchemy import select @@ -11,9 +12,21 @@ from app.services.error_codes import extract_error_message from app.services.generation_log_service import log_task_event, log_provider_call from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_provider_service import poll_provider_task +from app.services.redis_registry_service import ( + datetime_to_epoch, + ensure_aware_utc, + redis_remove_registry_item, + redis_upsert_registry_item, + utc_now, +) from app.tasks.celery_app import celery_app -ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"} +ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate", "shot_replicate"} +POLL_QUEUE = "gen_provider_poll" + + +def _now() -> datetime: + return datetime.now(timezone.utc) def _is_success(status: str) -> bool: @@ -31,6 +44,86 @@ def _engine_snapshot(task: ChatGenerationTask) -> dict: return {} +def _deadline_expired(task: ChatGenerationTask, now: datetime | None = None) -> bool: + deadline_at = ensure_aware_utc(task.deadline_at) + return bool(deadline_at and deadline_at <= (now or _now())) + + +def _poll_check_at(*, delay_seconds: int | float | None = None, now: datetime | None = None) -> datetime: + current_time = now or _now() + delay = int(delay_seconds or settings.CHATAPI_ASYNC_POLL_INTERVAL_SECONDS or 30) + grace = int(settings.POLL_TASK_QUEUE_TIMEOUT_SECONDS or 120) + return current_time + timedelta(seconds=max(1, delay) + max(0, grace)) + + +def _poll_lease_until(now: datetime | None = None) -> datetime: + current_time = now or _now() + return current_time + timedelta(seconds=int(settings.POLL_TASK_LEASE_SECONDS or 300)) + + +def _build_poll_active_payload( + task: ChatGenerationTask, + *, + stage: str, + reason: str, + next_poll_at: datetime | None = None, + check_at: datetime | None = None, +) -> dict[str, Any]: + current_time = utc_now() + checked_next_poll_at = ensure_aware_utc(next_poll_at) + checked_check_at = ensure_aware_utc(check_at) + return { + "task_id": task.id, + "provider_task_id": task.provider_task_id, + "seedance_task_id": task.seedance_task_id, + "generation_mode": task.generation_mode, + "gen_type": task.gen_type, + "stage": stage, + "queue": POLL_QUEUE, + "poll_count": int(task.poll_count or 0), + "retry_count": int(task.retry_count or 0), + "last_poll_at": datetime_to_epoch(task.last_poll_at) if task.last_poll_at else None, + "next_poll_at": datetime_to_epoch(checked_next_poll_at) if checked_next_poll_at else None, + "deadline_at": datetime_to_epoch(task.deadline_at) if task.deadline_at else None, + "check_at": datetime_to_epoch(checked_check_at) if checked_check_at else None, + "updated_at": datetime_to_epoch(current_time), + "reason": reason, + } + + +async def register_poll_active( + task: ChatGenerationTask, + *, + check_at: datetime, + reason: str, + next_poll_at: datetime | None = None, +) -> None: + payload = _build_poll_active_payload( + task, + stage=task.pipeline_stage or "", + reason=reason, + next_poll_at=next_poll_at, + check_at=check_at, + ) + await redis_upsert_registry_item( + hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY, + zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY, + item_id=task.id, + payload=payload, + check_at=check_at, + log_context="poll_active", + ) + + +async def remove_poll_active(task_id: str) -> None: + await redis_remove_registry_item( + hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY, + zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY, + item_id=task_id, + log_context="poll_active", + ) + + async def _notify_finished(db, task: ChatGenerationTask) -> None: from app.services.generation_module_hook_service import notify_chat_generation_task_finished @@ -55,6 +148,32 @@ async def _reload_task(db, task_id: str) -> ChatGenerationTask | None: return result.scalar_one_or_none() +async def _mark_timeout(db, task: ChatGenerationTask, *, message: str = "任务轮询超时") -> None: + await mark_chat_generation_task_failed_and_refund_once( + db, + task=task, + error_message=message, + pipeline_stage="timeout", + ) + await _notify_finished(db, task) + await db.commit() + await remove_poll_active(task.id) + await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout") + + +async def _mark_failed(db, task: ChatGenerationTask, *, message: str, detail: Any = None) -> None: + await mark_chat_generation_task_failed_and_refund_once( + db, + task=task, + error_message=message, + pipeline_stage="failed", + ) + await _notify_finished(db, task) + await db.commit() + await remove_poll_active(task.id) + await log_task_event(task, event_type="POLL_FAILED", message=task.error_message, detail=detail) + + async def _run(task_id: str): async with async_session() as db: result = await db.execute(select(ChatGenerationTask).where( @@ -62,43 +181,37 @@ async def _run(task_id: str): ChatGenerationTask.deleted_at.is_(None), ).with_for_update().limit(1)) task = result.scalar_one_or_none() - if not task or task.generation_mode not in ALLOWED_GENERATION_MODES: + if not task: + await remove_poll_active(task_id) + return + if task.generation_mode not in ALLOWED_GENERATION_MODES: + await remove_poll_active(task.id) return # 只处理正在生成,且处于远程等待/轮询中的任务。 if task.status != "generating" or task.pipeline_stage not in ("waiting_remote", "polling"): + await remove_poll_active(task.id) return - if task.deadline_at and datetime.now(timezone.utc) > task.deadline_at: - await mark_chat_generation_task_failed_and_refund_once( - db, - task=task, - error_message="任务轮询超时", - pipeline_stage="timeout", - ) - await _notify_finished(db, task) - await db.commit() - await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout") + if _deadline_expired(task): + await _mark_timeout(db, task, message="任务轮询超时") return if not (task.seedance_task_id or task.provider_task_id): - await mark_chat_generation_task_failed_and_refund_once( - db, - task=task, - error_message="缺少外部任务ID", - pipeline_stage="failed", - ) - await _notify_finished(db, task) - await db.commit() - await log_task_event(task, event_type="POLL_FAILED", message=task.error_message) + await _mark_failed(db, task, message="缺少外部任务ID") return - # 标记本次正在轮询。 - # 注意:pending 后会再改回 waiting_remote,避免任务长期卡在 polling。 + # 标记本次正在轮询,并登记 poll lease。 + # 如果 worker 在供应商接口调用过程中退出,启动恢复会在 lease 过期后重新投递。 task.pipeline_stage = "polling" task.poll_count = (task.poll_count or 0) + 1 - task.last_poll_at = datetime.now(timezone.utc) + task.last_poll_at = _now() await db.commit() + await register_poll_active( + task, + check_at=_poll_lease_until(task.last_poll_at), + reason="polling_lease", + ) try: poll_result = await poll_provider_task(db, task) @@ -134,20 +247,13 @@ async def _run(task_id: str): task.provider_response_json = response_data if not task.remote_result_url: - await mark_chat_generation_task_failed_and_refund_once( - db, - task=task, - error_message="供应商任务成功但未返回结果URL", - pipeline_stage="failed", - ) - await _notify_finished(db, task) - await db.commit() - await log_task_event(task, event_type="POLL_FAILED", message=task.error_message) + await _mark_failed(db, task, message="供应商任务成功但未返回结果URL", detail=poll_result) return task.pipeline_stage = "result_ready" task.retry_count = 0 await db.commit() + await remove_poll_active(task.id) await log_task_event(task, event_type="POLL_SUCCESS", to_stage="result_ready") @@ -158,34 +264,38 @@ async def _run(task_id: str): if _is_failed(status): task.provider_response_json = response_data - await mark_chat_generation_task_failed_and_refund_once( + await _mark_failed( db, - task=task, - error_message=poll_result.get("error") or f"供应商任务失败: {status}", - pipeline_stage="failed", + task, + message=poll_result.get("error") or f"供应商任务失败: {status}", + detail=poll_result, ) - await _notify_finished(db, task) - await db.commit() - await log_task_event(task, event_type="POLL_FAILED", message=task.error_message, detail=poll_result) return - # 关键修改 1: # 供应商仍在 pending / running 时,把阶段从 polling 改回 waiting_remote。 - # 这样数据库状态表示“等待下一次轮询”,不会长期停在 polling。 - # 同时可以降低重复 Celery 消息形成多条轮询链的概率。 + # 同时登记下一次 poll active,Celery countdown 丢失时可由恢复任务拉起。 task.pipeline_stage = "waiting_remote" task.retry_count = 0 await db.commit() await log_task_event(task, event_type="POLL_PENDING", message=f"status={status}") + delay_seconds = int(settings.CHATAPI_ASYNC_POLL_INTERVAL_SECONDS or 30) + next_poll_at = _now() + timedelta(seconds=max(1, delay_seconds)) + await register_poll_active( + task, + check_at=_poll_check_at(delay_seconds=delay_seconds), + next_poll_at=next_poll_at, + reason="poll_pending_next", + ) + poll_generation_task.apply_async( args=[task.id], - countdown=settings.CHATAPI_ASYNC_POLL_INTERVAL_SECONDS, + queue=POLL_QUEUE, + countdown=delay_seconds, ) except Exception as exc: - # 关键修改 2: # 异常后先 rollback,再重新查询 task,不继续使用 rollback 前的旧 ORM 对象。 try: await db.rollback() @@ -194,30 +304,33 @@ async def _run(task_id: str): task = await _reload_task(db, task_id) if not task: + await remove_poll_active(task_id) return task.retry_count = (task.retry_count or 0) + 1 if task.retry_count > settings.CHATAPI_ASYNC_MAX_RETRIES: error_message = extract_error_message(exc, "轮询") if callable(extract_error_message) else str(exc) - await mark_chat_generation_task_failed_and_refund_once( - db, - task=task, - error_message=error_message, - pipeline_stage="failed", - ) - await _notify_finished(db, task) - await db.commit() - await log_task_event(task, event_type="POLL_FAILED", message=task.error_message) + await _mark_failed(db, task, message=error_message) else: # 临时轮询异常时,不让任务停在 polling。 # 回到 waiting_remote,等待下一次重试轮询。 task.pipeline_stage = "waiting_remote" await db.commit() + delay_seconds = int(settings.CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS or 30) * int(task.retry_count or 1) + next_poll_at = _now() + timedelta(seconds=max(1, delay_seconds)) + await register_poll_active( + task, + check_at=_poll_check_at(delay_seconds=delay_seconds), + next_poll_at=next_poll_at, + reason="poll_exception_retry", + ) + poll_generation_task.apply_async( args=[task.id], - countdown=settings.CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS * task.retry_count, + queue=POLL_QUEUE, + countdown=delay_seconds, ) @@ -233,4 +346,4 @@ else: def apply_async(self, *args, **kwargs): raise RuntimeError("Celery is disabled") - poll_generation_task = _DisabledTask() \ No newline at end of file + poll_generation_task = _DisabledTask() diff --git a/video-gen-api/app/tasks/shot_replicate_flow_tasks.py b/video-gen-api/app/tasks/shot_replicate_flow_tasks.py new file mode 100644 index 00000000..2d574458 --- /dev/null +++ b/video-gen-api/app/tasks/shot_replicate_flow_tasks.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from app.models.base import async_session +from app.services.shot_replicate_flow_service import run_image_prompt_optimize, run_video_prompt_optimize +from app.tasks.async_runner import run_async +from app.tasks.celery_app import celery_app + + +async def _run_image_prompt(project_id: str, step_id: str | None = None): + async with async_session() as db: + await run_image_prompt_optimize(db, project_id=project_id, step_id=step_id) + await db.commit() + + +async def _run_video_prompt(project_id: str, step_id: str | None = None): + async with async_session() as db: + await run_video_prompt_optimize(db, project_id=project_id, step_id=step_id) + await db.commit() + + +if celery_app: + + @celery_app.task(name="shot_replicate.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30) + def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None): + return run_async(_run_image_prompt(project_id, step_id)) + + + @celery_app.task(name="shot_replicate.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30) + def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None): + return run_async(_run_video_prompt(project_id, step_id)) + +else: + + class _DisabledTask: + def delay(self, *args, **kwargs): + raise RuntimeError("Celery is disabled") + + def apply_async(self, *args, **kwargs): + raise RuntimeError("Celery is disabled") + + start_image_prompt_optimize = _DisabledTask() + start_video_prompt_optimize = _DisabledTask() diff --git a/video-gen-api/app/tasks/shot_replicate_tasks.py b/video-gen-api/app/tasks/shot_replicate_tasks.py new file mode 100644 index 00000000..6b98b29e --- /dev/null +++ b/video-gen-api/app/tasks/shot_replicate_tasks.py @@ -0,0 +1,505 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any + +from sqlalchemy import select + +from app.config import settings +from app.enums.shot_replicate import ( + ModuleCodeEnum, + ShotAnalysisStatusEnum, + ShotSegmentAnalysisStatusEnum, + ShotSegmentSourceModeEnum, + ShotSplitStatusEnum, + ShotTaskSetStatusEnum, +) +from app.models.base import async_session +from app.models.shot_replicate_segment import ShotReplicateSegment +from app.models.shot_replicate_task_set import ShotReplicateTaskSet +from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event +from app.services.redis_registry_service import redis_acquire_lock, redis_release_lock +from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary +from app.services.shot_video_analysis_service import analyze_video_for_shot_split +from app.services.shot_video_split_service import split_video_segment_async +from app.services.upload_video_asset_service import validate_split_range +from app.tasks.async_runner import run_async +from app.tasks.celery_app import celery_app + +logger = logging.getLogger("video_gen") + +MODULE = ModuleCodeEnum.SHOT_REPLICATE.value +SPLIT_QUEUE = "gen_result_download" +ANALYSIS_QUEUE = "gen_chatapi_create" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _lease_until(now: datetime | None = None) -> datetime: + return (now or _now()) + timedelta(seconds=int(settings.SHOT_SPLIT_LEASE_SECONDS or 600)) + + +def _retry_at(attempt: int, now: datetime | None = None) -> datetime: + base = int(settings.SHOT_SPLIT_RETRY_BACKOFF_SECONDS or settings.DOWNLOAD_TASK_RETRY_BACKOFF_SECONDS or 30) + return (now or _now()) + timedelta(seconds=max(1, base * max(1, attempt))) + + +async def _acquire_split_semaphore(segment_id: str) -> str | None: + """简单 Redis 并发闸门:用固定槽位锁限制 ffmpeg 同时运行数量。""" + max_concurrent = max(1, int(settings.SHOT_SPLIT_MAX_CONCURRENT or 1)) + ttl = int(settings.SHOT_SPLIT_LEASE_SECONDS or 600) + for slot in range(max_concurrent): + key = f"{settings.SHOT_SPLIT_SEMAPHORE_KEY_PREFIX}:{slot}" + token = await redis_acquire_lock(lock_key=key, ttl_seconds=ttl, token=segment_id, log_context="shot_split_semaphore") + if token: + return key + return None + + +async def _release_split_semaphore(lock_key: str | None, segment_id: str) -> None: + if lock_key: + await redis_release_lock(lock_key=lock_key, token=segment_id, log_context="shot_split_semaphore") + + +async def _run_analyze_original_video(task_set_id: str) -> None: + task_set_user_id: str | None = None + video_url: str | None = None + try: + async with async_session() as db: + result = await db.execute( + select(ShotReplicateTaskSet) + .where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + task_set = result.scalar_one_or_none() + if not task_set: + return + if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value: + return + task_set_user_id = task_set.user_id + video_url = task_set.video_url + task_set.status = ShotTaskSetStatusEnum.ANALYZING.value + task_set.analysis_status = ShotAnalysisStatusEnum.PROCESSING.value + task_set.analysis_error_message = None + await db.commit() + + log_module_event_file( + module=MODULE, + event_type="SHOT_ANALYSIS_STARTED", + project_id=task_set_id, + user_id=task_set_user_id, + message="原视频拆镜分析开始", + detail={"task_set_id": task_set_id, "video_url": video_url, "analysis_mode": "full_breakdown"}, + ) + + async with async_session() as db: + analyzed = await analyze_video_for_shot_split(db, video_url or "", user_id=task_set_user_id, mode="full_breakdown") + result = await db.execute( + select(ShotReplicateTaskSet) + .where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + task_set = result.scalar_one_or_none() + if not task_set: + await db.rollback() + return + result_json = analyzed.result + task_set.original_video_content = str(result_json.get("原视频内容") or "无") + task_set.original_video_category = str(result_json.get("原视频分类") or "无") + task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无") + task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or [] + task_set.analysis_raw_json = analyzed.raw_response + task_set.analysis_result_json = result_json + task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value + task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value + task_set.analysis_error_message = None + await db.commit() + + log_module_prompt_event( + event_type="SHOT_ANALYSIS_SUCCESS", + project_id=task_set_id, + step_id=task_set_id, + user_id=task_set_user_id or "", + module=MODULE, + prompt_type="shot_video_analysis", + request=analyzed.usage.get("log_request") if isinstance(analyzed.usage, dict) else {}, + response=analyzed.result, + token_usage=analyzed.usage, + ) + log_module_event_file( + module=MODULE, + event_type="SHOT_ANALYSIS_SUCCESS", + project_id=task_set_id, + user_id=task_set_user_id, + message="原视频拆镜分析成功", + detail={"suggestion_count": len(analyzed.result.get("拆镜内容剖析") or []), "token_usage": analyzed.usage}, + ) + except Exception as exc: + async with async_session() as db: + result = await db.execute( + select(ShotReplicateTaskSet) + .where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + task_set = result.scalar_one_or_none() + if task_set: + task_set_user_id = task_set_user_id or task_set.user_id + task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value + task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value + task_set.analysis_error_message = str(exc) + await db.commit() + log_module_error( + module=MODULE, + event_type="SHOT_ANALYSIS_FAILED", + project_id=task_set_id, + user_id=task_set_user_id, + message="原视频拆镜分析失败", + detail={"task_set_id": task_set_id, "video_url": video_url, "analysis_mode": "full_breakdown"}, + exc=exc, + ) + + +async def _run_analyze_custom_segment_video(segment_id: str) -> None: + user_id: str | None = None + task_set_id: str | None = None + video_url: str | None = None + try: + async with async_session() as db: + result = await db.execute( + select(ShotReplicateSegment) + .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + segment = result.scalar_one_or_none() + if not segment or not segment.segment_video_url: + return + if segment.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value: + return + user_id = segment.user_id + task_set_id = segment.task_set_id + video_url = segment.segment_video_url + segment.analysis_status = ShotSegmentAnalysisStatusEnum.PROCESSING.value + segment.analysis_error_message = None + await db.commit() + + log_module_event_file( + module=MODULE, + event_type="SHOT_SEGMENT_ANALYSIS_STARTED", + project_id=task_set_id, + step_id=segment_id, + user_id=user_id, + message="自定义拆镜片段分析开始", + detail={"segment_id": segment_id, "task_set_id": task_set_id, "video_url": video_url, "analysis_mode": "summary_only"}, + ) + + async with async_session() as db: + analyzed = await analyze_video_for_shot_split(db, video_url or "", user_id=user_id, mode="summary_only") + result = await db.execute( + select(ShotReplicateSegment) + .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + segment = result.scalar_one_or_none() + if not segment: + await db.rollback() + return + result_json = analyzed.result + segment.original_video_content = str(result_json.get("原视频内容") or "无") + segment.original_video_category = str(result_json.get("原视频分类") or "无") + segment.original_video_audience = str(result_json.get("原视频受众人群") or "无") + segment.segment_content = segment.original_video_content + segment.segment_category = segment.original_video_category + segment.segment_audience = segment.original_video_audience + segment.analysis_json = result_json + segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value + segment.analysis_error_message = None + await db.commit() + + log_module_prompt_event( + event_type="SHOT_SEGMENT_ANALYSIS_SUCCESS", + project_id=task_set_id or segment_id, + step_id=segment_id, + user_id=user_id or "", + module=MODULE, + prompt_type="shot_segment_analysis", + request=analyzed.usage.get("log_request") if isinstance(analyzed.usage, dict) else {}, + response=analyzed.result, + token_usage=analyzed.usage, + ) + log_module_event_file( + module=MODULE, + event_type="SHOT_SEGMENT_ANALYSIS_SUCCESS", + project_id=task_set_id, + step_id=segment_id, + user_id=user_id, + message="自定义拆镜片段分析成功", + detail={"segment_id": segment_id, "task_set_id": task_set_id, "token_usage": analyzed.usage}, + ) + except Exception as exc: + async with async_session() as db: + result = await db.execute( + select(ShotReplicateSegment) + .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + segment = result.scalar_one_or_none() + if segment: + user_id = user_id or segment.user_id + task_set_id = task_set_id or segment.task_set_id + segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value + segment.analysis_error_message = str(exc) + await db.commit() + log_module_error( + module=MODULE, + event_type="SHOT_SEGMENT_ANALYSIS_FAILED", + project_id=task_set_id, + step_id=segment_id, + user_id=user_id, + message="自定义拆镜片段分析失败", + detail={"segment_id": segment_id, "task_set_id": task_set_id, "video_url": video_url, "analysis_mode": "summary_only"}, + exc=exc, + ) + + +async def _run_split_one_segment(segment_id: str) -> None: + segment_lock_key = f"{settings.SHOT_SPLIT_LOCK_KEY_PREFIX}:{segment_id}" + segment_lock_token = await redis_acquire_lock( + lock_key=segment_lock_key, + ttl_seconds=int(settings.SHOT_SPLIT_LEASE_SECONDS or 600), + log_context="shot_split_segment_lock", + ) + if not segment_lock_token: + return + + semaphore_key: str | None = None + user_id: str | None = None + task_set_id: str | None = None + source_path: str | None = None + try: + semaphore_key = await _acquire_split_semaphore(segment_id) + if not semaphore_key: + log_module_event_file( + module=MODULE, + event_type="SHOT_SEGMENT_SPLIT_RETRY_WAITING", + step_id=segment_id, + message="拆镜 ffmpeg 并发闸门已满,稍后重试", + detail={"segment_id": segment_id, "reason": "semaphore_full"}, + ) + if celery_app: + split_one_segment.apply_async(args=[segment_id], queue=SPLIT_QUEUE, countdown=10, priority=settings.DOWNLOAD_TASK_PRIORITY_NORMAL) + return + + async with async_session() as db: + result = await db.execute( + select(ShotReplicateSegment) + .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + segment = result.scalar_one_or_none() + if not segment: + return + user_id = segment.user_id + task_set_id = segment.task_set_id + task_set_result = await db.execute( + select(ShotReplicateTaskSet) + .where(ShotReplicateTaskSet.id == segment.task_set_id, ShotReplicateTaskSet.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + task_set = task_set_result.scalar_one_or_none() + if not task_set: + return + if segment.split_status == ShotSplitStatusEnum.COMPLETED.value and segment.segment_video_url: + return + + validate_split_range( + start_second=segment.start_second, + end_second=segment.end_second, + video_duration_seconds=task_set.video_duration_seconds, + ) + + now = _now() + segment.split_status = ShotSplitStatusEnum.PROCESSING.value + segment.split_started_at = now + segment.split_lease_until = _lease_until(now) + segment.split_retry_count = int(segment.split_retry_count or 0) + 1 + segment.split_next_retry_at = None + segment.split_last_error = None + task_set.status = ShotTaskSetStatusEnum.SPLITTING.value + task_set.split_status = ShotSplitStatusEnum.PROCESSING.value + await db.commit() + + source_path = task_set.video_path + date_dir = (segment.created_at or now).strftime("%Y/%m/%d") + start_second = segment.start_second + end_second = segment.end_second + attempt = segment.split_retry_count + + log_module_event_file( + module=MODULE, + event_type="SHOT_SEGMENT_SPLIT_STARTED", + project_id=task_set_id, + step_id=segment_id, + user_id=user_id, + message="拆镜片段 ffmpeg 切割开始", + detail={ + "segment_id": segment_id, + "task_set_id": task_set_id, + "source_path": source_path, + "start_second": start_second, + "end_second": end_second, + "attempt": attempt, + }, + ) + + split_result = await split_video_segment_async( + source_path=source_path, + segment_id=segment_id, + start_second=start_second, + end_second=end_second, + date_dir=date_dir, + ) + + async with async_session() as db: + result = await db.execute( + select(ShotReplicateSegment) + .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + segment = result.scalar_one_or_none() + if not segment: + return + segment.segment_video_url = split_result.url + segment.segment_video_path = split_result.path + segment.split_status = ShotSplitStatusEnum.COMPLETED.value + segment.split_completed_at = _now() + segment.split_lease_until = None + segment.split_next_retry_at = None + segment.split_last_error = None + await refresh_task_set_split_summary(db, segment.task_set_id) + await db.commit() + + log_module_event_file( + module=MODULE, + event_type="SHOT_SEGMENT_SPLIT_SUCCESS", + project_id=segment.task_set_id, + step_id=segment.id, + user_id=segment.user_id, + message="拆镜片段 ffmpeg 切割成功", + detail={ + "segment_id": segment.id, + "task_set_id": segment.task_set_id, + "segment_video_url": split_result.url, + "segment_video_path": split_result.path, + "source_mode": segment.source_mode, + }, + ) + + if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value and celery_app: + analyze_custom_segment_video.apply_async(args=[segment.id], queue=ANALYSIS_QUEUE, countdown=0) + + except Exception as exc: + next_retry_delay: int | None = None + final_failed = False + async with async_session() as db: + result = await db.execute( + select(ShotReplicateSegment) + .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + segment = result.scalar_one_or_none() + if not segment: + return + user_id = user_id or segment.user_id + task_set_id = task_set_id or segment.task_set_id + attempt = int(segment.split_retry_count or 0) + segment.split_last_error = str(exc) + segment.split_lease_until = None + if attempt >= int(settings.SHOT_SPLIT_MAX_RETRY_COUNT or 3): + segment.split_status = ShotSplitStatusEnum.FAILED.value + segment.split_next_retry_at = None + final_failed = True + else: + segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value + segment.split_next_retry_at = _retry_at(attempt) + await refresh_task_set_split_summary(db, segment.task_set_id) + await db.commit() + + if segment.split_status == ShotSplitStatusEnum.RETRY_WAITING.value and celery_app: + next_retry_delay = max(1, int(((segment.split_next_retry_at or _now()) - _now()).total_seconds())) + split_one_segment.apply_async(args=[segment_id], queue=SPLIT_QUEUE, countdown=next_retry_delay, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER) + + log_module_error( + module=MODULE, + event_type="SHOT_SEGMENT_SPLIT_FAILED" if final_failed else "SHOT_SEGMENT_SPLIT_RETRY_WAITING", + project_id=task_set_id, + step_id=segment_id, + user_id=user_id, + message="拆镜片段 ffmpeg 切割失败" if final_failed else "拆镜片段 ffmpeg 切割失败,等待重试", + detail={ + "segment_id": segment_id, + "task_set_id": task_set_id, + "source_path": source_path, + "next_retry_delay_seconds": next_retry_delay, + "final_failed": final_failed, + }, + exc=exc, + ) + finally: + await _release_split_semaphore(semaphore_key, segment_id) + await redis_release_lock(lock_key=segment_lock_key, token=segment_lock_token, log_context="shot_split_segment_lock") + + +async def _run_recover_split_tasks_once() -> dict[str, Any]: + from app.services.shot_replicate_recovery_service import recover_shot_split_tasks_once + + async with async_session() as db: + return await recover_shot_split_tasks_once(db) + + +if celery_app: + + @celery_app.task(name="shot_replicate.analyze_original_video") + def analyze_original_video(task_set_id: str) -> None: + return run_async(_run_analyze_original_video(task_set_id)) + + + @celery_app.task(name="shot_replicate.split_one_segment", bind=True, max_retries=0) + def split_one_segment(self, segment_id: str) -> None: + return run_async(_run_split_one_segment(segment_id)) + + + @celery_app.task(name="shot_replicate.analyze_custom_segment_video") + def analyze_custom_segment_video(segment_id: str) -> None: + return run_async(_run_analyze_custom_segment_video(segment_id)) + + + @celery_app.task(name="shot_replicate.recover_split_tasks_once") + def recover_split_tasks_once() -> dict[str, Any]: + return run_async(_run_recover_split_tasks_once()) + +else: + + class _DisabledTask: + def delay(self, *args: Any, **kwargs: Any) -> None: + raise RuntimeError("Celery is disabled") + + def apply_async(self, *args: Any, **kwargs: Any) -> None: + raise RuntimeError("Celery is disabled") + + analyze_original_video = _DisabledTask() + split_one_segment = _DisabledTask() + analyze_custom_segment_video = _DisabledTask() + recover_split_tasks_once = _DisabledTask() diff --git a/video-gen-api/pyproject.toml b/video-gen-api/pyproject.toml index b7721cdb..68477bd0 100644 --- a/video-gen-api/pyproject.toml +++ b/video-gen-api/pyproject.toml @@ -27,6 +27,7 @@ pg = ["asyncpg>=0.30.0"] redis = ["redis>=5.2.0"] celery = ["celery>=5.4.0", "redis>=5.2.0"] alipay = ["alipay-sdk-python>=3.7.1160"] +wxpay = ["wechatpayv3>=2.0.2"] volc = ["volcengine-python-sdk>=1.1.0"] dev = [ "pytest>=8.3.0", diff --git a/video-gen-app/src/App.tsx b/video-gen-app/src/App.tsx index 751f3f72..86ddce4e 100644 --- a/video-gen-app/src/App.tsx +++ b/video-gen-app/src/App.tsx @@ -15,6 +15,7 @@ import RemoveLens from './pages/RemoveLens'; import GeneratedRecord from './pages/GeneratedRecord'; import AuthorizationPage from './pages/AuthorizationPage'; import RemoveInfo from './pages/RemoveInfo'; +import ConsumePage from './pages/ConsumePage'; @@ -100,11 +101,7 @@ const App = () => { } /> } /> } /> - - - - - + } /> } /> diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 48133ccf..d4318155 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -2,18 +2,14 @@ * API abstraction layer. * Switches between mock data and real backend based on VITE_USE_MOCK env var. */ - import { api, setToken, clearToken } from './client'; import * as mock from './mock'; import type { User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult, Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification, } from '../types'; - const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true'; - // ── Auth ────────────────────────────────────────────────── - export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise { if (USE_MOCK) return mock.mockLogin({ username, password }); const res = await api.post<{ accessToken: string; user: User }>('/auth/login', { username, password, captcha_token: captchaToken, remember_me: rememberMe || false }, false); @@ -26,19 +22,16 @@ export async function phonelogin(phone: string, code: string): Promise { setToken(res.accessToken); return res.user; } - export async function register(phone: string, code: string, password: string): Promise { const res = await api.post<{ accessToken: string; user: User }>('/auth/register', { phone, code, password }, false); setToken(res.accessToken); return res.user; } - export async function logout(): Promise { if (USE_MOCK) return mock.mockLogout(); await api.post('/auth/logout'); clearToken(); } - export async function getUser(): Promise { if (USE_MOCK) return mock.mockGetUser(); try { @@ -47,38 +40,30 @@ export async function getUser(): Promise { return null; } } - export async function changePassword(oldPwd: string, newPwd: string): Promise { if (USE_MOCK) return; await api.post('/auth/change-password', { old_password: oldPwd, new_password: newPwd }); } - // ── Projects ────────────────────────────────────────────── - export async function getProjects(): Promise { if (USE_MOCK) return mock.mockGetProjects(); return api.get('/projects'); } - export async function createProject(name: string, industry: Industry): Promise { if (USE_MOCK) return mock.mockCreateProject(name, industry); return api.post('/projects', { name, industry }); } - export async function deleteProject(id: string): Promise { if (USE_MOCK) return mock.mockDeleteProject(id); await api.delete(`/projects/${id}`); } - // ── Generation ──────────────────────────────────────────── - export interface GenerationRecordPageListOut { page: number; pageSize: number; total: number; items: GenerationRecord[]; } - export interface GetRecordsPageParams { projectId?: string; status?: string; @@ -86,7 +71,6 @@ export interface GetRecordsPageParams { pageSize?: number; signal?: AbortSignal; } - export async function getRecordsPage(params: GetRecordsPageParams = {}): Promise { const page = params.page && params.page > 0 ? params.page : 1; const pageSize = params.pageSize && params.pageSize > 0 ? params.pageSize : 10; @@ -105,7 +89,6 @@ export async function getRecordsPage(params: GetRecordsPageParams = {}): Promise items: filtered.slice(start, start + pageSize), }; } - const query = new URLSearchParams(); if (params.projectId) query.set('project_id', params.projectId); if (params.status) query.set('status', params.status); @@ -114,7 +97,6 @@ export async function getRecordsPage(params: GetRecordsPageParams = {}): Promise return api.get(`/generation-records?${query.toString()}`, { signal: params.signal }); } - export async function optimizePrompt( projectId: string, params: OptimizeParams ): Promise { @@ -131,7 +113,6 @@ export async function optimizePrompt( image_px: params.image_px || null, }); } - export async function uploadImage(file: File): Promise<{ url: string; filename: string }> { const form = new FormData(); form.append('file', file); @@ -145,7 +126,6 @@ export async function uploadImage(file: File): Promise<{ url: string; filename: const data = await res.json(); return { url: data.url, filename: data.filename }; } - export async function uploadVideo(file: File): Promise<{ url: string; filename: string }> { const form = new FormData(); form.append('file', file); @@ -159,15 +139,12 @@ export async function uploadVideo(file: File): Promise<{ url: string; filename: const data = await res.json(); return { url: data.url, filename: data.filename }; } - export async function deleteUpload(url: string): Promise { await api.post(`/generation-records/delete-file?url=${encodeURIComponent(url)}`); } - export async function updateRecordPrompt(recordId: string, optimizedPrompt: string): Promise { await api.put(`/generation-records/${recordId}/prompt`, { optimized_prompt: optimizedPrompt }); } - export async function generateVideo(recordId: string, params: GenerateParams): Promise { if (USE_MOCK) return mock.mockGenerateVideo(recordId); return api.post(`/generation-records/${recordId}/generate`, { @@ -175,36 +152,27 @@ export async function generateVideo(recordId: string, params: GenerateParams): P resolution: params.resolution, }); } - // ── Credits ─────────────────────────────────────────────── - export async function getCredits(): Promise<{ credits: number; records: CreditRecord[] }> { if (USE_MOCK) return mock.mockGetCredits(); return api.get('/credits'); } - // ── Captcha ─────────────────────────────────────────────── - export async function getSliderCaptcha(): Promise<{ captcha_id: string; bg_image: string; slider_image: string }> { if (USE_MOCK) return { captcha_id: 'mock', bg_image: '', slider_image: '' }; return api.get('/captcha/slider', false); } - export async function verifyCaptcha(captchaId: string, x: number): Promise { if (USE_MOCK) return 'mock-token'; const res = await api.post<{ token: string }>('/captcha/verify', { captcha_id: captchaId, x_offset: x }, false); return res.token; } - // ── Site Info ───────────────────────────────────────────── - export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementUrl: string; privacyPolicyUrl: string }> { if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementUrl: '', privacyPolicyUrl: '' }; return api.get('/auth/site-info', false); } - // ── Video Engines ───────────────────────────────────────── - export async function getVideoEngines(): Promise<{ items: { id: string; name: string; provider: string; supportedRatios: string[]; supportedResolutions: string[]; supportedDurations: number[] }[] }> { if (USE_MOCK) return { items: [{ id: 'mock', name: 'Seedance', provider: 'seedance', supportedRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], supportedResolutions: ['480p', '720p', '1080p'], supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] }] }; return api.get('/video-engines'); @@ -213,89 +181,69 @@ export async function getVideoEngines(): Promise<{ items: { id: string; name: st export async function getParameters(): Promise { return api.get('/image-engines'); } - - // ── SMS ─────────────────────────────────────────────────── - export async function sendSms(phone: string, scene: string): Promise { if (USE_MOCK) return; await api.post('/sms/send', { phone, scene: scene }, false); } - export async function verifySms(phone: string, code: string): Promise<{ token: string }> { if (USE_MOCK) return { token: 'mock-sms-token' }; return api.post('/sms/verify', { phone, code }, false); } - // ── Notifications ───────────────────────────────────────── - export async function getNotifications(): Promise { if (USE_MOCK) return mock.mockGetAdminNotifications(); return api.get('/notifications'); } - export async function getUnreadCount(): Promise { if (USE_MOCK) return mock.mockGetAdminNotifications().then(n => n.filter(x => !x.isRead).length); const res = await api.get<{ count: number }>('/notifications/unread-count'); return res.count; } - export async function markNotificationRead(id: string): Promise { if (USE_MOCK) return; await api.put(`/notifications/${id}/read`); } - // ── Admin ───────────────────────────────────────────────── - export async function getAdminStats(): Promise { if (USE_MOCK) return mock.mockGetAdminStats(); return api.get('/admin/stats'); } - export async function getAdminUsers(search?: string): Promise { if (USE_MOCK) return mock.mockGetAdminUsers(search); const q = search ? `?search=${encodeURIComponent(search)}` : ''; return api.get(`/admin/users${q}`); } - export async function adjustCredits(userId: string, amount: number, description: string): Promise { if (USE_MOCK) return mock.mockAdjustCredits(userId, amount, description); await api.post(`/admin/users/${userId}/credits`, { amount, description }); } - export async function toggleUserStatus(userId: string, isActive: boolean): Promise { if (USE_MOCK) return mock.mockToggleUserStatus(userId, isActive); await api.put(`/admin/users/${userId}/status`, { is_active: isActive }); } - export async function getModelConfigs(): Promise { if (USE_MOCK) return mock.mockGetModelConfigs(); return api.get('/admin/model-configs'); } - export async function saveModelConfig(config: Partial & { id?: string }): Promise { if (USE_MOCK) return mock.mockSaveModelConfig(config as any); if (config.id) return api.put(`/admin/model-configs/${config.id}`, config); return api.post('/admin/model-configs', config); } - export async function deleteModelConfig(id: string): Promise { if (USE_MOCK) return mock.mockDeleteModelConfig(id); await api.delete(`/admin/model-configs/${id}`); } - export async function getSystemConfigs(): Promise { if (USE_MOCK) return mock.mockGetSystemConfigs(); return api.get('/admin/system-configs'); } - export async function updateSystemConfig(id: string, value: string): Promise { if (USE_MOCK) return mock.mockUpdateSystemConfig(id, value); await api.put(`/admin/system-configs/${id}`, { value }); } - // ── Industries ───────────────────────────────────────────── - export async function getIndustries(): Promise { const data = await api.get('/industries'); return data.map((item: any) => { @@ -306,15 +254,11 @@ export async function getIndustries(): Promise { return { ...item, optionGroups }; }); } - // ── Menu Config ──────────────────────────────────────────── - export async function getMenuConfigs(): Promise { return api.get('/menu-configs'); } - // ── Recharge Packages ────────────────────────────────────── - export async function getRechargePackages(): Promise { return api.get('/recharge-packages'); } @@ -346,10 +290,7 @@ export async function getCreditRatios(): Promise { export async function getEngine(): Promise { return api.get('/generation-ai/engines'); } - // ── Generation AI Tasks ──────────────────────────────────── - - // 创建ai生成任务 export async function createGenerationTask(params: any): Promise { return api.post('/generation-ai/tasks', params); @@ -366,20 +307,38 @@ export async function gethistory(Pagebreak: any): Promise { export async function gethistoryItems(Pagebreak: any): Promise { return api.get('/generation-ai/history/'+Pagebreak); } - // 删除ai对话历史记录 export async function deleteHistory(id: string): Promise { await api.delete(`/generation-ai/tasks/${id}`); } - - export async function calculateCredits(): Promise { return api.get('/credits/credit-ratios'); } - - - // 获取验证码 export async function getSendcode(phone: string): Promise { return api.post('/sms/send', { phone }); } +export interface OAuthAppParam { + page: number; + pageSize: number; + open_type?: string; + status?: string; + app_id?: string; +} +export interface OAuthAppList { + page: number; + pageSize: number; + total: number; + data: any[]; +} +// 获取用户列表 +export async function getAuthorizationList(params: OAuthAppParam): Promise { + const query = new URLSearchParams(); + query.set('page', String(params.page)); + query.set('page_size', String(params.pageSize)); + if (params.open_type) query.set('open_type', params.open_type); + if (params.status) query.set('status', params.status); + if (params.app_id) query.set('app_id', params.app_id); + + return api.get(`/admin/user-oauth-apps/list?${query.toString()}`); +} diff --git a/video-gen-app/src/pages/AuthorizationPage.tsx b/video-gen-app/src/pages/AuthorizationPage.tsx index 7580b700..4617901c 100644 --- a/video-gen-app/src/pages/AuthorizationPage.tsx +++ b/video-gen-app/src/pages/AuthorizationPage.tsx @@ -1,12 +1,15 @@ -import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react'; -import { Button, Table, Checkbox, Tag, Space, message, Modal } from 'antd'; -import { PlusOutlined, CheckCircleOutlined, ClockCircleOutlined, CiCircleOutlined, EyeOutlined, XOutlined } from '@ant-design/icons'; -// 模拟授权数据 -const mockAuthorizations = [ - { id: '1867060028363785', status: 'active', description: '用户张三的API授权' }, - { id: '1867059757929740', status: 'pending', description: '用户李四的API授权' }, - { id: '1867059808785418', status: 'active', description: '用户王五的API授权' }, -]; +import React, { useEffect, useState } from 'react'; +import { Button, Table, Tag, message } from 'antd'; +import { PlusOutlined, CheckCircleOutlined, ClockCircleOutlined, CiCircleOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; + +// 授权数据类型 +interface AuthorizationData { + id: string; + status: string; + description: string; +} + // 状态配置 const statusConfig = { active: { label: '已授权', color: 'green', icon: CheckCircleOutlined }, @@ -15,27 +18,60 @@ const statusConfig = { revoked: { label: '已撤销', color: 'gray', icon: CiCircleOutlined }, }; -// 模拟消耗记录数据 -const mockConsumptionRecords = [ - { id: 'C001', authorizationId: '1867060028363785', amount: 100, type: 'video', description: '视频生成消耗', createdAt: '2024-01-15 10:30:00' }, - { id: 'C002', authorizationId: '1867060028363785', amount: 50, type: 'audio', description: '音频转换消耗', createdAt: '2024-01-15 11:20:00' }, - { id: 'C003', authorizationId: '1867059808785418', amount: 200, type: 'video', description: '视频生成消耗', createdAt: '2024-01-14 14:45:00' }, - { id: 'C004', authorizationId: '1867060028363785', amount: 75, type: 'image', description: '图片处理消耗', createdAt: '2024-01-14 09:15:00' }, - { id: 'C005', authorizationId: '1867059757929740', amount: 150, type: 'video', description: '视频生成消耗', createdAt: '2024-01-13 16:00:00' }, -]; +interface ApiResponse { + code: number; + message: string; + data: AuthorizationData[]; +} -// 消耗类型配置 -const consumptionTypeConfig = { - video: { label: '视频生成', color: 'blue' }, - audio: { label: '音频转换', color: 'purple' }, - image: { label: '图片处理', color: 'green' }, +// 模拟授权列表接口 +const mockApiResponse: ApiResponse = { + code: 200, + message: 'success', + data: [ + { id: '1867060028363785', status: 'active', description: '用户张三的API授权' }, + { id: '1867059757929740', status: 'pending', description: '用户李四的API授权' }, + { id: '1867059808785418', status: 'active', description: '用户王五的API授权' }, + { id: '1867060028363786', status: 'expired', description: '用户赵六的API授权' }, + { id: '1867060028363787', status: 'revoked', description: '用户钱七的API授权' }, + ], }; + +// 模拟调用接口 /api/admin/user-oauth-apps/list +const fetchAuthorizationList = async (): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(mockApiResponse); + }, 800); + }); +}; + const AuthorizationPage: React.FC = () => { - const [authorizations, setAuthorizations] = useState(mockAuthorizations); + const navigate = useNavigate(); + const [authorizations, setAuthorizations] = useState([]); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [loading, setLoading] = useState(false); - const [showConsumptionModal, setShowConsumptionModal] = useState(false); - const [consumptionRecords, setConsumptionRecords] = useState(mockConsumptionRecords); + const [listLoading, setListLoading] = useState(false); + + // 页面初始化时获取授权列表 + useEffect(() => { + const loadData = async () => { + setListLoading(true); + try { + const response = await fetchAuthorizationList(); + if (response.code === 200) { + setAuthorizations(response.data); + } else { + message.error(response.message); + } + } catch (error) { + message.error('获取授权列表失败'); + } finally { + setListLoading(false); + } + }; + loadData(); + }, []); // 状态标签渲染 const renderStatus = (status: string) => { @@ -49,26 +85,33 @@ const AuthorizationPage: React.FC = () => { ); }; + // 跳转到消耗记录页面 + const handleGoToConsume = () => { + navigate('/consume'); + }; + + // 处理点击授权按钮 + const handleAuthorize = () => { + if (selectedRowKeys.length === 0) { + message.warning('请先选择需要授权的记录'); + return; + } + setLoading(true); + // 模拟授权操作 + setTimeout(() => { + setAuthorizations(prev => + prev.map(item => + selectedRowKeys.includes(item.id) ? { ...item, status: 'active' } : item + ) + ); + setSelectedRowKeys([]); + setLoading(false); + message.success(`成功授权 ${selectedRowKeys.length} 条记录`); + }, 800); + }; + // 表格列配置 const columns = [ - // { - // title: '', - // dataIndex: 'selection', - // key: 'selection', - // width: 60, - // render: (_: any, record: typeof mockAuthorizations[0]) => ( - // { - // if (e.target.checked) { - // setSelectedRowKeys([...selectedRowKeys, record.id]); - // } else { - // setSelectedRowKeys(selectedRowKeys.filter(key => key !== record.id)); - // } - // }} - // /> - // ), - // }, { title: '序号', dataIndex: 'index', @@ -97,16 +140,15 @@ const AuthorizationPage: React.FC = () => { dataIndex: 'operation', key: 'operation', width: 120, - render: (_: any, record: typeof mockAuthorizations[0]) => ( + render: (_: any, record: AuthorizationData) => ( @@ -114,40 +156,12 @@ const AuthorizationPage: React.FC = () => { } ]; - // 处理点击授权按钮 - const handleAuthorize = () => { - if (selectedRowKeys.length === 0) { - message.warning('请先选择需要授权的记录'); - return; - } - setLoading(true); - // 模拟授权操作 - setTimeout(() => { - setAuthorizations(prev => - prev.map(item => - selectedRowKeys.includes(item.id) ? { ...item, status: 'active' } : item - ) - ); - setSelectedRowKeys([]); - setLoading(false); - message.success(`成功授权 ${selectedRowKeys.length} 条记录`); - }, 800); - }; - - // 准备表格数据(添加序号) const tableData = authorizations.map((item, index) => ({ ...item, index: index + 1, key: item.id, })); - // 准备消耗记录表格数据(添加序号) - const consumptionTableData = consumptionRecords.map((item, index) => ({ - ...item, - index: index + 1, - key: item.id, - })); - return (
{/* 页面标题 */} @@ -155,7 +169,6 @@ const AuthorizationPage: React.FC = () => {

授权管理

- {/*

管理系统授权信息,查看和操作授权状态

*/}
{/* 操作栏 */} @@ -166,7 +179,6 @@ const AuthorizationPage: React.FC = () => { icon={} onClick={handleAuthorize} loading={loading} - // disabled={selectedRowKeys.length === 0} style={{ height: 40, padding: '0 24px', @@ -184,6 +196,7 @@ const AuthorizationPage: React.FC = () => {
{ scroll={{ x: 'max-content' }} /> - - {/* 底部提示 */} - {/*
- 提示:勾选记录后点击"点击授权"按钮可批量授权 -
*/} - - {/* 消耗记录弹窗 */} - setShowConsumptionModal(false)} - footer={null} - width={800} - destroyOnClose={true} - > -
-
{text}, - }, - { - title: '消耗ID', - dataIndex: 'id', - key: 'id', - ellipsis: true, - render: (text: string) => {text}, - }, - { - title: '授权ID', - dataIndex: 'authorizationId', - key: 'authorizationId', - ellipsis: true, - }, - { - title: '消耗类型', - dataIndex: 'type', - key: 'type', - width: 120, - render: (text: string) => { - const config = consumptionTypeConfig[text as keyof typeof consumptionTypeConfig]; - return {config?.label}; - }, - }, - { - title: '消耗金额', - dataIndex: 'amount', - key: 'amount', - width: 120, - render: (text: number) => {text} 元, - }, - { - title: '消耗描述', - dataIndex: 'description', - key: 'description', - ellipsis: true, - }, - { - title: '消耗时间', - dataIndex: 'createdAt', - key: 'createdAt', - width: 160, - }, - ]} - pagination={{ - pageSize: 10, - showSizeChanger: true, - showTotal: (total) => `共 ${total} 条记录`, - }} - rowKey="id" - bordered={false} - scroll={{ x: 'max-content' }} - /> - - ); }; -export default AuthorizationPage; \ No newline at end of file +export default AuthorizationPage; diff --git a/video-gen-app/src/pages/ConsumePage.tsx b/video-gen-app/src/pages/ConsumePage.tsx new file mode 100644 index 00000000..0ed9147f --- /dev/null +++ b/video-gen-app/src/pages/ConsumePage.tsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect } from 'react'; +import { Table, Tag, Button, message } from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; + +// 消耗记录数据类型 +interface ConsumptionRecord { + id: string; + authorizationId: string; + amount: number; + type: string; + description: string; + createdAt: string; +} + +// 模拟消耗记录数据 +const mockConsumptionRecords: ConsumptionRecord[] = [ + { id: 'C001', authorizationId: '1867060028363785', amount: 100, type: 'video', description: '视频生成消耗', createdAt: '2024-01-15 10:30:00' }, + { id: 'C002', authorizationId: '1867060028363785', amount: 50, type: 'audio', description: '音频转换消耗', createdAt: '2024-01-15 11:20:00' }, + { id: 'C003', authorizationId: '1867059808785418', amount: 200, type: 'video', description: '视频生成消耗', createdAt: '2024-01-14 14:45:00' }, + { id: 'C004', authorizationId: '1867060028363785', amount: 75, type: 'image', description: '图片处理消耗', createdAt: '2024-01-14 09:15:00' }, + { id: 'C005', authorizationId: '1867059757929740', amount: 150, type: 'video', description: '视频生成消耗', createdAt: '2024-01-13 16:00:00' }, +]; + +// 消耗类型配置 +const consumptionTypeConfig = { + video: { label: '视频生成', color: 'blue' }, + audio: { label: '音频转换', color: 'purple' }, + image: { label: '图片处理', color: 'green' }, +}; + +// 表头配置 +const columns = [ + { title: '序号', dataIndex: 'index', key: 'index', width: 80, fixed: 'left', render: (text: number) => {text} }, + { title: '消耗ID', dataIndex: 'id', key: 'id', ellipsis: true, render: (text: string) => {text} }, + { title: '授权ID', dataIndex: 'authorizationId', key: 'authorizationId', ellipsis: true }, + { + title: '消耗类型', + dataIndex: 'type', + key: 'type', + width: 120, + render: (text: string) => { + const config = consumptionTypeConfig[text as keyof typeof consumptionTypeConfig]; + return {config?.label}; + } + }, + { + title: '消耗金额', + dataIndex: 'amount', + key: 'amount', + width: 120, + render: (text: number) => {text} 元 + }, + { title: '消耗描述', dataIndex: 'description', key: 'description', ellipsis: true }, + { title: '消耗时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, fixed: 'right' }, +]; + +const ConsumePage: React.FC = () => { + const navigate = useNavigate(); + const [consumptionRecords, setConsumptionRecords] = useState(mockConsumptionRecords); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + // 模拟异步获取数据 + setTimeout(() => { + setConsumptionRecords(mockConsumptionRecords); + setLoading(false); + }, 500); + }, []); + + const tableData = consumptionRecords.map((item, index) => ({ + ...item, + index: index + 1, + key: item.id, + })); + + const handleBack = () => { + navigate('/authorization'); + }; + + return ( +
+ {/* 页面标题和返回按钮 */} +
+ +

+ 消耗记录 +

+
+ + {/* 表格 */} +
+
`共 ${total} 条记录`, + }} + rowKey="id" + bordered={false} + style={{ padding: 16 }} + scroll={{ x: 'max-content' }} + /> + + + ); +}; + +export default ConsumePage;