mirror of
https://github.com/kunkunsh/kunkun-ext-video-processing.git
synced 2025-04-03 18:06:43 +00:00
feat: add GIF command
This commit is contained in:
parent
0b45ca5383
commit
7055599020
@ -10,3 +10,4 @@
|
||||
|
||||

|
||||

|
||||

|
||||
|
@ -1,14 +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"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
@ -2,13 +2,13 @@
|
||||
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({
|
||||
getAvailableCodecsNamesByType: video.getAvailableCodecsNamesByType,
|
||||
convertVideo: (
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
@ -28,7 +28,37 @@ expose({
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
convertToGif: (
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
options: {
|
||||
scale?: number;
|
||||
fps?: number;
|
||||
duration?: number;
|
||||
startTime?: number;
|
||||
},
|
||||
startCallback?: () => void,
|
||||
progressCallback?: (progress: Progress) => void,
|
||||
endCallback?: () => void
|
||||
) => {
|
||||
console.error(inputPath, outputPath, options);
|
||||
const outputOptions = [
|
||||
`-vf scale=${options.scale ?? 480}:-1:flags=lanczos,fps=${options.fps ?? 10}`,
|
||||
'-f gif'
|
||||
];
|
||||
console.error('outputOptions', outputOptions);
|
||||
video.convertVideo(
|
||||
inputPath,
|
||||
outputPath,
|
||||
{
|
||||
outputOptions,
|
||||
duration: options.duration,
|
||||
startTime: options.startTime
|
||||
},
|
||||
startCallback,
|
||||
progressCallback,
|
||||
endCallback
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
} satisfies API);
|
||||
|
36
package.json
36
package.json
@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.kunkun.sh",
|
||||
"license": "MIT",
|
||||
"name": "kunkun-ext-video-processing",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.10",
|
||||
"repository": "https://github.com/kunkunsh/kunkun-ext-video-processing",
|
||||
"kunkun": {
|
||||
"name": "Video Processing",
|
||||
@ -15,13 +15,19 @@
|
||||
},
|
||||
"demoImages": [
|
||||
"https://i.imgur.com/imtXN2D.png",
|
||||
"https://i.imgur.com/qhr7c7b.png"
|
||||
"https://i.imgur.com/qhr7c7b.png",
|
||||
"https://i.imgur.com/YHP96YM.png"
|
||||
],
|
||||
"permissions": [
|
||||
"clipboard:read-files",
|
||||
"system:fs",
|
||||
"notification:all",
|
||||
"dialog:all",
|
||||
{
|
||||
"permission": "fs:exists",
|
||||
"allow": [
|
||||
{
|
||||
"path": "**"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event:drag-drop",
|
||||
{
|
||||
"permission": "shell:deno:spawn",
|
||||
@ -58,6 +64,21 @@
|
||||
"dist": "build",
|
||||
"devMain": "http://localhost:5173",
|
||||
"name": "Video Conversion",
|
||||
"window": {
|
||||
"titleBarStyle": "overlay",
|
||||
"hiddenTitle": true
|
||||
},
|
||||
"cmds": []
|
||||
},
|
||||
{
|
||||
"main": "/gif",
|
||||
"dist": "build",
|
||||
"devMain": "http://localhost:5173/gif",
|
||||
"name": "Video to GIF",
|
||||
"window": {
|
||||
"titleBarStyle": "overlay",
|
||||
"hiddenTitle": true
|
||||
},
|
||||
"cmds": []
|
||||
}
|
||||
],
|
||||
@ -65,7 +86,10 @@
|
||||
{
|
||||
"name": "Video Info",
|
||||
"main": "dist/video-info.js",
|
||||
"cmds": []
|
||||
"cmds": [],
|
||||
"window": {
|
||||
"hiddenTitle": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import url("@kksh/svelte5/themes");
|
||||
@import url('@kksh/svelte5/themes');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@ -77,4 +77,4 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { shell } from '@kksh/api/ui/iframe';
|
||||
import { shell, toast } from '@kksh/api/ui/iframe';
|
||||
import type { API } from '../types';
|
||||
|
||||
export async function getRpcAPI() {
|
||||
@ -22,6 +22,12 @@ export async function getRpcAPI() {
|
||||
},
|
||||
{}
|
||||
);
|
||||
command.stderr.on('data', (data) => {
|
||||
console.warn(data);
|
||||
if (data.includes('Conversion failed!')) {
|
||||
toast.error('Conversion failed!');
|
||||
}
|
||||
});
|
||||
const api = rpcChannel.getAPI();
|
||||
return {
|
||||
api,
|
||||
|
@ -14,10 +14,7 @@
|
||||
<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"
|
||||
/>
|
||||
<InfoPopover description="Target Output FPS" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Input
|
||||
|
@ -20,7 +20,14 @@
|
||||
/>
|
||||
</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" />
|
||||
<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>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import type { TransitionConfig } from "svelte/transition";
|
||||
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));
|
||||
@ -19,13 +19,9 @@ export const flyAndScale = (
|
||||
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
|
||||
): TransitionConfig => {
|
||||
const style = getComputedStyle(node);
|
||||
const transform = style.transform === "none" ? "" : style.transform;
|
||||
const transform = style.transform === 'none' ? '' : style.transform;
|
||||
|
||||
const scaleConversion = (
|
||||
valueA: number,
|
||||
scaleA: [number, number],
|
||||
scaleB: [number, number]
|
||||
) => {
|
||||
const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => {
|
||||
const [minA, maxA] = scaleA;
|
||||
const [minB, maxB] = scaleB;
|
||||
|
||||
@ -35,13 +31,11 @@ export const flyAndScale = (
|
||||
return valueB;
|
||||
};
|
||||
|
||||
const styleToString = (
|
||||
style: Record<string, number | string | undefined>
|
||||
): string => {
|
||||
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 {
|
||||
@ -59,4 +53,4 @@ export const flyAndScale = (
|
||||
},
|
||||
easing: cubicOut
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -1,18 +1,19 @@
|
||||
<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 { toast, ui } from '@kksh/api/ui/iframe';
|
||||
import { 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';
|
||||
|
||||
onMount(() => {
|
||||
ui.showBackButton('bottom-right');
|
||||
});
|
||||
|
||||
let options: ProcessVideoOptions = $state({});
|
||||
let progress = $state(0);
|
||||
let elapsedTimeSecs = $state(0);
|
||||
|
138
src/routes/gif/+page.svelte
Normal file
138
src/routes/gif/+page.svelte
Normal file
@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import InputFile from '@/components/form-fields/input-file.svelte';
|
||||
import OutputPath from '@/components/form-fields/output-path.svelte';
|
||||
import { Label, Card, Input, Button, Progress } from '@kksh/svelte5';
|
||||
import { toast, fs, dialog, ui } from '@kksh/api/ui/iframe';
|
||||
import { getRpcAPI } from '@/api';
|
||||
import ExplainCard from './ExplainCard.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let progress = $state(0);
|
||||
let elapsedTimeSecs = $state(0);
|
||||
let inputPath = $state('');
|
||||
let outputPath = $state('');
|
||||
let fps = $state(10);
|
||||
let duration = $state(5);
|
||||
let startTime = $state(0);
|
||||
let scale = $state(480);
|
||||
|
||||
onMount(() => {
|
||||
ui.showBackButton('bottom-right');
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
elapsedTimeSecs = 0;
|
||||
const start = Date.now();
|
||||
if (!inputPath || !outputPath) {
|
||||
toast.error('Please select an input and output path');
|
||||
return;
|
||||
}
|
||||
if (!(await fs.exists(inputPath))) {
|
||||
toast.error('Input file does not exist');
|
||||
return;
|
||||
}
|
||||
if (!outputPath.endsWith('.gif')) {
|
||||
toast.error('Output file must end with .gif');
|
||||
return;
|
||||
}
|
||||
if (await fs.exists(outputPath)) {
|
||||
if (!(await dialog.confirm('Output file already exists. Overwrite?'))) {
|
||||
toast.info('Cancelled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getRpcAPI().then(({ api, process }) => {
|
||||
return api
|
||||
.convertToGif(
|
||||
inputPath,
|
||||
outputPath,
|
||||
{
|
||||
scale,
|
||||
fps,
|
||||
duration,
|
||||
startTime
|
||||
},
|
||||
() => {
|
||||
progress = 0;
|
||||
toast.info('Started');
|
||||
},
|
||||
(p) => {
|
||||
console.log(p);
|
||||
elapsedTimeSecs = Math.floor((Date.now() - start) / 1000);
|
||||
progress = p.percent ?? 0;
|
||||
},
|
||||
() => {
|
||||
console.log('end');
|
||||
progress = 100;
|
||||
process.kill();
|
||||
toast.info('Done');
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error('Failed', { description: e });
|
||||
process.kill();
|
||||
})
|
||||
.finally(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container max-w-screen-lg space-y-3 pb-10 pt-10">
|
||||
<h1 class="text-2xl font-semibold">Convert to GIF</h1>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<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.Header>
|
||||
<Card.Content class="space-y-1">
|
||||
<InputFile bind:inputPath />
|
||||
<OutputPath bind:outputPath />
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<div>
|
||||
<Label class="pl-1">FPS</Label>
|
||||
<div class="flex gap-0.5">
|
||||
<Input type="number" min={1} step={1} bind:value={fps} />
|
||||
<ExplainCard description="Frames per second" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="pl-1">Duration (s)</Label>
|
||||
<div class="flex gap-0.5">
|
||||
<Input type="number" min={1} step={1} bind:value={duration} />
|
||||
<ExplainCard
|
||||
description="Duration in seconds. You can take 5 seconds from a 3 minutes video. gif will be huge if you don't set a duration limit."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="pl-1">Start Time (s)</Label>
|
||||
<div class="flex gap-0.5">
|
||||
<Input type="number" min={0} bind:value={startTime} />
|
||||
<ExplainCard
|
||||
description="Start time in seconds. This is an offset from the start of the video."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="pl-1">Scale</Label>
|
||||
<div class="flex gap-0.5">
|
||||
<Input type="number" min={100} step={10} bind:value={scale} />
|
||||
<ExplainCard description="This number will be the final width of the gif" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button class="w-full" disabled={!inputPath || !outputPath} onclick={handleSubmit}>
|
||||
Convert
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</main>
|
13
src/routes/gif/ExplainCard.svelte
Normal file
13
src/routes/gif/ExplainCard.svelte
Normal file
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { Button, HoverCard } from '@kksh/svelte5';
|
||||
import { InfoIcon } from 'lucide-svelte';
|
||||
|
||||
let { description } = $props();
|
||||
</script>
|
||||
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger>
|
||||
<Button size="icon" variant="outline"><InfoIcon /></Button>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content>{description}</HoverCard.Content>
|
||||
</HoverCard.Root>
|
19
src/types.ts
19
src/types.ts
@ -5,9 +5,9 @@ import type {
|
||||
} from '@hk/photographer-toolbox/types';
|
||||
|
||||
export type API = {
|
||||
setFfprobePath: (path: string) => void;
|
||||
setFfmpegPath: (path: string) => void;
|
||||
readDefaultVideoMetadata: (path: string) => Promise<DefaultVideoMetadata | null>;
|
||||
// setFfprobePath: (path: string) => void;
|
||||
// setFfmpegPath: (path: string) => void;
|
||||
// readDefaultVideoMetadata: (path: string) => Promise<DefaultVideoMetadata | null>;
|
||||
getAvailableCodecsNamesByType: (
|
||||
type: 'video' | 'audio' | 'subtitle' | string,
|
||||
source?: string
|
||||
@ -20,4 +20,17 @@ export type API = {
|
||||
progressCallback?: (progress: Progress) => void,
|
||||
endCallback?: () => void
|
||||
) => Promise<void>;
|
||||
convertToGif: (
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
options: {
|
||||
scale?: number;
|
||||
fps?: number;
|
||||
duration?: number;
|
||||
startTime?: number;
|
||||
},
|
||||
startCallback?: () => void,
|
||||
progressCallback?: (progress: Progress) => void,
|
||||
endCallback?: () => void
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user