This commit is contained in:
Huakun Shen 2025-01-18 04:03:05 -05:00
commit 54d6e3519c
No known key found for this signature in database
64 changed files with 10536 additions and 0 deletions

48
.github/workflows/npm-publish.yml vendored Normal file
View File

@ -0,0 +1,48 @@
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
name: NPM Package Publish
on:
push:
branches: [main]
release:
types: [created]
workflow_dispatch:
jobs:
publish-npm:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org/
- uses: pnpm/action-setup@v2
with:
version: latest
- uses: oven-sh/setup-bun@v2
- run: pnpm install
- run: pnpm build
- run: |
PACKAGE_NAME=$(jq -r '.name' package.json)
PACKAGE_VERSION=$(jq -r '.version' package.json)
# Get the version from npm registry
REGISTRY_VERSION=$(npm show "$PACKAGE_NAME" version)
# Compare versions
if [ "$PACKAGE_VERSION" == "$REGISTRY_VERSION" ]; then
echo "Version $PACKAGE_VERSION already exists in the npm registry."
exit 0
else
echo "Version $PACKAGE_VERSION does not exist in the npm registry. Proceeding..."
npm publish --provenance --access public
fi
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
extensions_support/
.pnpm-store

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
engine-strict=true
@jsr:registry=https://npm.jsr.io

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

15
.prettierrc Normal file
View File

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

15
CHANGELOG.md Normal file
View File

@ -0,0 +1,15 @@
# template-ext-sveltekit
## 0.0.3
### Patch Changes
- Updated dependencies
- @kksh/api@0.0.4
## 0.0.2
### Patch Changes
- Updated dependencies [fba6a49]
- @kksh/svelte@0.0.2

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# Kunkun Custom UI Extension Template (SvelteKit)
```ts
export type ProcessVideoOptions = {
resizePercentage?: number;
size?: string;
aspectRatio?: string;
videoCodec?: string;
audioCodec?: string;
format?: string;
outputOptions?: string[];
audioFilters?: string[];
noAudio?: boolean;
takeFrames?: number;
noVideo?: boolean;
autopad?: {
pad?: boolean;
color?: string;
};
audioQuality?: number;
fps?: number;
preset?:
| 'ultrafast'
| 'superfast'
| 'veryfast'
| 'faster'
| 'fast'
| 'medium'
| 'slow'
| 'slower'
| 'veryslow';
startTime?: string | number;
duration?: string | number;
audioBitrate?: number;
videoBitrate?: number;
audioChannels?: number;
ffprobePath?: string;
ffmpegPath?: string;
};
```

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "new-york",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
}

14
deno-src/deno.json Normal file
View File

@ -0,0 +1,14 @@
{
"tasks": {
"dev": "deno run --watch main.ts"
},
"imports": {
"@hk/photographer-toolbox": "jsr:@hk/photographer-toolbox@^0.1.8",
"@kunkun/api": "jsr:@kunkun/api@^0.0.40",
"@std/assert": "jsr:@std/assert@1",
"@std/path": "jsr:@std/path@^1.0.7",
"valibot": "jsr:@valibot/valibot@^0.42.1",
"sharp": "npm:sharp@0.33.5",
"fluent-ffmpeg": "npm:fluent-ffmpeg@2.1.3"
}
}

313
deno-src/deno.lock generated Normal file
View File

@ -0,0 +1,313 @@
{
"version": "4",
"specifiers": {
"jsr:@hk/photographer-toolbox@~0.1.8": "0.1.8",
"jsr:@kunkun/api@^0.0.40": "0.0.40",
"jsr:@std/assert@1": "1.0.6",
"jsr:@std/internal@^1.0.4": "1.0.4",
"jsr:@std/path@^1.0.7": "1.0.7",
"jsr:@valibot/valibot@~0.42.1": "0.42.1",
"npm:@types/fluent-ffmpeg@2.1.27": "2.1.27",
"npm:@types/node@*": "22.5.4",
"npm:@types/sharp@*": "0.32.0",
"npm:exiftool-vendored@28.5.0": "28.5.0",
"npm:fluent-ffmpeg@2.1.3": "2.1.3",
"npm:kkrpc@^0.0.12": "0.0.12_typescript@5.6.3",
"npm:semver@^7.6.3": "7.6.3",
"npm:sharp@*": "0.33.5",
"npm:sharp@0.33.5": "0.33.5"
},
"jsr": {
"@hk/comlink-stdio@0.1.6": {
"integrity": "77e0ec03157e61baba895142107b871bb1fc2f9ffbd4244413e12dab62478bab"
},
"@hk/photographer-toolbox@0.1.8": {
"integrity": "6cf1162f1eef019164ec158d9114fe705c22cc11a4866113a3237b77d515050b",
"dependencies": [
"jsr:@valibot/valibot",
"npm:@types/fluent-ffmpeg",
"npm:exiftool-vendored",
"npm:fluent-ffmpeg",
"npm:sharp@0.33.5"
]
},
"@kunkun/api@0.0.40": {
"integrity": "eab67c01e1cc87f3e5e7f7613a302cba7fccb18a1745f1a5508cf48df1e3649e",
"dependencies": [
"npm:kkrpc"
]
},
"@std/assert@1.0.6": {
"integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.4": {
"integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422"
},
"@std/path@1.0.7": {
"integrity": "76a689e07f0e15dcc6002ec39d0866797e7156629212b28f27179b8a5c3b33a1"
},
"@valibot/valibot@0.42.1": {
"integrity": "ba0f6f7964aaeec0e4b1f793d575061f325ae6254cbb9d7ff01fb65068a0a23b"
}
},
"npm": {
"@emnapi/runtime@1.3.1": {
"integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==",
"dependencies": [
"tslib"
]
},
"@img/sharp-darwin-arm64@0.33.5": {
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"dependencies": [
"@img/sharp-libvips-darwin-arm64"
]
},
"@img/sharp-darwin-x64@0.33.5": {
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"dependencies": [
"@img/sharp-libvips-darwin-x64"
]
},
"@img/sharp-libvips-darwin-arm64@1.0.4": {
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="
},
"@img/sharp-libvips-darwin-x64@1.0.4": {
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="
},
"@img/sharp-libvips-linux-arm64@1.0.4": {
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="
},
"@img/sharp-libvips-linux-arm@1.0.5": {
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="
},
"@img/sharp-libvips-linux-s390x@1.0.4": {
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="
},
"@img/sharp-libvips-linux-x64@1.0.4": {
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="
},
"@img/sharp-libvips-linuxmusl-arm64@1.0.4": {
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="
},
"@img/sharp-libvips-linuxmusl-x64@1.0.4": {
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="
},
"@img/sharp-linux-arm64@0.33.5": {
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"dependencies": [
"@img/sharp-libvips-linux-arm64"
]
},
"@img/sharp-linux-arm@0.33.5": {
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"dependencies": [
"@img/sharp-libvips-linux-arm"
]
},
"@img/sharp-linux-s390x@0.33.5": {
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"dependencies": [
"@img/sharp-libvips-linux-s390x"
]
},
"@img/sharp-linux-x64@0.33.5": {
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"dependencies": [
"@img/sharp-libvips-linux-x64"
]
},
"@img/sharp-linuxmusl-arm64@0.33.5": {
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"dependencies": [
"@img/sharp-libvips-linuxmusl-arm64"
]
},
"@img/sharp-linuxmusl-x64@0.33.5": {
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"dependencies": [
"@img/sharp-libvips-linuxmusl-x64"
]
},
"@img/sharp-wasm32@0.33.5": {
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"dependencies": [
"@emnapi/runtime"
]
},
"@img/sharp-win32-ia32@0.33.5": {
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="
},
"@img/sharp-win32-x64@0.33.5": {
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="
},
"@photostructure/tz-lookup@11.0.0": {
"integrity": "sha512-QMV5/dWtY/MdVPXZs/EApqzyhnqDq1keYEqpS+Xj2uidyaqw2Nk/fWcsszdruIXjdqp1VoWNzsgrO6bUHU1mFw=="
},
"@types/fluent-ffmpeg@2.1.27": {
"integrity": "sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==",
"dependencies": [
"@types/node"
]
},
"@types/luxon@3.4.2": {
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA=="
},
"@types/node@22.5.4": {
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
"dependencies": [
"undici-types"
]
},
"@types/sharp@0.32.0": {
"integrity": "sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==",
"dependencies": [
"sharp"
]
},
"async@0.2.10": {
"integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
},
"batch-cluster@13.0.0": {
"integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og=="
},
"color-convert@2.0.1": {
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": [
"color-name"
]
},
"color-name@1.1.4": {
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"color-string@1.9.1": {
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dependencies": [
"color-name",
"simple-swizzle"
]
},
"color@4.2.3": {
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"dependencies": [
"color-convert",
"color-string"
]
},
"detect-libc@2.0.3": {
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="
},
"exiftool-vendored.exe@12.96.0": {
"integrity": "sha512-pKPN9F/Evw2yyO5/+ml3spbXIqejzOxyF7jEnj8tLU2JPSmIlziPUZ75XIhcPbilX86jVKmuiso7FUDicOg8pQ=="
},
"exiftool-vendored.pl@12.96.0": {
"integrity": "sha512-v4nGnovAMBsTfOWhwAcOiRiq/8kuJOo3GUMHNpug7Mr4jLz3tmWEo7DdNyOYmpcvWbA6smOTG0SmwsrY8fsW+A=="
},
"exiftool-vendored@28.5.0": {
"integrity": "sha512-/XbVpZGP5P/tifRbO2BIBuDxLkHrUoxhJGOKAeASHnIBNNgBzp3UWtp0wLPhEd24ETe/ohuEUPmpUaKcNSDYsg==",
"dependencies": [
"@photostructure/tz-lookup",
"@types/luxon",
"batch-cluster",
"exiftool-vendored.exe",
"exiftool-vendored.pl",
"he",
"luxon"
]
},
"fluent-ffmpeg@2.1.3": {
"integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
"dependencies": [
"async",
"which"
]
},
"he@1.2.0": {
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
"is-arrayish@0.3.2": {
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
},
"isexe@2.0.0": {
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"kkrpc@0.0.12_typescript@5.6.3": {
"integrity": "sha512-PBk4AhGfkesIdAwmIoj7dHHIp7qN97XT4yr5Rl7h2WL79gxWQVgZRJYLt7Gb17GoLDh991rnL85mhCoPG5VC/Q==",
"dependencies": [
"typescript",
"ws"
]
},
"luxon@3.5.0": {
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ=="
},
"semver@7.6.3": {
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="
},
"sharp@0.33.5": {
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"dependencies": [
"@img/sharp-darwin-arm64",
"@img/sharp-darwin-x64",
"@img/sharp-libvips-darwin-arm64",
"@img/sharp-libvips-darwin-x64",
"@img/sharp-libvips-linux-arm",
"@img/sharp-libvips-linux-arm64",
"@img/sharp-libvips-linux-s390x",
"@img/sharp-libvips-linux-x64",
"@img/sharp-libvips-linuxmusl-arm64",
"@img/sharp-libvips-linuxmusl-x64",
"@img/sharp-linux-arm",
"@img/sharp-linux-arm64",
"@img/sharp-linux-s390x",
"@img/sharp-linux-x64",
"@img/sharp-linuxmusl-arm64",
"@img/sharp-linuxmusl-x64",
"@img/sharp-wasm32",
"@img/sharp-win32-ia32",
"@img/sharp-win32-x64",
"color",
"detect-libc",
"semver"
]
},
"simple-swizzle@0.2.2": {
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"dependencies": [
"is-arrayish"
]
},
"tslib@2.8.0": {
"integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA=="
},
"typescript@5.6.3": {
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="
},
"undici-types@6.19.8": {
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"which@1.3.1": {
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"dependencies": [
"isexe"
]
},
"ws@8.18.0": {
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="
}
},
"workspace": {
"dependencies": [
"jsr:@hk/photographer-toolbox@~0.1.8",
"jsr:@kunkun/api@^0.0.40",
"jsr:@std/assert@1",
"jsr:@std/path@^1.0.7",
"jsr:@valibot/valibot@~0.42.1",
"npm:fluent-ffmpeg@2.1.3",
"npm:sharp@0.33.5"
]
}
}

39
deno-src/dev.ts Normal file
View File

@ -0,0 +1,39 @@
import { video } from '@hk/photographer-toolbox';
// @ts-types="npm:@types/fluent-ffmpeg@2.1.27"
import ffmpeg from 'fluent-ffmpeg';
// video
// .readMainVideoMetadata('/Users/hacker/Dev/workspace/DJI_20240319171825_0014_D.MP4')
// .then(console.log);
// video
// .readVideoMetadata('/Users/hacker/Dev/workspace/DJI_20240319171825_0014_D.MP4')
// .then((data) => {
// console.log(data);
// // Deno.writeFileSync(
// // './video-metadata.json',
// // new TextEncoder().encode(JSON.stringify(data, null, 2))
// // );
// });
// video.convertVideo(
// '/Users/hacker/Library/Mobile Documents/iCloud~me~damir~dropover-mac/Documents/Uploads/2022-07-01_08.08.02/crosscopy-cli-demo-sync.mp4',
// '/Users/hacker/Desktop/crosscopy-cli-demo-sync.mp4',
// {
// aspectRatio: '1:1',
// videoCodec: 'h264_videotoolbox',
// videoBitrate: 1000
// },
// () => {},
// (progress) => {
// console.log(progress.percent);
// }
// );
ffmpeg('/Users/hacker/Desktop/crosscopy-cli-demo-sync.mp4')
.withAspectRatio('1:1')
.save('/Users/hacker/Desktop/output.mp4')
.on('progress', function (progress) {
console.log('Processing: ' + progress.percent + '% done');
});

34
deno-src/index.ts Normal file
View File

@ -0,0 +1,34 @@
// @ts-types="npm:@types/fluent-ffmpeg@2.1.27"
import ffmpeg from 'fluent-ffmpeg';
import type { API } from '../src/types.ts';
import type { ProcessVideoOptions, Progress } from '@hk/photographer-toolbox/types';
import { convertVideo } from 'https://jsr.io/@hk/photographer-toolbox/0.1.8/src/video/convert.ts';
// ffmpeg.setFfprobePath('/opt/homebrew/bin/ffprobe');
import { video } from '@hk/photographer-toolbox';
import { expose } from '@kunkun/api/runtime/deno';
expose({
convertVideo: (
inputPath: string,
outputPath: string,
options: ProcessVideoOptions,
startCallback?: () => void,
progressCallback?: (progress: Progress) => void,
endCallback?: () => void
) => {
return Promise.resolve(
video.convertVideo(
inputPath,
outputPath,
options,
startCallback,
progressCallback,
endCallback
)
);
},
});

0
deno-src/lib.ts Normal file
View File

3043
dist/video-info.js vendored Normal file

File diff suppressed because it is too large Load Diff

38
eslint.config.js Normal file
View File

@ -0,0 +1,38 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
},
{
rules: {
'@typescript-eslint/no-unused-vars': 'off'
}
}
];

134
package.json Normal file
View File

@ -0,0 +1,134 @@
{
"$schema": "https://schema.kunkun.sh",
"name": "kunkun-ext-video-processing",
"version": "0.0.7",
"repository": "https://github.com/kunkunsh/kunkun-ext-video-processing",
"kunkun": {
"name": "Video Processing",
"shortDescription": "Video Info, Conversion and more",
"longDescription": "Video conversion, compression, and more",
"identifier": "video-processing",
"icon": {
"type": "iconify",
"value": "mingcute:video-fill"
},
"demoImages": [],
"permissions": [
"clipboard:read-files",
"system:fs",
"notification:all",
"dialog:all",
"event:drag-drop",
{
"permission": "shell:deno:spawn",
"allow": [
{
"path": "$EXTENSION/deno-src/index.ts",
"env": "*",
"ffi": "*",
"read": "*",
"sys": "*",
"run": "*"
}
]
},
"shell:stdin-write",
"shell:kill",
{
"permission": "shell:execute",
"allow": [
{
"cmd": {
"program": "ffprobe",
"args": [
"--help"
]
}
}
]
}
],
"customUiCmds": [
{
"main": "/",
"dist": "build",
"devMain": "http://localhost:5173",
"name": "Video Conversion",
"cmds": []
}
],
"templateUiCmds": [
{
"name": "Video Info",
"main": "dist/video-info.js",
"cmds": []
}
]
},
"scripts": {
"dev": "vite dev",
"dev:template": "bun scripts/build-template-ext.ts dev",
"build:template": "bun scripts/build-template-ext.ts",
"build:custom": "vite build",
"build": "bun scripts/build.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@hk/photographer-toolbox": "npm:@jsr/hk__photographer-toolbox@^0.1.8",
"@iconify/svelte": "^4.0.2",
"@kksh/api": "^0.0.48",
"@kksh/svelte5": "^0.1.9",
"@tanstack/table-core": "^8.20.5",
"clsx": "^2.1.1",
"embla-carousel-svelte": "^8.3.1",
"filesize": "^10.1.6",
"lucide-svelte": "^0.416.0",
"mode-watcher": "^0.4.0",
"paneforge": "^0.0.6",
"svelte-radix": "^2.0.1",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^2.4.0",
"tailwind-variants": "^0.2.1",
"valibot": "^0.42.1",
"vaul-svelte": "^0.3.2"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/typography": "^0.5.13",
"@types/eslint": "^9.6.0",
"autoprefixer": "^10.4.19",
"bits-ui": "1.0.0-next.54",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"formsnap": "2.0.0-next.1",
"globals": "^15.0.0",
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-tailwindcss": "^0.6.4",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"sveltekit-superforms": "^2.20.0",
"tailwindcss": "^3.4.4",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0-alpha.20",
"vite": "^5.0.3",
"zod": "^3.23.8"
},
"type": "module",
"files": [
"build",
"dist",
"deno-src",
".gitignore"
],
"packageManager": "pnpm@9.15.3"
}

4619
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@ -0,0 +1,36 @@
import { watch } from 'fs';
import { join } from 'path';
import { refreshTemplateWorkerExtension } from '@kksh/api/dev';
import { $ } from 'bun';
const entrypoints = ['./template-ext-src/video-info.ts'];
async function build() {
try {
// for (const entrypoint of entrypoints) {
// await $`bun build --minify --target=browser --outdir=./dist ${entrypoint}`;
// }
await Bun.build({
entrypoints,
target: 'browser',
outdir: './dist',
minify: false
});
if (Bun.argv.includes('dev')) {
await refreshTemplateWorkerExtension();
}
} catch (error) {
console.error(error);
}
}
const srcDir = join(import.meta.dir, '..', 'template-ext-src');
await build();
if (Bun.argv.includes('dev')) {
console.log(`Watching ${srcDir} for changes...`);
watch(srcDir, { recursive: true }, async (event, filename) => {
await build();
});
}

4
scripts/build.ts Normal file
View File

@ -0,0 +1,4 @@
import { $ } from 'bun';
await $`bun build:custom`;
await $`bun build:template`;

80
src/app.css Normal file
View File

@ -0,0 +1,80 @@
@import url("@kksh/svelte5/themes");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 0 0% 98%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

13
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

89
src/lib/api.ts Normal file
View File

@ -0,0 +1,89 @@
// @ts-nocheck
import { shell } from '@kksh/api/ui/iframe';
import type { API } from '../types';
export async function getRpcAPI() {
const { rpcChannel, process, command } = await shell.createDenoRpcChannel<object, API>(
'$EXTENSION/deno-src/index.ts',
[],
{
allowAllEnv: true,
// allowEnv: ['NODE_V8_COVERAGE', 'npm_package_config_libvips', 'EXIFTOOL_HOME', 'OSTYPE'],
// allowFfi: ["*sharp-darwin-arm64.node"],
allowAllFfi: true,
allowAllRead: true,
allowAllSys: true,
// allowSys: ['uid', 'cpus'],
// allowRun: ["*exiftool"]
allowAllRun: true,
env: {
FFMPEG_PATH: '/opt/homebrew/bin/ffmpeg',
FFPROBE_PATH: '/opt/homebrew/bin/ffprobe'
}
},
{}
);
const api = rpcChannel.getAPI();
return {
api,
rpcChannel,
process,
command
};
}
export function getFFmpegPath() {
return shell.hasCommand('ffmpeg').then((has) => {
if (has) {
return shell.whereIsCommand('ffmpeg');
} else {
return null;
}
});
}
(async () => {
import { shell } from '@kksh/api/ui/iframe';
const { rpcChannel, process, command } = await shell.createDenoRpcChannel<object, API>(
'$EXTENSION/ext.ts',
{
allowEnv: ['NODE_V8_COVERAGE', 'npm_package_config_libvips', 'EXIFTOOL_HOME', 'OSTYPE'],
allowAllRead: true,
allowAllSys: true,
allowAllRun: true,
env: {
FFMPEG_PATH: '/opt/homebrew/bin/ffmpeg',
FFPROBE_PATH: '/opt/homebrew/bin/ffprobe'
}
}
);
const api = rpcChannel.getAPI();
api
.convertVideo(
inputPath,
outputPath,
verifiedOptions,
() => {
// on start
toast.info('Started');
},
(progress) => {
console.log('progress', progress);
},
() => {
// on end
process.kill();
toast.success('Done');
}
)
.catch((e) => {
console.error(e);
process.kill();
});
})();

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { ThemeCustomizerButton, type ThemeConfig, updateTheme } from '@kksh/svelte5';
import { ui } from '@kksh/api/ui/iframe';
import { onMount } from 'svelte';
let config: ThemeConfig = {
radius: 0.5,
theme: 'zinc',
lightMode: 'auto'
};
onMount(() => {
ui.getTheme().then((theme) => {
config = theme;
});
});
$: updateTheme(config);
</script>
<ThemeCustomizerButton bind:config />

View File

@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button } from '@kksh/svelte5';
import { CheckIcon } from 'lucide-svelte';
import Icon from '@iconify/svelte';
let { enabled = $bindable(false) }: { enabled?: boolean } = $props();
</script>
<Button
variant={enabled ? 'default' : 'secondary'}
size="icon"
class={cn('shrink-0 text-xl', {
'text-green-500': enabled,
'text-gray-500': !enabled
})}
onclick={() => (enabled = !enabled)}
>
<Icon
icon="mdi:check-bold"
class={cn('h-6 w-6', {
'scale-125': enabled
})}
/>
</Button>

View File

@ -0,0 +1,34 @@
<script lang="ts">
import { cn } from '@/utils';
import { CheckIcon } from 'lucide-svelte';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
aspectRatio = $bindable(undefined),
enabled = $bindable(false)
}: { class?: string; name?: string; aspectRatio?: string; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Aspect Ratio</Label>
<InfoPopover
description="Aspect Ratio is ignored if Frame Size is set to a fixed value. set frame size to something like 640x?, instead of 640x480."
class="h-4 w-4"
/>
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="text"
placeholder="4:3 or 1.3333"
bind:value={aspectRatio}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
audioBitrate = $bindable(),
enabled = $bindable(false)
}: {
class?: string;
name?: string;
audioBitrate?: number | string;
enabled?: boolean;
} = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Audio Bitrate</Label>
<InfoPopover description="Sets the audio bitrate in kbps." class="h-4 w-4" />
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="number"
step="1"
placeholder="128"
bind:value={audioBitrate}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
audioChannels = $bindable(),
enabled = $bindable(false)
}: {
class?: string;
name?: string;
audioChannels?: number | string;
enabled?: boolean;
} = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Audio Channels</Label>
<InfoPopover description="Set audio channel count." class="h-4 w-4" />
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="number"
step="1"
placeholder="2"
bind:value={audioChannels}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,85 @@
<script lang="ts">
import { cn } from '@/utils';
import { Check, CheckIcon, ChevronsUpDown } from 'lucide-svelte';
import { Button, Input, Label, Toggle, Command, Popover } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import { onMount, tick } from 'svelte';
import { api } from '@/stores/api';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
audioCodec = $bindable(undefined),
enabled = $bindable(false),
codecs = $bindable([])
}: {
class?: string;
name?: string;
codecs?: string[];
audioCodec?: string;
enabled?: boolean;
} = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Audio Codec</Label>
<InfoPopover description="" class="h-4 w-4" />
</div>
<div class="flex gap-1">
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef} class="grow" disabled={!enabled}>
{#snippet child({ props }: { props: any })}
<Button
variant="outline"
class="w-full justify-between"
{...props}
role="combobox"
aria-expanded={open}
>
{audioCodec || 'Select a codec, e.g. aac'}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
<Command.Input placeholder="Search codec..." disabled={!enabled} autofocus />
<Command.List>
<Command.Empty>No codec found.</Command.Empty>
<Command.Group>
{#each codecs as codec}
<Command.Item
value={codec}
disabled={!enabled}
onSelect={() => {
if (!enabled) return;
audioCodec = codec;
closeAndFocusTrigger();
}}
>
<Check class={cn('mr-2 size-4', audioCodec !== codec && 'text-transparent')} />
{codec}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
audioQuality = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; audioQuality?: number; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Audio Quality</Label>
<InfoPopover
description="This method fixes a quality factor for the audio codec (VBR encoding). The quality scale depends on the actual codec used."
class="h-4 w-4"
/>
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="number"
step="1"
placeholder="0"
class="grow"
bind:value={audioQuality}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,28 @@
<script lang="ts">
import type { ProcessVideoOptions } from '@/types';
import { cn } from '@/utils';
import { Button, Input, Label } from '@kksh/svelte5';
import EnableButton from '../enable-button.svelte';
let {
autopad = $bindable(),
autopadColor = $bindable(),
name,
enabled = $bindable(false),
class: className
}: {
autopad?: ProcessVideoOptions['enableAutopad'];
autopadColor?: ProcessVideoOptions['autopadColor'];
name?: string;
enabled?: boolean;
class?: string;
} = $props();
</script>
<div class={cn('', className)}>
<Label for={name} class="font-semibold">Autopad</Label>
<div class="flex gap-1">
<Input id={name} {name} type="color" bind:value={autopadColor} disabled={!autopad} />
<EnableButton bind:enabled={autopad} />
</div>
</div>

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
duration = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; duration?: number | string; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Duration</Label>
<InfoPopover
description="Forces ffmpeg to stop transcoding after a specific output duration. The time parameter may be a number (in seconds) or a timestamp string (with format [[hh:]mm:]ss[.xxx])."
class="h-4 w-4"
/>
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="text"
placeholder="e.g. 134.5 or 2:14.500"
bind:value={duration}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,34 @@
<script lang="ts">
import { cn } from '@/utils';
import { CheckIcon } from 'lucide-svelte';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
import { onMount } from 'svelte';
import { getFFmpegPath, getRpcAPI } from '@/api';
let {
class: className,
name,
ffmpegPath = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; ffmpegPath?: string; enabled?: boolean } = $props();
let placeholder = $state('/usr/bin/ffmpeg');
onMount(() => {
getFFmpegPath().then((path) => {
if (path) {
placeholder = path;
}
});
});
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">FFmpeg Path</Label>
<InfoPopover description="Set ffmpeg path." class="h-4 w-4" />
</div>
<div class="flex gap-1">
<Input id={name} {name} disabled={!enabled} type="text" {placeholder} bind:value={ffmpegPath} />
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,104 @@
<script lang="ts">
import { cn } from '@/utils';
import { Check, CheckIcon, ChevronsUpDown } from 'lucide-svelte';
import { Button, Input, Label, Toggle, Command, Popover } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import { onMount, tick } from 'svelte';
import { api } from '@/stores/api';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
format = $bindable(undefined),
enabled = $bindable(false),
inputFilePath
}: {
class?: string;
name?: string;
format?: string;
enabled?: boolean;
inputFilePath?: string;
} = $props();
const videoFormats = $state(['mp4', 'mkv', 'webm', 'mpg', 'avi', 'ogv', 'flv']);
const audioFormats = $state(['mp3', 'm4a', 'ogg', 'flac', 'wav']);
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Format</Label>
<InfoPopover description="" class="h-4 w-4" />
</div>
<div class="flex gap-1">
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef} class="grow" disabled={!enabled}>
{#snippet child({ props }: { props: any })}
<Button
variant="outline"
class="w-full justify-between"
{...props}
role="combobox"
aria-expanded={open}
>
{format || 'Select a format, e.g. mp4'}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
<Command.Input placeholder="Search format..." disabled={!enabled} autofocus />
<Command.List>
<Command.Empty>No format found.</Command.Empty>
<Command.Group heading="Video">
{#each videoFormats as _format}
<Command.Item
value={_format}
disabled={!enabled}
onSelect={() => {
if (!enabled) return;
format = _format;
closeAndFocusTrigger();
}}
>
<Check class={cn('mr-2 size-4', format !== _format && 'text-transparent')} />
{_format}
</Command.Item>
{/each}
</Command.Group>
<Command.Group heading="Audio">
{#each audioFormats as _format}
<Command.Item
value={_format}
disabled={!enabled}
onSelect={() => {
if (!enabled) return;
format = _format;
closeAndFocusTrigger();
}}
>
<Check class={cn('mr-2 size-4', format !== _format && 'text-transparent')} />
{_format}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
fps = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; fps?: number; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">FPS</Label>
<InfoPopover
description="Target Output FPS"
class="h-4 w-4"
/>
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="number"
step="1"
placeholder="0"
class="grow"
bind:value={fps}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { cn } from '@/utils';
import { CheckIcon } from 'lucide-svelte';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
size = $bindable(undefined),
enabled = $bindable(false)
}: { class?: string; name?: string; size?: string; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Frame Size</Label>
<InfoPopover
description="Don't set this if you've set a Resize Percentage. This will set the frame size to the specified width and height. You can use ? to set either width or height automatically."
class="h-4 w-4"
/>
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="number"
min="0"
max="100"
placeholder="1920x1080 or 1920x?"
step="1"
bind:value={size}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,48 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label } from '@kksh/svelte5';
import { dialog, event } from '@kksh/api/ui/iframe';
import { onDestroy, onMount } from 'svelte';
let {
class: className,
name,
inputPath = $bindable('')
}: { class?: string; name?: string; inputPath?: string } = $props();
onMount(async () => {
event.onDragDrop((e) => {
if (e.paths && e.paths.length > 0) {
inputPath = e.paths[0];
}
});
});
onDestroy(() => {});
function pickFile() {
console.log('pickFile');
dialog
.open({
directory: false
})
.then((path: string) => {
console.log('path', path);
inputPath = path;
});
}
</script>
<div class={cn('', className)}>
<Label for={name} class="font-semibold">Input</Label>
<div class="flex gap-1">
<Input
id={name}
{name}
type="text"
class="font-mono"
bind:value={inputPath}
placeholder="You can drag and drop a file"
/>
<Button variant="secondary" onclick={pickFile}>Pick</Button>
</div>
</div>

View File

@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Label, Switch } from '@kksh/svelte5';
import { CheckIcon } from 'lucide-svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
noAudio = $bindable(false),
enabled = $bindable(false)
}: { class?: string; name?: string; noAudio?: boolean; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex"><Label class="font-semibold">No Audio</Label></div>
<div class="flex items-center justify-between space-x-2">
<div class="flex items-center gap-2">
<Switch id={name} bind:checked={noAudio} disabled={!enabled} />
{#if noAudio}
<Label for={name}>Disable Audio</Label>
{:else}
<Label for={name}>Keep Default</Label>
{/if}
</div>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Label, Switch } from '@kksh/svelte5';
import { CheckIcon } from 'lucide-svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
noVideo = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; noVideo?: boolean; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex"><Label class="font-semibold">No Video</Label></div>
<div class="flex items-center justify-between space-x-2">
<div class="flex items-center gap-2">
<Switch id={name} bind:checked={noVideo} disabled={!enabled} />
{#if noVideo}
<Label for={name}>Disable Video</Label>
{:else}
<Label for={name}>Keep Default</Label>
{/if}
</div>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label } from '@kksh/svelte5';
import { dialog } from '@kksh/api/ui/iframe';
let {
class: className,
name,
outputPath = $bindable('')
}: { class?: string; name?: string; outputPath?: string } = $props();
function pickSavePath() {
dialog.save().then((path: string | null) => {
if (path) {
outputPath = path;
}
});
}
</script>
<div class={cn('', className)}>
<Label for={name} class="font-semibold">Output</Label>
<div class="flex gap-1">
<Input id={name} {name} type="text" class="font-mono" bind:value={outputPath} />
<Button variant="secondary" onclick={pickSavePath}>Pick</Button>
</div>
</div>

View File

@ -0,0 +1,52 @@
<script lang="ts">
import { cn } from '@/utils';
import { Select, Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
const presets = [
'ultrafast',
'superfast',
'veryfast',
'faster',
'fast',
'medium',
'slow',
'slower',
'veryslow'
];
let {
class: className,
name,
preset = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; preset?: string; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Preset</Label>
<InfoPopover description="Preset for the encoding process" class="h-4 w-4" />
</div>
<div class="flex gap-1">
<Select.Root type="single" name="favoriteFruit" bind:value={preset} disabled={!enabled}>
<Select.Trigger class="">
{#if preset}
{preset}
{:else}
Select Preset
{/if}
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.GroupHeading>Presets</Select.GroupHeading>
{#each presets as preset}
<Select.Item value={preset} label={preset}>
{preset}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
resizePercentage = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; resizePercentage?: number; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Resize Percentage</Label>
<InfoPopover
description="Resize frame size to a percentage of the original size. Use 50 instead of 0.5. Don't set this if you've set a Frame Size."
class="h-4 w-4"
/>
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="number"
min="0"
max="100"
placeholder="100"
step="1"
class="grow"
bind:value={resizePercentage}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
startTime = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; startTime?: number | string; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Start Time</Label>
<InfoPopover
description="Seeks an input and only start decoding at given time offset. The time argument may be a number (in seconds) or a timestamp string (with format [[hh:]mm:]ss[.xxx])."
class="h-4 w-4"
/>
</div>
<div class="flex gap-1">
<Input id={name} {name} disabled={!enabled} class="grow" bind:value={startTime} placeholder="e.g. 134.5 or 2:14.500" />
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
takeFrames = $bindable(),
enabled = $bindable(false)
}: { class?: string; name?: string; takeFrames?: number; enabled?: boolean } = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Take Frames</Label>
<InfoPopover description="Only encode a certain number of frames" class="h-4 w-4" />
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="number"
min="0"
step="1"
placeholder="240"
class="grow"
bind:value={takeFrames}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from '@/utils';
import { Button, Input, Label, Toggle } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
videoBitrate = $bindable(),
enabled = $bindable(false)
}: {
class?: string;
name?: string;
videoBitrate?: number | string;
enabled?: boolean;
} = $props();
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Video Bitrate (kbps)</Label>
<InfoPopover description="Sets the video bitrate in kbps." class="h-4 w-4" />
</div>
<div class="flex gap-1">
<Input
id={name}
{name}
disabled={!enabled}
type="number"
step="1"
placeholder="1000"
bind:value={videoBitrate}
/>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { cn } from '@/utils';
import { Check, CheckIcon, ChevronsUpDown } from 'lucide-svelte';
import { Button, Input, Label, Toggle, Command, Popover } from '@kksh/svelte5';
import InfoPopover from '../info-popover.svelte';
import { onMount, tick } from 'svelte';
import { api } from '@/stores/api';
import EnableButton from '../enable-button.svelte';
let {
class: className,
name,
videoCodec = $bindable(undefined),
enabled = $bindable(false),
codecs = $bindable([])
}: {
class?: string;
name?: string;
videoCodec?: string;
enabled?: boolean;
codecs?: string[];
} = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
</script>
<div class={cn('flex flex-col gap-1', className)}>
<div class="flex items-center gap-1">
<Label for={name} class="font-semibold">Video Codec</Label>
<InfoPopover
description={`To take advantage of hardware acceleration, select a codec that is supported by your GPU.
For example, on an M1 Mac, you can use h264_videotoolbox. Or codec with nvenc if you have an NVIDIA GPU.
`}
class="h-4 w-4"
/>
</div>
<div class="flex gap-1">
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef} disabled={!enabled}>
{#snippet child({ props }: { props: any })}
<Button
variant="outline"
class="w-full justify-between"
{...props}
role="combobox"
aria-expanded={open}
>
{videoCodec || 'Select a codec, e.g. libx264'}
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
<Command.Input placeholder="Search codec..." disabled={!enabled} autofocus />
<Command.List>
<Command.Empty>No codec found.</Command.Empty>
<Command.Group>
{#each codecs as codec}
<Command.Item
value={codec}
disabled={!enabled}
onSelect={() => {
if (!enabled) return;
videoCodec = codec;
closeAndFocusTrigger();
}}
>
<Check class={cn('mr-2 size-4', videoCodec !== codec && 'text-transparent')} />
{codec}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
<EnableButton bind:enabled />
</div>
</div>

View File

@ -0,0 +1,15 @@
<script lang="ts">
import { Popover } from '@kksh/svelte5';
import { InfoIcon } from 'lucide-svelte';
let { description, class: className }: { description: string; class?: string } = $props();
</script>
<Popover.Root>
<Popover.Trigger>
<InfoIcon class={className} />
</Popover.Trigger>
<Popover.Content>
<div class="text-sm">{description}</div>
</Popover.Content>
</Popover.Root>

View File

@ -0,0 +1,222 @@
<script lang="ts">
import { browser, dev } from '$app/environment';
import { ProcessVideoOptionsSchema, type OptionsEnable } from '@/types';
import type { ProcessVideoOptions } from '@hk/photographer-toolbox/types';
import type { ProcessVideoOptions as LocalProcessVideoOptions } from '@/types';
import {
Tabs,
Switch,
Label,
Input,
Form,
Card,
Button,
Separator,
Accordion
} from '@kksh/svelte5';
// import * as Form from '$lib/components/ui/form';
import SuperDebug, { defaults, superForm } from 'sveltekit-superforms';
import { valibot, valibotClient } from 'sveltekit-superforms/adapters';
import * as v from 'valibot';
import InputFile from './form-fields/input-file.svelte';
import FFmpegPath from './form-fields/ffmpeg-path.svelte';
import FPS from './form-fields/fps.svelte';
import OutputPath from './form-fields/output-path.svelte';
import ResizePercentage from './form-fields/resize-percentage.svelte';
import FrameSize from './form-fields/frame-size.svelte';
import AspectRatio from './form-fields/aspect-ratio.svelte';
import VideoCodec from './form-fields/video-codec.svelte';
import AudioCodec from './form-fields/audio-codec.svelte';
import Format from './form-fields/format.svelte';
import NoAudio from './form-fields/no-audio.svelte';
import TakeFrames from './form-fields/take-frames.svelte';
import NoVideo from './form-fields/no-video.svelte';
import AudioQuality from './form-fields/audio-quality.svelte';
import Preset from './form-fields/preset.svelte';
import StartTime from './form-fields/start-time.svelte';
import Duration from './form-fields/duration.svelte';
import AudioBitrate from './form-fields/audio-bitrate.svelte';
import VideoBitrate from './form-fields/video-bitrate.svelte';
import AudioChannels from './form-fields/audio-channels.svelte';
import Autopad from './form-fields/autopad.svelte';
import { getRpcAPI } from '@/api';
import type { Child } from '@kksh/api/ui/worker';
import { toast } from 'svelte-sonner';
let {
options = $bindable({}),
onSubmit,
inProgress
}: {
options: ProcessVideoOptions;
onSubmit?: (options: LocalProcessVideoOptions, enabled: OptionsEnable) => void;
inProgress?: boolean;
} = $props();
let videoCodecs = $state<string[]>([]);
let audioCodecs = $state<string[]>([]);
let apiProcess: Child | undefined;
const enabled = $state<OptionsEnable>({
resizePercentage: false,
size: false,
aspectRatio: false,
videoCodec: false,
audioCodec: false,
format: false,
outputOptions: false,
audioFilters: false,
noAudio: false,
takeFrames: false,
noVideo: false,
autopadPad: false,
autopadColor: false,
audioQuality: false,
fps: false,
preset: false,
startTime: false,
duration: false,
audioBitrate: false,
videoBitrate: false,
audioChannels: false,
ffmpegPath: false
});
const form = superForm(defaults(valibot(ProcessVideoOptionsSchema)), {
validators: valibotClient(ProcessVideoOptionsSchema),
SPA: true,
onUpdate({ form, cancel }) {
const result = v.safeParse(ProcessVideoOptionsSchema, form.data);
if (result.issues) {
console.log(v.flatten(result.issues));
}
if (!form.valid) {
console.log('invalid');
return;
}
onSubmit?.(form.data, enabled);
cancel();
}
});
$effect(() => {
getRpcAPI().then(({ api, process }) => {
apiProcess = process;
return api
.getAvailableCodecsNamesByType('video', $formData.inputPath)
.then((_codecs) => {
videoCodecs = ['copy', ..._codecs];
})
.then(() => api.getAvailableCodecsNamesByType('audio', $formData.inputPath))
.then((_codecs) => {
audioCodecs = ['copy', ..._codecs];
})
.catch((err) => {
toast.error('Fail to load codecs', {
description: err.message
});
})
.finally(() => {
process.kill();
});
});
});
const { form: formData, enhance, errors } = form;
</script>
<form method="POST" use:enhance>
<Card.Root>
<Card.Content class="space-y-1">
<InputFile bind:inputPath={$formData.inputPath} />
<OutputPath bind:outputPath={$formData.outputPath} />
<div class="grid grid-cols-2 gap-2 pt-2">
<Format bind:format={$formData.format} bind:enabled={enabled.format} />
<ResizePercentage
bind:resizePercentage={$formData.resizePercentage}
bind:enabled={enabled.resizePercentage}
/>
</div>
</Card.Content>
</Card.Root>
<Form.Button class="mt-3" disabled={inProgress}>Start Processing</Form.Button>
<Separator class="my-3" />
<Accordion.Root type="single" class="w-full">
<Accordion.Item value="item-1">
<Accordion.Trigger>More Options</Accordion.Trigger>
<Accordion.Content>
<Tabs.Root value="video" class="w-full">
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.Trigger value="video">Video</Tabs.Trigger>
<Tabs.Trigger value="audio">Audio</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="video">
<Card.Root>
<Card.Content class="grid grid-cols-2 gap-2 space-y-2">
<FrameSize bind:size={$formData.size} bind:enabled={enabled.size} />
<!-- <AspectRatio
bind:aspectRatio={$formData.aspectRatio}
bind:enabled={enabled.aspectRatio}
/> -->
<VideoCodec
bind:videoCodec={$formData.videoCodec}
bind:enabled={enabled.videoCodec}
codecs={videoCodecs}
/>
<TakeFrames
bind:takeFrames={$formData.takeFrames}
bind:enabled={enabled.takeFrames}
/>
<NoVideo bind:noVideo={$formData.noVideo} bind:enabled={enabled.noVideo} />
<FPS bind:fps={$formData.fps} bind:enabled={enabled.fps} />
<Preset bind:preset={$formData.preset} bind:enabled={enabled.preset} />
<StartTime bind:startTime={$formData.startTime} bind:enabled={enabled.startTime} />
<Duration bind:duration={$formData.duration} bind:enabled={enabled.duration} />
<VideoBitrate
bind:videoBitrate={$formData.videoBitrate}
bind:enabled={enabled.videoBitrate}
/>
<Autopad
bind:autopad={$formData.enableAutopad}
bind:autopadColor={$formData.autopadColor}
bind:enabled={enabled.autopadPad}
/>
<FFmpegPath
bind:ffmpegPath={$formData.ffmpegPath}
bind:enabled={enabled.ffmpegPath}
/>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="audio">
<Card.Root>
<Card.Content class="grid grid-cols-2 gap-2 space-y-2">
<AudioCodec
bind:audioCodec={$formData.audioCodec}
bind:enabled={enabled.audioCodec}
codecs={audioCodecs}
/>
<NoAudio bind:noAudio={$formData.noAudio} bind:enabled={enabled.noAudio} />
<AudioQuality
bind:audioQuality={$formData.audioQuality}
bind:enabled={enabled.audioQuality}
/>
<AudioBitrate
bind:audioBitrate={$formData.audioBitrate}
bind:enabled={enabled.audioBitrate}
/>
<AudioChannels
bind:audioChannels={$formData.audioChannels}
bind:enabled={enabled.audioChannels}
/>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
{#if browser && dev}
<SuperDebug data={$formData} />
{/if}
</form>
<!-- <pre>{JSON.stringify(enabled, null, 2)}</pre> -->

77
src/lib/form.ts Normal file
View File

@ -0,0 +1,77 @@
import type { OptionsEnable, ProcessVideoOptions as LocalProcessVideoOptions } from './types';
import type { ProcessVideoOptions } from '@hk/photographer-toolbox/types';
export function verifyFormOptions(
options: LocalProcessVideoOptions,
enabled: OptionsEnable
): ProcessVideoOptions | null {
const options2: ProcessVideoOptions = {};
if (enabled.resizePercentage) {
options2.resizePercentage = options.resizePercentage;
}
if (enabled.size) {
options2.size = options.size;
}
if (enabled.aspectRatio) {
options2.aspectRatio = options.aspectRatio;
}
if (enabled.videoCodec) {
options2.videoCodec = options.videoCodec;
}
if (enabled.audioCodec) {
options2.audioCodec = options.audioCodec;
}
if (enabled.format) {
options2.format = options.format;
}
if (enabled.outputOptions) {
options2.outputOptions = options.outputOptions;
}
if (enabled.audioFilters) {
options2.audioFilters = options.audioFilters;
}
if (enabled.noAudio) {
options2.noAudio = options.noAudio;
}
if (enabled.takeFrames) {
options2.takeFrames = options.takeFrames;
}
if (enabled.noVideo) {
options2.noVideo = options.noVideo;
}
if (enabled.autopadPad) {
options2.autopad = { pad: options.enableAutopad };
if (enabled.autopadColor) {
options2.autopad.color = options.autopadColor;
}
}
if (enabled.audioQuality) {
options2.audioQuality = options.audioQuality;
}
if (enabled.fps) {
options2.fps = options.fps;
}
if (enabled.preset) {
options2.preset = options.preset;
}
if (enabled.startTime) {
options2.startTime = options.startTime;
}
if (enabled.duration) {
options2.duration = options.duration;
}
if (enabled.audioBitrate) {
options2.audioBitrate = options.audioBitrate;
}
if (enabled.videoBitrate) {
options2.videoBitrate = options.videoBitrate;
}
if (enabled.audioChannels) {
options2.audioChannels = options.audioChannels;
}
if (enabled.ffmpegPath) {
options2.ffmpegPath = options.ffmpegPath;
}
return options2;
return null;
}

1
src/lib/index.ts Normal file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

31
src/lib/stores/api.ts Normal file
View File

@ -0,0 +1,31 @@
import { get, writable } from 'svelte/store';
import type { API } from '../../types';
import { getRpcAPI } from '@/api';
import type { Child, DenoCommand } from '@kksh/api/ui/worker';
export function createApiStore() {
const store = writable<{
api: API;
process: Child;
command: DenoCommand<string>;
} | null>(null);
async function init() {
return getRpcAPI().then(({ api, process, command }) => {
console.log('init api', api);
store.set({ api, process, command });
});
}
return {
...store,
init,
api: get(store)?.api,
destroy() {
console.log('destroy api');
get(store)?.process.kill();
}
};
}
export const api = createApiStore();

76
src/lib/types.ts Normal file
View File

@ -0,0 +1,76 @@
import * as v from 'valibot';
export const ProcessVideoOptionsSchema = v.object({
inputPath: v.string(),
outputPath: v.string(),
resizePercentage: v.optional(v.number()),
size: v.optional(v.string()),
aspectRatio: v.optional(v.string()),
videoCodec: v.optional(v.string()),
audioCodec: v.optional(v.string()),
format: v.optional(v.string()),
outputOptions: v.optional(v.array(v.string())),
audioFilters: v.optional(v.array(v.string())),
noAudio: v.optional(v.boolean(), false),
takeFrames: v.optional(v.number()),
noVideo: v.optional(v.boolean(), false),
enableAutopad: v.optional(v.boolean(), false),
autopadColor: v.optional(v.string()),
// autopad: v.optional(
// v.object({
// pad: v.optional(v.boolean()),
// color: v.optional(v.string())
// }),
// { pad: false, color: '' }
// ),
audioQuality: v.optional(v.number()),
fps: v.optional(v.number()),
preset: v.optional(
v.union([
v.literal('ultrafast'),
v.literal('superfast'),
v.literal('veryfast'),
v.literal('faster'),
v.literal('fast'),
v.literal('medium'),
v.literal('slow'),
v.literal('slower'),
v.literal('veryslow')
]),
'medium'
),
startTime: v.optional(v.union([v.string(), v.number()])),
duration: v.optional(v.union([v.string(), v.number()])),
audioBitrate: v.optional(v.number()),
videoBitrate: v.optional(v.number()),
audioChannels: v.optional(v.number()),
ffprobePath: v.optional(v.string()),
ffmpegPath: v.optional(v.string())
});
export type ProcessVideoOptions = v.InferOutput<typeof ProcessVideoOptionsSchema>;
export const OptionsEnableSchema = v.object({
resizePercentage: v.boolean(),
size: v.boolean(),
aspectRatio: v.boolean(),
videoCodec: v.boolean(),
audioCodec: v.boolean(),
format: v.boolean(),
outputOptions: v.boolean(),
audioFilters: v.boolean(),
noAudio: v.boolean(),
takeFrames: v.boolean(),
noVideo: v.boolean(),
autopadPad: v.boolean(),
autopadColor: v.boolean(),
audioQuality: v.boolean(),
fps: v.boolean(),
preset: v.boolean(),
startTime: v.boolean(),
duration: v.boolean(),
audioBitrate: v.boolean(),
videoBitrate: v.boolean(),
audioChannels: v.boolean(),
ffmpegPath: v.boolean()
});
export type OptionsEnable = v.InferOutput<typeof OptionsEnableSchema>;

62
src/lib/utils.ts Normal file
View File

@ -0,0 +1,62 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
type FlyAndScaleParams = {
y?: number;
x?: number;
start?: number;
duration?: number;
};
export const flyAndScale = (
node: Element,
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
): TransitionConfig => {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
const scaleConversion = (
valueA: number,
scaleA: [number, number],
scaleB: [number, number]
) => {
const [minA, maxA] = scaleA;
const [minB, maxB] = scaleB;
const percentage = (valueA - minA) / (maxA - minA);
const valueB = percentage * (maxB - minB) + minB;
return valueB;
};
const styleToString = (
style: Record<string, number | string | undefined>
): string => {
return Object.keys(style).reduce((str, key) => {
if (style[key] === undefined) return str;
return str + `${key}:${style[key]};`;
}, "");
};
return {
duration: params.duration ?? 200,
delay: 0,
css: (t) => {
const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);
const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);
const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);
return styleToString({
transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
opacity: t
});
},
easing: cubicOut
};
};

45
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,45 @@
<script>
import '../app.css';
import { ModeWatcher } from 'mode-watcher';
import { Toaster, ThemeWrapper, updateTheme } from '@kksh/svelte5';
import { toast } from 'svelte-sonner';
import { onDestroy, onMount } from 'svelte';
import { ui, shell } from '@kksh/api/ui/iframe';
import { api } from '@/stores/api';
import { getFFmpegPath } from '@/api';
onMount(async () => {
ui.registerDragRegion();
ui.getTheme().then((theme) => {
updateTheme(theme);
});
getFFmpegPath().then((path) => {
if (!path) {
toast.error('ffmpeg not found in PATH', {
description: 'Please install ffmpeg and ensure it is in your PATH'
});
}
});
});
onDestroy(() => {
api.destroy();
});
</script>
<svelte:window
on:keydown={(e) => {
if (e.key === 'Escape') {
if (document.activeElement?.nodeName === 'BODY') {
e.preventDefault();
ui.goBack();
}
}
}}
/>
<Toaster richColors />
<ModeWatcher />
<ThemeWrapper>
<div class="fixed top-0 h-12 w-full" data-kunkun-drag-region></div>
<slot />
</ThemeWrapper>

2
src/routes/+layout.ts Normal file
View File

@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;

69
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,69 @@
<script lang="ts">
import { base } from '$app/paths';
import { clipboard, notification, shell, ui, toast, event } from '@kksh/api/ui/iframe';
import { Button, Label, Progress } from '@kksh/svelte5';
import type { ProcessVideoOptions } from '@hk/photographer-toolbox/types';
import { Card } from '@kksh/svelte5';
import type { ProcessVideoOptions as LocalProcessVideoOptions } from '@/types';
import OptionsForm from '@/components/options-form.svelte';
import type { API } from '../types';
import type { OptionsEnable } from '@/types';
import { api } from '@/stores/api';
import { verifyFormOptions } from '@/form';
import { getRpcAPI } from '@/api';
import { onMount } from 'svelte';
let options: ProcessVideoOptions = $state({});
let progress = $state(0);
let elapsedTimeSecs = $state(0);
async function handleSubmit(options: LocalProcessVideoOptions, enabled: OptionsEnable) {
progress = 1;
const verifiedOptions = verifyFormOptions(options, enabled);
if (!verifiedOptions) {
toast.error('Invalid options');
return;
}
const startTime = Date.now();
getRpcAPI().then(({ api, process }) => {
return api
.convertVideo(
options.inputPath,
options.outputPath,
verifiedOptions,
() => {
toast.info('Started');
},
(p) => {
elapsedTimeSecs = Math.floor((Date.now() - startTime) / 1000);
progress = p.percent ?? 0;
},
() => {
progress = 0;
process.kill();
toast.success('Done');
}
)
.catch((e) => {
toast.error('Failed', { description: e });
process.kill();
});
});
}
</script>
<main class="container max-w-screen-lg space-y-3 pb-10 pt-10">
<h1 class="text-2xl font-semibold">Convert Video</h1>
<Card.Root class="sticky top-12 z-50">
<Card.Content class="px-8 pb-0 pt-1">
<Label class="text-lg font-semibold"
>Progress
{#if elapsedTimeSecs > 0}
({elapsedTimeSecs}s)
{/if}
</Label>
<Progress value={progress} max={100} class="pointer-events-none my-5" />
</Card.Content>
</Card.Root>
<OptionsForm bind:options onSubmit={handleSubmit} inProgress={progress > 0} />
</main>

23
src/types.ts Normal file
View File

@ -0,0 +1,23 @@
import type {
DefaultVideoMetadata,
ProcessVideoOptions,
Progress
} from '@hk/photographer-toolbox/types';
export type API = {
setFfprobePath: (path: string) => void;
setFfmpegPath: (path: string) => void;
readDefaultVideoMetadata: (path: string) => Promise<DefaultVideoMetadata | null>;
getAvailableCodecsNamesByType: (
type: 'video' | 'audio' | 'subtitle' | string,
source?: string
) => Promise<string[]>;
convertVideo: (
inputPath: string,
outputPath: string,
options: ProcessVideoOptions,
startCallback?: () => void,
progressCallback?: (progress: Progress) => void,
endCallback?: () => void
) => Promise<void>;
};

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

21
svelte.config.js Normal file
View File

@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({}),
alias: {
'@/*': './src/lib/*'
}
}
};
export default config;

86
tailwind.config.ts Normal file
View File

@ -0,0 +1,86 @@
import { fontFamily } from 'tailwindcss/defaultTheme';
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./src/**/*.{html,js,svelte,ts}',
'node_modules/@kksh/svelte5/dist/**/*.{html,js,svelte,ts}'
],
safelist: ['dark'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border) / <alpha-value>)',
input: 'hsl(var(--input) / <alpha-value>)',
ring: 'hsl(var(--ring) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)'
},
secondary: {
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)'
},
destructive: {
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)'
},
muted: {
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)'
},
accent: {
DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)'
},
popover: {
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)'
},
card: {
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
sans: [...fontFamily.sans]
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--bits-accordion-content-height)' }
},
'accordion-up': {
from: { height: 'var(--bits-accordion-content-height)' },
to: { height: '0' }
},
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite'
}
}
}
};
export default config;

View File

@ -0,0 +1,196 @@
import { VideoMetadata, DefaultVideoMetadata } from '@hk/photographer-toolbox/types';
import type { API } from '../src/types';
import {
Action,
app,
Child,
clipboard,
expose,
Form,
fs,
Icon,
IconEnum,
List,
path,
shell,
system,
toast,
ui,
WorkerExtension
} from '@kksh/api/ui/worker';
import { filesize } from 'filesize';
class VideoInfo extends WorkerExtension {
api: API | undefined;
apiProcess: Child | undefined;
videoMetadata: Record<string, DefaultVideoMetadata> = {};
async fillApi() {
if (this.api) return;
const { rpcChannel, process, command } = await shell.createDenoRpcChannel<object, API>(
'$EXTENSION/deno-src/index.ts',
[],
{
allowAllEnv: true,
// allowEnv: ['NODE_V8_COVERAGE', 'npm_package_config_libvips', 'EXIFTOOL_HOME', 'OSTYPE'],
// allowFfi: ["*sharp-darwin-arm64.node"],
allowAllFfi: true,
allowAllRead: true,
allowAllSys: true,
// allowSys: ['uid', 'cpus'],
// allowRun: ["*exiftool"]
allowAllRun: true,
env: {
FFMPEG_PATH: '/opt/homebrew/bin/ffmpeg',
FFPROBE_PATH: '/opt/homebrew/bin/ffprobe'
}
},
{}
);
command.stderr.on('data', (stderr) => {
console.warn('stderr', stderr);
});
this.api = rpcChannel.getAPI();
this.apiProcess = process;
}
async refreshList(paths: string[]) {
ui.render(new List.List({ items: [] }));
if (!this.api) await this.fillApi();
ui.showLoadingBar(true);
return Promise.all(paths.map((p) => this.api?.readDefaultVideoMetadata(p)))
.then((metadatas) => metadatas.filter((m) => !!m))
.then((metadatas) => {
this.videoMetadata = Object.fromEntries(
paths.map((file, index) => [file, metadatas[index]])
);
})
.then(async () => {
return ui.render(
new List.List({
detail: new List.ItemDetail({
width: 60,
children: []
}),
items: await Promise.all(
paths.map(async (file) => {
const baseName = await path.basename(file);
return new List.Item({
title: baseName,
value: file
});
})
)
})
);
})
.finally(() => {
ui.showLoadingBar(false);
console.log('finally, kill api process', this.apiProcess?.pid);
this.apiProcess?.kill();
this.apiProcess = undefined;
this.api = undefined;
});
}
async load() {
ui.render(new List.List({ items: [] }));
await this.fillApi();
ui.showLoadingBar(true);
const ffprobePath = await shell.whereIsCommand('ffprobe');
console.log('ffprobePath', ffprobePath);
if (!ffprobePath) {
return toast.error('ffprobe not found in path');
}
// await this.api?.setFfprobePath(ffprobePath);
let videoPaths = (
await Promise.all([
system.getSelectedFilesInFileExplorer().catch(() => {
return [];
}),
clipboard.hasFiles().then((has) => (has ? clipboard.readFiles() : Promise.resolve([])))
])
).flat();
console.log('videoPaths', videoPaths);
videoPaths = Array.from(new Set(videoPaths));
this.refreshList(videoPaths);
}
async onFilesDropped(paths: string[]): Promise<void> {
return this.refreshList(paths);
}
async onHighlightedListItemChanged(filePath: string): Promise<void> {
const metadata = this.videoMetadata[filePath];
const metadataLabels = [
// genMetadataLabel(metadata, 'Width', 'width'),
// genMetadataLabel(metadata, 'Height', 'height'),
new List.ItemDetailMetadataLabel({
title: 'Resolution',
text: `${metadata.width}x${metadata.height}`
}),
new List.ItemDetailMetadataLabel({
title: 'Size',
text: metadata.size ? filesize(metadata.size) : 'N/A'
}),
genMetadataLabel(metadata, 'Average Frame Rate', 'avgFrameRate'),
// genMetadataLabel(metadata, 'Bit Rate', 'bitRate'),
new List.ItemDetailMetadataLabel({
title: 'Bit Rate',
// text: metadata.bitRate ? metadata.bitRate.toString() : 'N/A'
text: metadata.bitRate ? `${filesize(metadata.bitRate / 8, { bits: true })}/s` : 'N/A'
}),
genMetadataLabel(metadata, 'Bits Per Raw Sample', 'bitsPerRawSample'),
genMetadataLabel(metadata, 'Codec', 'codec'),
genMetadataLabel(metadata, 'Codec Long Name', 'codecLongName'),
genMetadataLabel(metadata, 'Codec Tag', 'codecTag'),
genMetadataLabel(metadata, 'Codec Tag String', 'codecTagString'),
genMetadataLabel(metadata, 'Codec Type', 'codecType'),
genMetadataLabel(metadata, 'Duration', 'duration'),
genMetadataLabel(metadata, 'File Path', 'filePath'),
genMetadataLabel(metadata, 'Format Long Name', 'formatLongName'),
genMetadataLabel(metadata, 'Format Name', 'formatName'),
genMetadataLabel(metadata, 'Number Of Frames', 'numberOfFrames'),
genMetadataLabel(metadata, 'Number Of Streams', 'numberOfStreams'),
genMetadataLabel(metadata, 'Numeric Average Frame Rate', 'numericAvgFrameRate'),
genMetadataLabel(metadata, 'Profile', 'profile'),
genMetadataLabel(metadata, 'Raw Frame Rate', 'rFrameRate'),
genMetadataLabel(metadata, 'Start Time', 'startTime'),
genMetadataLabel(metadata, 'Time Base', 'timeBase')
].filter((label) => label !== null);
return ui.render(
new List.List({
inherits: ['items'],
detail: new List.ItemDetail({
width: 55,
children: [new List.ItemDetailMetadata(metadataLabels)]
})
})
);
}
async onBeforeGoBack(): Promise<void> {
console.log('onBeforeGoBack, kill api process', this.apiProcess?.pid);
await this.apiProcess?.kill();
}
async onListItemSelected(value: string): Promise<void> {
return Promise.resolve();
}
}
function genMetadataLabel(metadata: DefaultVideoMetadata, title: string, key: string) {
if (!metadata[key]) return null;
return new List.ItemDetailMetadataLabel({
title,
text:
typeof metadata[key] === 'number'
? Number.isInteger(metadata[key])
? metadata[key].toString()
: metadata[key].toFixed(3).toString()
: metadata[key].toString()
});
}
expose(new VideoInfo());

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

6
vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});