generalize convert-to-mp3 into convert-audio with selectable target format

- rename task to convert-audio, support mp3/flac/wav/ogg/aac/opus targets
- add options input type with selectable pill buttons in UI
- extend seed script parser to handle YAML list properties (options)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:42:44 +00:00
co-authored by Claude Opus 4.6
parent 729ffb930b
commit 2caccc183c
4 changed files with 91 additions and 14 deletions
@@ -1,12 +1,13 @@
--- ---
name: Convert To MP3 name: Convert Audio
description: Convert audio files to MP3 320kbps, preserving metadata. description: Convert audio files between formats, preserving metadata.
version: 1 version: 1
mode: script mode: script
language: bash language: bash
triggers: triggers:
- type: file - type: file
extensions: extensions:
- mp3
- flac - flac
- wav - wav
- ogg - ogg
@@ -26,6 +27,17 @@ inputs:
file_path: file_path:
type: string type: string
description: Path to an audio file or directory to convert. description: Path to an audio file or directory to convert.
target_format:
type: string
description: Target format
default: mp3
options:
- mp3
- flac
- wav
- ogg
- aac
- opus
delete_source: delete_source:
type: boolean type: boolean
description: Delete source files after successful conversion. description: Delete source files after successful conversion.
@@ -33,7 +45,7 @@ inputs:
args: [file_path] args: [file_path]
--- ---
# Convert To MP3 # Convert Audio
Convert audio files to MP3 320kbps using ffmpeg, preserving metadata. Convert audio files between formats using ffmpeg, preserving metadata.
Supports single file conversion and batch directory conversion. Supports single file conversion and batch directory conversion.
@@ -1,11 +1,26 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# ── Task: Convert To MP3 ── # ── Task: Convert Audio ──
EXTENSIONS="flac|wav|ogg|wma|aac|m4a|opus|aiff|aif|ape|wv|alac|dsf|dff" EXTENSIONS="mp3|flac|wav|ogg|wma|aac|m4a|opus|aiff|aif|ape|wv|alac|dsf|dff"
TARGET_FORMAT="${INPUT_TARGET_FORMAT:-mp3}"
DELETE_SOURCE="${INPUT_DELETE_SOURCE:-false}" DELETE_SOURCE="${INPUT_DELETE_SOURCE:-false}"
ffmpeg_args_for_format() {
case "$1" in
mp3) echo "-codec:a libmp3lame -b:a 320k -id3v2_version 3" ;;
flac) echo "-codec:a flac" ;;
wav) echo "-codec:a pcm_s16le" ;;
ogg) echo "-codec:a libvorbis -q:a 6" ;;
aac) echo "-codec:a aac -b:a 256k" ;;
opus) echo "-codec:a libopus -b:a 192k" ;;
*) echo "Unsupported format: $1" >&2; return 1 ;;
esac
}
FFMPEG_ARGS=$(ffmpeg_args_for_format "$TARGET_FORMAT") || exit 1
process_file() { process_file() {
local input="$1" local input="$1"
local ext="${input##*.}" local ext="${input##*.}"
@@ -13,10 +28,10 @@ process_file() {
dir="$(dirname "$input")" dir="$(dirname "$input")"
local base local base
base="$(basename "$input" ".$ext")" base="$(basename "$input" ".$ext")"
local output="$dir/$base.mp3" local output="$dir/$base.$TARGET_FORMAT"
if [[ "${ext,,}" == "mp3" ]]; then if [[ "${ext,,}" == "$TARGET_FORMAT" ]]; then
echo "Skipping (already MP3): $input" echo "Skipping (already $TARGET_FORMAT): $input"
return 0 return 0
fi fi
@@ -26,7 +41,8 @@ process_file() {
fi fi
echo "Converting: $input$output" echo "Converting: $input$output"
if ffmpeg -nostdin -i "$input" -codec:a libmp3lame -b:a 320k -map_metadata 0 -id3v2_version 3 -y "$output" 2>/dev/null; then # shellcheck disable=SC2086
if ffmpeg -nostdin -i "$input" $FFMPEG_ARGS -map_metadata 0 -y "$output" 2>/dev/null; then
echo " Done" echo " Done"
if [[ "$DELETE_SOURCE" == "true" ]]; then if [[ "$DELETE_SOURCE" == "true" ]]; then
rm "$input" rm "$input"
+32 -4
View File
@@ -48,24 +48,52 @@ function parseFrontmatter(content: string) {
} }
meta.trigger = triggers.length > 0 ? triggers : undefined; meta.trigger = triggers.length > 0 ? triggers : undefined;
// Parse inputs (simplified — store as-is for now) // Parse inputs
const inputsMatch = yaml.match(/^inputs:\s*\n((?:[ \t]+.+\n?)*)/m); const inputsMatch = yaml.match(/^inputs:\s*\n((?:[ \t]+.+\n?)*)/m);
if (inputsMatch) { if (inputsMatch) {
const inputBlock = inputsMatch[1]!; const inputBlock = inputsMatch[1]!;
const inputEntries: Record<string, Record<string, string>> = {}; const inputEntries: Record<string, Record<string, string | string[]>> = {};
let currentInput: string | null = null; let currentInput: string | null = null;
let currentListKey: string | null = null;
let currentList: string[] = [];
const flushList = () => {
if (currentInput && currentListKey && currentList.length > 0) {
inputEntries[currentInput]![currentListKey] = currentList;
}
currentListKey = null;
currentList = [];
};
for (const line of inputBlock.split('\n')) { for (const line of inputBlock.split('\n')) {
const topMatch = line.match(/^\s{2}(\w[\w_-]*):\s*$/); const topMatch = line.match(/^\s{2}(\w[\w_-]*):\s*$/);
if (topMatch) { if (topMatch) {
flushList();
currentInput = topMatch[1]!; currentInput = topMatch[1]!;
inputEntries[currentInput] = {}; inputEntries[currentInput] = {};
continue; continue;
} }
const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.+)$/); // List item (6 spaces + dash)
const listItemMatch = line.match(/^\s{6}-\s*(.+)$/);
if (listItemMatch && currentInput && currentListKey) {
currentList.push(listItemMatch[1]!.trim());
continue;
}
// Property with value or start of list
const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.*)$/);
if (propMatch && currentInput) { if (propMatch && currentInput) {
inputEntries[currentInput]![propMatch[1]!] = propMatch[2]!.trim(); flushList();
const value = propMatch[2]!.trim();
if (value === '') {
// Start of a list (e.g. "options:")
currentListKey = propMatch[1]!;
currentList = [];
} else {
inputEntries[currentInput]![propMatch[1]!] = value;
}
} }
} }
flushList();
if (Object.keys(inputEntries).length > 0) { if (Object.keys(inputEntries).length > 0) {
meta.inputs = inputEntries; meta.inputs = inputEntries;
} }
@@ -198,6 +198,7 @@ type TaskInputDef = {
type: string; type: string;
description?: string; description?: string;
default?: string; default?: string;
options?: string[];
}; };
type TaskInputFormProps = { type TaskInputFormProps = {
@@ -239,6 +240,26 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys }: TaskInpu
); );
} }
if (def.options && def.options.length > 0) {
const selected = values[key] ?? def.default ?? def.options[0]!;
return (
<div key={key} className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
<div className="flex flex-wrap gap-1.5">
{def.options.map((opt) => (
<button
key={opt}
onClick={() => onChange(key, opt)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${selected === opt ? 'bg-duck-teal text-white' : 'bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/60 dark:text-foreground/60 hover:text-duck-dark dark:hover:text-foreground hover:bg-duck-dark/10 dark:hover:bg-foreground/10'}`}
>
{opt}
</button>
))}
</div>
</div>
);
}
// Default: text input for string/number // Default: text input for string/number
return ( return (
<div key={key} className="flex flex-col gap-1"> <div key={key} className="flex flex-col gap-1">