功能完善
UI改进
@@ -3,8 +3,11 @@
|
|||||||
<Header
|
<Header
|
||||||
:currentView="currentView"
|
:currentView="currentView"
|
||||||
:theme="theme"
|
:theme="theme"
|
||||||
|
:activeTaskCount="activeTaskCount"
|
||||||
|
:showTaskDrawer="showTaskDrawer"
|
||||||
@navigate="navigate"
|
@navigate="navigate"
|
||||||
@toggleTheme="toggleTheme"
|
@toggleTheme="toggleTheme"
|
||||||
|
@toggleTaskDrawer="showTaskDrawer = !showTaskDrawer"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="main-area">
|
<div class="main-area">
|
||||||
@@ -24,15 +27,15 @@
|
|||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
<LogPage v-show="currentView === 'logs'" ref="logPageRef" />
|
<LogPage v-show="currentView === 'logs'" ref="logPageRef" />
|
||||||
</main>
|
</main>
|
||||||
<TaskDrawer :tasks="tasks" @cancel="handleCancel" @remove="handleRemove" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<TaskDrawer :tasks="tasks" :show="showTaskDrawer" @cancel="handleCancel" @remove="handleRemove" @close="showTaskDrawer = false" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import Header from './components/Header.vue'
|
import Header from './components/Header.vue'
|
||||||
import Sidebar from './components/Sidebar.vue'
|
import Sidebar from './components/Sidebar.vue'
|
||||||
import TaskDrawer from './components/TaskDrawer.vue'
|
import TaskDrawer from './components/TaskDrawer.vue'
|
||||||
@@ -58,12 +61,23 @@ async function restoreTheme() {
|
|||||||
document.documentElement.setAttribute('data-theme', theme.value)
|
document.documentElement.setAttribute('data-theme', theme.value)
|
||||||
}
|
}
|
||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
|
const showTaskDrawer = ref(false)
|
||||||
|
const activeTaskCount = computed(() => tasks.value.filter(t => t.status === 'running' || t.status === 'pending').length)
|
||||||
const gpuInfo = ref<any[]>([])
|
const gpuInfo = ref<any[]>([])
|
||||||
|
|
||||||
const logPageRef = ref<InstanceType<typeof LogPage> | null>(null)
|
const logPageRef = ref<InstanceType<typeof LogPage> | null>(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Suppress browser-native behaviours in the desktop webview
|
||||||
document.addEventListener('contextmenu', e => e.preventDefault())
|
document.addEventListener('contextmenu', e => e.preventDefault())
|
||||||
|
document.addEventListener('wheel', e => { if (e.ctrlKey) e.preventDefault() }, { passive: false })
|
||||||
|
document.addEventListener('dragover', e => e.preventDefault())
|
||||||
|
document.addEventListener('drop', e => e.preventDefault())
|
||||||
|
document.addEventListener('dragstart', e => e.preventDefault())
|
||||||
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
// Block refresh: F5 / Ctrl+R / Ctrl+Shift+R
|
||||||
|
if (e.key === 'F5' || ((e.ctrlKey || e.metaKey) && e.key === 'r')) e.preventDefault()
|
||||||
|
})
|
||||||
restoreTheme()
|
restoreTheme()
|
||||||
loadTasks()
|
loadTasks()
|
||||||
loadGPUInfo()
|
loadGPUInfo()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785380202265" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14723" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="currentColor" d="M509.184 84.992a427.008 427.008 0 0 1 85.888 8.704 38.4 38.4 0 0 1 1.92 74.496 207.104 207.104 0 1 0 252.864 252.864 38.4 38.4 0 0 1 74.752 1.92 424.256 424.256 0 1 1-415.424-337.984z m140.288 567.808a283.968 283.968 0 0 1-199.68-485.952 347.392 347.392 0 1 0 401.6 401.6 283.904 283.904 0 0 1-201.92 84.352z" p-id="14724"></path></svg>
|
||||||
|
After Width: | Height: | Size: 665 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785380229847" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16726" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="currentColor" d="M533.333333 853.333333a21.333333 21.333333 0 0 1 21.333334 21.333334v106.666666a21.333333 21.333333 0 0 1-21.333334 21.333334h-42.666666a21.333333 21.333333 0 0 1-21.333334-21.333334v-106.666666a21.333333 21.333333 0 0 1 21.333334-21.333334h42.666666z m-282.282666-100.949333l30.272 30.08a21.333333 21.333333 0 0 1 0.085333 30.165333L207.637333 886.826667a21.333333 21.333333 0 0 1-30.165333 0.085333l-30.250667-30.08a21.333333 21.333333 0 0 1-0.085333-30.165333l73.749333-74.176a21.333333 21.333333 0 0 1 30.165334-0.085334z m564.714666-8.682667l82.816 82.816a21.333333 21.333333 0 0 1 0 30.165334l-30.165333 30.165333a21.333333 21.333333 0 0 1-30.165333 0L755.413333 804.053333a21.333333 21.333333 0 0 1 0-30.165333l30.165334-30.165333a21.333333 21.333333 0 0 1 30.165333 0zM512.426667 256c153.173333 0 277.333333 124.16 277.333333 277.333333s-124.16 277.333333-277.333333 277.333334-277.333333-124.16-277.333334-277.333334 124.16-277.333333 277.333334-277.333333z m0 85.333333a192 192 0 1 0 0 384 192 192 0 0 0 0-384zM192 490.666667a21.333333 21.333333 0 0 1 21.333333 21.333333v42.666667a21.333333 21.333333 0 0 1-21.333333 21.333333H64a21.333333 21.333333 0 0 1-21.333333-21.333333v-42.666667a21.333333 21.333333 0 0 1 21.333333-21.333333h128z m768-21.333334a21.333333 21.333333 0 0 1 21.333333 21.333334v42.666666a21.333333 21.333333 0 0 1-21.333333 21.333334h-128a21.333333 21.333333 0 0 1-21.333333-21.333334v-42.666666a21.333333 21.333333 0 0 1 21.333333-21.333334h128zM846.08 160.704l30.250667 30.08a21.333333 21.333333 0 0 1 0.085333 30.165333l-82.56 83.050667a21.333333 21.333333 0 0 1-30.165333 0.085333l-30.250667-30.08a21.333333 21.333333 0 0 1-0.106667-30.186666l82.56-83.029334a21.333333 21.333333 0 0 1 30.186667-0.085333z m-616.746667 0.042667l73.962667 73.962666a21.333333 21.333333 0 0 1 0 30.165334l-30.165333 30.165333a21.333333 21.333333 0 0 1-30.165334 0l-73.962666-73.962667a21.333333 21.333333 0 0 1 0-30.165333l30.165333-30.165333a21.333333 21.333333 0 0 1 30.165333 0zM533.333333 42.666667a21.333333 21.333333 0 0 1 21.333334 21.333333v128a21.333333 21.333333 0 0 1-21.333334 21.333333h-42.666666a21.333333 21.333333 0 0 1-21.333334-21.333333V64a21.333333 21.333333 0 0 1 21.333334-21.333333h42.666666z" p-id="16727"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785378916571" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2496" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M812.3 959.4H213.7c-81.6 0-148-66.4-148-148V212.9c0-81.6 66.4-148 148-148h598.5c81.6 0 148 66.4 148 148v598.5C960.3 893 893.9 959.4 812.3 959.4zM213.7 120.9c-50.7 0-92 41.3-92 92v598.5c0 50.7 41.3 92 92 92h598.5c50.7 0 92-41.3 92-92V212.9c0-50.7-41.3-92-92-92H213.7z" fill="currentColor" p-id="2497"></path></svg>
|
||||||
|
After Width: | Height: | Size: 621 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785378927584" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2651" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M929.8 528.1H93.5c-15.5 0-28-12.5-28-28s12.5-28 28-28h836.3c15.5 0 28 12.5 28 28s-12.5 28-28 28z" fill="currentColor" p-id="2652"></path></svg>
|
||||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785379007596" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3117" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M812.2 65H351.6c-78.3 0-142.5 61.1-147.7 138.1-77 5.1-138.1 69.4-138.1 147.7v460.6c0 81.6 66.4 148 148 148h460.6c78.3 0 142.5-61.1 147.7-138.1 77-5.1 138.1-69.4 138.1-147.7V213c0-81.6-66.4-148-148-148z m-45.8 746.3c0 50.7-41.3 92-92 92H213.8c-50.7 0-92-41.3-92-92V350.7c0-50.7 41.3-92 92-92h460.6c50.7 0 92 41.3 92 92v460.6z m137.8-137.7c0 47.3-35.8 86.3-81.8 91.4V350.7c0-81.6-66.4-148-148-148H260.2c5.1-45.9 44.2-81.8 91.4-81.8h460.6c50.7 0 92 41.3 92 92v460.7z" fill="currentColor" p-id="3118"></path></svg>
|
||||||
|
After Width: | Height: | Size: 818 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785378895955" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2340" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M512.7 664.3c-82.9 0-150.4-67.4-150.4-150.4 0-82.9 67.4-150.4 150.4-150.4 82.9 0 150.4 67.4 150.4 150.4-0.1 83-67.5 150.4-150.4 150.4z m0-244.7c-52 0-94.4 42.3-94.4 94.4 0 52 42.3 94.4 94.4 94.4S607 566 607 514c0-52-42.3-94.4-94.3-94.4z" fill="currentColor" p-id="2341"></path><path d="M631.2 940.5c-15.2 0-30.1-6-41.2-17.3l-63.5-64.8c-4.1-4.2-9.5-6.5-15.4-6.5-5.8 0-11.3 2.3-15.3 6.4l-63.5 64.4c-17.4 17.6-44 22.2-66.2 11.4l-94.5-45.7c-22.2-10.8-35.2-34.5-32.2-59l11-90.1c0.7-5.8-0.9-11.5-4.5-16-3.6-4.6-8.8-7.4-14.6-8l-89.9-9.5c-24.6-2.6-44.8-20.5-50.2-44.6L67.7 558.8c-5.5-24.1 5-49 26-62l77.3-47.6c5-3.1 8.4-7.9 9.7-13.5 1.3-5.7 0.3-11.5-2.8-16.4L129.2 343c-13.3-20.8-11.9-47.8 3.5-67.1l65.5-82c15.4-19.3 41.4-26.7 64.7-18.3l85.4 30.7c5.5 2 11.4 1.7 16.6-0.9 5.2-2.5 9.2-7 11.1-12.5l29.2-85.6c8-23.4 29.9-39.1 54.6-39.1h105c24.7 0 46.7 15.7 54.6 39.1l29.6 86.8c1.9 5.5 5.8 9.9 11 12.5s11.1 2.8 16.6 0.9l86.1-30.6c23.3-8.3 49.2-0.8 64.6 18.5l65.2 82.3c15.3 19.4 16.7 46.3 3.3 67.1l-49.1 76.3c-3.2 4.9-4.2 10.7-2.9 16.4 1.3 5.7 4.7 10.5 9.7 13.6l76.8 47.7c21 13 31.4 38 25.8 62l-23.6 102.3a57.67 57.67 0 0 1-50.4 44.4l-90.3 9.2c-5.8 0.6-11 3.4-14.6 8-3.6 4.5-5.3 10.2-4.6 16l10.7 89.8c2.9 24.5-10.1 48.2-32.4 58.9l-94.7 45.4c-8.1 3.9-16.6 5.7-25 5.7zM511 795.9h0.1c21 0 40.6 8.3 55.3 23.3l63.5 64.8c0.5 0.5 1.3 0.7 2 0.4l94.7-45.4c0.7-0.3 1.1-1 1-1.8l-10.7-89.8c-2.5-20.8 3.4-41.3 16.5-57.6s31.8-26.5 52.7-28.7l90.3-9.2c0.7-0.1 1.3-0.6 1.5-1.3l23.6-102.3c0.2-0.7-0.1-1.5-0.8-1.9l-76.8-47.7c-17.8-11.1-30.2-28.4-34.8-48.8-4.6-20.4-0.9-41.4 10.5-59l49.1-76.3c0.4-0.6 0.4-1.4-0.1-2l-65.2-82.3c-0.5-0.6-1.2-0.8-1.9-0.6l-86.1 30.6c-19.7 7-40.9 5.9-59.7-3.2-18.8-9.1-32.9-25-39.7-44.8l-29.6-86.8c-0.2-0.7-0.9-1.2-1.6-1.2h-105c-0.7 0-1.4 0.5-1.6 1.2L429 211c-6.8 19.8-20.9 35.8-39.8 44.9-18.9 9.1-40.1 10.2-59.9 3.1l-85.4-30.7c-0.7-0.2-1.5 0-1.9 0.5l-65.5 82c-0.5 0.6-0.5 1.4-0.1 2l48.7 76.2c11.3 17.7 14.9 38.6 10.2 59.1-4.7 20.4-17.1 37.7-34.9 48.7l-77.3 47.6c-0.6 0.4-0.9 1.1-0.8 1.9l23.3 102.4c0.2 0.7 0.8 1.3 1.5 1.3l89.9 9.5c20.8 2.2 39.5 12.4 52.6 28.8 13 16.4 18.8 36.9 16.3 57.7l-11 90.1c-0.1 0.7 0.3 1.4 1 1.8l94.5 45.7c0.7 0.3 1.5 0.2 2-0.3l63.5-64.4c14.6-14.8 34.2-23 55.1-23z" fill="currentColor" p-id="2342"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785378934771" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2806" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M96.6 915c-10.9-10.9-10.9-28.7 0-39.6L887 85c10.9-10.9 28.7-10.9 39.6 0 10.9 10.9 10.9 28.7 0 39.6L136.2 915c-10.9 10.9-28.6 10.9-39.6 0z" fill="currentColor" p-id="2807"></path><path d="M887 915L96.6 124.6c-10.9-10.9-10.9-28.7 0-39.6 10.9-10.9 28.7-10.9 39.6 0l790.4 790.4c10.9 10.9 10.9 28.7 0 39.6-10.9 10.9-28.6 10.9-39.6 0z" fill="currentColor" p-id="2808"></path></svg>
|
||||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785380061683" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9491" xmlns:xlink="http://www.w3.org/1999/xlink"><path fill="currentColor" d="M832 64H192c-70.4 0-128 57.6-128 128v640c0 70.4 57.6 128 128 128h640c70.4 0 128-57.6 128-128V192c0-70.4-57.6-128-128-128z m64 768c0 38.4-25.6 64-64 64H192c-38.4 0-64-25.6-64-64V192c0-38.4 25.6-64 64-64h640c38.4 0 64 25.6 64 64v640z" p-id="9492"></path><path fill="currentColor" d="M332.8 537.6c-51.2 0-96 44.8-96 96s44.8 96 96 96 96-44.8 96-96S384 537.6 332.8 537.6z m0 128c-19.2 0-32-12.8-32-32s12.8-32 32-32 32 12.8 32 32-12.8 32-32 32zM403.2 281.6L307.2 377.6l-44.8-38.4c-12.8-12.8-32-12.8-44.8 0s-12.8 32 0 44.8l64 64c6.4 6.4 12.8 6.4 25.6 6.4s12.8 0 19.2-6.4L448 326.4c12.8-12.8 12.8-32 0-44.8s-32-12.8-44.8 0zM787.2 332.8h-256c-19.2 0-32 12.8-32 32s12.8 32 32 32h256c19.2 0 32-12.8 32-32s-12.8-32-32-32zM787.2 601.6h-256c-19.2 0-32 12.8-32 32s12.8 32 32 32h256c19.2 0 32-12.8 32-32s-12.8-32-32-32z" p-id="9493"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
|||||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785291728333" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1480" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M861.257143 54.857143c27.428571 0 51.2 21.942857 53.028571 49.371428v446.171429h-69.485714V124.342857H142.628571v791.771429h316.342858V987.428571H126.171429c-27.428571 0-51.2-21.942857-53.028572-49.371428V107.885714C73.142857 78.628571 95.085714 56.685714 122.514286 54.857143H861.257143zM592.457143 499.2c3.657143 0 7.314286 1.828571 9.142857 3.657143L950.857143 725.942857c9.142857 5.485714 10.971429 16.457143 5.485714 25.6l-5.485714 5.485714-347.428572 221.257143c-9.142857 5.485714-20.114286 3.657143-25.6-5.485714-1.828571-3.657143-3.657143-5.485714-3.657142-9.142857V517.485714c0-10.971429 9.142857-18.285714 18.285714-18.285714z m54.857143 117.028571v245.028572L841.142857 738.742857l-193.828571-122.514286zM457.142857 548.571429c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h182.857143z m292.571429-256c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h475.428572z" fill="#1296db" p-id="1481"></path></svg>
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1785291728333" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1480" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M861.257143 54.857143c27.428571 0 51.2 21.942857 53.028571 49.371428v446.171429h-69.485714V124.342857H142.628571v791.771429h316.342858V987.428571H126.171429c-27.428571 0-51.2-21.942857-53.028572-49.371428V107.885714C73.142857 78.628571 95.085714 56.685714 122.514286 54.857143H861.257143zM592.457143 499.2c3.657143 0 7.314286 1.828571 9.142857 3.657143L950.857143 725.942857c9.142857 5.485714 10.971429 16.457143 5.485714 25.6l-5.485714 5.485714-347.428572 221.257143c-9.142857 5.485714-20.114286 3.657143-25.6-5.485714-1.828571-3.657143-3.657143-5.485714-3.657142-9.142857V517.485714c0-10.971429 9.142857-18.285714 18.285714-18.285714z m54.857143 117.028571v245.028572L841.142857 738.742857l-193.828571-122.514286zM457.142857 548.571429c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h182.857143z m292.571429-256c10.971429 0 18.285714 7.314286 18.285714 18.285714v36.571428c0 10.971429-7.314286 18.285714-18.285714 18.285715H274.285714c-10.971429 0-18.285714-7.314286-18.285714-18.285715v-36.571428c0-10.971429 7.314286-18.285714 18.285714-18.285714h475.428572z" fill="#1296db" p-id="1481"></path></svg>
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,48 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="app-header" @dblclick="toggleMaximize">
|
<header class="app-header" @dblclick="toggleMaximize">
|
||||||
<div class="header-left" style="--wails-draggable: drag">
|
<div class="header-left" style="--wails-draggable: drag">
|
||||||
<img :src="logoSrc" class="app-logo" width="22" height="22" alt="" />
|
<span class="hdr-icon app-logo" v-html="logoSrc"></span>
|
||||||
<span class="app-name">FFmpeg GUI</span>
|
<span class="app-name">FFmpeg GUI</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-center" style="--wails-draggable: drag"></div>
|
<div class="header-center" style="--wails-draggable: drag"></div>
|
||||||
|
|
||||||
<div class="header-right" style="--wails-draggable: no-drag">
|
<div class="header-right" style="--wails-draggable: no-drag">
|
||||||
|
<button
|
||||||
|
class="btn-ghost header-btn task-btn"
|
||||||
|
:class="{ active: showTaskDrawer }"
|
||||||
|
title="任务列表"
|
||||||
|
@click="$emit('toggleTaskDrawer')"
|
||||||
|
>
|
||||||
|
<span class="hdr-icon" v-html="taskListIcon"></span>
|
||||||
|
<span v-if="activeTaskCount" class="task-badge">{{ activeTaskCount }}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-ghost header-btn"
|
class="btn-ghost header-btn"
|
||||||
:class="{ active: currentView === 'settings' }"
|
:class="{ active: currentView === 'settings' }"
|
||||||
title="设置"
|
title="设置"
|
||||||
@click="$emit('navigate', 'settings')"
|
@click="$emit('navigate', 'settings')"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<span class="hdr-icon" v-html="settingsIcon"></span>
|
||||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-ghost header-btn"
|
class="btn-ghost header-btn"
|
||||||
:title="theme === 'dark' ? '亮色模式' : '暗色模式'"
|
:title="theme === 'dark' ? '亮色模式' : '暗色模式'"
|
||||||
@click="$emit('toggleTheme')"
|
@click="$emit('toggleTheme')"
|
||||||
>
|
>
|
||||||
<svg v-if="theme === 'dark'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
<span v-if="theme === 'dark'" class="hdr-icon" v-html="lightModeIcon"></span>
|
||||||
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
<span v-else class="hdr-icon" v-html="darkModeIcon"></span>
|
||||||
</svg>
|
|
||||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="win-controls">
|
<div class="win-controls">
|
||||||
<button class="win-btn" title="最小化" @click="minimize">
|
<button class="win-btn" title="最小化" @click="minimize">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<span class="hdr-icon" v-html="minimizeIcon"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="win-btn" :title="maximised ? '还原' : '最大化'" @click="toggleMaximize">
|
<button class="win-btn" :title="maximised ? '还原' : '最大化'" @click="toggleMaximize">
|
||||||
<svg v-if="maximised" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span v-if="maximised" class="hdr-icon" v-html="restoreIcon"></span>
|
||||||
<rect x="8" y="4" width="12" height="12" rx="1"/><path d="M5 16V7a1 1 0 0 1 1-1h8"/>
|
<span v-else class="hdr-icon" v-html="maximizeIcon"></span>
|
||||||
</svg>
|
|
||||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="win-btn win-btn-close" title="关闭" @click="closeWindow">
|
<button class="win-btn win-btn-close" title="关闭" @click="closeWindow">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<span class="hdr-icon" v-html="closeIcon"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,22 +51,40 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import logoSrc from '../assets/icon/app-logo.svg'
|
import logoSrc from '../assets/icon/app-logo.svg?raw'
|
||||||
|
import settingsIcon from '../assets/icon/Settings.svg?raw'
|
||||||
|
import taskListIcon from '../assets/icon/TaskList.svg?raw'
|
||||||
|
import minimizeIcon from '../assets/icon/Minimize.svg?raw'
|
||||||
|
import maximizeIcon from '../assets/icon/Maximize.svg?raw'
|
||||||
|
import restoreIcon from '../assets/icon/Normalmize.svg?raw'
|
||||||
|
import closeIcon from '../assets/icon/Shut down.svg?raw'
|
||||||
|
import darkModeIcon from '../assets/icon/DarkMode.svg?raw'
|
||||||
|
import lightModeIcon from '../assets/icon/LightMode.svg?raw'
|
||||||
|
|
||||||
defineProps<{ currentView: string; theme: string }>()
|
defineProps<{ currentView: string; theme: string; activeTaskCount: number; showTaskDrawer: boolean }>()
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
navigate: [view: string]
|
navigate: [view: string]
|
||||||
toggleTheme: []
|
toggleTheme: []
|
||||||
|
toggleTaskDrawer: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const maximised = ref(false)
|
const maximised = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
async function syncMaximisedState() {
|
||||||
try {
|
try {
|
||||||
const app = (window as any).go?.main?.App
|
const app = (window as any).go?.main?.App
|
||||||
if (app?.IsMaximised) maximised.value = await app.IsMaximised()
|
if (app?.IsMaximised) maximised.value = await app.IsMaximised()
|
||||||
} catch {}
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncMaximisedState()
|
||||||
|
window.addEventListener('resize', syncMaximisedState)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', syncMaximisedState)
|
||||||
})
|
})
|
||||||
|
|
||||||
function minimize() {
|
function minimize() {
|
||||||
@@ -108,7 +127,7 @@ function closeWindow() {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo { flex-shrink: 0; }
|
.app-logo { width: 22px; height: 22px; flex-shrink: 0; }
|
||||||
.app-name {
|
.app-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -123,6 +142,20 @@ function closeWindow() {
|
|||||||
padding-right: 2px;
|
padding-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Icon — inline SVG injected via v-html inherits button text color */
|
||||||
|
.hdr-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.hdr-icon :deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.header-btn {
|
.header-btn {
|
||||||
color: var(--header-text-dim) !important;
|
color: var(--header-text-dim) !important;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
@@ -136,6 +169,24 @@ function closeWindow() {
|
|||||||
.header-btn:hover { color: var(--header-text) !important; background: var(--bg-hover) !important; }
|
.header-btn:hover { color: var(--header-text) !important; background: var(--bg-hover) !important; }
|
||||||
.header-btn.active { color: var(--accent) !important; background: var(--accent-light) !important; }
|
.header-btn.active { color: var(--accent) !important; background: var(--accent-light) !important; }
|
||||||
|
|
||||||
|
.task-btn { position: relative; }
|
||||||
|
.task-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--danger, #e81123);
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Window controls */
|
/* Window controls */
|
||||||
.win-controls {
|
.win-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="task-drawer" :class="{ expanded }">
|
<Transition name="slide-right">
|
||||||
<!-- Collapsed bar -->
|
<div v-if="show" class="task-overlay" @click.self="$emit('close')">
|
||||||
<div class="drawer-bar" @click="expanded = !expanded">
|
<div class="task-panel">
|
||||||
<div class="bar-left">
|
<!-- Title bar -->
|
||||||
<span class="bar-label">任务进度</span>
|
<div class="panel-header">
|
||||||
<span v-if="runningTasks.length" class="bar-status running">
|
<span class="panel-title">任务列表</span>
|
||||||
{{ runningTasks.length }} 个运行中 — 速度:{{ fmtFps(runningTasks[0].progress.fps) }} FPS
|
<button class="btn-ghost panel-close" title="关闭" @click="$emit('close')">
|
||||||
</span>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
<span v-else class="bar-status idle">空闲</span>
|
</button>
|
||||||
</div>
|
|
||||||
<div class="bar-right">
|
|
||||||
<span class="bar-count" v-if="taskCount">{{ taskCount }}</span>
|
|
||||||
<svg
|
|
||||||
class="bar-arrow"
|
|
||||||
:class="{ flipped: expanded }"
|
|
||||||
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="18 15 12 9 6 15"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expanded panel -->
|
<!-- Body -->
|
||||||
<div class="drawer-body" v-show="expanded">
|
<div class="panel-body">
|
||||||
<!-- Running tasks -->
|
<!-- Running tasks -->
|
||||||
<div v-for="t in runningTasks" :key="t.id" class="current-task">
|
<div v-for="t in runningTasks" :key="t.id" class="current-task">
|
||||||
<div class="current-task-header">
|
<div class="current-task-header">
|
||||||
@@ -75,7 +64,7 @@
|
|||||||
<!-- Done tasks -->
|
<!-- Done tasks -->
|
||||||
<div v-if="doneTasks.length" class="done-section">
|
<div v-if="doneTasks.length" class="done-section">
|
||||||
<div class="queued-header">已完成 ({{ doneTasks.length }})</div>
|
<div class="queued-header">已完成 ({{ doneTasks.length }})</div>
|
||||||
<template v-for="t in doneTasks.slice(-10)" :key="t.id">
|
<template v-for="t in doneTasks.slice(-20)" :key="t.id">
|
||||||
<div class="queued-item done" @click="toggleDoneDetail(t.id)">
|
<div class="queued-item done" @click="toggleDoneDetail(t.id)">
|
||||||
<span class="queued-name">{{ basename(t.inputFile) }}</span>
|
<span class="queued-name">{{ basename(t.inputFile) }}</span>
|
||||||
<span class="queued-type">{{ typeLabel(t) }}</span>
|
<span class="queued-type">{{ typeLabel(t) }}</span>
|
||||||
@@ -125,21 +114,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!taskCount" class="empty-hint">
|
||||||
|
暂无任务
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { Task } from '../types'
|
import type { Task } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{ tasks: Task[] }>()
|
const props = defineProps<{ tasks: Task[]; show: boolean }>()
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
cancel: [id: string]
|
cancel: [id: string]
|
||||||
remove: [id: string]
|
remove: [id: string]
|
||||||
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const expanded = ref(false)
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
const onEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') { emit('close'); document.removeEventListener('keydown', onEsc) }
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onEsc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const expandedDone = ref<string | null>(null)
|
const expandedDone = ref<string | null>(null)
|
||||||
|
|
||||||
function toggleDoneDetail(id: string) {
|
function toggleDoneDetail(id: string) {
|
||||||
@@ -204,59 +208,80 @@ function fmtTime(iso?: string): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.task-drawer {
|
/* Overlay — catches clicks outside the panel */
|
||||||
flex-shrink: 0;
|
.task-overlay {
|
||||||
border-top: 1px solid var(--border);
|
position: fixed;
|
||||||
background: var(--bg-card);
|
inset: 0;
|
||||||
transition: height 0.25s ease;
|
z-index: 20;
|
||||||
height: var(--drawer-collapsed);
|
|
||||||
}
|
|
||||||
.task-drawer.expanded {
|
|
||||||
height: var(--drawer-expanded);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapsed bar */
|
/* Panel */
|
||||||
.drawer-bar {
|
.task-panel {
|
||||||
height: var(--drawer-collapsed);
|
position: fixed;
|
||||||
|
top: var(--header-height);
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 380px;
|
||||||
|
max-width: calc(100vw - 200px);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title bar */
|
||||||
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 16px;
|
||||||
cursor: pointer;
|
height: 44px;
|
||||||
user-select: none;
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.drawer-bar:hover { background: var(--bg-hover); }
|
.panel-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.panel-close {
|
||||||
.bar-left { display: flex; align-items: center; gap: 16px; }
|
width: 28px; height: 28px; padding: 0;
|
||||||
.bar-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
display: flex; align-items: center; justify-content: center;
|
||||||
.bar-status { font-size: 12px; color: var(--text-dim); }
|
border-radius: 6px;
|
||||||
.bar-status.running { color: var(--accent); font-weight: 500; }
|
color: var(--text-dim);
|
||||||
|
|
||||||
.bar-right { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.bar-count {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
min-width: 20px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 9px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 6px;
|
|
||||||
}
|
}
|
||||||
.bar-arrow { color: var(--text-dim); transition: transform 0.2s; }
|
.panel-close:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
.bar-arrow.flipped { transform: rotate(180deg); }
|
|
||||||
|
|
||||||
/* Body */
|
/* Body */
|
||||||
.drawer-body {
|
.panel-body {
|
||||||
padding: 0 20px 16px;
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
max-height: calc(var(--drawer-expanded) - var(--drawer-collapsed));
|
}
|
||||||
overflow-y: auto;
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide transition */
|
||||||
|
.slide-right-enter-active,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.slide-right-enter-active .task-panel,
|
||||||
|
.slide-right-leave-active .task-panel {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.slide-right-enter-from,
|
||||||
|
.slide-right-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.slide-right-enter-from .task-panel,
|
||||||
|
.slide-right-leave-to .task-panel {
|
||||||
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Current task */
|
/* Current task */
|
||||||
@@ -379,6 +404,7 @@ function fmtTime(iso?: string): string {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.expand-arrow.rotated { transform: rotate(180deg); }
|
.expand-arrow.rotated { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,22 @@
|
|||||||
<div class="card-header"><h3>编码参数</h3></div>
|
<div class="card-header"><h3>编码参数</h3></div>
|
||||||
<div class="fields-row">
|
<div class="fields-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">质量 / CRF (越小质量越高)</label>
|
<label class="field-label">
|
||||||
|
质量 / CRF (越小质量越高)
|
||||||
|
<span class="tip-wrap">
|
||||||
|
<span class="tip-icon">?</span>
|
||||||
|
<span class="tip-popup">
|
||||||
|
<em class="tip-title">CRF / CQ 参考</em>
|
||||||
|
<span class="tip-row"><b>1–10</b> 视觉无损,文件极大</span>
|
||||||
|
<span class="tip-row"><b>15–17</b> 非常清晰</span>
|
||||||
|
<span class="tip-row"><b>18–20</b> 优秀(推荐)</span>
|
||||||
|
<span class="tip-row"><b>21–23</b> 良好(默认 23)</span>
|
||||||
|
<span class="tip-row"><b>24–28</b> 可接受,体积小</span>
|
||||||
|
<span class="tip-row"><b>30+</b> 明显损失</span>
|
||||||
|
<span class="tip-note">H.265/HEVC 同 CQ 约省 30–50% 体积</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<div class="crf-group">
|
<div class="crf-group">
|
||||||
<input type="range" min="1" max="51" v-model.number="encodeSettings.crf" />
|
<input type="range" min="1" max="51" v-model.number="encodeSettings.crf" />
|
||||||
<span class="crf-value">{{ encodeSettings.crf }}</span>
|
<span class="crf-value">{{ encodeSettings.crf }}</span>
|
||||||
@@ -128,6 +143,22 @@
|
|||||||
<option value="p6">P6</option>
|
<option value="p6">P6</option>
|
||||||
<option value="p7">P7 — 最慢(高画质)</option>
|
<option value="p7">P7 — 最慢(高画质)</option>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="isSvtAv1">
|
||||||
|
<option value="13">13 — 最快(低画质)</option>
|
||||||
|
<option value="12">12</option>
|
||||||
|
<option value="11">11</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="9">9</option>
|
||||||
|
<option value="8">8</option>
|
||||||
|
<option value="7">7 — 中等</option>
|
||||||
|
<option value="6">6</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="0">0 — 最慢(高画质)</option>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<option value="ultrafast">ultrafast</option>
|
<option value="ultrafast">ultrafast</option>
|
||||||
<option value="superfast">superfast</option>
|
<option value="superfast">superfast</option>
|
||||||
@@ -262,6 +293,10 @@ const isHardwareCodec = computed(() => {
|
|||||||
const c = encodeSettings.value.videoCodec
|
const c = encodeSettings.value.videoCodec
|
||||||
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
|
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
|
||||||
})
|
})
|
||||||
|
const isSvtAv1 = computed(() => {
|
||||||
|
const c = encodeSettings.value.videoCodec
|
||||||
|
return c.includes('svtav1') || c === 'libsvtav1'
|
||||||
|
})
|
||||||
|
|
||||||
// When switching between HW/software encoder, reset preset and hwaccel
|
// When switching between HW/software encoder, reset preset and hwaccel
|
||||||
watch(() => encodeSettings.value.videoCodec, (codec) => {
|
watch(() => encodeSettings.value.videoCodec, (codec) => {
|
||||||
@@ -274,8 +309,10 @@ watch(() => encodeSettings.value.videoCodec, (codec) => {
|
|||||||
} else if (codec.includes('amf')) {
|
} else if (codec.includes('amf')) {
|
||||||
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
|
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
|
||||||
hwAccel.value = 'd3d11va'
|
hwAccel.value = 'd3d11va'
|
||||||
|
} else if (codec.includes('svtav1') || codec === 'libsvtav1') {
|
||||||
|
if (!/^\d+$/.test(encodeSettings.value.preset)) encodeSettings.value.preset = '10'
|
||||||
} else {
|
} else {
|
||||||
if (encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'medium'
|
if (encodeSettings.value.preset.startsWith('p') || /^\d+$/.test(encodeSettings.value.preset)) encodeSettings.value.preset = 'medium'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -389,6 +426,39 @@ function subTagLabel(s: StreamInfo): string {
|
|||||||
|
|
||||||
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
|
.submit-area { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
.tip-wrap { position: relative; display: inline-flex; vertical-align: middle; }
|
||||||
|
.tip-icon {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 15px; height: 15px; border-radius: 50%;
|
||||||
|
background: var(--text-dim); color: #fff;
|
||||||
|
font-size: 10px; font-weight: 700; cursor: help;
|
||||||
|
margin-left: 3px; opacity: 0.6; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.tip-wrap:hover .tip-icon { opacity: 1; }
|
||||||
|
.tip-popup {
|
||||||
|
display: none; position: absolute; bottom: calc(100% + 8px); left: 50%;
|
||||||
|
transform: translateX(-50%); z-index: 50;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); padding: 10px 14px;
|
||||||
|
min-width: 260px; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||||
|
flex-direction: column; gap: 4px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tip-wrap:hover .tip-popup { display: flex; }
|
||||||
|
.tip-title {
|
||||||
|
font-style: normal; font-size: 13px; color: var(--text-primary);
|
||||||
|
margin-bottom: 6px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.tip-row {
|
||||||
|
font-style: normal; font-size: 11px; color: var(--text-secondary);
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
}
|
||||||
|
.tip-row b { color: var(--accent); min-width: 36px; font-weight: 600; }
|
||||||
|
.tip-note {
|
||||||
|
font-style: normal; font-size: 11px; color: var(--text-dim);
|
||||||
|
margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
.crf-group { display: flex; align-items: center; gap: 10px; }
|
.crf-group { display: flex; align-items: center; gap: 10px; }
|
||||||
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
|
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
|
||||||
input[type="range"] { accent-color: var(--accent); height: auto; padding: 0; box-shadow: none; flex: 1; }
|
input[type="range"] { accent-color: var(--accent); height: auto; padding: 0; box-shadow: none; flex: 1; }
|
||||||
|
|||||||
@@ -56,6 +56,22 @@
|
|||||||
<option value="p6">P6</option>
|
<option value="p6">P6</option>
|
||||||
<option value="p7">P7 — 最慢(高画质)</option>
|
<option value="p7">P7 — 最慢(高画质)</option>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="isSvtAv1">
|
||||||
|
<option value="13">13 — 最快(低画质)</option>
|
||||||
|
<option value="12">12</option>
|
||||||
|
<option value="11">11</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="9">9</option>
|
||||||
|
<option value="8">8</option>
|
||||||
|
<option value="7">7 — 中等</option>
|
||||||
|
<option value="6">6</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="0">0 — 最慢(高画质)</option>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<option value="ultrafast">ultrafast</option>
|
<option value="ultrafast">ultrafast</option>
|
||||||
<option value="superfast">superfast</option>
|
<option value="superfast">superfast</option>
|
||||||
@@ -84,7 +100,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" v-if="rateControl === 'crf'">
|
<div class="field" v-if="rateControl === 'crf'">
|
||||||
<label class="field-label">CRF / CQ (越小质量越高)</label>
|
<label class="field-label">
|
||||||
|
CRF / CQ (越小质量越高)
|
||||||
|
<span class="tip-wrap">
|
||||||
|
<span class="tip-icon">?</span>
|
||||||
|
<span class="tip-popup">
|
||||||
|
<em class="tip-title">CRF / CQ 参考</em>
|
||||||
|
<span class="tip-row"><b>1–10</b> 视觉无损,文件极大</span>
|
||||||
|
<span class="tip-row"><b>15–17</b> 非常清晰</span>
|
||||||
|
<span class="tip-row"><b>18–20</b> 优秀(推荐)</span>
|
||||||
|
<span class="tip-row"><b>21–23</b> 良好(默认 23)</span>
|
||||||
|
<span class="tip-row"><b>24–28</b> 可接受,体积小</span>
|
||||||
|
<span class="tip-row"><b>30+</b> 明显损失</span>
|
||||||
|
<span class="tip-note">H.265/HEVC 同 CQ 约省 30–50% 体积</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<div class="crf-group">
|
<div class="crf-group">
|
||||||
<input type="range" min="1" max="51" v-model.number="encodeSettings.crf"
|
<input type="range" min="1" max="51" v-model.number="encodeSettings.crf"
|
||||||
style="height:auto;padding:0;box-shadow:none;flex:1" />
|
style="height:auto;padding:0;box-shadow:none;flex:1" />
|
||||||
@@ -251,6 +282,10 @@ const isHardwareCodec = computed(() => {
|
|||||||
const c = encodeSettings.value.videoCodec
|
const c = encodeSettings.value.videoCodec
|
||||||
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
|
return c.includes('nvenc') || c.includes('qsv') || c.includes('amf')
|
||||||
})
|
})
|
||||||
|
const isSvtAv1 = computed(() => {
|
||||||
|
const c = encodeSettings.value.videoCodec
|
||||||
|
return c.includes('svtav1') || c === 'libsvtav1'
|
||||||
|
})
|
||||||
|
|
||||||
// When switching rate control mode, clear the unused field
|
// When switching rate control mode, clear the unused field
|
||||||
watch(rateControl, (mode) => {
|
watch(rateControl, (mode) => {
|
||||||
@@ -312,8 +347,11 @@ watch(() => encodeSettings.value.videoCodec, (codec) => {
|
|||||||
} else if (codec.includes('amf')) {
|
} else if (codec.includes('amf')) {
|
||||||
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
|
if (!encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'p4'
|
||||||
hwAccel.value = 'd3d11va'
|
hwAccel.value = 'd3d11va'
|
||||||
|
} else if (codec.includes('svtav1') || codec === 'libsvtav1') {
|
||||||
|
// SVT-AV1 uses numeric presets 0–13, not named presets
|
||||||
|
if (!/^\d+$/.test(encodeSettings.value.preset)) encodeSettings.value.preset = '10'
|
||||||
} else {
|
} else {
|
||||||
if (encodeSettings.value.preset.startsWith('p')) encodeSettings.value.preset = 'medium'
|
if (encodeSettings.value.preset.startsWith('p') || /^\d+$/.test(encodeSettings.value.preset)) encodeSettings.value.preset = 'medium'
|
||||||
// For software encoders, keep hwaccel as-is (user's choice for decode only)
|
// For software encoders, keep hwaccel as-is (user's choice for decode only)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -382,6 +420,39 @@ function resetForm() {
|
|||||||
}
|
}
|
||||||
.stream-tag.dur { font-weight: 500; }
|
.stream-tag.dur { font-weight: 500; }
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
.tip-wrap { position: relative; display: inline-flex; vertical-align: middle; }
|
||||||
|
.tip-icon {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 15px; height: 15px; border-radius: 50%;
|
||||||
|
background: var(--text-dim); color: #fff;
|
||||||
|
font-size: 10px; font-weight: 700; cursor: help;
|
||||||
|
margin-left: 3px; opacity: 0.6; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.tip-wrap:hover .tip-icon { opacity: 1; }
|
||||||
|
.tip-popup {
|
||||||
|
display: none; position: absolute; bottom: calc(100% + 8px); left: 50%;
|
||||||
|
transform: translateX(-50%); z-index: 50;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); padding: 10px 14px;
|
||||||
|
min-width: 260px; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||||
|
flex-direction: column; gap: 4px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tip-wrap:hover .tip-popup { display: flex; }
|
||||||
|
.tip-title {
|
||||||
|
font-style: normal; font-size: 13px; color: var(--text-primary);
|
||||||
|
margin-bottom: 6px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.tip-row {
|
||||||
|
font-style: normal; font-size: 11px; color: var(--text-secondary);
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
}
|
||||||
|
.tip-row b { color: var(--accent); min-width: 36px; font-weight: 600; }
|
||||||
|
.tip-note {
|
||||||
|
font-style: normal; font-size: 11px; color: var(--text-dim);
|
||||||
|
margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
.crf-group { display: flex; align-items: center; gap: 10px; }
|
.crf-group { display: flex; align-items: center; gap: 10px; }
|
||||||
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
|
.crf-value { font-size: 16px; font-weight: 700; color: var(--accent); min-width: 28px; }
|
||||||
.range-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); }
|
.range-hint { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); }
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log content -->
|
<!-- Log content -->
|
||||||
<div v-if="activeTab" class="log-body" ref="logBody">
|
<div v-if="activeTab" class="log-body" ref="logBody" @scroll="onLogScroll">
|
||||||
<div class="log-head">
|
<div class="log-head">
|
||||||
<span class="log-cmd">Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}</span>
|
<span class="log-cmd">Command: ffmpeg {{ activeTask?.args?.join(' ') || '' }}</span>
|
||||||
<button class="btn-ghost btn-sm" @click="copyLogs" :title="copied ? '已复制' : '复制日志'">
|
<button class="btn-ghost btn-sm" @click="copyLogs" :title="copied ? '已复制' : '复制日志'">
|
||||||
@@ -71,8 +71,20 @@ async function copyLogs() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll on new lines
|
// Auto-scroll: only when user is near the bottom
|
||||||
|
let autoScroll = true
|
||||||
|
function onLogScroll() {
|
||||||
|
const el = logBody.value
|
||||||
|
if (!el) return
|
||||||
|
// If user scrolled away from bottom (>50px), stop auto-following
|
||||||
|
autoScroll = el.scrollTop + el.clientHeight >= el.scrollHeight - 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset auto-scroll when switching tabs
|
||||||
|
watch(activeTab, () => { autoScroll = true })
|
||||||
|
|
||||||
watch(activeLogs, () => {
|
watch(activeLogs, () => {
|
||||||
|
if (!autoScroll) return
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = logBody.value
|
const el = logBody.value
|
||||||
if (el) el.scrollTop = el.scrollHeight
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
@@ -116,17 +128,17 @@ function lineClass(line: string): string {
|
|||||||
|
|
||||||
// Exposed for App.vue to push log events
|
// Exposed for App.vue to push log events
|
||||||
function upsertTask(t: Partial<TaskInfo> & { id: string }) {
|
function upsertTask(t: Partial<TaskInfo> & { id: string }) {
|
||||||
const existing = tasks.value.find(x => x.id === t.id)
|
const idx = tasks.value.findIndex(x => x.id === t.id)
|
||||||
if (existing) {
|
if (idx !== -1) {
|
||||||
// Protect logs/args — they come via appendLog and the first task:updated
|
const existing = tasks.value[idx]
|
||||||
|
// Replace to trigger reactivity; protect logs/args from being overwritten
|
||||||
const keepLogs = existing.logs
|
const keepLogs = existing.logs
|
||||||
const keepArgs = existing.args
|
const keepArgs = existing.args
|
||||||
Object.assign(existing, t)
|
tasks.value[idx] = {
|
||||||
if (!t.logs || (Array.isArray(t.logs) && t.logs.length === 0)) {
|
...existing,
|
||||||
existing.logs = keepLogs
|
...t,
|
||||||
}
|
logs: (!t.logs || t.logs.length === 0) ? keepLogs : t.logs,
|
||||||
if (!t.args || (Array.isArray(t.args) && t.args.length === 0)) {
|
args: (!t.args || t.args.length === 0) ? keepArgs : t.args,
|
||||||
existing.args = keepArgs
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tasks.value.push({
|
tasks.value.push({
|
||||||
@@ -144,14 +156,15 @@ function upsertTask(t: Partial<TaskInfo> & { id: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendLog(taskId: string, line: string) {
|
function appendLog(taskId: string, line: string) {
|
||||||
let t = tasks.value.find(x => x.id === taskId)
|
const idx = tasks.value.findIndex(x => x.id === taskId)
|
||||||
if (!t) {
|
if (idx === -1) {
|
||||||
// Log arrived before task:updated event — create entry now
|
tasks.value.push({ id: taskId, inputFile: '', type: '', status: 'running', args: [], logs: [line] })
|
||||||
t = { id: taskId, inputFile: '', type: '', status: 'running', args: [], logs: [] }
|
|
||||||
tasks.value.push(t)
|
|
||||||
if (!activeTab.value) activeTab.value = taskId
|
if (!activeTab.value) activeTab.value = taskId
|
||||||
|
return
|
||||||
}
|
}
|
||||||
t.logs.push(line)
|
// Replace object to trigger shallow-ref reactivity
|
||||||
|
const t = tasks.value[idx]
|
||||||
|
tasks.value[idx] = { ...t, logs: [...t.logs, line] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow parent to call these
|
// Allow parent to call these
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
@@ -141,6 +142,24 @@ func escapeFilterPath(path string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qualityArg maps a CRF-like quality value to encoder-specific arguments.
|
||||||
|
// Software encoders use -crf, NVENC uses -cq, QSV uses -global_quality,
|
||||||
|
// and AMF uses -qp_i/-qp_p for constant-quality encoding.
|
||||||
|
func qualityArg(codec string, crf int) []string {
|
||||||
|
v := strconv.Itoa(crf)
|
||||||
|
lower := strings.ToLower(codec)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(lower, "nvenc"):
|
||||||
|
return []string{"-cq", v}
|
||||||
|
case strings.Contains(lower, "qsv"):
|
||||||
|
return []string{"-global_quality", v}
|
||||||
|
case strings.Contains(lower, "amf"):
|
||||||
|
return []string{"-qp_i", v, "-qp_p", v}
|
||||||
|
default:
|
||||||
|
return []string{"-crf", v}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func tailLines(s string, n int) string {
|
func tailLines(s string, n int) string {
|
||||||
lines := strings.Split(s, "\n")
|
lines := strings.Split(s, "\n")
|
||||||
if len(lines) > n {
|
if len(lines) > n {
|
||||||
@@ -197,11 +216,11 @@ func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel stri
|
|||||||
|
|
||||||
args = append(args, "-i", input)
|
args = append(args, "-i", input)
|
||||||
|
|
||||||
if s.HWEncoder != "" {
|
codec := s.HWEncoder
|
||||||
args = append(args, "-c:v", s.HWEncoder)
|
if codec == "" {
|
||||||
} else {
|
codec = s.VideoCodec
|
||||||
args = append(args, "-c:v", s.VideoCodec)
|
|
||||||
}
|
}
|
||||||
|
args = append(args, "-c:v", codec)
|
||||||
|
|
||||||
if s.Preset != "" {
|
if s.Preset != "" {
|
||||||
// libx* uses named presets (fast, medium…), HW encoders use p1–p7
|
// libx* uses named presets (fast, medium…), HW encoders use p1–p7
|
||||||
@@ -209,7 +228,7 @@ func BuildEncodeArgs(input string, output string, s EncodeSettings, hwAccel stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.CRF > 0 {
|
if s.CRF > 0 {
|
||||||
args = append(args, "-crf", fmt.Sprintf("%d", s.CRF))
|
args = append(args, qualityArg(codec, s.CRF)...)
|
||||||
} else if s.VideoBitrate != "" {
|
} else if s.VideoBitrate != "" {
|
||||||
args = append(args, "-b:v", s.VideoBitrate)
|
args = append(args, "-b:v", s.VideoBitrate)
|
||||||
}
|
}
|
||||||
@@ -327,16 +346,16 @@ func BuildSubtitleArgs(input string, output string, s SubtitleSettings, encode E
|
|||||||
args = append(args, "-vf", strings.Join(subFilters, ","))
|
args = append(args, "-vf", strings.Join(subFilters, ","))
|
||||||
}
|
}
|
||||||
|
|
||||||
if encode.HWEncoder != "" {
|
codec := encode.HWEncoder
|
||||||
args = append(args, "-c:v", encode.HWEncoder)
|
if codec == "" {
|
||||||
} else {
|
codec = encode.VideoCodec
|
||||||
args = append(args, "-c:v", encode.VideoCodec)
|
|
||||||
}
|
}
|
||||||
|
args = append(args, "-c:v", codec)
|
||||||
if encode.Preset != "" {
|
if encode.Preset != "" {
|
||||||
args = append(args, "-preset", encode.Preset)
|
args = append(args, "-preset", encode.Preset)
|
||||||
}
|
}
|
||||||
if encode.CRF > 0 {
|
if encode.CRF > 0 {
|
||||||
args = append(args, "-crf", fmt.Sprintf("%d", encode.CRF))
|
args = append(args, qualityArg(codec, encode.CRF)...)
|
||||||
} else if encode.VideoBitrate != "" {
|
} else if encode.VideoBitrate != "" {
|
||||||
args = append(args, "-b:v", encode.VideoBitrate)
|
args = append(args, "-b:v", encode.VideoBitrate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,28 +19,59 @@ type Progress struct {
|
|||||||
Percent float64 `json:"percent"`
|
Percent float64 `json:"percent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var progressRe = regexp.MustCompile(
|
// kvPairRe matches a single key=value token from an ffmpeg progress line.
|
||||||
`frame=\s*(\d+)\s+fps=\s*([\d.]+)\s+q=\s*([\d.-]+)\s+(?:size=\s*(\S+)\s+)?time=\s*([\d:.]+)\s+bitrate=\s*(\S+)\s+speed=\s*(\S+)`,
|
// Keys are alphanumeric (e.g. frame, dup, Lsize). Values are the contiguous
|
||||||
)
|
// non-whitespace characters after the '=' — this handles N/A, kbits/s, times,
|
||||||
|
// and numeric values equally.
|
||||||
|
var kvPairRe = regexp.MustCompile(`(\w+)=\s*(\S+)`)
|
||||||
|
|
||||||
|
// parseProgressLine parses an ffmpeg stderr progress line into a Progress struct.
|
||||||
|
// Instead of relying on a fixed field order, it extracts every key=value pair
|
||||||
|
// and picks the ones we care about. This tolerates extra fields (dup, drop,
|
||||||
|
// Lsize vs size, etc.) that appear between standard ffmpeg releases.
|
||||||
func parseProgressLine(line string) (Progress, bool) {
|
func parseProgressLine(line string) (Progress, bool) {
|
||||||
m := progressRe.FindStringSubmatch(line)
|
pairs := kvPairRe.FindAllStringSubmatch(line, -1)
|
||||||
if m == nil {
|
if len(pairs) == 0 {
|
||||||
return Progress{}, false
|
return Progress{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
frame, _ := strconv.ParseInt(m[1], 10, 64)
|
kv := make(map[string]string, len(pairs))
|
||||||
fps, _ := strconv.ParseFloat(m[2], 64)
|
for _, m := range pairs {
|
||||||
q, _ := strconv.ParseFloat(m[3], 64)
|
kv[m[1]] = m[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame is required — without it we don't have a real progress line.
|
||||||
|
frameStr, ok := kv["frame"]
|
||||||
|
if !ok {
|
||||||
|
return Progress{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
frame, _ := strconv.ParseInt(frameStr, 10, 64)
|
||||||
|
fps, _ := strconv.ParseFloat(kv["fps"], 64)
|
||||||
|
q, _ := strconv.ParseFloat(kv["q"], 64)
|
||||||
|
|
||||||
|
// ffmpeg emits either "size" or "Lsize" depending on context.
|
||||||
|
size := kv["size"]
|
||||||
|
if size == "" {
|
||||||
|
size = kv["Lsize"]
|
||||||
|
}
|
||||||
|
if size == "N/A" {
|
||||||
|
size = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
bitrate := kv["bitrate"]
|
||||||
|
if bitrate == "N/A" {
|
||||||
|
bitrate = ""
|
||||||
|
}
|
||||||
|
|
||||||
return Progress{
|
return Progress{
|
||||||
Frame: frame,
|
Frame: frame,
|
||||||
FPS: fps,
|
FPS: fps,
|
||||||
Q: q,
|
Q: q,
|
||||||
Size: m[4],
|
Size: size,
|
||||||
Time: m[5],
|
Time: kv["time"],
|
||||||
Bitrate: m[6],
|
Bitrate: bitrate,
|
||||||
Speed: m[7],
|
Speed: kv["speed"],
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ func (m *Manager) runTask(t *Task) {
|
|||||||
if sub.Source == "internal" {
|
if sub.Source == "internal" {
|
||||||
tmpPath := t.OutputFile + fmt.Sprintf(".sub_%d_tmp.srt", i)
|
tmpPath := t.OutputFile + fmt.Sprintf(".sub_%d_tmp.srt", i)
|
||||||
if _, err := m.exec.RunSync("-y", "-i", t.InputFile,
|
if _, err := m.exec.RunSync("-y", "-i", t.InputFile,
|
||||||
"-map", fmt.Sprintf("0:s:%d", sub.Index), "-c:s", "srt", tmpPath); err != nil {
|
"-map", fmt.Sprintf("0:%d", sub.Index), "-c:s", "srt", tmpPath); err != nil {
|
||||||
m.completeTask(t, false, fmt.Sprintf("提取字幕轨道 #%d 失败: %v", sub.Index, err))
|
m.completeTask(t, false, fmt.Sprintf("提取字幕轨道 #%d 失败: %v", sub.Index, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||