@tikagent/video-editor-cli 0.0.0-stage → 1.5.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +82 -0
  2. package/THIRD-PARTY-NOTICES.md +21 -0
  3. package/dist/bin/tik-video-editor-cli.js +2 -0
  4. package/dist/lib/asr.js +1 -0
  5. package/dist/lib/audio.js +1 -0
  6. package/dist/lib/auth.js +1 -0
  7. package/dist/lib/browser-zip.js +1 -0
  8. package/dist/lib/browser.js +1 -0
  9. package/dist/lib/cli.js +1 -0
  10. package/dist/lib/doctor.js +1 -0
  11. package/dist/lib/download.js +1 -0
  12. package/dist/lib/export-progress.js +1 -0
  13. package/dist/lib/import-video.js +1 -0
  14. package/dist/lib/local.js +1 -0
  15. package/dist/lib/media.js +1 -0
  16. package/dist/lib/operation.js +1 -0
  17. package/dist/lib/preset.js +1 -0
  18. package/dist/lib/processes.js +1 -0
  19. package/dist/lib/self.js +1 -0
  20. package/dist/lib/service-urls.js +1 -0
  21. package/dist/lib/storage.js +1 -0
  22. package/dist/runtime/LICENSE +21 -0
  23. package/dist/runtime/analyze.js +1 -0
  24. package/dist/runtime/app.js +1 -0
  25. package/dist/runtime/cli/lib/browser-zip.js +1 -0
  26. package/dist/runtime/cli/lib/browser.js +1 -0
  27. package/dist/runtime/cli/lib/processes.js +1 -0
  28. package/dist/runtime/export-audio.js +1 -0
  29. package/dist/runtime/export-cache.js +1 -0
  30. package/dist/runtime/export-encoder.js +1 -0
  31. package/dist/runtime/export-save.js +1 -0
  32. package/dist/runtime/export-worker.js +1 -0
  33. package/dist/runtime/favicon.svg +14 -0
  34. package/dist/runtime/i18n.js +1 -0
  35. package/dist/runtime/icons/favicon-32.png +0 -0
  36. package/dist/runtime/icons/tik-logo.png +0 -0
  37. package/dist/runtime/index.html +320 -0
  38. package/dist/runtime/library/README.md +12 -0
  39. package/dist/runtime/library/elements/bokeh-drift.svg +19 -0
  40. package/dist/runtime/library/elements/light-leak-cool.svg +16 -0
  41. package/dist/runtime/library/elements/light-leak-warm.svg +22 -0
  42. package/dist/runtime/library/elements/vhs-scanlines.svg +18 -0
  43. package/dist/runtime/library/elements/vignette-frame.svg +14 -0
  44. package/dist/runtime/library/fonts//351/230/277/351/207/214/345/267/264/345/267/264/346/231/256/346/203/240/344/275/223-/344/270/255/351/273/221.woff2 +0 -0
  45. package/dist/runtime/library/fonts//351/230/277/351/207/214/345/267/264/345/267/264/346/231/256/346/203/240/344/275/223-/345/270/270/350/247/204.woff2 +0 -0
  46. package/dist/runtime/library/fonts//351/230/277/351/207/214/345/267/264/345/267/264/346/231/256/346/203/240/344/275/223-/347/262/227/344/275/223.woff2 +0 -0
  47. package/dist/runtime/library/sfx/README.md +13 -0
  48. package/dist/runtime/library/svg/arrow-bounce.svg +18 -0
  49. package/dist/runtime/library/svg/arrow-scribble.svg +13 -0
  50. package/dist/runtime/library/svg/burst-lines.svg +30 -0
  51. package/dist/runtime/library/svg/checkmark-success.svg +19 -0
  52. package/dist/runtime/library/svg/confetti-burst.svg +23 -0
  53. package/dist/runtime/library/svg/countdown-ring.svg +92 -0
  54. package/dist/runtime/library/svg/equalizer.svg +18 -0
  55. package/dist/runtime/library/svg/glow-orbs.svg +29 -0
  56. package/dist/runtime/library/svg/heart-pop.svg +26 -0
  57. package/dist/runtime/library/svg/loading-dots.svg +16 -0
  58. package/dist/runtime/library/svg/location-pin.svg +25 -0
  59. package/dist/runtime/library/svg/lower-third.svg +17 -0
  60. package/dist/runtime/library/svg/new-badge.svg +18 -0
  61. package/dist/runtime/library/svg/progress-bar.svg +16 -0
  62. package/dist/runtime/library/svg/radial-pulse.svg +19 -0
  63. package/dist/runtime/library/svg/rating-stars.svg +25 -0
  64. package/dist/runtime/library/svg/rec-indicator.svg +41 -0
  65. package/dist/runtime/library/svg/sparkles.svg +17 -0
  66. package/dist/runtime/library/svg/speech-bubble.svg +23 -0
  67. package/dist/runtime/library/svg/subscribe-bell.svg +29 -0
  68. package/dist/runtime/library/svg/subscribe-pill.svg +22 -0
  69. package/dist/runtime/library/svg/swipe-up.svg +46 -0
  70. package/dist/runtime/library/svg/underline-swoosh.svg +15 -0
  71. package/dist/runtime/manifest.json +40 -0
  72. package/dist/runtime/meter-worklet.js +1 -0
  73. package/dist/runtime/optimized-encoder-worker.js +1 -0
  74. package/dist/runtime/optimized-export.js +1 -0
  75. package/dist/runtime/paths.js +1 -0
  76. package/dist/runtime/project-store.js +1 -0
  77. package/dist/runtime/ruler-worker.js +1 -0
  78. package/dist/runtime/server.js +1 -0
  79. package/dist/runtime/style.css +2126 -0
  80. package/dist/runtime/timeline.js +1 -0
  81. package/dist/runtime/waveform.js +1 -0
  82. package/package.json +37 -4
  83. package/scripts/build-package.js +69 -0
  84. package/scripts/next-version.js +56 -0
  85. package/scripts/pack-runtime.js +26 -0
  86. package/scripts/release-channel.js +115 -0
  87. package/README.md +0 -3
@@ -0,0 +1 @@
1
+ "use strict";const r=require("fs"),e=require("path"),{Transform:t,Readable:n}=require("stream"),{pipeline:o}=require("stream/promises"),{createInflateRaw:a}=require("zlib"),s=Array.from({length:256},(r,e)=>{for(let r=0;r<8;r++)e=1&e?3988292384^e>>>1:e>>>1;return e>>>0});function i(r,e=4294967295){for(const t of r)e=s[255&(e^t)]^e>>>8;return e>>>0}function w(r,t){const n=e.relative(r,t);return""!==n&&".."!==n&&!n.startsWith(".."+e.sep)&&!e.isAbsolute(n)}module.exports={extractZip:async function(s,c){const d=await r.promises.open(s,"r"),l=e.resolve(c),f=[],I=new Set;try{const{size:h}=await d.stat();async function E(r,e){if(r<0||e<0||r+e>h)throw new Error("Truncated browser ZIP");const t=Buffer.alloc(e);let n=0;for(;n<e;){const{bytesRead:o}=await d.read(t,n,e-n,r+n);if(!o)throw new Error("Truncated browser ZIP");n+=o}return t}const u=await E(Math.max(0,h-65557),Math.min(h,65557));let m=u.length-22;for(;m>=0&&(101010256!==u.readUInt32LE(m)||m+22+u.readUInt16LE(m+20)!==u.length);)m--;if(m<0)throw new Error("Invalid browser ZIP directory");const p=u.readUInt16LE(m+10),U=u.readUInt32LE(m+12),L=u.readUInt32LE(m+16);if(u.readUInt32LE(m+4)||u.readUInt16LE(m+8)!==p||65535===p||U>16777216)throw new Error("Unsupported browser ZIP format");const b=await E(L,U);let y=0,Z=0;for(let g=0;g<p;g++){if(y+46>b.length||33639248!==b.readUInt32LE(y))throw new Error("Invalid browser ZIP entry");const v=b.readUInt16LE(y+8),k=b.readUInt16LE(y+10),x=b.readUInt32LE(y+16),q=b.readUInt32LE(y+20),z=b.readUInt32LE(y+24),B=b.readUInt16LE(y+28),R=b.readUInt16LE(y+30),S=b.readUInt16LE(y+32),T=b.readUInt32LE(y+38)>>>16,A=b.readUInt32LE(y+42),W=y+46+B+R+S;if(W>b.length||1&v||![0,8].includes(k)||4294967295===q||4294967295===A)throw new Error("Unsupported browser ZIP entry");const M=b.subarray(y+46,y+46+B).toString("utf8");y=W;const F=e.resolve(l,M);if(!M||/[\\:\0]/.test(M)||M.split("/").some(r=>".."===r)||!w(l,F)||I.has(F))throw new Error("Unsafe browser ZIP path: "+M);if(I.add(F),Z+=z,Z>3*1024**3)throw new Error("Browser ZIP exceeds extraction limit");const j=61440&T;if(j&&![16384,32768,40960].includes(j))throw new Error("Unsupported browser ZIP file type");if(M.endsWith("/")){if(z||j&&16384!==j)throw new Error("Invalid browser ZIP directory entry");await r.promises.mkdir(F,{recursive:!0});continue}const C=await E(A,30);if(67324752!==C.readUInt32LE(0)||C.readUInt16LE(8)!==k)throw new Error("Invalid browser ZIP local entry");const D=A+30+C.readUInt16LE(26)+C.readUInt16LE(28);if(D+q>L)throw new Error("Truncated browser ZIP payload");if(40960===j&&z>4096)throw new Error("Invalid browser ZIP symlink");let G=0,H=4294967295;const J=new t({transform(r,e,t){if(G+=r.length,G>z)return t(new Error("Browser ZIP size mismatch"));H=i(r,H),t(null,r)}});await r.promises.mkdir(e.dirname(F),{recursive:!0});const K=[q?r.createReadStream(s,{start:D,end:D+q-1}):n.from([])];if(8===k&&K.push(a()),K.push(J,r.createWriteStream(F,{flags:"wx",mode:73&T?493:420})),await o(...K),G!==z||(4294967295^H)>>>0!==x)throw new Error("Browser ZIP checksum mismatch: "+M);if(40960===j){const N=await r.promises.readFile(F,"utf8");if(!N||/[\\:\0]/.test(N)||e.isAbsolute(N)||!w(l,e.resolve(e.dirname(F),N)))throw new Error("Unsafe browser ZIP symlink: "+M);f.push({target:F,link:N})}}if(y!==b.length)throw new Error("Invalid browser ZIP directory size");for(const{target:O,link:Q}of f)await r.promises.unlink(O),await r.promises.symlink(Q,O);const P=await r.promises.realpath(l);for(const{target:V}of f)if(!w(P,await r.promises.realpath(V)))throw new Error("Unsafe browser ZIP symlink chain")}finally{await d.close()}},crc32:i};
@@ -0,0 +1 @@
1
+ "use strict";const e=require("fs"),r=require("os"),o=require("path"),t=require("http"),n=require("https"),{spawn:s}=require("./processes"),{Transform:i}=require("stream"),{pipeline:a}=require("stream/promises"),{extractZip:c}=require("./browser-zip"),l="153.0.8010.52";function m(e=process.platform,r=process.arch){return{"darwin-arm64":"mac-arm64","darwin-x64":"mac-x64","linux-x64":"linux64","linux-arm64":"linux-arm64","win32-x64":"win64","win32-ia32":"win32"}[`${e}-${r}`]}function u(e){return o.join("chrome-"+e,e.startsWith("mac-")?"Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing":e.startsWith("win")?"chrome.exe":"chrome")}function w(r){try{return!!e.statSync(r).isFile()&&(e.accessSync(r,"win32"===process.platform?e.constants.F_OK:e.constants.X_OK),!0)}catch{return!1}}function d(e){if(o.isAbsolute(e)||/[/\\]/.test(e))return w(o.resolve(e))?o.resolve(e):null;for(const r of(process.env.PATH||"").split(o.delimiter).filter(Boolean))for(const t of"win32"===process.platform?["",".exe"]:[""]){const n=o.resolve(r,e+t);if(w(n))return n}return null}function p(){return["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome","/Applications/Chromium.app/Contents/MacOS/Chromium",...[process.env.PROGRAMFILES,process.env["PROGRAMFILES(X86)"],process.env.LOCALAPPDATA].filter(Boolean).map(e=>o.join(e,"Google/Chrome/Application/chrome.exe")),"google-chrome","google-chrome-stable","chromium","chromium-browser","chrome"]}async function h(r,o,t){const s=new AbortController,c=setTimeout(()=>s.abort(),9e5);try{const c=await async function e(r,o=0){const t=new URL(r);if("https:"!==t.protocol||t.username||t.password)throw new Error("Browser downloads require an HTTPS URL without credentials");return new Promise((r,i)=>{const a=n.get(t,{signal:s.signal,timeout:3e4},n=>{if(n.on("error",i),[301,302,303,307,308].includes(n.statusCode)){if(n.resume(),!n.headers.location||o>=5)return i(new Error("Too many browser download redirects"));try{r(e(new URL(n.headers.location,t),o+1))}catch(e){i(e)}}else 200!==n.statusCode?(n.resume(),i(new Error(`Browser download returned HTTP ${n.statusCode}`))):r(n)});a.on("timeout",()=>a.destroy(new Error("Browser download timed out"))),a.on("error",i)})}(r),l=Number(c.headers["content-length"]||0);let m=0,u=Date.now();const w=new i({transform(e,r,o){if(m+=e.length,m>536870912)return o(new Error("Browser download exceeds 512 MiB"));Date.now()-u>2e3&&(t(`Downloading browser: ${Math.round(m/1048576)}${l?"/"+Math.round(l/1048576):""} MiB`),u=Date.now()),o(null,e)}});if(await a(c,w,e.createWriteStream(o,{flags:"wx"}),{signal:s.signal}),!m||l&&m!==l)throw new Error("Incomplete browser download")}finally{clearTimeout(c),s.abort()}}async function f(n){const i=e.mkdtempSync(o.join(r.tmpdir(),"tik-browser-check-")),a=require("crypto").randomBytes(16).toString("hex");let c,l,m,u,w="";const d=new Promise(e=>{u=e}),p=t.createServer((e,r)=>{e.url==="/"+a?(r.writeHead(200,{"Content-Type":"text/html"}),r.end(`<script>fetch("/${a}/ready")<\/script>`)):e.url===`/${a}/ready`?(r.end("ok"),u()):(r.writeHead(404),r.end())});try{await new Promise((e,r)=>{p.once("error",r),p.listen(0,"127.0.0.1",e)}),c=s(n,["--headless=new","--no-first-run","--no-default-browser-check",..."darwin"===process.platform?["--use-mock-keychain"]:[],"--user-data-dir="+i,`http://127.0.0.1:${p.address().port}/${a}`],{stdio:["ignore","ignore","pipe"],windowsHide:!0}),l=new Promise(e=>c.once("close",e)),c.stderr.on("data",e=>{w=(w+e).slice(-600)}),await Promise.race([d,new Promise((e,r)=>{c.once("error",r),c.once("close",e=>r(new Error(`Chrome exited (${e})`))),m=setTimeout(()=>r(new Error("Chrome startup timed out")),3e4)})])}catch(e){throw new Error(`Downloaded Chrome could not run: ${e.message}. Check OS compatibility and, on Linux, required system libraries. ${w}`)}finally{if(clearTimeout(m),c&&null===c.exitCode&&null===c.signalCode){c.kill();const e=setTimeout(()=>c.kill("SIGKILL"),3e3);await l,clearTimeout(e)}p.closeAllConnections?.(),await new Promise(e=>p.close(e)),e.rmSync(i,{recursive:!0,force:!0})}}module.exports={ensureBrowser:async function(t,{cacheRoot:n=o.join(r.homedir(),".tik-video-editor-cli","browsers"),env:s=process.env,platform:i=process.platform,arch:a=process.arch,candidates:y=p(),download:g=h,verify:C=f,log:b=e=>process.stderr.write(e+"\n")}={}){const x=t||s.CHROME_PATH;if(x){const e=d(x);if(!e)throw new Error("Browser is not executable: "+x+"; fix --browser / CHROME_PATH or unset it for automatic setup");return e}const E=m(i,a),A=E&&o.join(n,l,E),T=E&&o.join(A,u(E)),$=()=>{try{return w(T)&&e.readFileSync(o.join(A,".complete"),"utf8")===l}catch{return!1}};if(E&&$())return T;for(const e of y){const r=d(e);if(r)return r}if(!E)throw new Error(`Automatic browser setup is unavailable on ${i}/${a}; pass --browser <path> or set CHROME_PATH`);const S=`${(s.FABLECUT_BROWSER_DOWNLOAD_BASE_URL||"https://cdn.npmmirror.com/binaries/chrome-for-testing").replace(/\/+$/,"")}/${l}/${E}/chrome-${E}.zip`,v=o.dirname(A);await e.promises.mkdir(v,{recursive:!0});const B=await e.promises.mkdtemp(o.join(v,".install-"));try{b(`Preparing Chrome for Testing ${l} (${E}); this download is cached for future exports.`);const r=o.join(B,"browser.zip"),t=o.join(B,"unpacked");await g(S,r,b),await e.promises.mkdir(t),b("Extracting browser…"),await c(r,t);const n=o.join(t,u(E));if(!w(n))throw new Error("Browser archive is missing its executable");if(await C(n),await e.promises.writeFile(o.join(t,".complete"),l),!$())try{await e.promises.rename(t,A)}catch(e){if(!$())throw new Error(`${e.message}; remove the incomplete cache at ${A} and retry`)}return b("Browser ready."),T}catch(e){throw new Error(`Automatic browser setup failed: ${e.message}. Retry export, set FABLECUT_BROWSER_DOWNLOAD_BASE_URL to a trusted HTTPS mirror, or use --browser <path>.`)}finally{await e.promises.rm(B,{recursive:!0,force:!0})}},downloadArchive:h,verifyBrowser:f,platformKey:m,executable:u,VERSION:l};
@@ -0,0 +1 @@
1
+ "use strict";const e=require("node:fs"),r=require("node:os"),t=require("node:path"),o=require("node:child_process"),n=()=>t.join(r.homedir(),".tik-video-editor-cli-processes"),i=()=>"1"===process.env.TIK_VIDEO_EDITOR_MANAGED,s=e=>new Promise(r=>setTimeout(r,e));function c(){try{e.rmdirSync(n())}catch{}}function p(e){let r;if("win32"===process.platform){const n=t.join(process.env.SystemRoot||"C:\\Windows","System32","WindowsPowerShell","v1.0","powershell.exe"),i=`Get-CimInstance Win32_Process${e?` -Filter 'ProcessId=${Number(e)}'`:""} | Select-Object ProcessId,ParentProcessId,@{n='Birth';e={$_.CreationDate.ToUniversalTime().Ticks.ToString()}} | ConvertTo-Json -Compress`;if(r=o.spawnSync(n,["-NoProfile","-NonInteractive","-Command",i],{encoding:"utf8",windowsHide:!0,timeout:1e4,maxBuffer:8<<20}),r.error||0!==r.status)throw new Error("Cannot inspect CLI processes: "+(r.error?.message||r.stderr));const s=JSON.parse(r.stdout.trim()||"[]");return new Map([].concat(s).filter(Boolean).map(e=>[e.ProcessId,{pid:e.ProcessId,ppid:e.ParentProcessId,birth:e.Birth,group:0}]))}if(r=o.spawnSync("/bin/ps",[...e?["-p",String(e)]:["-ax"],"-o","pid=,ppid=,pgid=,stat=,lstart="],{encoding:"utf8",env:{...process.env,LC_ALL:"C",TZ:"UTC"},timeout:5e3,maxBuffer:8<<20}),r.error||0!==r.status&&(!e||1!==r.status||r.stdout.trim()))throw new Error("Cannot inspect CLI processes: "+(r.error?.message||r.stderr));const n=new Map;for(const e of r.stdout.trim().split("\n")){const r=e.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);if(!r||r[4].includes("Z"))continue;const[,t,o,i,,s]=r;n.set(+t,{pid:+t,ppid:+o,group:+i,birth:s.trim()})}return n}const d=(e,r)=>!!r&&e.pid===r.pid&&e.birth===r.birth;function a(r){try{return JSON.parse(e.readFileSync(r,"utf8"))}catch(e){if("ENOENT"===e.code||e instanceof SyntaxError)return null;throw e}}function f(r){const o=t.join(n(),r),i=a(o);if(!i)return null;let s;try{s=p(i.pid).get(i.pid)}catch{return i}return d(i,s)?i:(e.rmSync(o,{force:!0}),null)}function u(){if(f("shutdown.json"))throw new Error("CLI shutdown is in progress; retry after it finishes");const e=f("migration.json");if(e&&e.pid!==process.pid)throw new Error("CLI storage migration is in progress; retry after it finishes")}function l(e){try{return process.kill(e,0),!0}catch{return!1}}function w(r,o=!1){const i=p(r).get(r);if(!i)return null;i.group=o&&i.group===r?r:0,e.mkdirSync(n(),{recursive:!0,mode:448});const s=t.join(n(),`${r}.json`),c=`${s}.${process.pid}.tmp`;try{e.writeFileSync(c,JSON.stringify(i),{mode:384})}catch(r){if("ENOENT"!==r.code)throw r;e.mkdirSync(n(),{recursive:!0,mode:448}),e.writeFileSync(c,JSON.stringify(i),{mode:384})}return e.renameSync(c,s),{record:i,file:s}}function h(e){const r={...e};return"win32"===process.platform&&(r.windowsHide=!0),r}function g(r,t,n){const s=h(t[n]);if(!i()){const e=t.slice();return e[n]=s,o[r](...e)}u(),"win32"!==process.platform&&(s.detached=!0),t[n]=s;const c=o[r](...t);let d;try{c.pid&&(d=w(c.pid,!0))}catch(e){throw c.kill("SIGKILL"),e}return c.once("close",()=>{if(d)try{const r=p();d.record.group&&[...r.values()].some(e=>e.group===d.record.group)||e.rmSync(d.file,{force:!0})}catch{}}),c}function m(r,o,i){for(const i of e.readdirSync(n())){if(!/^\d+\.json$/.test(i))continue;const s=t.join(n(),i),c=a(s);if(!c||!Number.isInteger(c.pid)||c.pid<=1||"string"!=typeof c.birth)continue;const p=r.get(c.pid);if(d(c,p))o.set(c.pid,p);else if(p){e.rmSync(s,{force:!0});continue}if(c.group===c.pid)for(const e of r.values())e.group===c.group&&o.set(e.pid,e);p||[...r.values()].some(e=>c.group&&e.group===c.group)||e.rmSync(s,{force:!0})}let s;do{s=!1;for(const e of r.values())o.has(e.pid)||i.has(e.pid)||!d(o.get(e.ppid)||{},r.get(e.ppid))||(o.set(e.pid,e),s=!0)}while(s);for(const e of i)o.delete(e);return[...o.values()].filter(e=>d(e,r.get(e.pid)))}module.exports={registerCurrent:function(){u();const r=w(process.pid,i());process.env.TIK_VIDEO_EDITOR_MANAGED="1",process.once("exit",()=>{if(r)try{r.record.group&&[...p().values()].some(e=>e.pid!==process.pid&&e.group===r.record.group)||e.rmSync(r.file,{force:!0}),c()}catch{}}),u()},shutdown:async function(){e.mkdirSync(n(),{recursive:!0,mode:448});const r=t.join(n(),"shutdown.json"),i=p(),f=i.get(process.pid),u=a(r);if(u&&d(u,i.get(u.pid)))throw new Error("CLI shutdown is already in progress");u&&e.rmSync(r,{force:!0}),e.writeFileSync(r,JSON.stringify(f),{flag:"wx",mode:384});const l=new Map,w=new Set,h=new Set,g=new Map,S=new Set([process.pid]);let y=i.get(process.ppid);for(;y&&!S.has(y.pid);)S.add(y.pid),y=i.get(y.ppid);try{if("win32"!==process.platform)for(;;){const e=m(p(),l,S).filter(e=>!g.has(e.pid));if(!e.length)break;for(const r of e)try{process.kill(r.pid,"SIGSTOP"),g.set(r.pid,r)}catch(e){if("ESRCH"!==e.code)throw e}}const e=Date.now()+3e3;let r,t="win32"===process.platform?i:null;do{r=m(t||p(),l,S),t=null;for(const e of r.reverse())if(!w.has(e.pid))try{process.kill(e.pid,"SIGTERM"),w.add(e.pid),g.has(e.pid)&&(process.kill(e.pid,"SIGCONT"),g.delete(e.pid))}catch(e){if("ESRCH"!==e.code)throw e}if(!r.length)break;await s(100)}while(Date.now()<e);if(r.length){const e=Date.now()+3e3;do{r=m(p(),l,S);for(const e of r.reverse())try{if("win32"===process.platform){const r=o.spawnSync("taskkill.exe",["/PID",String(e.pid),"/T","/F"],{encoding:"utf8",windowsHide:!0,timeout:5e3});if(r.error)throw r.error}else process.kill(e.pid,"SIGKILL");w.add(e.pid),h.add(e.pid)}catch(e){if("ESRCH"!==e.code)throw e}if(!r.length)break;await s(100)}while(Date.now()<e)}const n=r.length?m(p(),l,S).map(e=>e.pid):[];return{ok:0===n.length,stopped:[...w],forced:[...h],remaining:n}}finally{for(const e of g.values())try{d(e,p(e.pid).get(e.pid))&&process.kill(e.pid,"SIGCONT")}catch{}e.rmSync(r,{force:!0}),c()}},spawn:(e,r,t)=>g("spawn",[e,r,t],2),fork:(e,r,t)=>g("fork",[e,r,t],2),execFile:(e,r,t,o)=>g("execFile",[e,r,t,o],2),spawnSync:function(e,r,t){return o.spawnSync(e,r,h(t))},listManaged:function(){return e.existsSync(n())?m(p(),new Map,new Set):[]},descendantPids:function(e){const r=new Set;if(!e)return r;const t=p();r.add(e);let o=!0;for(;o;){o=!1;for(const e of t.values())r.has(e.ppid)&&!r.has(e.pid)&&(r.add(e.pid),o=!0)}return r},extraManaged:function(r){const o=r||new Set;return function(){if(!e.existsSync(n()))return[];const r=p(),o=[];for(const i of e.readdirSync(n())){if(!/^\d+\.json$/.test(i))continue;const e=a(t.join(n(),i)),s=e&&r.get(e.pid);e&&d(e,s)&&o.push(e.pid)}return o}().filter(e=>e!==process.pid&&!o.has(e))},beginMigration:function(){u(),e.mkdirSync(n(),{recursive:!0,mode:448});const r=t.join(n(),"migration.json"),o=p(process.pid).get(process.pid);if(!o)throw new Error("Cannot record the migration process");const i=a(r);if(i&&i.pid!==process.pid&&f("migration.json"))throw new Error("CLI storage migration is in progress; retry after it finishes");return e.writeFileSync(r,JSON.stringify(o),{mode:384}),()=>{try{e.rmSync(r,{force:!0})}catch{}}},migrationOwnedByOther:function(){const e=f("migration.json");return!(!e||e.pid===process.pid)},stopPids:async function(e){const r=[...new Set(e)].filter(e=>e&&e!==process.pid&&l(e));for(const e of r)try{process.kill(e,"SIGTERM")}catch{}const t=Date.now()+2e3;for(;Date.now()<t&&r.some(l);)await s(100);for(const e of r)if(l(e))try{process.kill(e,"SIGKILL")}catch{}}};
@@ -0,0 +1 @@
1
+ "use strict";const e=require("node:fs/promises"),r=require("node:os"),t=require("node:path"),{localMedia:o}=require("./export-cache"),{run:a}=require("./export-encoder");module.exports={ExportAudio:class{constructor({libraryDir:e}){this.libraryDir=e,this.queue=Promise.resolve()}async prepare(i,n,s){const c=await o(i,this.libraryDir,n),u=this.queue.then(async()=>{if(s?.aborted)throw new Error("export cancelled");const o=await e.mkdtemp(t.join(r.tmpdir(),"fablecut-export-audio-")),i=t.join(o,"source.wav"),n=()=>e.rm(o,{recursive:!0,force:!0}),u=new AbortController,l=()=>u.abort();s?.addEventListener("abort",l,{once:!0}),s?.aborted&&l();const d=setTimeout(l,6e5);try{return await a("ffmpeg",["-nostdin","-v","error","-y","-i",c,"-map","0:a:0?","-vn","-af","aresample=48000:async=1:first_pts=0","-c:a","pcm_f32le","-rf64","auto",i],u.signal),{file:i,cleanup:n}}catch(e){if(await n(),!u.signal.aborted&&/does not contain any stream/i.test(e.message))return null;throw e}finally{clearTimeout(d),s?.removeEventListener("abort",l)}});return this.queue=u.catch(()=>{}),u}}};
@@ -0,0 +1 @@
1
+ "use strict";const e=require("node:fs"),t=e.promises,s=require("node:path"),r=require("node:crypto"),{spawn:i}=require("./cli/lib/processes"),a=require("node:os"),n=Math.min(2,a.availableParallelism?.()||a.cpus().length||1),o=e=>{const[t,s=1]=String(e).split("/").map(Number);return t/s};function c(e,t,s,r){return new Promise((a,n)=>{if(s?.aborted)return n(new Error("cancelled"));const o=i(e,t,{stdio:["ignore","pipe","pipe"]});let c,l="",d="";const h=()=>{c=new Error("cancelled"),o.kill("SIGKILL")},p=setTimeout(()=>{c=new Error(e+" timed out"),o.kill("SIGKILL")},12e4);s?.addEventListener("abort",h,{once:!0}),o.stdout.on("data",e=>{l+=e,l.length>16777216&&(c=new Error("probe output too large"),o.kill("SIGKILL"))}),o.stderr.on("data",e=>{r?.(String(e)),d=(d+e).slice(-4e3)}),o.on("error",e=>{c=e}),o.on("close",t=>{clearTimeout(p),s?.removeEventListener("abort",h),c||0!==t?n(c||new Error(d||e+" failed")):a(l)})})}function l(e){const t=/ffmpeg version\s+n?(\d+)\.(\d+)/.exec(e);if(t){const e=Number(t[1]),s=Number(t[2]);return e>5||5===e&&s>=1?["-fps_mode","passthrough"]:["-vsync","0"]}return/(?:^|[\r\n])\s*-fps_mode\b/.test(e)?["-fps_mode","passthrough"]:/(?:^|[\r\n])\s*-vsync\b/.test(e)?["-vsync","0"]:null}let d;function h(e){const t=o(e.r_frame_rate),s=o(e.avg_frame_rate);return Number.isFinite(t)&&t>0&&t<=120&&Math.abs(t-s)<1e-4&&0===Number(e.start_time||0)&&["1:1","0:1",void 0].includes(e.sample_aspect_ratio)&&!e.tags?.rotate&&!e.side_data_list?.some(e=>e.rotation)&&["yuv420p","yuvj420p"].includes(e.pix_fmt)&&["bt709","smpte170m","bt470bg"].includes(e.color_space)&&[void 0,"unknown","bt709","smpte170m","bt470bg"].includes(e.color_primaries)&&[void 0,"unknown","bt709","smpte170m","gamma22","gamma28"].includes(e.color_transfer)&&e.width>0&&e.height>0}function p(e,t){const s=1/t,r=Math.max(.002,.1*s);return e.length>0&&e.every((t,i)=>Number.isFinite(t)&&(!i||t>e[i-1]&&Math.abs(t-e[i-1]-s)<=r))}async function m(e,r,i){const a=`/projects/${e.id}/media/`;let n,o;if(i?.startsWith(a))n=e.mediaDir,o=i.slice(a.length);else if("default"===e.id&&i?.startsWith("/media/"))n=e.mediaDir,o=i.slice(7);else{if(!i?.startsWith("/library/"))throw new Error("non-local or cross-project source");n=r,o=i.slice(9)}n=await t.realpath(n);const c=await t.realpath(s.resolve(n,decodeURIComponent(o)));if(!c.startsWith(n+s.sep))throw new Error("source escapes media directory");return c}class u{constructor(e=((a.availableParallelism?.()||a.cpus().length||1)>=8?2:1)){this.concurrency=e,this.pending=[],this.active=new Set,this.lastOwner=new Map}enqueue(e,t,s,r,i="default"){let a,n;const o={run:e,priority:t,neededAt:s,signal:r,owner:i,enqueuedAt:Date.now(),promise:new Promise((e,t)=>{a=e,n=t}),resolve:a,reject:n},c=()=>{const e=this.pending.indexOf(o);e>=0&&(this.pending.splice(e,1),o.cleanup(),n(new Error("cancelled")),this.pump())};return o.cleanup=()=>r.removeEventListener("abort",c),r.addEventListener("abort",c,{once:!0}),this.pending.push(o),queueMicrotask(()=>this.pump()),o}promote(e,t,s){"demand"===t&&(e.priority="demand"),e.neededAt=Math.min(e.neededAt,s),this.pump()}pump(){for(;this.active.size<this.concurrency;){const e=[...this.active].some(e=>"prefetch"===e.priority),t=this.pending.some(e=>"demand"===e.priority)?"demand":"prefetch";if("prefetch"===t&&e)return;const s=this.pending.filter(e=>e.priority===t);if(!s.length)return;const r=[...new Set(s.map(e=>e.owner))],i=r.indexOf(this.lastOwner.get(t)),a=r[(i+1)%r.length],n=s.filter(e=>e.owner===a).sort((e,t)=>e.neededAt-t.neededAt)[0];this.pending.splice(this.pending.indexOf(n),1),n.cleanup(),this.active.add(n),this.lastOwner.set(t,a),n.startedAt=Date.now(),Promise.resolve().then(()=>{if(n.signal.aborted)throw new Error("cancelled");return n.run(n.startedAt-n.enqueuedAt)}).then(n.resolve,n.reject).finally(()=>{this.active.delete(n),this.pump()})}}}module.exports={ExportCache:class{constructor({libraryDir:e,limit:t=2147483648,ttl:s=9e4,concurrency:r}={}){this.libraryDir=e,this.limit=t,this.ttl=s,this.queue=new u(r),this.sessions=new Map,this.jobs=new Map,this.pins=new Map,this.roots=new Map,this.releases=new Map,this.timer=setInterval(()=>{for(const e of this.sessions.values())!e.managed&&Date.now()-e.touched>s&&this.release(e.id,e.pp.id)},Math.min(s,15e3)),this.timer.unref()}get(e,t){const s=this.sessions.get(e);if(!s||s.pp.id!==t)throw new Error("no such cache session");return s.touched=Date.now(),s}create(t,s,i,{managed:a=!1}={}){const n=i||JSON.parse(e.readFileSync(t.projectFile,"utf8").replace(/^\uFEFF/,""));if(n.revision!==s)throw new Error("Project changed; save/reload before exporting");const o={id:r.randomBytes(16).toString("hex"),pp:t,project:n,managed:a,touched:Date.now(),operations:new Set,controller:new AbortController,sources:new Map,blocks:new Map,leases:new Map,pins:new Set,prefetchSequence:-1,prefetchPlan:null,waitSequence:-1,state:"preparing",stats:{hits:0,misses:0,fallbacks:{},extractionMs:0,queueWaitMs:0,decodeMs:0,publishMs:0,prefetchSkipped:0,capacityFallbacks:0,peakDiskBytes:0}};return this.sessions.set(o.id,o),o.ready=this.prepare(o).then(()=>{o.state="ready"}).catch(e=>{o.state="error",o.error=e.message}),o.id}async prepare(s){const i=new Set(s.project.disabledTracks||[]),a=new Map;for(const n of s.project.clips)if("video"===n.kind&&!i.has(n.track)){if(s.controller.signal.aborted)throw new Error("cancelled");try{if(n.keyframes?.speed?.length)throw new Error("speed ramp");const i=Math.max(.1,Math.min(8,Number(n.props?.speed)||1));if(![n.in,n.duration,i].every(Number.isFinite)||n.in<0||n.duration<=0)throw new Error("invalid source interval");const l=s.project.media.find(e=>e.id===n.mediaId);a.has(n.mediaId)||a.set(n.mediaId,(async()=>{const i=await m(s.pp,this.libraryDir,l?.src),a=await t.stat(i),n=JSON.parse(await c("ffprobe",["-v","error","-select_streams","v:0","-show_streams","-of","json",i],s.controller.signal)),d=n.streams?.[0];if(!d||!h(d))throw new Error("unsupported/uncertain video metadata");const p=r.createHash("sha256");for await(const t of e.createReadStream(i)){if(s.controller.signal.aborted)throw new Error("cancelled");p.update(t)}return{file:i,stamp:`${a.size}:${a.mtimeMs}`,hash:p.digest("hex"),fps:o(d.r_frame_rate),width:d.width,height:d.height,duration:Number(d.duration)}})());const d=await a.get(n.mediaId);s.sources.set(n.id,{...d,start:n.in,end:n.in+n.duration*i})}catch(e){s.stats.fallbacks[n.id]=e.message}}}status(e,t){const s=this.get(e,t);return{id:e,state:s.state,error:s.error,clips:[...s.sources.keys()],stats:s.stats}}pin(e,t){e.pins.has(t)||(e.pins.add(t),this.pins.set(t,(this.pins.get(t)||0)+1))}unpin(e,t){if(!e.pins.delete(t))return;const s=(this.pins.get(t)||1)-1;s?this.pins.set(t,s):this.pins.delete(t)}async rootIndex(e){if(!this.roots.has(e)){const s=(async()=>{await t.mkdir(e,{recursive:!0});const s={entries:new Map,partials:new Map,lock:Promise.resolve()};return await this.scan(e,s),s})();this.roots.set(e,s),s.catch(()=>this.roots.delete(e))}return this.roots.get(e)}async scan(e,r){const i=new Map;for(const a of await t.readdir(e)){const n=s.join(e,a);if(/^[a-f0-9]{64}$/.test(a))try{const e=JSON.parse(await t.readFile(s.join(n,"manifest.json"))),r=await t.stat(n);if(!Number.isFinite(e.bytes)||e.bytes<0)throw new Error("invalid cache manifest");i.set(n,{dir:n,bytes:e.bytes,time:r.mtimeMs,manifest:e})}catch(e){if("ENOENT"!==e.code)throw e}else if(/^[a-f0-9]{64}\.partial-[a-f0-9]+$/.test(a)&&!this.jobs.has(n.slice(0,n.lastIndexOf(".partial-")))){const e=await t.stat(n);if(Date.now()-e.mtimeMs>12e4)await t.rm(n,{recursive:!0,force:!0}),r.partials.delete(n);else{const e=await Promise.all((await t.readdir(n)).map(e=>t.stat(s.join(n,e))));r.partials.set(n,e.reduce((e,t)=>e+t.size,0))}}}r.entries=i}async locked(e,t){const s=await this.rootIndex(e),r=s.lock.then(()=>t(s));return s.lock=r.catch(()=>{}),r}async makeRoom(e,s,r=0,i=!0){let a=[...s.entries.values()].reduce((e,t)=>e+t.bytes,0)+[...s.partials.values()].reduce((e,t)=>e+t,0)+r;for(const r of[...s.entries.values()].sort((e,t)=>e.time-t.time)){if(a<=this.limit)break;if(!this.pins.has(r.dir)&&!this.jobs.has(r.dir)){try{await t.rm(r.dir,{recursive:!0,force:!0})}catch(t){throw await this.scan(e,s),t}s.entries.delete(r.dir),a-=r.bytes}}if(a>this.limit&&i)return await this.scan(e,s),this.makeRoom(e,s,r,!1);if(a>this.limit)throw new Error("frame cache capacity exceeded");return a}async room(e,t=0,r){const i=()=>this.locked(e,async s=>(r&&s.partials.set(r,t),this.makeRoom(e,s,r?0:t)));try{return await i()}catch(t){const a=r&&this.jobs.get(r.slice(0,r.lastIndexOf(".partial-")));if(!/capacity/.test(t.message)||"demand"!==a?.queued.priority)throw t;const n=new Set,o=new Set;for(const t of this.sessions.values())for(const r of t.leases.values())s.dirname(r.dir)===e&&("demand"===r.priority?o:n).add(r.dir);for(const e of o)n.delete(e);for(const e of n)"demand"===this.jobs.get(e)?.queued.priority&&n.delete(e);if(!n.size)throw t;const c=[...n].map(e=>this.jobs.get(e)).filter(Boolean);for(const e of this.sessions.values())for(const[t,s]of e.leases)n.has(s.dir)&&this.removeLease(e,t);return await Promise.allSettled(c.map(e=>e.promise)),i()}}async build(e,i,a,o,h,m={}){const u=i+".partial-"+r.randomBytes(6).toString("hex");await t.mkdir(u,{recursive:!0});const f=[];let w="";const b=Math.max(0,5*o-2/a.fps),g=5*(o+1)+1/a.fps,y=await(d||(d=(async()=>{const e=l(await c("ffmpeg",["-version"]));if(e)return e;const t=l(await c("ffmpeg",["-hide_banner","-h","full"]));if(!t)throw new Error("ffmpeg 缺少 -fps_mode 和 -vsync");return t})().catch(e=>{throw d=void 0,e})),d),v=["-hide_banner","-loglevel","info","-nostdin","-y","-copyts","-ss",String(Math.max(0,b-2)),"-t",String(g-Math.max(0,b-2)+1),"-i",a.file,"-map","0:v:0","-an","-vf",`select='gte(t,${b-1e-5})*lt(t,${g})',showinfo`,...y,"-threads",String(n),"-compression_level","1",s.join(u,"%06d.png")];let k,E,S=Promise.resolve(),M=!1;const j=new AbortController,I=()=>j.abort();h.addEventListener("abort",I,{once:!0});try{await this.room(e,0,u),k=setInterval(()=>{M||(M=!0,S=(async()=>{const r=await t.readdir(u),i=await Promise.all(r.map(e=>t.stat(s.join(u,e))));await this.room(e,i.reduce((e,t)=>e+t.size,0),u)})().catch(e=>{E=e,j.abort()}).finally(()=>{M=!1}))},250),h.aborted&&j.abort();const r=Date.now();try{await c("ffmpeg",v,j.signal,e=>{w+=e;const t=w.split(/[\r\n]/);w=t.pop();for(const e of t){const t=/\bn:\s*\d+.*?pts_time:([\d.e+\-]+)/.exec(e);t&&f.push(Number(t[1]))}})}finally{m.decodeMs=Date.now()-r}const n=Date.now();if(clearInterval(k),await S,E)throw E;if(!p(f,a.fps))throw new Error("variable or invalid frame timestamps");const o=(await t.readdir(u)).filter(e=>e.endsWith(".png"));if(o.length!==f.length)throw new Error("incomplete extracted frames");const l=(await Promise.all(o.map(e=>t.stat(s.join(u,e))))).reduce((e,t)=>e+t.size,0);await this.room(e,l,u);const d={times:f,bytes:l,width:a.width,height:a.height};if(await t.writeFile(s.join(u,"manifest.json"),JSON.stringify(d)),h.aborted)throw new Error("cancelled");return await this.locked(e,async s=>{await this.makeRoom(e,s),await t.rename(u,i),s.partials.delete(u),s.entries.set(i,{dir:i,bytes:l,time:Date.now(),manifest:d})}),m.publishMs=Date.now()-n,d}catch(e){if(E)throw E;throw e}finally{clearInterval(k),await S,h.removeEventListener("abort",I),await this.locked(e,async e=>{await t.rm(u,{recursive:!0,force:!0}),e.partials.delete(u)})}}async block(e,t,s,r,i={}){const a=this.get(e,t),n=this.loadBlock(a,s,r,i);a.operations.add(n);try{return await n}finally{a.operations.delete(n)}}async loadBlock(e,i,a,{priority:n="demand",neededAt:o=0,sequence:c}={}){if(!["demand","prefetch"].includes(n)||!Number.isFinite(o)||o<0)throw new Error("invalid cache priority");await e.ready;const l=e.sources.get(i);if(!l||e.stats.fallbacks[i])return{fallback:e.stats.fallbacks[i]||"source unavailable"};if(!Number.isInteger(a)||a<Math.floor(l.start/5)||a>Math.floor(l.end/5))throw new Error("block outside clip interval");const d=i+":"+a,h=()=>"demand"===n||"demand"===e.leases.get(d)?.priority||null===e.prefetchPlan||c<=e.prefetchSequence&&e.prefetchPlan.has(d);let p,m;try{if(!h())return{skipped:"obsolete prefetch"};const c=await t.stat(l.file);if(`${c.size}:${c.mtimeMs}`!==l.stamp)throw new Error("source changed during export");const f=s.join(e.pp.dir,".export-cache"),w=await this.rootIndex(f),b=(u=JSON.stringify([2,l.hash,a,l.fps,"png-1"]),r.createHash("sha256").update(u).digest("hex"));if(p=s.join(f,b),e.controller.signal.aborted)throw new Error("cancelled");if(!h())return{skipped:"obsolete prefetch"};this.pin(e,p),m={clipId:i,block:a,key:b,dir:p,priority:"demand"===e.leases.get(d)?.priority?"demand":n},e.leases.set(d,m);for(const[t,s]of e.leases)"demand"===n&&s.clipId===i&&s.block<a-1&&(e.leases.delete(t),[...e.leases.values()].some(e=>e.dir===s.dir)||(e.blocks.delete(s.key),this.unpin(e,s.dir)));let g;try{g=await this.locked(f,async e=>{try{await t.access(s.join(p,"manifest.json"));const r=e.entries.get(p)?.manifest;if(!r)throw new Error("cache block not indexed");return r}catch(t){throw e.entries.delete(p),t}}),e.stats.hits++}catch{if(e.controller.signal.aborted)throw new Error("cancelled");e.stats.misses++;const t=this.jobs.get(p);if(t?.controller.signal.aborted&&await t.promise.catch(()=>{}),e.controller.signal.aborted)throw new Error("cancelled");if(!this.jobs.has(p)){const t=new AbortController,s=Date.now(),r={},i=this.queue.enqueue(async s=>{e.stats.queueWaitMs+=s;try{return await this.build(f,p,l,a,t.signal,r)}finally{e.stats.decodeMs+=r.decodeMs||0,e.stats.publishMs+=r.publishMs||0}},n,o,t.signal,e.id),c=i.promise;this.jobs.set(p,{controller:t,promise:c,queued:i}),c.finally(()=>{this.jobs.delete(p),e.stats.extractionMs+=Date.now()-s}).catch(()=>{})}const s=this.jobs.get(p);this.queue.promote(s.queued,n,o),g=await s.promise}if(e.controller.signal.aborted)throw new Error("cancelled");if(!h()||!e.leases.has(d))return{skipped:"obsolete prefetch"};const y=new Date;return await t.utimes(p,y,y),w.entries.has(p)&&(w.entries.get(p).time=y.getTime()),e.blocks.set(b,{clipId:i,block:a,dir:p,manifest:g}),e.stats.peakDiskBytes=Math.max(e.stats.peakDiskBytes,[...w.entries.values()].reduce((e,t)=>e+t.bytes,0)+[...w.partials.values()].reduce((e,t)=>e+t,0)),{key:b,...g}}catch(t){if("prefetch"===n&&!/variable or invalid frame timestamps|incomplete extracted frames/.test(t.message))return e.stats.prefetchSkipped++,e.leases.get(d)===m&&"prefetch"===m?.priority&&this.removeLease(e,d),{skipped:t.message};/capacity/.test(t.message)&&e.stats.capacityFallbacks++;const s=new Set(/variable or invalid frame timestamps|incomplete extracted frames/.test(t.message)?[...e.sources].filter(([,e])=>e.hash===l.hash).map(([e])=>e):[i]);for(const r of s)e.stats.fallbacks[r]=t.message;for(const[t,r]of e.leases)s.has(r.clipId)&&(e.leases.delete(t),[...e.leases.values()].some(e=>e.dir===r.dir)||(e.blocks.delete(r.key),this.unpin(e,r.dir)));return{fallback:t.message}}var u}removeLease(e,t){const s=e.leases.get(t);s&&(e.leases.delete(t),[...e.leases.values()].some(e=>e.dir===s.dir)||(e.blocks.delete(s.key),this.unpin(e,s.dir),this.pins.has(s.dir)||this.jobs.get(s.dir)?.controller.abort()))}retain(e,t,s,r,i=[]){const a=this.get(e,t);if(!Number.isSafeInteger(s)||s<0||!Array.isArray(r)||r.length>3||r.some(e=>"string"!=typeof e.clipId||!Number.isSafeInteger(e.block)||e.block<0))throw new Error("invalid prefetch plan");if(!Array.isArray(i)||i.length>a.project.clips.length||i.some(e=>!a.sources.has(e.clipId)||!Number.isSafeInteger(e.block)||e.block<0))throw new Error("invalid current blocks");if(!(s<=a.prefetchSequence)){a.prefetchSequence=s,a.prefetchPlan=new Set(r.map(e=>e.clipId+":"+e.block));for(const e of i){const t=a.leases.get(e.clipId+":"+e.block);if(t){t.priority="demand";const e=this.jobs.get(t.dir);e&&this.queue.promote(e.queued,"demand",0)}}for(const[e,t]of a.leases)"prefetch"!==t.priority||a.prefetchPlan.has(e)||this.removeLease(a,e)}}reportWait(e,t,{sequence:s,reason:r,elapsedMs:i=0}){const a=this.get(e,t);if(!Number.isSafeInteger(s)||s<0||![null,"source-block","source-image","encoder"].includes(r)||!Number.isFinite(i)||i<0||i>864e5)throw new Error("invalid export wait");s<=a.waitSequence||(a.waitSequence=s,a.wait=r?{reason:r,since:Date.now()-i}:null)}drop(e,t,s){const r=this.get(e,t);if(!Array.isArray(s)||s.length>r.project.clips.length)throw new Error("invalid clip list");const i=new Set(s);for(const[e,t]of r.leases)i.has(t.clipId)&&(r.leases.delete(e),[...r.leases.values()].some(e=>e.dir===t.dir)||(r.blocks.delete(t.key),this.unpin(r,t.dir),this.pins.has(t.dir)||this.jobs.get(t.dir)?.controller.abort()))}frame(e,t,r,i){const a=this.get(e,t).blocks.get(r);if(!a||!Number.isInteger(i)||i<0||i>=a.manifest.times.length)throw new Error("unknown cache frame");return s.join(a.dir,String(i+1).padStart(6,"0")+".png")}release(e,t){const s=this.sessions.get(e);if(!s)return this.releases.get(e)?.projectId===t?this.releases.get(e).done:void 0;if(s.pp.id!==t)return;this.sessions.delete(e),s.controller.abort();const r=[s.ready,...s.operations];for(const e of[...s.pins])if(this.unpin(s,e),!this.pins.has(e)){const t=this.jobs.get(e);t&&(t.controller.abort(),r.push(t.promise))}const i=Promise.allSettled(r).finally(()=>this.releases.delete(e));return this.releases.set(e,{projectId:t,done:i}),i}close(){clearInterval(this.timer);for(const e of this.sessions.values())this.release(e.id,e.pp.id)}},ExtractionQueue:u,frameIndex:function(e,t){let s=0,r=e.length;for(;s<r;){const i=s+r>>>1;e[i]<=t+1e-5?s=i+1:r=i}return s-1},frameSyncArgs:l,supported:h,validFrameTimes:p,localMedia:m,command:c,BLOCK_SECONDS:5};
@@ -0,0 +1 @@
1
+ "use strict";const{spawn:e}=require("./cli/lib/processes");module.exports={writeFrame:function(e,r){const{proc:o,controller:n}=e,t=o.stdin;return new Promise((i,l)=>{let s=!1;const c=e=>{s||(s=!0,t.removeListener("error",a),t.removeListener("close",d),o.removeListener("exit",d),o.removeListener("error",a),n.signal.removeEventListener("abort",u),e?l(e):i())},a=e=>c(e),d=()=>c(new Error("ffmpeg closed during frame upload: "+e.err())),u=()=>c(new Error("export cancelled"));if(t.once("error",a),t.once("close",d),o.once("exit",d),o.once("error",a),n.signal.addEventListener("abort",u,{once:!0}),n.signal.aborted)return u();if(null!==o.exitCode||null!==o.signalCode||t.destroyed||t.writableEnded)return d();try{t.write(r,e=>c(e))}catch(e){c(e)}})},stopProcess:async function(e,r){let o;null===e.exitCode&&null===e.signalCode&&(e.kill(),o=setTimeout(()=>e.kill("SIGKILL"),3e3));try{await r}finally{clearTimeout(o)}},run:function(r,o,n){return new Promise((t,i)=>{if(n?.aborted)return i(new Error("export cancelled"));const l=e(r,o,{stdio:["ignore","ignore","pipe"]});let s,c,a="";const d=()=>{s||=new Error("export cancelled"),l.kill(),c=setTimeout(()=>l.kill("SIGKILL"),3e3)};n?.addEventListener("abort",d,{once:!0}),l.stderr.on("data",e=>{a=(a+e).slice(-2e3)}),l.on("error",e=>{s||=e}),l.once("close",e=>{clearTimeout(c),n?.removeEventListener("abort",d),s||0!==e?i(s||new Error(a||r+" failed")):t()})})}};
@@ -0,0 +1 @@
1
+ "use strict";const e=require("node:fs"),r=require("node:path"),{pipeline:o}=require("node:stream/promises"),{execFile:t}=require("./cli/lib/processes");function n(e,r=400){return Object.assign(new Error(e),{status:r})}function i(e){return"string"==typeof e&&!/[<>:"/\\|?*\x00-\x1f]/.test(e)&&/\.(mp4|webm)$/i.test(e)&&!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])\./i.test(e)}function s({signal:e,timeoutMs:o=12e4}={}){let i,s;if("win32"===process.platform){i=r.join(process.env.SystemRoot||"C:\\Windows","System32/WindowsPowerShell/v1.0/powershell.exe");const e="\n$ErrorActionPreference = 'Stop'\n[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\nAdd-Type -AssemblyName System.Windows.Forms\n[System.Windows.Forms.Application]::EnableVisualStyles()\n$owner = New-Object System.Windows.Forms.Form\n$owner.TopMost = $true\n$owner.ShowInTaskbar = $false\n$owner.StartPosition = 'CenterScreen'\n$owner.Size = New-Object System.Drawing.Size(1, 1)\n$owner.Opacity = 0\n$dialog = New-Object System.Windows.Forms.FolderBrowserDialog\n$dialog.Description = '选择成片保存文件夹'\n$dialog.ShowNewFolderButton = $true\ntry {\n # 隐藏启动的进程需要先显示并激活父窗口,选择框才能正常置前。\n $owner.Show()\n $owner.Activate()\n if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {\n [Console]::Write($dialog.SelectedPath)\n }\n} finally { $dialog.Dispose(); $owner.Dispose() }\n";s=["-NoProfile","-STA","-EncodedCommand",Buffer.from(e,"utf16le").toString("base64")]}else if("darwin"===process.platform)i="/usr/bin/osascript",s=["-e",'try\nactivate\nreturn POSIX path of (choose folder with prompt "选择成片保存文件夹")\non error number -128\nreturn ""\nend try'];else{if("linux"!==process.platform)return Promise.reject(n("System folder selection is unavailable on this platform",501));i="zenity",s=["--file-selection","--directory","--title=选择成片保存文件夹"]}return new Promise((r,n)=>{t(i,s,{windowsHide:!0,encoding:"utf8",maxBuffer:65536,signal:e,timeout:o},(e,o)=>{if("linux"!==process.platform||1!==e?.code||e.killed)return e?("AbortError"!==e.name&&(e.browserSave=!0),void n(e)):void r(o.replace(/[\r\n]+$/,"")||null);r(null)})})}module.exports={chooseExportDirectory:s,createExportSaver:function({pickDirectory:t=s}={}){let a=!1;return async function({source:s,stream:l,name:c,signal:d}){if(a)throw n("A save dialog is already open",409);if(!i(c))throw n("Invalid export filename");a=!0;let p,m,w=!1;try{d?.throwIfAborted();const n=await t({signal:d});if(d?.throwIfAborted(),!n)return{saved:!1,cancelled:!0};const i=r.extname(c),a=r.basename(c,i);for(let o=0;;o++){m=r.join(n,o?`${a}_${o}${i}`:c);try{p=await e.promises.open(m,"wx");break}catch(e){if("EEXIST"!==e.code)throw e}}return await o(s?e.createReadStream(s):l,p.createWriteStream(),{signal:d}),w=!0,{saved:!0,name:r.basename(m)}}finally{try{p&&(await p.close(),w||await e.promises.rm(m,{force:!0}))}finally{a=!1}}}},resolveExportSource:async function(o,t){const s=`/projects/${o.id}/exports/`,a="string"==typeof t&&t.startsWith(s)?t.slice(s.length):"default"===o.id&&"string"==typeof t&&t.startsWith("/exports/")?t.slice(9):"";let l;try{l=decodeURIComponent(a)}catch{throw n("Invalid export source")}if(!i(l))throw n("Invalid export source");const c=await e.promises.realpath(o.exportsDir),d=await e.promises.realpath(r.join(c,l));if(r.dirname(d)!==c||!(await e.promises.stat(d)).isFile())throw n("Invalid export source");return d},validVideoName:i};
@@ -0,0 +1 @@
1
+ "use strict";const e=require("node:fs"),r=require("node:os"),s=require("node:path"),{spawn:o}=require("./cli/lib/processes"),{ensureBrowser:t}=require("./cli/lib/browser");let i,n,c=!1;async function a(){if(!c){if(c=!0,i&&null===i.exitCode&&null===i.signalCode){const e=new Promise(e=>i.once("close",e));i.kill();const r=setTimeout(()=>i.kill("SIGKILL"),3e3);await e,clearTimeout(r)}n&&e.rmSync(n,{recursive:!0,force:!0}),process.exit()}}process.on("SIGTERM",a),process.on("disconnect",a),process.once("message",async d=>{try{process.send({state:"preparing-browser"});const l=await t(d.browser);if(c)return;n=e.mkdtempSync(s.join(r.tmpdir(),"fablecut-export-chrome-"));const p=new URL(d.url);for(const[e,r]of Object.entries({project_id:d.projectId,cliExport:d.id,cliExportName:d.name,cliExportEngine:d.engine,exportJob:d.id}))p.searchParams.set(e,r);process.send({state:"preparing-media",browser:l}),i=o(l,["--headless=new","--no-first-run","--no-default-browser-check",..."darwin"===process.platform?["--use-mock-keychain"]:[],"--autoplay-policy=no-user-gesture-required","--disable-background-timer-throttling","--disable-renderer-backgrounding","--user-data-dir="+n,"--window-size=1440,1000",p.href],{stdio:["ignore","ignore","pipe"]});let u="";i.stderr.on("data",e=>{u=(u+e).slice(-2e3)}),i.on("error",e=>process.send?.({state:"error",error:e.message})),i.once("close",()=>{c||process.send?.({state:"error",error:"Chrome exited before export completed: "+u.slice(-800)}),a()})}catch(e){process.send?.({state:"error",error:e.message}),await a()}});
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#7b6cff"/>
5
+ <stop offset="1" stop-color="#4f8cff"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="512" height="512" rx="112" fill="url(#g)"/>
9
+ <!-- play triangle sliced by a cut (the "Cut" in FableCut) -->
10
+ <g fill="#fff">
11
+ <path d="M188 146 L244 176 L244 336 L188 366 Z"/>
12
+ <path d="M268 189 L392 256 L268 323 Z"/>
13
+ </g>
14
+ </svg>
@@ -0,0 +1 @@
1
+ "use strict";!function(e){const t={"Thumbnails {loaded}/{total}":"缩略图 {loaded}/{total}","{failed} thumbnails unavailable":"{failed} 张缩略图无法加载","Project not found":"项目不存在","Project could not be loaded":"项目加载失败","Invalid project data":"工程数据格式无效","Invalid project link. Open the project again from its original entry.":"项目链接无效,请从原入口重新打开项目。","Project request failed ({status})":"工程读取请求失败({status})","Project ID: {id}":"项目 ID:{id}","This project is not in the current or legacy storage folder. It may have been moved or deleted. Restore the project folder, then retry.":"当前及旧存储目录中均未找到此工程,可能已被移动或删除。请恢复工程文件夹后重试。","The project file could not be read or the editor service is unavailable. Check the file or service, then retry.":"工程文件无法读取,或编辑器服务暂时不可用。请检查文件或服务后重试。","Error details":"错误详情","Retry loading":"重新加载","Media missing":"媒体丢失","Media unreadable":"媒体无法读取","Check again":"重新检测","The source file was deleted or moved. Restore it to its original location, then retry.":"源文件已被删除或移动,请恢复到原位置后重新检测。","The source file could not be loaded. Check the file or connection, then retry.":"源文件无法加载,请检查文件或网络连接后重新检测。","Snapping on (N)":"吸附已开启(N)","Snapping off (N)":"吸附已关闭(N)","Zoom out (-)":"缩小时间轴(-)","Zoom in (+)":"放大时间轴(+)","Timeline zoom":"时间轴缩放","Insert before {clip}":"插到「{clip}」之前","Insert after {clip}":"插到「{clip}」之后","Move to {track} · {seconds}s":"移到{track} · {seconds}秒","Moves together":"一起移动","Moves together: {tracks}":"同时移动:{tracks}","Adjusted to an available position":"位置受限,已预览可用落点","Drop on a matching track":"请拖入同类型轨道或轨道间隙","Trimmed {seconds}s":"裁掉 {seconds} 秒","Restored {seconds}s":"恢复 {seconds} 秒","Duration {seconds}s":"剩余时长 {seconds} 秒","Following clips move together":"后续片段随裁剪联动","Linked media stays in sync":"关联音视频保持同步","Source or clip limit reached":"已到裁剪边界(素材范围/最短时长/重叠限制)","Clip actions":"片段操作","Timeline actions":"时间轴操作","Selected {count} clips · linked media follows":"已选 {count} 个片段 · 编辑包含关联音视频","Shared gap on enabled tracks":"闭合各启用轨道的公共空隙","No shared gap: {track} contains a clip":"没有公共空隙:{track}此处有片段","Move the playhead inside the clip first":"请先将播放头移到片段内部","Go to clip start":"跳到此片段片头","Zoom to selected clips":"放大所选片段","Split at playhead":"在播放头处分割","Trim start to playhead":"裁片头到播放头(当前片段)","Trim end to playhead":"裁片尾到播放头(当前片段)","Delete selected clips":"删除所选片段","Close shared gap":"闭合公共空隙","Timeline changed. Open the menu again.":"时间轴已更新,请重新打开菜单。","+ New track":"+ 新建轨道","Place here":"放置到此处","Drop media here to create a track":"拖入素材以创建轨道","+ Adjust":"+ 调整层","+ Import":"+ 导入","+ Text":"+ 文字","+click bin":"+单击素材区","+click clip":"+单击片段","+wheel":"+滚轮","AI bg remove":"AI 去背景","Add / remove from selection":"加入/移出选区","Add / remove marker at playhead":"在播放头处添加/移除标记","Add text at the playhead":"在播放头处添加文字","Add an adjustment layer (grades everything below it)":"添加调整图层(影响下方所有图层)","Add at playhead":"添加到播放头位置","Add transition at playhead (in / out by half)":"在播放头处添加转场(按前后半段选择入场/出场)","Adjustment layer":"调整图层",Align:"水平对齐",Animation:"动画",Assets:"素材","Assets panel":"素材面板","Inspector panel":"属性面板","Connection status":"连接状态","Settings button":"设置入口","Project switcher":"项目切换","Layout controls":"布局切换","Monitor toolbar":"预览工具栏","Audio meter":"音量表","Go to start / end":"跳至开头/末尾","Fit timeline":"适应时间轴","Clip {number}":"片段{number}","Visible modules":"显示模块","Hidden modules keep their functions and shortcuts.":"隐藏模块仍保留原有功能和快捷键。","Audio / Time":"音频/时间","Audio Hold — while paused, loop one frame of audio at the playhead. Play/Stop turns it off.":"音频保持:暂停时循环播放播放头处一帧音频。播放/停止会关闭此功能。","Audio hold":"音频保持","Audio {number}":"音频{number}","Video {number}":"视频{number}",Undo:"撤销",Redo:"重做","Undo (Ctrl/Cmd+Z)":"撤销(Ctrl/Cmd+Z)","Redo (Ctrl/Cmd+Shift+Z)":"重做(Ctrl/Cmd+Shift+Z)","Back 1 frame (←)":"后退一帧(←)","Background removal unavailable — couldn't load MediaPipe (offline?). Using chroma key still works.":"去背景不可用:无法加载 MediaPipe,请检查网络。仍可使用色度抠像。","Bg color":"背景色",Blend:"混合模式",Bold:"粗体","Box W/H":"文本框宽/高","Buffering…":"缓冲中…","Cached video frames · {count} compatibility fallbacks · You can switch tabs.":"使用缓存帧 · {count} 个片段使用兼容模式 · 可切换标签页。",Cancel:"取消","Canvas aspect preset":"画布宽高比预设",Channel:"声道","Channel {number}":"声道 {number}","Choose…":"选择…","Clear IN / OUT marker":"清除入点/出点标记","Clear keyframes":"清除关键帧","Click to switch RMS / LUFS / Peak":"单击切换 RMS/LUFS/峰值","Click: keyframe graph · Ctrl-click: reset":"单击:关键帧曲线 · Ctrl+单击:重置","Clip — {kind}":"片段 — {kind}",Close:"关闭","Close gap":"闭合空隙","Close gap at playhead (enabled tracks)":"闭合播放头处的空隙(已启用轨道)","Close gap at playhead (⇧G)":"闭合播放头处的空隙(⇧G)","Close graph":"关闭曲线","Closed {seconds}s gap":"已闭合 {seconds} 秒空隙",Collapse:"折叠",Color:"颜色","Compact tracks (no thumbnails)":"紧凑轨道(无缩略图)",Content:"内容","Couldn't create project: ":"无法创建项目:","Couldn't import — unsupported or unreadable file type":"无法导入:文件类型不受支持或无法读取","Couldn't play ":"无法播放:","Create a project":"创建项目","Crop L/R %":"左右裁剪 %","Crop T/B %":"上下裁剪 %","Ctrl-click to reset":"Ctrl+单击重置","Custom · {fps} fps":"自定义 · {fps} fps","Custom · {width}×{height}":"自定义 · {width}×{height}","Cycle RMS → LUFS → Peak":"循环切换 RMS → LUFS → 峰值",Delete:"删除","Delete folder (keeps media)":"删除文件夹(保留素材)","Delete selected (Del)":"删除所选片段(Del)","Delete selected clip(s), or clear focused transition":"删除所选片段,或清除当前转场",Direction:"文字方向","Disable chroma key":"关闭色度抠像","Disable gradient":"关闭渐变","Disable track":"禁用轨道","Drag empty area":"拖动空白区域","Drag to adjust duration":"拖动调整时长","Drag to resize · double-click to reset":"拖动调整大小 · 双击重置","Drop files into":"将文件放入","Drop here to move to root":"拖到此处移至根目录","Drop video, audio or images here":"将视频、音频或图片拖到此处",Elements:"装饰素材","Enable track":"启用轨道","Encoding…":"编码中…",Expand:"展开",Export:"导出","Export preview":"成片预览","Export progress":"导出进度","Play exported video":"播放成片","Video duration {seconds}s":"成片时长 {seconds} 秒","Preparing video…":"正在准备视频…","Rendering video…":"正在合成视频…","Rendering clip {current}/{total}":"正在合成第 {current}/{total} 个片段"," · About {seconds}s remaining":" · 预计剩余 {seconds} 秒","Finalizing video…":"正在封装视频,即将完成…","Video complete. Your file is ready.":"合成完成,文件已就绪",Save:"保存","Saving…":"保存中…","Choose a folder to save your video.":"请选择成片保存文件夹","Saved: {name}":"已保存:{name}","Save cancelled. Click Save to choose a folder again.":"已取消保存,可点击「保存」重新选择文件夹","Unable to save. Click Save to try again.":"保存失败,请点击「保存」重试","Click Save to choose a location in your browser.":"请点击「保存」,通过浏览器选择保存位置","Check your browser downloads to confirm the file was saved.":"已请求浏览器下载,请在下载列表中确认保存结果","Retry export":"重试导出","Cancel this export?":"确定取消导出?","The video is still exporting. Cancelling will stop this task.":"视频仍在导出中,取消后将停止本次任务。","Continue export":"继续导出","Confirm cancellation":"确定取消","Quick export":"快捷合成","Quick export in progress. You can switch chats.":"正在后台快捷合成,可以切换聊天","Background recording failed. You can record in this page while it stays open.":"后台录制失败,可在当前页面录制。录制期间请保持页面在前台。","Record in this page":"在当前页面录制","Recording failed. Keep this page open to try again.":"当前页面录制失败,可重新录制。录制期间请保持页面在前台。","Export interrupted. Try quick export to export again.":"合成任务中断,可尝试快捷合成重新导出","Quick export in progress. Keep this page open and in the foreground.":"快捷合成中,请保持当前页面,不要关闭窗口或切换页面","The exported file is unavailable. Please retry.":"未获取到成片文件,请重试。","Unable to check export progress. Please retry.":"暂时无法查询导出进度,请重试。","Unable to preview. Open the downloaded video to watch it.":"暂时无法预览,请打开已下载的视频观看。","Export failed: ":"导出失败:","Exporting…":"导出中…","Exporting… {percent}%":"导出中… {percent}%","Tik — Video Editor":"Tik — 视频编辑器",Family:"字体","CLI Fast (ffmpeg)":"CLI 快速(ffmpeg)","CLI Optimized (ffmpeg + frame cache)":"CLI 优化(ffmpeg + 帧缓存)","Fast (ffmpeg)":"快速(ffmpeg)","Filter / Color":"滤镜/调色","Find gap":"查找空隙","Find gap (G)":"查找空隙(G)","Find next shared gap (wraps; respects IN/OUT)":"查找下一处共同空隙(循环查找,遵循入点/出点)",Fit:"适应","Flip H":"水平翻转","Flip V":"垂直翻转",Font:"字体","Font size":"字号","Forward 1 frame (→)":"前进一帧(→)","Queued for export…":"正在排队,等待导出…","Starting background export…":"正在启动后台导出…","CLI renders in a separate process. Closing this page does not stop export.":"CLI 在独立进程中渲染,关闭此页面后仍继续导出。","CLI pre-decodes and caches frames in a separate process. You can close this page.":"CLI 在独立进程中预解码并缓存视频帧,关闭此页面后仍继续导出。","CLI renders frames in a separate process with ffmpeg. You can close this page.":"CLI 在独立进程中逐帧渲染并交由 ffmpeg 编码,关闭此页面后仍继续导出。","Frame-accurate ffmpeg encode. Keeps rendering if you switch tabs.":"使用 ffmpeg 逐帧编码,切换标签页后仍继续渲染。","Glow color":"发光颜色","Glow uses the text color":"发光使用文字颜色","Go to end (End)":"跳到结尾(End)","Go to previous / next keyframe":"跳到上一/下一关键帧","Go to start (Home)":"跳到开头(Home)","Google font":"Google 字体","Google fonts":"Google 字体","Gradient bottom color":"渐变底部颜色","Height in px (0 = no box — hug content)":"高度(像素);0 表示按内容自适应","IN and OUT must be at different times":"入点与出点不能相同","IN must be before OUT":"入点必须早于出点","Import media files":"导入素材文件","Imported {added}, skipped {skipped}":"已导入 {added} 个,跳过 {skipped} 个","Imported, but cannot replace {kind} with {replacement}":"已导入,但无法用{replacement}替换{kind}片段",In:"入场",Inspector:"属性","Interface language":"界面语言",Italic:"斜体","Jump to start / end":"跳到开头/结尾","Key color":"抠像颜色","Keyboard shortcuts":"键盘快捷键","Keying / Cut-out":"抠像/去背景",L:"L",Language:"语言","Large tracks":"大轨道",Layout:"布局",Left:"左声道","Length (s)":"时长(秒)","Library fonts":"本地字体库",Limit:"限区播放","Limit (toolbar)":"限区播放(工具栏)","Limit playback to IN/OUT (Home/End jump to markers; playhead outside = override)":"限制在入点/出点之间播放(Home/End 跳到标记;播放头在范围外时不限制)","Link timeline and Project bin selection":"联动时间线与项目素材选择","Linked {count} audio channels from {name}":"已关联 {name} 的 {count} 个声道",Load:"加载",'Loading Google font "{name}"…':"正在加载 Google 字体“{name}”…",M:"M","Marquee-select clips":"框选片段","Max size":"最大字号","Media replaced":"已替换素材","Media replaced — trimmed to fit shorter source":"已替换素材,并按较短素材裁剪","Media used by this project — right-click for New folder":"本项目使用的素材,右键可新建文件夹","MediaRecorder is not supported in this browser.":"当前浏览器不支持 MediaRecorder。","Medium tracks":"中等轨道","Missing media":"素材缺失","Mixing audio…":"混音中…","Mode: {mode} — click to switch":"模式:{mode} — 单击切换","Motion FX":"运动特效","Move playhead over the selected clip":"请将播放头移到所选片段上",Name:"名称","Needs the server + ffmpeg and ffprobe on PATH.":"需要运行本地服务,且 PATH 中包含 ffmpeg 和 ffprobe。","Needs the server + ffmpeg on PATH.":"需要运行本地服务,且 PATH 中包含 ffmpeg。","New folder":"新建文件夹","New subfolder":"新建子文件夹","No assets yet.":"暂无素材。","No clips on the timeline use this media":"时间线上没有使用此素材的片段","No enabled tracks":"没有已启用的轨道","No gap on {track} — disable the track or move the playhead":"轨道 {track} 没有空隙,请禁用该轨道或移动播放头","No gaps found":"未找到空隙","No gaps in IN/OUT range":"入点/出点范围内没有空隙","No keyframes":"没有关键帧","No next keyframe":"没有下一关键帧","No other {kind} in bin":"素材区没有其他{kind}素材","No previous keyframe":"没有上一关键帧","Nothing to close at playhead":"播放头处没有可闭合的空隙","Nothing to split at IN/OUT":"入点/出点处没有可分割的片段","Nothing to trim":"没有可裁剪的片段","OUT must be after IN":"出点必须晚于入点","Open project":"打开项目","Optimized (ffmpeg + frame cache)":"优化(ffmpeg + 帧缓存)","Optimized export complete: {seconds}s · {hits} cache hits":"优化导出完成:{seconds} 秒 · 命中缓存 {hits} 次","Optimized export failed: ":"优化导出失败:",Out:"出场","Overlapping audio":"重叠音频","Overlay art from library/elements":"来自 library/elements 的叠加素材","Pick a style — hover to preview it live":"选择样式,悬停可实时预览","Play / Pause (Space)":"播放/暂停(空格)","Play / pause":"播放/暂停","Play stops at OUT; Home/End use IN/OUT":"播放到出点停止;Home/End 跳到入点/出点","Plays the timeline once and records it. Keep the tab focused.":"播放并录制一遍时间线,请保持当前标签页处于前台。","Position X":"位置 X","Position Y":"位置 Y","Pre-decodes video frames and caches them. First export needs preparation; you can switch tabs.":"预解码并缓存视频帧,首次导出需要准备,可切换标签页。","Pre-decoding video and caching frames. You can switch tabs.":"正在预解码视频并缓存帧,可切换标签页。","Preferences are stored in this browser only.":"偏好设置仅保存在当前浏览器中。","Preparing source frames…":"准备素材帧…","Preparing the next part of your video. Export will continue automatically.":"正在准备下一段视频,完成后将自动继续导出。","Preparing video frames…":"准备视频帧…",Preset:"预设",Preview:"预览","Preview speed — click to cycle 1×/1.5×/2×/4×. While playing: L faster · J slower · K play/pause and reset to 1×. Preview only, not export.":"预览速度:单击切换 1×/1.5×/2×/4×。播放时 L 加速、J 减速、K 播放/暂停并恢复 1×。仅影响预览。","Program Monitor":"预览监视器",Project:"项目",Media:"媒体","Project frame rate":"项目帧率","Project name":"项目名称","Project was updated externally — your last change may need redoing.":"项目已被外部更新,可能需要重新应用刚才的修改。","Random style":"随机样式","Realtime (in-browser)":"实时(浏览器内录制)","Remove (and its clips)":"移除素材及其片段","Rendering frames → ffmpeg. You can switch tabs; export continues.":"正在渲染并交由 ffmpeg 编码,切换标签页后仍继续导出。","Rendering your sequence in real time. Keep this tab focused.":"正在实时渲染时间线,请保持当前标签页处于前台。","Rendering… {percent}%":"渲染中… {percent}%","Replace this clip's media — keeps position, trim, keyframes and effects":"替换片段素材,保留位置、裁剪、关键帧和特效","Reset monitor view to fit stage":"重置预览缩放以适应窗口","Restore default layout":"恢复默认布局",Right:"右声道",S:"S",SVG:"SVG","Scale to fit":"缩放以适应","Select a clip first":"请先选择片段","Select a clip to edit its":"选择片段以编辑","Select a clip to zoom to":"请选择要缩放显示的片段","Select all / deselect":"全选/取消选择","Selecting a timeline clip highlights its media in Project, and the reverse. Off by default.":"选择时间线片段时高亮项目中的对应素材,反之亦然。默认关闭。",Selection:"选择","Set IN / OUT work-area marker":"设置工作区入点/出点标记","Set IN and OUT markers first (I / O)":"请先设置入点和出点标记(I / O)","Set an IN or OUT marker first (I / O)":"请先设置入点或出点标记(I / O)","Set keyframe at playhead":"在播放头处设置关键帧",Settings:"设置","Show / hide keyframe graph":"显示/隐藏关键帧曲线",Shuffle:"随机","Shuttle preview —":"变速预览 —",Snap:"吸附","Sound FX":"音效","Sound effects from library/sfx":"来自 library/sfx 的音效",Source:"素材源","Split at playhead (S)":"在播放头处分割(S)","Split clip(s) at playhead":"在播放头处分割片段","Split clips at IN/OUT markers":"在入点/出点标记处分割片段","Start (s)":"开始(秒)","Step 1 frame (⇧ = 1 second)":"前进/后退一帧(⇧ = 一秒)","Stroke col.":"描边颜色",System:"系统字体",Text:"文本","Text style":"文本样式","Then drag media onto the timeline below.":"然后将素材拖到下方时间线上。","Timeline is empty — add some clips first.":"时间线为空,请先添加片段。","Timeline track height":"时间线轨道高度","Toggle safe-area guides":"显示/隐藏安全区域参考线","Toggle snapping":"开关吸附","Toggle snapping (N)":"开关吸附(N)","Track {tracks} is disabled and will be omitted from the export.":"轨道 {tracks} 已禁用,不会包含在导出中。","Tracks {tracks} are disabled and will be omitted from the export.":"轨道 {tracks} 已禁用,不会包含在导出中。",Transform:"变换",Transition:"转场",Trim:"裁剪","Trim clips to IN/OUT (remove heads/tails)":"按入点/出点裁剪片段(移除首尾)","Trim clips to IN/OUT (⇧T)":"按入点/出点裁剪片段(⇧T)","Trim selected in / out to playhead":"将所选片段的入点/出点裁至播放头","Type any Google Font name…":"输入 Google 字体名称…","Undo / redo":"撤销/重做","Unsupported file type: ":"不支持的文件类型:","Untitled Project":"未命名项目",Uppercase:"全部大写","V-align":"垂直对齐","Vector animations from library/svg":"来自 library/svg 的矢量动画","Video preview: {name}: {error}":"视频预览:{name}:{error}",Weight:"字重","Width in px (0 = no box — hug content)":"宽度(像素);0 表示按内容自适应",Zoom:"缩放","Zoom timeline":"缩放时间线","Zoom timeline at cursor":"以光标为中心缩放时间线","Zoom timeline to fit":"缩放以显示完整时间线","Zoom timeline to fit (⇧Z)":"缩放以显示完整时间线(⇧Z)","Zoom to IN–OUT work area":"缩放以显示入点至出点的工作区","Zoom to selected clip(s)":"缩放以显示所选片段","faster /":"加速/",flat:"纯色","loading timed out":"加载超时",off:"关闭","or click":"或单击","play/pause + reset to 1×":"播放/暂停并恢复 1×","slower (1× · 1.5× · 2× · 4×);":"减速(1× · 1.5× · 2× · 4×);","transform, effects & audio.":"变换、特效与音频。","{count} clips selected — drag moves them together, Del deletes all. Fields below edit the primary (white-outlined) clip.":"已选 {count} 个片段,拖动可一起移动,Del 删除全部。下方属性仅编辑主选片段(白色边框)。","{name} has fewer channels — removed {count} linked audio clips":"{name} 的声道更少,已移除 {count} 个关联音频片段","{name}: added {tracks} audio tracks and linked {channels} channels":"{name}:已添加 {tracks} 条音轨并关联 {channels} 个声道","{name}: {count} audio channels, only {max} channels supported — extra channels dropped":"{name}:共 {count} 个声道,单个素材最多支持 {max} 个声道,已舍弃多余声道","— this list live-updates.":"— 列表会自动更新。","⊡ Layout":"⊡ 布局","▦ Safe":"▦ 安全区","⚪ local session":"⚪ 本地会话","✂ Split":"✂ 分割","⬇ Export":"⬇ 导出","+ Project":"+ 项目","📂 Browse file…":"📂 浏览文件…","🟢 connected":"🟢 已连接",video:"视频",audio:"音频",image:"图片",svg:"矢量动画",text:"文字",adjust:"调整层",none:"无",fade:"淡入淡出","slide-left":"向左滑入","slide-right":"向右滑入","slide-up":"向上滑入","slide-down":"向下滑入",zoom:"缩放",wipe:"向左擦除","wipe-right":"向右擦除","wipe-up":"向上擦除","wipe-down":"向下擦除",iris:"圆形展开",spin:"旋转",blur:"模糊",whip:"甩镜",glitch:"故障",pop:"弹出",typewriter:"打字机","word-pop":"逐词弹出","word-slide":"逐词滑入",karaoke:"卡拉 OK","letter-pop":"逐字弹出",wave:"波浪",bounce:"弹跳",shake:"抖动","clip-reveal":"裁切揭示","zoom-in":"放大入场","font-cut":"字体切换","rise-mask":"遮罩上升",normal:"正常",multiply:"正片叠底",screen:"滤色",overlay:"叠加",lighter:"加亮","soft-light":"柔光","hard-light":"强光","color-dodge":"颜色减淡",darken:"变暗",lighten:"变亮",difference:"差值",contain:"完整显示",cover:"填满画布",stretch:"拉伸",left:"左对齐",center:"居中",right:"右对齐",justify:"两端对齐",top:"顶部",middle:"中部",bottom:"底部",auto:"自动",ltr:"从左到右",rtl:"从右到左",cinematic:"电影感","teal-orange":"青橙",noir:"黑白电影",vintage:"复古",faded:"褪色",warm:"暖色",cold:"冷色",dreamy:"梦幻",retro:"怀旧","bw-soft":"柔和黑白",cyberpunk:"赛博朋克",sunset:"日落",midnight:"午夜",Plain:"简约",Impact:"冲击",Elegant:"优雅","Kinetic cut":"动态切换",Neon:"霓虹",Handwritten:"手写","Serif drop":"衬线落入",Subtitle:"字幕","Bold rise":"粗体上升",Luxury:"典雅",Scale:"缩放",Rotation:"旋转",Opacity:"不透明度",Brightness:"亮度",Contrast:"对比度",Saturation:"饱和度",Hue:"色相",Temperature:"色温",Tint:"色调",Blur:"模糊",Grayscale:"灰度",Sepia:"褐色",Invert:"反相",Vignette:"暗角",CornerRadius:"圆角",Shake:"抖动",ShakeSpeed:"抖动速度",RgbSplit:"色差分离",Grain:"颗粒",ChromaTolerance:"抠像容差",ChromaSoftness:"边缘柔化",Volume:"音量",Speed:"速度",LetterSpacing:"字距",LineHeight:"行距",StrokeWidth:"描边宽度",BgOpacity:"背景不透明度",TextShadow:"文字阴影",Glow:"发光",WordRate:"逐词间隔",FontSize:"字号","Pos X":"位置 X","Pos Y":"位置 Y",Bright:"亮度",Sat:"饱和度",Gray:"灰度",Temp:"色温",Radius:"圆角",RGB:"RGB",Size:"字号",Track:"字距","9:16 · Reel 1080×1920":"9:16 · 竖屏 1080×1920","4:5 · IG 1080×1350":"4:5 · 社交媒体 1080×1350"};let o="zh-CN";function a(e){return"en"===e?"en":"zh-CN"}function i(e,a={}){const i="zh-CN"===o&&Object.hasOwn(t,e)?t[e]:e;return String(i).replace(/\{(\w+)\}/g,(e,t)=>Object.hasOwn(a,t)?String(a[t]):e)}const r={text:i,normalize:a,setLanguage:function(e){o=a(e)},apply:function(e){e.documentElement.lang=o;for(const t of e.querySelectorAll("[data-i18n]"))t.textContent=i(t.getAttribute("data-i18n"));for(const t of["title","aria-label","placeholder"])for(const o of e.querySelectorAll(`[data-i18n-${t}]`))o.setAttribute(t,i(o.getAttribute(`data-i18n-${t}`)))},messages:t,get language(){return o}};"undefined"!=typeof module&&module.exports?module.exports=r:e.FableCutI18n=r}(globalThis);
Binary file
@@ -0,0 +1,320 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>提客AI智能体</title>
7
+ <link rel="icon" href="icons/tik-logo.png" type="image/png">
8
+ <meta name="theme-color" content="#0e0e10">
9
+ <link rel="stylesheet" href="style.css">
10
+ </head>
11
+ <body>
12
+ <dialog id="projectLoadDialog" class="dialog project-load-dialog" aria-labelledby="projectLoadTitle" aria-describedby="projectLoadHelp">
13
+ <h2 id="projectLoadTitle"></h2>
14
+ <p id="projectLoadHelp"></p>
15
+ <p id="projectLoadId" class="project-load-id"></p>
16
+ <details><summary data-i18n="Error details">Error details</summary><p id="projectLoadDetails"></p></details>
17
+ <div class="dialog-actions"><button class="btn primary" id="btnRetryProject" data-i18n="Retry loading">Retry loading</button></div>
18
+ </dialog>
19
+ <div class="app" id="app">
20
+
21
+ <!-- ══════════ TOP BAR ══════════ -->
22
+ <header class="topbar">
23
+ <div class="logo"><img class="logo-mark" src="icons/tik-logo.png" alt=""> <b>提客AI智能体</b></div>
24
+ <div class="topbar-center">
25
+ <span id="currentProjectName" class="current-project-name"></span>
26
+ <select id="projectSelect" class="project-select" data-ui-setting="showProjectSwitcher" title="Open project" data-i18n-title="Open project"></select>
27
+ <button type="button" class="btn tiny" id="btnNewProject" data-ui-setting="showNewProject" title="Create a project" data-i18n-title="Create a project"><span data-i18n="+ Project">+ Project</span></button>
28
+ <span id="projectName" class="connection-status" data-ui-setting="showConnectionStatus"><span data-i18n="⚪ local session">⚪ local session</span></span>
29
+ </div>
30
+ <div class="topbar-right">
31
+ <span class="layout-tools" data-ui-setting="showLayoutControls">
32
+ <button type="button" class="btn tiny" id="btnLayoutReset" title="Restore default layout" data-i18n-title="Restore default layout"><span data-i18n="⊡ Layout">⊡ Layout</span></button>
33
+ <span class="btn-group" id="trackSizeGroup" title="Timeline track height" data-i18n-title="Timeline track height">
34
+ <button type="button" class="btn tiny" data-track-size="s" title="Compact tracks (no thumbnails)" data-i18n-title="Compact tracks (no thumbnails)"><span data-i18n="S">S</span></button>
35
+ <button type="button" class="btn tiny" data-track-size="m" title="Medium tracks" data-i18n-title="Medium tracks"><span data-i18n="M">M</span></button>
36
+ <button type="button" class="btn tiny" data-track-size="l" title="Large tracks" data-i18n-title="Large tracks"><span data-i18n="L">L</span></button>
37
+ </span>
38
+ </span>
39
+ <button class="btn ghost" id="btnSettings" data-ui-setting="showSettings" title="Settings" aria-label="Settings" data-i18n-title="Settings" data-i18n-aria-label="Settings">
40
+ <svg class="ico" viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
41
+ <path fill="currentColor" fill-rule="evenodd" d="M6.71 2.81L6.88.94L9.12.94L9.29 2.81L10.76 3.41L12.2 2.22L13.78 3.8L12.59 5.24L13.19 6.71L15.06 6.88L15.06 9.12L13.19 9.29L12.59 10.76L13.78 12.2L12.2 13.78L10.76 12.59L9.29 13.19L9.12 15.06L6.88 15.06L6.71 13.19L5.24 12.59L3.8 13.78L2.22 12.2L3.41 10.76L2.81 9.29L.94 9.12L.94 6.88L2.81 6.71L3.41 5.24L2.22 3.8L3.8 2.22L5.24 3.41Z M5.15 8a2.85 2.85 0 1 0 5.7 0a2.85 2.85 0 1 0-5.7 0"/>
42
+ </svg>
43
+ </button>
44
+ <button class="btn ghost" id="btnHelp" data-ui-setting="showShortcuts" title="Keyboard shortcuts" data-i18n-title="Keyboard shortcuts">?</button>
45
+ <button class="btn primary" id="btnExport"><span data-i18n="⬇ Export">⬇ Export</span></button>
46
+ </div>
47
+ </header>
48
+
49
+ <!-- ══════════ UPPER PANELS ══════════ -->
50
+ <div class="upper">
51
+
52
+ <!-- Media bin -->
53
+ <aside class="panel bin" data-ui-setting="showAssets">
54
+ <div class="panel-head">
55
+ <span><span data-i18n="Assets">Assets</span></span>
56
+ <span class="panel-head-actions">
57
+ <button type="button" class="btn tiny" id="btnTitle" title="Add text at the playhead" data-i18n-title="Add text at the playhead"><span data-i18n="+ Text">+ Text</span></button>
58
+ <button type="button" class="btn tiny" id="btnAdjust" title="Add an adjustment layer (grades everything below it)" data-i18n-title="Add an adjustment layer (grades everything below it)"><span data-i18n="+ Adjust">+ Adjust</span></button>
59
+ <label class="btn tiny accent" id="btnImport" for="fileInput" title="Import media files" data-i18n-title="Import media files"><span data-i18n="+ Import">+ Import</span></label>
60
+ </span>
61
+ </div>
62
+ <div class="bin-tabs" id="binTabs">
63
+ <button type="button" data-tab="project" class="on" title="Media used by this project — right-click for New folder" data-i18n-title="Media used by this project — right-click for New folder"><span data-i18n="Media">Media</span></button>
64
+ <button type="button" data-tab="elements" title="Overlay art from library/elements" data-i18n-title="Overlay art from library/elements"><span data-i18n="Elements">Elements</span></button>
65
+ <button type="button" data-tab="sfx" title="Sound effects from library/sfx" data-i18n-title="Sound effects from library/sfx"><span data-i18n="Sound FX">Sound FX</span></button>
66
+ <button type="button" data-tab="svg" title="Vector animations from library/svg" data-i18n-title="Vector animations from library/svg"><span data-i18n="SVG">SVG</span></button>
67
+ </div>
68
+ <input type="file" id="fileInput" class="sr-only" multiple accept="video/*,audio/*,image/*,.mp4,.mov,.webm,.mkv,.mp3,.wav,.m4a,.png,.jpg,.jpeg,.gif,.webp,.svg">
69
+ <div class="bin-list" id="binList">
70
+ <div class="bin-empty" id="binEmpty">
71
+ <div class="bin-empty-icon">🎬</div>
72
+ <p><span data-i18n="Drop video, audio or images here">Drop video, audio or images here</span><br><span data-i18n="or click">or click</span> <b><span data-i18n="+ Import">+ Import</span></b></p>
73
+ <p class="hint"><span data-i18n="Then drag media onto the timeline below.">Then drag media onto the timeline below.</span></p>
74
+ </div>
75
+ </div>
76
+ <div class="bin-list hidden" id="libList"></div>
77
+ </aside>
78
+
79
+ <!-- Program monitor -->
80
+ <section class="panel monitor">
81
+ <div class="panel-head" data-ui-setting="showMonitorHeader">
82
+ <span class="panel-head-title">
83
+ <span><span data-i18n="Program Monitor">Program Monitor</span></span>
84
+ <button class="btn tiny hidden" id="btnZoom100" title="Reset monitor view to fit stage" data-i18n-title="Reset monitor view to fit stage"><svg class="ico" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="square" d="M1.5 5V1.5H5M11 1.5h3.5V5M14.5 11v3.5H11M5 14.5H1.5V11"/><path fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" d="M3.2 3.2l2.6 2.6M12.8 3.2l-2.6 2.6M12.8 12.8l-2.6-2.6M3.2 12.8l2.6-2.6"/></svg> <span data-i18n="Fit">Fit</span></button>
85
+ </span>
86
+ <span class="panel-head-actions">
87
+ <select id="aspectSel" class="head-select" title="Canvas aspect preset" data-i18n-title="Canvas aspect preset"></select>
88
+ <select id="fpsSel" class="head-select" title="Project frame rate" data-i18n-title="Project frame rate"></select>
89
+ <button class="btn tiny toggle" id="btnGuides" title="Toggle safe-area guides" data-i18n-title="Toggle safe-area guides"><span data-i18n="▦ Safe">▦ Safe</span></button>
90
+ </span>
91
+ </div>
92
+ <div class="monitor-stage" id="monitorStage">
93
+ <div class="monitor-scroll" id="monitorScroll">
94
+ <div class="monitor-zoom-inner" id="monitorZoomInner">
95
+ <canvas id="preview" width="1280" height="720"></canvas>
96
+ <div id="safeOverlay" class="hidden">
97
+ <div class="sa-action"></div>
98
+ <div class="sa-title"></div>
99
+ <div class="sa-ui-bottom"></div>
100
+ <div class="sa-ui-right"></div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ <div class="kf-graphs" id="kfGraphs" hidden></div>
105
+ <div class="missing-media-notice" id="missingMediaNotice" role="status" aria-live="polite" hidden>
106
+ <span class="missing-media-icon" aria-hidden="true">⚠</span>
107
+ <strong id="missingMediaTitle"></strong>
108
+ <span id="missingMediaNames"></span>
109
+ <span id="missingMediaHelp"></span>
110
+ <button class="btn" id="btnRetryMedia" data-i18n="Check again">Check again</button>
111
+ </div>
112
+ <div class="vu-meter" id="vuMeter" data-ui-setting="showAudioMeter" title="Click to switch RMS / LUFS / Peak" data-i18n-title="Click to switch RMS / LUFS / Peak"></div>
113
+ </div>
114
+ <div class="transport">
115
+ <span class="timecode" id="tcCurrent">00:00:00</span>
116
+ <div class="transport-btns">
117
+ <button class="btn icon" id="btnHome" data-ui-setting="showBoundaryNavigation" title="Go to start (Home)" data-i18n-title="Go to start (Home)">⏮</button>
118
+ <button class="btn icon" id="btnBack" title="Back 1 frame (←)" data-i18n-title="Back 1 frame (←)">◀︎</button>
119
+ <button class="btn icon big" id="btnPlay" title="Play / Pause (Space)" data-i18n-title="Play / Pause (Space)">▶</button>
120
+ <button class="btn icon" id="btnFwd" title="Forward 1 frame (→)" data-i18n-title="Forward 1 frame (→)">▶︎</button>
121
+ <button class="btn icon" id="btnEnd" data-ui-setting="showBoundaryNavigation" title="Go to end (End)" data-i18n-title="Go to end (End)">⏭</button>
122
+ <button class="btn icon toggle" id="btnSpeed" title="Preview speed — click to cycle 1×/1.5×/2×/4×. While playing: L faster · J slower · K play/pause and reset to 1×. Preview only, not export." data-i18n-title="Preview speed — click to cycle 1×/1.5×/2×/4×. While playing: L faster · J slower · K play/pause and reset to 1×. Preview only, not export.">1×</button>
123
+ </div>
124
+ <span class="timecode dim" id="tcTotal">00:00:00</span>
125
+ </div>
126
+ </section>
127
+
128
+ <!-- Inspector -->
129
+ <aside class="panel inspector" data-ui-setting="showInspector">
130
+ <div class="panel-head"><span><span data-i18n="Inspector">Inspector</span></span></div>
131
+ <div class="inspector-body" id="inspector">
132
+ <div class="inspector-empty"><span data-i18n="Select a clip to edit its">Select a clip to edit its</span><br><span data-i18n="transform, effects &amp; audio.">transform, effects &amp; audio.</span></div>
133
+ </div>
134
+ </aside>
135
+ </div>
136
+
137
+ <div class="v-split" id="splitUpperTimeline" role="separator" aria-orientation="horizontal" title="Drag to resize · double-click to reset" data-i18n-title="Drag to resize · double-click to reset"></div>
138
+
139
+ <!-- ══════════ TIMELINE ══════════ -->
140
+ <section class="panel timeline-panel" id="timelinePanel">
141
+ <div class="timeline-toolbar">
142
+ <button class="btn tiny hidden" id="btnTrimIO" title="Trim clips to IN/OUT (⇧T)" data-i18n-title="Trim clips to IN/OUT (⇧T)"><svg class="ico" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="square" d="M1.25 4.5H12M4.5 1.25V12M4.5 12H14.75M12 4.5V14.75"/></svg> <span data-i18n="Trim">Trim</span></button>
143
+ <button class="btn tiny toggle hidden" id="btnWorkAreaPlay" title="Limit playback to IN/OUT (Home/End jump to markers; playhead outside = override)" data-i18n-title="Limit playback to IN/OUT (Home/End jump to markers; playhead outside = override)"><svg class="ico" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="square" stroke-linejoin="miter" d="M1.5 2.5V13.5M14.5 2.5V13.5M4.25 8H11.75M9.35 5.6L12 8L9.35 10.4"/></svg> <span data-i18n="Limit">Limit</span></button>
144
+ <button class="btn tiny" id="btnSplit" title="Split at playhead (S)" data-i18n-title="Split at playhead (S)"><span data-i18n="✂ Split">✂ Split</span></button>
145
+ <button type="button" class="btn tiny" id="btnUndo" disabled title="Undo (Ctrl/Cmd+Z)" data-i18n-title="Undo (Ctrl/Cmd+Z)"><svg class="ico" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M5.5 2.5 2 6l3.5 3.5M2 6h7a4 4 0 0 1 0 8"/></svg> <span data-i18n="Undo">Undo</span></button>
146
+ <button type="button" class="btn tiny" id="btnRedo" disabled title="Redo (Ctrl/Cmd+Shift+Z)" data-i18n-title="Redo (Ctrl/Cmd+Shift+Z)"><svg class="ico" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="m10.5 2.5 3.5 3.5-3.5 3.5M14 6H7a4 4 0 0 0 0 8"/></svg> <span data-i18n="Redo">Redo</span></button>
147
+ <button class="btn tiny" id="btnCloseGap" data-ui-setting="showCloseGap" title="Close gap at playhead (⇧G)" data-i18n-title="Close gap at playhead (⇧G)"><svg class="ico" viewBox="0 0 16 16" width="14" height="14" style="vertical-align:-3px" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="miter" d="M0.75 4.5H3.5V11.5H0.75M15.25 4.5H12.5V11.5H15.25"/><path fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" d="M6.35 5.85L9.65 10.15M9.65 5.85L6.35 10.15"/></svg> <span data-i18n="Close gap">Close gap</span></button>
148
+ <button class="btn tiny" id="btnNextGap" data-ui-setting="showFindGap" title="Find gap (G)" data-i18n-title="Find gap (G)"><svg class="ico" viewBox="0 0 16 16" width="14" height="14" style="vertical-align:-3px" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="miter" d="M0.75 4.5H3.5V11.5H0.75M15.25 4.5H12.5V11.5H15.25"/><text x="8" y="11.1" text-anchor="middle" fill="currentColor" font-size="9.5" font-family="Segoe UI, system-ui, sans-serif" font-weight="700">?</text></svg> <span data-i18n="Find gap">Find gap</span></button>
149
+ <button class="btn tiny" id="btnDelete" title="Delete selected (Del)" data-i18n-title="Delete selected (Del)"><svg class="ico" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path fill="currentColor" d="M5.5 1.5h5v1.25H14v1.5H2v-1.5h3.5V1.5zM3.75 5.5h8.5v8.25a1.5 1.5 0 0 1-1.5 1.5h-5.5a1.5 1.5 0 0 1-1.5-1.5V5.5zm2 2v5h1.25v-5H5.75zm3.25 0v5H10.25v-5H9z"/></svg> <span data-i18n="Delete">Delete</span></button>
150
+ <button class="btn tiny toggle on" id="btnSnap" data-ui-setting="showSnap" title="Toggle snapping (N)" data-i18n-title="Toggle snapping (N)"><svg class="ico" viewBox="0 0 16 16" width="13" height="13" style="vertical-align:-3px" aria-hidden="true"><g transform="rotate(270 8 7)"><path fill="none" stroke="currentColor" stroke-width="3.25" stroke-linecap="butt" d="M4 10.25V7a4 4 0 0 1 8 0v3.25"/><path fill="currentColor" d="M2.375 10.75h3.25v2.5h-3.25zm8 0h3.25v2.5h-3.25z"/></g></svg> <span data-i18n="Snap">Snap</span></button>
151
+ <button class="btn tiny toggle" id="btnAudioHold" data-ui-setting="showAudioHold" title="Audio Hold — while paused, loop one frame of audio at the playhead. Play/Stop turns it off." data-i18n-title="Audio Hold — while paused, loop one frame of audio at the playhead. Play/Stop turns it off."><svg class="ico" viewBox="0 0 16 16" width="13" height="13" style="vertical-align:-3px" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="square" d="M1.75 2.75V13.25M14.25 2.75V13.25"/><path fill="currentColor" d="M5.2 5.85h1.15v4.3H5.2V5.85zM7.05 5.85h0.55l2.85-2.2v8.7l-2.85-2.2H7.05V5.85z"/></svg> <span data-i18n="Audio hold">Audio hold</span></button>
152
+ <div class="thumbnail-progress" id="thumbProgress" hidden>
153
+ <label id="thumbProgressLabel" for="thumbProgressBar"></label>
154
+ <progress id="thumbProgressBar" max="1" value="0"></progress>
155
+ </div>
156
+ <div class="timeline-zoom-controls">
157
+ <span class="dim tiny-label"><span data-i18n="Zoom">Zoom</span></span>
158
+ <button type="button" class="btn tiny zoom-step" id="btnZoomOut" title="Zoom out (-)" aria-label="Zoom out (-)" data-i18n-title="Zoom out (-)" data-i18n-aria-label="Zoom out (-)">−</button>
159
+ <input type="range" id="zoomSlider" min="1" max="300" value="60" aria-label="Timeline zoom" data-i18n-aria-label="Timeline zoom">
160
+ <button type="button" class="btn tiny zoom-step" id="btnZoomIn" title="Zoom in (+)" aria-label="Zoom in (+)" data-i18n-title="Zoom in (+)" data-i18n-aria-label="Zoom in (+)">+</button>
161
+ <button class="btn tiny" id="btnZoomFit" data-ui-setting="showTimelineFit" title="Zoom timeline to fit (⇧Z)" data-i18n-title="Zoom timeline to fit (⇧Z)"><span class="fit-icon">│<span class="fit-arrow">↔</span>│</span> <span data-i18n="Fit">Fit</span></button>
162
+ </div>
163
+ </div>
164
+ <div class="timeline">
165
+ <div class="track-headers" id="trackHeaders"></div>
166
+ <div class="timeline-scroll" id="timelineScroll">
167
+ <canvas class="ruler" id="ruler"></canvas>
168
+ <div class="tracks-content" id="tracksContent">
169
+ <div class="work-dim work-dim-l hidden" id="workDimL"></div>
170
+ <div class="work-dim work-dim-r hidden" id="workDimR"></div>
171
+ <div class="tracks" id="tracks"></div>
172
+ <div class="playhead" id="playhead"><div class="playhead-cap"></div></div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </section>
177
+ </div>
178
+
179
+ <!-- ══════════ EXPORT OVERLAY ══════════ -->
180
+ <div class="overlay hidden" id="exportOverlay">
181
+ <div class="dialog export-dialog" id="exportDialog" role="dialog" aria-modal="true" aria-labelledby="exportTitle" tabindex="-1">
182
+ <header class="export-header">
183
+ <svg class="export-header-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M3 5h18v15H3zM3 10h18M7 5l3 5m4-5 3 5"/></svg>
184
+ <div class="export-heading"><h2 id="exportTitle"></h2><p id="exportDuration"></p></div>
185
+ <button class="export-close" id="btnCloseExport" data-i18n-aria-label="Close" data-i18n-title="Close" aria-label="Close" title="Close">×</button>
186
+ </header>
187
+ <div class="export-body">
188
+ <div class="export-preview" id="exportPreview">
189
+ <video id="exportVideo" playsinline preload="metadata" data-i18n-aria-label="Export preview" aria-label="Export preview"></video>
190
+ <button class="export-play" id="btnExportPreview" disabled data-i18n-aria-label="Play exported video" aria-label="Play exported video"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="m9 5 10 7-10 7z"/></svg></button>
191
+ <span class="export-time" id="exportTime"></span>
192
+ </div>
193
+ </div>
194
+ <footer class="export-footer">
195
+ <div class="export-status-row"><span class="export-status-icon" aria-hidden="true"></span><span id="exportNote" role="status"></span><strong id="exportPercent">0%</strong></div>
196
+ <div class="progress" id="exportProgressTrack" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-i18n-aria-label="Export progress" aria-label="Export progress"><div class="progress-fill" id="exportProgress"></div></div>
197
+ <div class="export-track-warn hidden" id="exportTrackWarn" role="status"></div>
198
+ <div class="export-recovery">
199
+ <div class="dialog-actions">
200
+ <button class="btn primary export-quick" id="btnSaveExport" hidden><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M5 15v5h14v-5"/></svg><span id="exportSaveLabel" data-i18n="Save">Save</span></button>
201
+ <button class="btn" id="btnRetryExport" hidden data-i18n="Retry export">Retry export</button>
202
+ <button class="btn primary export-quick" id="btnExportFallback" hidden><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="m13 2-9 12h7l-1 8 10-13h-7z"/></svg><span data-i18n="Quick export">Quick export</span></button>
203
+ <button class="btn" id="btnCancelExport" hidden data-i18n="Cancel">Cancel</button>
204
+ </div>
205
+ </div>
206
+ <p class="export-save-note" id="exportSaveNote" role="status" hidden></p>
207
+ </footer>
208
+ </div>
209
+ </div>
210
+
211
+ <!-- 取消导出需明确确认;原生模态框负责焦点限制与键盘访问。 -->
212
+ <dialog class="dialog export-cancel-dialog" id="cancelExportDialog" aria-labelledby="cancelExportTitle" aria-describedby="cancelExportDescription">
213
+ <h2 id="cancelExportTitle" data-i18n="Cancel this export?">Cancel this export?</h2>
214
+ <p id="cancelExportDescription" data-i18n="The video is still exporting. Cancelling will stop this task.">The video is still exporting. Cancelling will stop this task.</p>
215
+ <div class="dialog-actions">
216
+ <button class="btn" id="btnContinueExport" autofocus data-i18n="Continue export">Continue export</button>
217
+ <button class="btn export-cancel-confirm" id="btnConfirmCancelExport" data-i18n="Confirm cancellation">Confirm cancellation</button>
218
+ </div>
219
+ </dialog>
220
+
221
+ <!-- ══════════ SETTINGS OVERLAY ══════════ -->
222
+ <div class="overlay hidden" id="settingsOverlay">
223
+ <div class="dialog settings" id="settingsDialog" role="dialog" aria-modal="true" aria-labelledby="settingsTitle" tabindex="-1">
224
+ <h2 id="settingsTitle"><span data-i18n="Settings">Settings</span></h2>
225
+ <p class="dim settings-intro"><span data-i18n="Preferences are stored in this browser only.">Preferences are stored in this browser only.</span></p>
226
+ <div class="settings-section">
227
+ <h3><span data-i18n="Language">Language</span></h3>
228
+ <label class="settings-row language-row" for="setLanguage">
229
+ <span class="settings-label"><span data-i18n="Interface language">Interface language</span></span>
230
+ <select id="setLanguage" aria-label="Interface language" data-i18n-aria-label="Interface language">
231
+ <option value="zh-CN">简体中文</option>
232
+ <option value="en">English</option>
233
+ </select>
234
+ </label>
235
+ </div>
236
+ <div class="settings-section">
237
+ <h3><span data-i18n="Selection">Selection</span></h3>
238
+ <label class="settings-row">
239
+ <input type="checkbox" id="setLinkSelect">
240
+ <span>
241
+ <span class="settings-label"><span data-i18n="Link timeline and Project bin selection">Link timeline and Project bin selection</span></span>
242
+ <span class="settings-hint"><span data-i18n="Selecting a timeline clip highlights its media in Project, and the reverse. Off by default.">Selecting a timeline clip highlights its media in Project, and the reverse. Off by default.</span></span>
243
+ </span>
244
+ </label>
245
+ </div>
246
+ <div class="settings-section">
247
+ <h3><span data-i18n="Visible modules">Visible modules</span></h3>
248
+ <p class="settings-hint"><span data-i18n="Hidden modules keep their functions and shortcuts.">Hidden modules keep their functions and shortcuts.</span></p>
249
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showAssets"><span data-i18n="Assets panel">Assets panel</span></label>
250
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showInspector"><span data-i18n="Inspector panel">Inspector panel</span></label>
251
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showNewProject"><span data-i18n="Create a project">Create a project</span></label>
252
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showConnectionStatus"><span data-i18n="Connection status">Connection status</span></label>
253
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showSettings"><span data-i18n="Settings button">Settings button</span></label>
254
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showCloseGap"><span data-i18n="Close gap">Close gap</span></label>
255
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showFindGap"><span data-i18n="Find gap">Find gap</span></label>
256
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showSnap"><span data-i18n="Snap">Snap</span></label>
257
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showAudioHold"><span data-i18n="Audio hold">Audio hold</span></label>
258
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showProjectSwitcher"><span data-i18n="Project switcher">Project switcher</span></label>
259
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showLayoutControls"><span data-i18n="Layout controls">Layout controls</span></label>
260
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showShortcuts"><span data-i18n="Keyboard shortcuts">Keyboard shortcuts</span></label>
261
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showMonitorHeader"><span data-i18n="Monitor toolbar">Monitor toolbar</span></label>
262
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showAudioMeter"><span data-i18n="Audio meter">Audio meter</span></label>
263
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showBoundaryNavigation"><span data-i18n="Go to start / end">Go to start / end</span></label>
264
+ <label class="settings-row"><input type="checkbox" data-visibility-setting="showTimelineFit"><span data-i18n="Fit timeline">Fit timeline</span></label>
265
+ </div>
266
+ <div class="dialog-actions"><button class="btn primary" id="btnCloseSettings"><span data-i18n="Close">Close</span></button></div>
267
+ </div>
268
+ </div>
269
+
270
+ <!-- ══════════ HELP OVERLAY ══════════ -->
271
+ <div class="overlay hidden" id="helpOverlay">
272
+ <div class="dialog help">
273
+ <h2><span data-i18n="Keyboard shortcuts">Keyboard shortcuts</span></h2>
274
+ <div class="keys-cols">
275
+ <table class="keys">
276
+ <tr><td><kbd>Space</kbd></td><td><span data-i18n="Play / pause">Play / pause</span></td></tr>
277
+ <tr><td><kbd>J</kbd> <kbd>K</kbd> <kbd>L</kbd></td><td><span data-i18n="Shuttle preview —">Shuttle preview —</span> <kbd>L</kbd> <span data-i18n="faster /">faster /</span> <kbd>J</kbd> <span data-i18n="slower (1× · 1.5× · 2× · 4×);">slower (1× · 1.5× · 2× · 4×);</span> <kbd>K</kbd> <span data-i18n="play/pause + reset to 1×">play/pause + reset to 1×</span></td></tr>
278
+ <tr><td><kbd>S</kbd></td><td><span data-i18n="Split clip(s) at playhead">Split clip(s) at playhead</span></td></tr>
279
+ <tr><td><kbd>G</kbd></td><td><span data-i18n="Find next shared gap (wraps; respects IN/OUT)">Find next shared gap (wraps; respects IN/OUT)</span></td></tr>
280
+ <tr><td><kbd>⇧</kbd>+<kbd>G</kbd></td><td><span data-i18n="Close gap at playhead (enabled tracks)">Close gap at playhead (enabled tracks)</span></td></tr>
281
+ <tr><td><kbd>Alt</kbd>+<kbd>T</kbd></td><td><span data-i18n="Add transition at playhead (in / out by half)">Add transition at playhead (in / out by half)</span></td></tr>
282
+ <tr><td><kbd>Del</kbd></td><td><span data-i18n="Delete selected clip(s), or clear focused transition">Delete selected clip(s), or clear focused transition</span></td></tr>
283
+ <tr><td><span data-i18n="Drag empty area">Drag empty area</span></td><td><span data-i18n="Marquee-select clips">Marquee-select clips</span></td></tr>
284
+ <tr><td><kbd>Ctrl</kbd><span data-i18n="+click bin">+click bin</span></td><td><span data-i18n="Import media files">Import media files</span></td></tr>
285
+ <tr><td><kbd>Ctrl</kbd><span data-i18n="+click clip">+click clip</span></td><td><span data-i18n="Add / remove from selection">Add / remove from selection</span></td></tr>
286
+ <tr><td><kbd>Ctrl</kbd>+<kbd>A</kbd> / <kbd>Esc</kbd></td><td><span data-i18n="Select all / deselect">Select all / deselect</span></td></tr>
287
+ <tr><td><kbd>←</kbd> <kbd>→</kbd></td><td><span data-i18n="Step 1 frame (⇧ = 1 second)">Step 1 frame (⇧ = 1 second)</span></td></tr>
288
+ <tr><td><kbd>Ctrl</kbd>+<kbd>←</kbd> / <kbd>Ctrl</kbd>+<kbd>→</kbd></td><td><span data-i18n="Go to previous / next keyframe">Go to previous / next keyframe</span></td></tr>
289
+ <tr><td><kbd>[</kbd> / <kbd>]</kbd></td><td><span data-i18n="Trim selected in / out to playhead">Trim selected in / out to playhead</span></td></tr>
290
+ <tr><td><kbd>Home</kbd> / <kbd>End</kbd></td><td><span data-i18n="Jump to start / end">Jump to start / end</span></td></tr>
291
+ </table>
292
+ <table class="keys">
293
+ <tr><td><kbd>M</kbd></td><td><span data-i18n="Add / remove marker at playhead">Add / remove marker at playhead</span></td></tr>
294
+ <tr><td><kbd>I</kbd> / <kbd>O</kbd></td><td><span data-i18n="Set IN / OUT work-area marker">Set IN / OUT work-area marker</span></td></tr>
295
+ <tr><td><kbd>⇧</kbd>+<kbd>I</kbd> / <kbd>⇧</kbd>+<kbd>O</kbd></td><td><span data-i18n="Clear IN / OUT marker">Clear IN / OUT marker</span></td></tr>
296
+ <tr><td><kbd>T</kbd></td><td><span data-i18n="Split clips at IN/OUT markers">Split clips at IN/OUT markers</span></td></tr>
297
+ <tr><td><kbd>⇧</kbd>+<kbd>T</kbd></td><td><span data-i18n="Trim clips to IN/OUT (remove heads/tails)">Trim clips to IN/OUT (remove heads/tails)</span></td></tr>
298
+ <tr><td><span data-i18n="Limit (toolbar)">Limit (toolbar)</span></td><td><span data-i18n="Play stops at OUT; Home/End use IN/OUT">Play stops at OUT; Home/End use IN/OUT</span></td></tr>
299
+ <tr><td><kbd>N</kbd></td><td><span data-i18n="Toggle snapping">Toggle snapping</span></td></tr>
300
+ <tr><td><kbd>+</kbd> / <kbd>-</kbd></td><td><span data-i18n="Zoom timeline">Zoom timeline</span></td></tr>
301
+ <tr><td><kbd>Z</kbd></td><td><span data-i18n="Zoom to selected clip(s)">Zoom to selected clip(s)</span></td></tr>
302
+ <tr><td><kbd>Alt</kbd>+<kbd>Z</kbd></td><td><span data-i18n="Zoom to IN–OUT work area">Zoom to IN–OUT work area</span></td></tr>
303
+ <tr><td><kbd>⇧</kbd>+<kbd>Z</kbd></td><td><span data-i18n="Zoom timeline to fit">Zoom timeline to fit</span></td></tr>
304
+ <tr><td><kbd>Ctrl</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>⇧</kbd>+<kbd>Z</kbd></td><td><span data-i18n="Undo / redo">Undo / redo</span></td></tr>
305
+ <tr><td><kbd>Ctrl</kbd><span data-i18n="+wheel">+wheel</span></td><td><span data-i18n="Zoom timeline at cursor">Zoom timeline at cursor</span></td></tr>
306
+ </table>
307
+ </div>
308
+ <div class="dialog-actions"><button class="btn primary" id="btnCloseHelp"><span data-i18n="Close">Close</span></button></div>
309
+ </div>
310
+ </div>
311
+
312
+ <div id="toast"></div>
313
+ <div id="timelineGestureHint" class="timeline-gesture-hint" role="status" hidden></div>
314
+
315
+ <script src="i18n.js"></script>
316
+ <script src="optimized-export.js"></script>
317
+ <script src="timeline.js"></script>
318
+ <script src="app.js"></script>
319
+ </body>
320
+ </html>