ossclip 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/editor-dist/assets/{index-B44I-xE4.js → index-MLGz89mM.js} +32 -32
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/analyze.ts +96 -4
- package/src/edit.ts +63 -3
- package/src/interactive/picker.ts +47 -26
- package/src/produce.ts +158 -23
- package/src/program.ts +10 -2
- package/src/ui/animation.ts +421 -0
package/editor-dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>ossclip editor</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-MLGz89mM.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-ChRVBVLj.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ossclip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"commander": "^12.1.0",
|
|
37
37
|
"tsx": "^4.19.0",
|
|
38
38
|
"zod": "^3.25.76",
|
|
39
|
-
"@ossclip/renderer": "0.1.
|
|
40
|
-
"@ossclip/
|
|
41
|
-
"@ossclip/
|
|
39
|
+
"@ossclip/renderer": "0.1.22",
|
|
40
|
+
"@ossclip/scenes": "0.1.22",
|
|
41
|
+
"@ossclip/core": "0.1.22"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
package/src/analyze.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from "zod/v4";
|
|
|
4
4
|
import {
|
|
5
5
|
ProductionSchema,
|
|
6
6
|
buildFcpxmlMarkers,
|
|
7
|
+
buildPremiereProject,
|
|
7
8
|
buildPremiereXmlMarkers,
|
|
8
9
|
buildResolveMarkerEdl,
|
|
9
10
|
keptPauses,
|
|
@@ -33,7 +34,16 @@ import type { PhaseTimings } from "./phase-timing";
|
|
|
33
34
|
* silently drops clip markers (field-verified the day fcpxml shipped);
|
|
34
35
|
* Premiere reads the fcpxml markers fine.
|
|
35
36
|
*/
|
|
36
|
-
export const ExportFormatSchema = z.enum([
|
|
37
|
+
export const ExportFormatSchema = z.enum([
|
|
38
|
+
"fcpxml",
|
|
39
|
+
"resolve-edl",
|
|
40
|
+
"premiere-xml",
|
|
41
|
+
// The pre-cut project: markers annotate an untouched take; this one applies
|
|
42
|
+
// the cutlist and carries the render's camera motion as editable keyframes
|
|
43
|
+
// (plan 2026-08-13). A distinct name, not a flag on premiere-xml — the two
|
|
44
|
+
// answer different questions ("review my cuts" vs "hand me the edit").
|
|
45
|
+
"premiere-project",
|
|
46
|
+
]);
|
|
37
47
|
export type ExportFormat = z.infer<typeof ExportFormatSchema>;
|
|
38
48
|
|
|
39
49
|
/** The FILE extension per format — "demo.resolve-edl" would import nowhere. */
|
|
@@ -41,8 +51,50 @@ const FORMAT_EXTENSIONS: Record<ExportFormat, string> = {
|
|
|
41
51
|
fcpxml: "fcpxml",
|
|
42
52
|
"resolve-edl": "edl",
|
|
43
53
|
"premiere-xml": "xml",
|
|
54
|
+
// NOT bare "xml": the marker format above already owns `<input>.xml`, and
|
|
55
|
+
// an editor who exported markers yesterday and the project today would
|
|
56
|
+
// silently clobber the file they sent their editor. Distinct default,
|
|
57
|
+
// same importable extension.
|
|
58
|
+
"premiere-project": "project.xml",
|
|
44
59
|
};
|
|
45
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The slice of `render-props.json` the project export needs — SELECTIVE on
|
|
63
|
+
* purpose, and parsed rather than cast (CLAUDE.md): the file is user-visible
|
|
64
|
+
* and hand-editable, so a truncated or tweaked one must error here with a
|
|
65
|
+
* field name, not export a project with NaN trims. `srcStart` and the rest
|
|
66
|
+
* of a caption word are deliberately not asked for: the SRT only needs text
|
|
67
|
+
* and output-time bounds, and legacy files predate `srcStart` (§137).
|
|
68
|
+
*/
|
|
69
|
+
export const RenderPropsExportSchema = z.object({
|
|
70
|
+
spans: z.array(
|
|
71
|
+
z.object({
|
|
72
|
+
srcIn: z.number(),
|
|
73
|
+
srcOut: z.number(),
|
|
74
|
+
outIn: z.number(),
|
|
75
|
+
outOut: z.number(),
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
captionLines: z.array(
|
|
79
|
+
z.object({
|
|
80
|
+
words: z.array(z.object({ text: z.string() })),
|
|
81
|
+
start: z.number(),
|
|
82
|
+
end: z.number(),
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
zoomPlan: z.array(
|
|
86
|
+
z.object({
|
|
87
|
+
startSec: z.number(),
|
|
88
|
+
endSec: z.number(),
|
|
89
|
+
from: z.number(),
|
|
90
|
+
to: z.number(),
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
// Written only when the camera is OFF (produce's absent-means-default
|
|
94
|
+
// contract) — absent must read as "motion on".
|
|
95
|
+
staticCamera: z.boolean().optional(),
|
|
96
|
+
});
|
|
97
|
+
|
|
46
98
|
/** Same shape as produce's `defaultOutPath`: beside the input, new extension. */
|
|
47
99
|
export function defaultExportPath(input: string, format: ExportFormat): string {
|
|
48
100
|
return input.replace(/(\.[^.]+)?$/, `.${FORMAT_EXTENSIONS[format]}`);
|
|
@@ -66,6 +118,8 @@ export interface AnalyzeOptions {
|
|
|
66
118
|
export interface AnalyzeResult {
|
|
67
119
|
workdir: string;
|
|
68
120
|
outPath: string;
|
|
121
|
+
/** The SRT sidecar — present only for `premiere-project` (two-file format). */
|
|
122
|
+
srtPath?: string;
|
|
69
123
|
/** Suggested-cut markers — one per remove segment. */
|
|
70
124
|
markerCount: number;
|
|
71
125
|
/** Informational kept-pause markers (§142 round 2) — counted separately
|
|
@@ -104,6 +158,47 @@ export async function runAnalyze(
|
|
|
104
158
|
const production = ProductionSchema.parse(
|
|
105
159
|
JSON.parse(await readFile(join(result.workdir, "production.json"), "utf8")),
|
|
106
160
|
);
|
|
161
|
+
const markerCount = (production.cutlist ?? []).filter((s) => s.kind === "remove").length;
|
|
162
|
+
const pauseCount = keptPauses(production).length;
|
|
163
|
+
const outPath = resolve(opts.out ?? defaultExportPath(resolve(inputArg), opts.format));
|
|
164
|
+
|
|
165
|
+
if (opts.format === "premiere-project") {
|
|
166
|
+
// The project export reads render-props.json, not just production.json:
|
|
167
|
+
// spans/captions/zoom are the RENDER's own inputs, so the exported
|
|
168
|
+
// project is the render, not a re-derivation of it. Same parse rule as
|
|
169
|
+
// the production read-back above — the file is hand-editable.
|
|
170
|
+
const props = RenderPropsExportSchema.parse(
|
|
171
|
+
JSON.parse(await readFile(join(result.workdir, "render-props.json"), "utf8")),
|
|
172
|
+
);
|
|
173
|
+
const { xml, srt } = buildPremiereProject({
|
|
174
|
+
production,
|
|
175
|
+
spans: props.spans,
|
|
176
|
+
captionLines: props.captionLines,
|
|
177
|
+
zoomPlan: props.zoomPlan,
|
|
178
|
+
staticCamera: props.staticCamera,
|
|
179
|
+
// The OUTPUT frame — production.render already carries --aspect.
|
|
180
|
+
frame: { width: production.render.width, height: production.render.height },
|
|
181
|
+
});
|
|
182
|
+
// Same basename, .srt — beside the xml so the pair travels together.
|
|
183
|
+
const srtPath = outPath.replace(/(\.[^.]+)?$/, ".srt");
|
|
184
|
+
await writeFile(outPath, xml);
|
|
185
|
+
await writeFile(srtPath, srt);
|
|
186
|
+
console.log(
|
|
187
|
+
`✓ premiere-project → ${outPath} + ${srtPath} ` +
|
|
188
|
+
`(cuts applied, camera motion as Basic Motion keyframes — in Premiere: ` +
|
|
189
|
+
`File → Import the .xml; then import the .srt onto the timeline for captions)`,
|
|
190
|
+
);
|
|
191
|
+
return {
|
|
192
|
+
workdir: result.workdir,
|
|
193
|
+
outPath,
|
|
194
|
+
srtPath,
|
|
195
|
+
markerCount,
|
|
196
|
+
pauseCount,
|
|
197
|
+
sourceDurationSec: result.sourceDurationSec,
|
|
198
|
+
phaseTimings: result.phaseTimings,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
107
202
|
// §142, learned the hard way in one field-test hour: each NLE gets its OWN
|
|
108
203
|
// dialect. Premiere rejects modern fcpxml outright, Resolve's fcpxml import
|
|
109
204
|
// silently drops markers — so fcpxml is for actual Final Cut Pro.
|
|
@@ -113,9 +208,6 @@ export async function runAnalyze(
|
|
|
113
208
|
: opts.format === "premiere-xml"
|
|
114
209
|
? buildPremiereXmlMarkers(production)
|
|
115
210
|
: buildFcpxmlMarkers(production);
|
|
116
|
-
const markerCount = (production.cutlist ?? []).filter((s) => s.kind === "remove").length;
|
|
117
|
-
const pauseCount = keptPauses(production).length;
|
|
118
|
-
const outPath = resolve(opts.out ?? defaultExportPath(resolve(inputArg), opts.format));
|
|
119
211
|
await writeFile(outPath, content);
|
|
120
212
|
const detail =
|
|
121
213
|
opts.format === "resolve-edl"
|
package/src/edit.ts
CHANGED
|
@@ -289,6 +289,20 @@ export async function startEditServer(
|
|
|
289
289
|
videoFileName: renderProps.videoFileName,
|
|
290
290
|
// Whether the Render button has a recorded invocation to replay.
|
|
291
291
|
canRender: existsSync(commandPath()),
|
|
292
|
+
defaultOutPath: (() => {
|
|
293
|
+
try {
|
|
294
|
+
if (existsSync(commandPath())) {
|
|
295
|
+
const cmd = JSON.parse(readFileSync(commandPath(), "utf8")) as { args?: string[] };
|
|
296
|
+
if (Array.isArray(cmd.args)) {
|
|
297
|
+
const idx = cmd.args.findIndex((a) => a === "-o" || a === "--out");
|
|
298
|
+
if (idx !== -1 && cmd.args[idx + 1]) return cmd.args[idx + 1];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
// ignore
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
})(),
|
|
292
306
|
// Recents ride along here too, so the top bar's Open picker has
|
|
293
307
|
// a list without a second endpoint.
|
|
294
308
|
recent: await readRecentProjects(opts.recentDir),
|
|
@@ -345,6 +359,28 @@ export async function startEditServer(
|
|
|
345
359
|
return send(200, { ok: true, workdir });
|
|
346
360
|
}
|
|
347
361
|
|
|
362
|
+
if (url.pathname === "/api/pick-save-path" && req.method === "POST") {
|
|
363
|
+
const chunks: Buffer[] = [];
|
|
364
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
365
|
+
let defaultPath: string | undefined;
|
|
366
|
+
try {
|
|
367
|
+
const raw = Buffer.concat(chunks).toString();
|
|
368
|
+
if (raw.trim()) {
|
|
369
|
+
const body = JSON.parse(raw) as { defaultPath?: string };
|
|
370
|
+
if (typeof body.defaultPath === "string") defaultPath = body.defaultPath;
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
// ignore
|
|
374
|
+
}
|
|
375
|
+
let startLoc = defaultPath;
|
|
376
|
+
if (!startLoc && workdir) {
|
|
377
|
+
startLoc = dirname(workdir);
|
|
378
|
+
}
|
|
379
|
+
const { pickPath, livePickerDeps } = await import("./interactive/picker");
|
|
380
|
+
const picked = await pickPath("save", livePickerDeps(), startLoc);
|
|
381
|
+
return send(200, { path: picked ?? null });
|
|
382
|
+
}
|
|
383
|
+
|
|
348
384
|
if (url.pathname === "/api/fs" && req.method === "GET") {
|
|
349
385
|
// The picker's folder browser (R17 §83): directories only, with
|
|
350
386
|
// "this one is a project" flagged. Local tool on a local loopback
|
|
@@ -394,8 +430,6 @@ export async function startEditServer(
|
|
|
394
430
|
}
|
|
395
431
|
|
|
396
432
|
if (url.pathname === "/api/render" && req.method === "POST") {
|
|
397
|
-
// Replays ONLY the argv `produce` recorded — the request body is
|
|
398
|
-
// never read, let alone executed.
|
|
399
433
|
if (!workdir) return send(409, { error: "no workdir open" });
|
|
400
434
|
if (renderChild) return send(409, { error: "a render is already running" });
|
|
401
435
|
if (!existsSync(commandPath())) {
|
|
@@ -405,6 +439,20 @@ export async function startEditServer(
|
|
|
405
439
|
"the terminal so the invocation is recorded, then Render can replay it",
|
|
406
440
|
});
|
|
407
441
|
}
|
|
442
|
+
const chunks: Buffer[] = [];
|
|
443
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
444
|
+
let customOut: string | undefined;
|
|
445
|
+
try {
|
|
446
|
+
const raw = Buffer.concat(chunks).toString();
|
|
447
|
+
if (raw.trim()) {
|
|
448
|
+
const body = JSON.parse(raw) as { out?: string };
|
|
449
|
+
if (typeof body.out === "string" && body.out.trim()) {
|
|
450
|
+
customOut = body.out.trim();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// ignore
|
|
455
|
+
}
|
|
408
456
|
const parsed = CommandSchema.safeParse(
|
|
409
457
|
JSON.parse(await readFile(commandPath(), "utf8")),
|
|
410
458
|
);
|
|
@@ -420,7 +468,19 @@ export async function startEditServer(
|
|
|
420
468
|
// reconstruct the command that ran. A modern record — and a legacy
|
|
421
469
|
// directly-typed `ossclip produce …` — already starts with it and
|
|
422
470
|
// is untouched.
|
|
423
|
-
|
|
471
|
+
let args = cmd.args[0] === "produce" ? [...cmd.args] : ["produce", ...cmd.args];
|
|
472
|
+
if (customOut) {
|
|
473
|
+
const filteredArgs: string[] = [];
|
|
474
|
+
for (let i = 0; i < args.length; i++) {
|
|
475
|
+
if (args[i] === "-o" || args[i] === "--out") {
|
|
476
|
+
i++;
|
|
477
|
+
} else {
|
|
478
|
+
filteredArgs.push(args[i]!);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
filteredArgs.push("--out", customOut);
|
|
482
|
+
args = filteredArgs;
|
|
483
|
+
}
|
|
424
484
|
renderLines = [];
|
|
425
485
|
renderExit = null;
|
|
426
486
|
renderStartedAt = Date.now();
|
|
@@ -13,7 +13,7 @@ import { binOnPath } from "../llm-detect";
|
|
|
13
13
|
* this shape exists to prevent.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
export type PickMode = "file" | "folder";
|
|
16
|
+
export type PickMode = "file" | "folder" | "save";
|
|
17
17
|
|
|
18
18
|
export interface PickerDeps {
|
|
19
19
|
platform: NodeJS.Platform;
|
|
@@ -86,14 +86,19 @@ export function pickerCommand(
|
|
|
86
86
|
// the alternative and is NOT used: its mkv coverage is unconfirmed, and
|
|
87
87
|
// mkv is in VIDEO_EXTENSIONS.
|
|
88
88
|
const types = VIDEO_EXTENSIONS.map((e) => `"${e}"`).join(",");
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
let clause = `choose file with prompt "Pick your video" of type {${types}}`;
|
|
90
|
+
let loc = "";
|
|
91
|
+
if (mode === "folder") {
|
|
92
|
+
clause = 'choose folder with prompt "Pick a folder of clips"';
|
|
93
|
+
loc = startDir === undefined ? "" : ` default location POSIX file ${JSON.stringify(startDir)}`;
|
|
94
|
+
} else if (mode === "save") {
|
|
95
|
+
const defaultName = startDir && !startDir.endsWith("/") ? startDir.split("/").pop()! : "video.ossclip.mp4";
|
|
96
|
+
const dir = startDir && !startDir.endsWith("/") ? startDir.slice(0, startDir.lastIndexOf("/")) : (startDir ?? "");
|
|
97
|
+
const dirClause = dir ? ` default location POSIX file ${JSON.stringify(dir)}` : "";
|
|
98
|
+
clause = `choose file name with prompt "Save rendered video as" default name ${JSON.stringify(defaultName)}${dirClause}`;
|
|
99
|
+
} else {
|
|
100
|
+
loc = startDir === undefined ? "" : ` default location POSIX file ${JSON.stringify(startDir)}`;
|
|
101
|
+
}
|
|
97
102
|
return { bin: "osascript", args: ["-e", `POSIX path of (${clause}${loc})`] };
|
|
98
103
|
}
|
|
99
104
|
|
|
@@ -102,29 +107,34 @@ export function pickerCommand(
|
|
|
102
107
|
// `powershell -Command` uses by default, and a deadlock here looks
|
|
103
108
|
// exactly like a hung CLI. `powershell` (5.1, in-box) rather than
|
|
104
109
|
// `pwsh`, which is not installed by default.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// at Desktop while darwin, zenity and kdialog all land in the project
|
|
108
|
-
// folder — the same flag, three platforms agreeing and one not.
|
|
110
|
+
const defaultName = startDir ? startDir.split(/[\\/]/).pop()! : "video.ossclip.mp4";
|
|
111
|
+
const dir = startDir ? startDir.slice(0, Math.max(startDir.lastIndexOf("\\"), startDir.lastIndexOf("/"))) : "";
|
|
109
112
|
const start =
|
|
110
113
|
startDir === undefined
|
|
111
114
|
? ""
|
|
112
115
|
: mode === "folder"
|
|
113
|
-
?
|
|
114
|
-
|
|
115
|
-
`$d.
|
|
116
|
-
|
|
116
|
+
? `$d.SelectedPath = '${psQuote(startDir)}'; `
|
|
117
|
+
: mode === "save"
|
|
118
|
+
? `$d.InitialDirectory = '${psQuote(dir || startDir)}'; `
|
|
119
|
+
: `$d.InitialDirectory = '${psQuote(startDir)}'; `;
|
|
117
120
|
const script =
|
|
118
121
|
mode === "folder"
|
|
119
122
|
? "Add-Type -AssemblyName System.Windows.Forms; " +
|
|
120
123
|
"$d = New-Object System.Windows.Forms.FolderBrowserDialog; " +
|
|
121
124
|
start +
|
|
122
125
|
"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }"
|
|
123
|
-
:
|
|
124
|
-
"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
: mode === "save"
|
|
127
|
+
? "Add-Type -AssemblyName System.Windows.Forms; " +
|
|
128
|
+
"$d = New-Object System.Windows.Forms.SaveFileDialog; " +
|
|
129
|
+
`$d.FileName = '${psQuote(defaultName)}'; ` +
|
|
130
|
+
`$d.Filter = 'Video|${VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(";")}|All files|*.*'; ` +
|
|
131
|
+
start +
|
|
132
|
+
"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.FileName }"
|
|
133
|
+
: "Add-Type -AssemblyName System.Windows.Forms; " +
|
|
134
|
+
"$d = New-Object System.Windows.Forms.OpenFileDialog; " +
|
|
135
|
+
`$d.Filter = 'Video|${VIDEO_EXTENSIONS.map((e) => `*.${e}`).join(";")}|All files|*.*'; ` +
|
|
136
|
+
start +
|
|
137
|
+
"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.FileName }";
|
|
128
138
|
return { bin: "powershell", args: ["-NoProfile", "-STA", "-Command", script] };
|
|
129
139
|
}
|
|
130
140
|
|
|
@@ -134,13 +144,18 @@ export function pickerCommand(
|
|
|
134
144
|
if (d.hasBin("zenity")) {
|
|
135
145
|
const args = [
|
|
136
146
|
"--file-selection",
|
|
137
|
-
mode === "folder"
|
|
147
|
+
mode === "folder"
|
|
148
|
+
? "--title=Pick a folder of clips"
|
|
149
|
+
: mode === "save"
|
|
150
|
+
? "--title=Save rendered video as"
|
|
151
|
+
: "--title=Pick your video",
|
|
138
152
|
];
|
|
139
153
|
if (mode === "folder") args.push("--directory");
|
|
154
|
+
else if (mode === "save") args.push("--save", "--confirm-overwrite", `--file-filter=Video | ${globs()}`, "--file-filter=All files | *");
|
|
140
155
|
else args.push(`--file-filter=Video | ${globs()}`, "--file-filter=All files | *");
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
156
|
+
if (startDir !== undefined) {
|
|
157
|
+
args.push(mode === "save" ? `--filename=${startDir}` : `--filename=${startDir}/`);
|
|
158
|
+
}
|
|
144
159
|
return { bin: "zenity", args };
|
|
145
160
|
}
|
|
146
161
|
|
|
@@ -149,6 +164,12 @@ export function pickerCommand(
|
|
|
149
164
|
// filter here is an error, not an ignored argument.
|
|
150
165
|
return { bin: "kdialog", args: ["--getexistingdirectory", startDir ?? "."] };
|
|
151
166
|
}
|
|
167
|
+
if (mode === "save") {
|
|
168
|
+
return {
|
|
169
|
+
bin: "kdialog",
|
|
170
|
+
args: ["--getsavefilename", startDir ?? ".", `Video files(${globs()})`],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
152
173
|
return {
|
|
153
174
|
bin: "kdialog",
|
|
154
175
|
args: ["--getopenfilename", startDir ?? ".", `Video files(${globs()})`],
|