Skip to content
Closed
409 changes: 389 additions & 20 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions src/commands/file/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { FileUploadResponse } from '../../types/api';
import { existsSync } from 'fs';
import { resolve } from 'path';
import { readFile } from 'fs/promises';
import { resolve, basename } from 'path';

export default defineCommand({
name: 'file upload',
Expand Down Expand Up @@ -53,9 +54,9 @@ export default defineCommand({
}

const formData = new FormData();
// Read file as a Blob-like File object for fetch compatibility
const fileData = await Bun.file(fullPath).arrayBuffer();
const fileName = fullPath.split('/').pop() || 'file';
// Read file using Node.js fs/promises (compatible with both Node and Bun)
const fileData = await readFile(fullPath);
const fileName = basename(fullPath);
const fileBlob = new Blob([fileData]);
formData.append('file', fileBlob, fileName);
formData.append('purpose', purpose);
Expand Down
80 changes: 74 additions & 6 deletions src/commands/music/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,87 @@ export default defineCommand({
description: 'Generate a song (music-2.5)',
usage: 'minimax music generate --prompt <text> [--lyrics <text>] [--out <path>] [flags]',
options: [
{ flag: '--prompt <text>', description: 'Music style description' },
{ flag: '--lyrics <text>', description: 'Song lyrics' },
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file (use - for stdin)' },
{ flag: '--prompt <text>', description: 'Music style description (can be detailed — see examples)' },
{ flag: '--lyrics <text>', description: 'Song lyrics with structure tags. Use "无歌词" for instrumental music. Cannot be used with --instrumental.' },
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file. Use "无歌词" for instrumental. (use - for stdin)' },
{ flag: '--vocals <text>', description: 'Vocal style, e.g. "warm male and bright female duet"' },
{ flag: '--genre <text>', description: 'Music genre, e.g. folk, pop, jazz' },
{ flag: '--mood <text>', description: 'Mood or emotion, e.g. warm, melancholic, uplifting' },
{ flag: '--instruments <text>', description: 'Instruments to feature, e.g. "acoustic guitar, piano"' },
{ flag: '--tempo <text>', description: 'Tempo description, e.g. fast, slow, moderate' },
{ flag: '--bpm <number>', description: 'Exact tempo in beats per minute', type: 'number' },
{ flag: '--key <text>', description: 'Musical key, e.g. C major, A minor, G sharp' },
{ flag: '--avoid <text>', description: 'Elements to avoid in the generated music' },
{ flag: '--use-case <text>', description: 'Use case context, e.g. "background music for video", "theme song"' },
{ flag: '--structure <text>', description: 'Song structure, e.g. "verse-chorus-verse-bridge-chorus"' },
{ flag: '--references <text>', description: 'Reference tracks or artists, e.g. "similar to Ed Sheeran, Taylor Swift"' },
{ flag: '--extra <text>', description: 'Additional fine-grained requirements not covered above' },
{ flag: '--instrumental', description: 'Generate instrumental music (no vocals)' },
{ flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in audio for content provenance' },
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 44100)', type: 'number' },
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 256000)', type: 'number' },
{ flag: '--stream', description: 'Stream raw audio to stdout' },
{ flag: '--out <path>', description: 'Save audio to file (uses hex decoding)' },
],
examples: [
'minimax music generate --prompt "Upbeat pop" --lyrics "La la la..."',
'minimax music generate --prompt "Indie folk, melancholic" --lyrics-file song.txt --out my_song.mp3',
'minimax music generate --prompt "Upbeat pop" --lyrics "La la la..." --out summer.mp3',
'minimax music generate --prompt "Indie folk, melancholic" --lyrics-file song.txt --out my_song.mp3',
'# Detailed prompt with vocal characteristics — music-2.5 responds well to rich descriptions:',
'minimax music generate --prompt "Warm morning folk" --vocals "male and female duet, harmonies in chorus" --instruments "acoustic guitar, piano" --bpm 95 --lyrics-file song.txt --out duet.mp3',
'# Instrumental (use --instrumental flag):',
'minimax music generate --prompt "Cinematic orchestral, building tension" --instrumental --out bgm.mp3',
'# Or specify "无歌词" in lyrics:',
'minimax music generate --prompt "Cinematic orchestral" --lyrics "无歌词" --out bgm.mp3',
],
async run(config: Config, flags: GlobalFlags) {
const prompt = flags.prompt as string | undefined;
let prompt = flags.prompt as string | undefined;
let lyrics = flags.lyrics as string | undefined;

if (flags.lyricsFile) {
lyrics = readTextFromPathOrStdin(flags.lyricsFile as string);
}

// Check for conflicting flags: --instrumental and --lyrics/--lyrics-file
if (flags.instrumental && (lyrics || flags.lyricsFile)) {
throw new CLIError(
'Cannot use --instrumental with --lyrics or --lyrics-file. For instrumental music, simply use --instrumental without --lyrics.',
ExitCode.USAGE,
'minimax music generate --prompt <style> --instrumental',
);
}

// Build structured prompt from optional music characteristic flags.
// music-2.5 interprets rich natural-language prompts — these flags make it
// easy to describe vocal style, genre, mood, and instrumentation without
// needing to hand-craft a long --prompt string.
const structuredParts: string[] = [];
if (flags.vocals) structuredParts.push(`Vocals: ${flags.vocals as string}`);
if (flags.genre) structuredParts.push(`Genre: ${flags.genre as string}`);
if (flags.mood) structuredParts.push(`Mood: ${flags.mood as string}`);
if (flags.instruments) structuredParts.push(`Instruments: ${flags.instruments as string}`);
if (flags.tempo) structuredParts.push(`Tempo: ${flags.tempo as string}`);
if (flags.bpm) structuredParts.push(`BPM: ${flags.bpm as number}`);
if (flags.key) structuredParts.push(`Key: ${flags.key as string}`);
if (flags.avoid) structuredParts.push(`Avoid: ${flags.avoid as string}`);
if (flags.useCase) structuredParts.push(`Use case: ${flags.useCase as string}`);
if (flags.structure) structuredParts.push(`Structure: ${flags.structure as string}`);
if (flags.references) structuredParts.push(`References: ${flags.references as string}`);
if (flags.extra) structuredParts.push(`Extra: ${flags.extra as string}`);

// Handle "无歌词" as instrumental request
if (lyrics === '无歌词' || lyrics === 'no lyrics') {
lyrics = '[intro] [outro]';
structuredParts.push('Style: instrumental, no vocals, pure music');
}

// Handle --instrumental: music-2.5 has no is_instrumental flag,
// so we use the empty-structure lyrics workaround.
if (flags.instrumental) {
lyrics = '[intro] [outro]';
structuredParts.push('Style: instrumental, no vocals, pure music');
}

if (!prompt && !lyrics) {
throw new CLIError(
'At least one of --prompt or --lyrics is required.',
Expand All @@ -49,6 +108,11 @@ export default defineCommand({
process.stderr.write('Warning: No lyrics provided. Use --lyrics or --lyrics-file to include lyrics.\n');
}

if (structuredParts.length > 0) {
const structured = structuredParts.join('. ');
prompt = prompt ? `${prompt}. ${structured}` : structured;
}

const outPath = flags.out as string | undefined;
const outFormat = outPath ? 'hex' : 'url';
const format = detectOutputFormat(config.output);
Expand All @@ -66,6 +130,10 @@ export default defineCommand({
stream: flags.stream === true,
};

if (flags.aigcWatermark) {
body.aigc_watermark = true;
}

if (config.dryRun) {
console.log(formatOutput({ request: body }, format));
return;
Expand Down
4 changes: 4 additions & 0 deletions src/output/status-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { maskToken } from '../utils/token';

let printed = false;

export function resetStatusBar(): void {
printed = false;
}

const reset = '\x1b[0m';
const dim = '\x1b[2m';
const bold = '\x1b[1m';
Expand Down
1 change: 1 addition & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export interface MusicRequest {
};
output_format?: 'url' | 'hex';
stream?: boolean;
aigc_watermark?: boolean;
}

export interface MusicResponse {
Expand Down
4 changes: 2 additions & 2 deletions test/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('auth login command', () => {
noColor: true,
yes: false,
dryRun: false,
nonInteractive: false,
nonInteractive: true,
async: false,
};

Expand All @@ -31,7 +31,7 @@ describe('auth login command', () => {
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
nonInteractive: true,
async: false,
}),
).rejects.toThrow('--api-key is required');
Expand Down
4 changes: 2 additions & 2 deletions test/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('auth logout command', () => {
noColor: true,
yes: false,
dryRun: true,
nonInteractive: false,
nonInteractive: true,
async: false,
};

Expand All @@ -33,7 +33,7 @@ describe('auth logout command', () => {
yes: false,
dryRun: true,
help: false,
nonInteractive: false,
nonInteractive: true,
async: false,
});

Expand Down
4 changes: 2 additions & 2 deletions test/commands/auth/refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('auth refresh command', () => {
noColor: true,
yes: false,
dryRun: false,
nonInteractive: false,
nonInteractive: true,
async: false,
};

Expand All @@ -29,7 +29,7 @@ describe('auth refresh command', () => {
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
nonInteractive: true,
async: false,
}),
).rejects.toThrow('not authenticated via OAuth');
Expand Down
4 changes: 2 additions & 2 deletions test/commands/auth/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('auth status command', () => {
noColor: true,
yes: false,
dryRun: false,
nonInteractive: false,
nonInteractive: true,
async: false,
};

Expand All @@ -33,7 +33,7 @@ describe('auth status command', () => {
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
nonInteractive: true,
async: false,
});

Expand Down
8 changes: 4 additions & 4 deletions test/commands/config/set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('config set command', () => {
noColor: true,
yes: false,
dryRun: false,
nonInteractive: false,
nonInteractive: true,
async: false,
};

Expand All @@ -29,7 +29,7 @@ describe('config set command', () => {
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
nonInteractive: true,
async: false,
}),
).rejects.toThrow('--key and --value are required');
Expand All @@ -46,7 +46,7 @@ describe('config set command', () => {
noColor: true,
yes: false,
dryRun: false,
nonInteractive: false,
nonInteractive: true,
async: false,
};

Expand All @@ -60,7 +60,7 @@ describe('config set command', () => {
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
nonInteractive: true,
async: false,
}),
).rejects.toThrow('Invalid config key');
Expand Down
4 changes: 2 additions & 2 deletions test/commands/config/show.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('config show command', () => {
noColor: true,
yes: false,
dryRun: false,
nonInteractive: false,
nonInteractive: true,
async: false,
};

Expand All @@ -34,7 +34,7 @@ describe('config show command', () => {
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
nonInteractive: true,
async: false,
});

Expand Down
38 changes: 38 additions & 0 deletions test/commands/file/upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'bun:test';
import { default as uploadCommand } from '../../../src/commands/file/upload';

describe('file upload command', () => {
it('has correct name', () => {
expect(uploadCommand.name).toBe('file upload');
});

it('requires file argument', async () => {
const config = {
apiKey: 'test-key',
region: 'global' as const,
baseUrl: 'https://api.minimax.io',
output: 'text' as const,
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: true,
nonInteractive: true,
async: false,
};

await expect(
uploadCommand.execute(config, {
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: true,
help: false,
nonInteractive: true,
async: false,
}),
).rejects.toThrow('Missing required argument: --file');
});
});
4 changes: 2 additions & 2 deletions test/commands/image/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('image generate command', () => {
noColor: true,
yes: false,
dryRun: false,
nonInteractive: false,
nonInteractive: true,
async: false,
};

Expand All @@ -30,7 +30,7 @@ describe('image generate command', () => {
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
nonInteractive: true,
async: false,
}),
).rejects.toThrow('Missing required argument: --prompt');
Expand Down
Loading