Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ Creation commands also support:
OAuth commands (`music cover`, `image create`, `video create`) auto-detect local paths, validate format/size, and upload to cloud storage before calling the API.

OpenAPI `image create` supports local file references via base64 encoding (no size limit enforced by CLI).
OpenAPI `video create` supports local image/video/audio paths via a presigned upload URL. For Seedance, local image references automatically include width/height metadata; remote image URLs and all reference videos still need explicit metadata.

```bash
# OAuth: local audio for cover (mp3, wav, flac, m4a, ogg, aac; max 20MB)
Expand All @@ -256,8 +257,14 @@ listenhub image create --prompt "inspired by this" --reference ./photo.jpg
# OpenAPI: local image reference (base64 encoded)
listenhub openapi image create --prompt "in this style" --reference ./sketch.png --provider google

# URLs are passed through directly in both modes
listenhub openapi video create --prompt "same style" --reference-video https://example.com/clip.mp4 --input-video-duration 5
# OpenAPI Seedance: local image references auto-populate width/height metadata
listenhub openapi video create --prompt "same style" --first-frame ./frame.png

# Remote URLs and reference videos need dimensions to avoid server-side 32004 validation errors
listenhub openapi video create --prompt "same style" \
--reference-video https://example.com/clip.mp4 \
--reference-video-meta 1280x720:5:30:8000000 \
--input-video-duration 5
```

## Examples
Expand Down Expand Up @@ -288,9 +295,14 @@ listenhub openapi podcast text-stream abc123 --event script
# Text-to-video
listenhub openapi video create --prompt "A cat playing piano" --no-wait -j

# With first frame
# With local first frame (auto-upload + auto metadata)
listenhub openapi video create --prompt "Camera zooms out" \
--first-frame ./frame.png

# With remote first frame
listenhub openapi video create --prompt "Camera zooms out" \
--first-frame https://example.com/frame.png
--first-frame https://example.com/frame.png \
--first-frame-meta 1080x1920:3600000

# Estimate credits before creating
listenhub openapi video estimate --model doubao-seedance-2-pro --resolution 1080p --duration 10
Expand Down
20 changes: 16 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ listenhub openapi subscription -j
OAuth 命令(`music cover`、`image create`、`video create`)自动检测本地路径,校验格式和大小,上传到云存储后传给 API。

OpenAPI `image create` 通过 base64 编码支持本地参考图(CLI 侧不限制文件大小)。
OpenAPI `video create` 通过预签名 URL 支持本地图片/视频/音频路径。Seedance 的本地图片参考会自动带上宽高元数据;远程图片 URL 和所有参考视频仍需要显式 metadata。

```bash
# OAuth:本地音频用于翻唱(mp3, wav, flac, m4a, ogg, aac;最大 20MB)
Expand All @@ -256,8 +257,14 @@ listenhub image create --prompt "以此为灵感" --reference ./photo.jpg
# OpenAPI:本地图片参考(base64 编码)
listenhub openapi image create --prompt "这种风格" --reference ./sketch.png --provider google

# URL 在两种模式下都直接透传
listenhub openapi video create --prompt "同样风格" --reference-video https://example.com/clip.mp4 --input-video-duration 5
# OpenAPI Seedance:本地图片参考会自动补宽高 metadata
listenhub openapi video create --prompt "同样风格" --first-frame ./frame.png

# 远程 URL 和参考视频仍需要尺寸元数据,否则服务端会返回 32004 参数错误
listenhub openapi video create --prompt "同样风格" \
--reference-video https://example.com/clip.mp4 \
--reference-video-meta 1280x720:5:30:8000000 \
--input-video-duration 5
```

## 使用示例
Expand Down Expand Up @@ -288,9 +295,14 @@ listenhub openapi podcast text-stream abc123 --event script
# 文字生成视频
listenhub openapi video create --prompt "一只猫在弹钢琴" --no-wait -j

# 指定首帧
# 指定本地首帧(自动上传 + 自动 metadata)
listenhub openapi video create --prompt "镜头缓缓拉远" \
--first-frame ./frame.png

# 指定远程首帧
listenhub openapi video create --prompt "镜头缓缓拉远" \
--first-frame https://example.com/frame.png
--first-frame https://example.com/frame.png \
--first-frame-meta 1080x1920:3600000

# 生成前预估积分
listenhub openapi video estimate --model doubao-seedance-2-pro --resolution 1080p --duration 10
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"@marswave/listenhub-sdk": "^0.0.16",
"@marswave/listenhub-sdk": "^0.0.17",
"commander": "^14.0.3",
"open": "^10.0.0",
"ora": "^8.0.0"
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

146 changes: 146 additions & 0 deletions source/_shared/image-dimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {readFile, stat} from 'node:fs/promises';
import type {VideoReferenceImageMeta, VideoReferenceImageRole} from './video-reference-metadata.js';

type ImageDimensions = {
width: number;
height: number;
};

function byte(data: Uint8Array, index: number): number {
const value = data[index];
if (typeof value !== 'number') throw new Error('Cannot read image dimensions: file is truncated');
return value;
}

function readUint16BE(data: Uint8Array, offset: number): number {
return (byte(data, offset) << 8) + byte(data, offset + 1);
}

function readUint16LE(data: Uint8Array, offset: number): number {
return byte(data, offset) + (byte(data, offset + 1) << 8);
}

function readUint24LE(data: Uint8Array, offset: number): number {
return byte(data, offset) + (byte(data, offset + 1) << 8) + (byte(data, offset + 2) << 16);
}

function readUint32BE(data: Uint8Array, offset: number): number {
return (
(byte(data, offset) << 24) +
(byte(data, offset + 1) << 16) +
(byte(data, offset + 2) << 8) +
byte(data, offset + 3)
);
}

function matches(data: Uint8Array, offset: number, text: string): boolean {
return Array.from(text).every((char, index) => byte(data, offset + index) === char.charCodeAt(0));
}

function parsePng(data: Uint8Array): ImageDimensions | undefined {
if (
data.length < 24 ||
byte(data, 0) !== 0x89 ||
!matches(data, 1, 'PNG') ||
!matches(data, 12, 'IHDR')
) {
return undefined;
}

return {width: readUint32BE(data, 16), height: readUint32BE(data, 20)};
}

function parseGif(data: Uint8Array): ImageDimensions | undefined {
if (data.length < 10 || (!matches(data, 0, 'GIF87a') && !matches(data, 0, 'GIF89a'))) {
return undefined;
}

return {width: readUint16LE(data, 6), height: readUint16LE(data, 8)};
}

function parseJpeg(data: Uint8Array): ImageDimensions | undefined {
if (data.length < 4 || byte(data, 0) !== 0xff || byte(data, 1) !== 0xd8) return undefined;

let offset = 2;
while (offset + 9 < data.length) {
if (byte(data, offset) !== 0xff) {
offset += 1;
continue;
}

let marker = byte(data, offset + 1);
while (marker === 0xff) {
offset += 1;
marker = byte(data, offset + 1);
}

if (marker === 0xd9 || marker === 0xda) break;
const length = readUint16BE(data, offset + 2);
if (length < 2) break;

const isStartOfFrame =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);
if (isStartOfFrame) {
return {height: readUint16BE(data, offset + 5), width: readUint16BE(data, offset + 7)};
}

offset += 2 + length;
}

return undefined;
}

function parseWebp(data: Uint8Array): ImageDimensions | undefined {
if (data.length < 30 || !matches(data, 0, 'RIFF') || !matches(data, 8, 'WEBP')) {
return undefined;
}

const chunk = String.fromCharCode(byte(data, 12), byte(data, 13), byte(data, 14), byte(data, 15));
if (chunk === 'VP8X') {
return {width: readUint24LE(data, 24) + 1, height: readUint24LE(data, 27) + 1};
}

if (chunk === 'VP8 ' && matches(data, 23, '\u009d\u0001*')) {
return {
width: readUint16LE(data, 26) & 0x3fff,
height: readUint16LE(data, 28) & 0x3fff,
};
}

if (chunk === 'VP8L' && byte(data, 20) === 0x2f) {
const b1 = byte(data, 21);
const b2 = byte(data, 22);
const b3 = byte(data, 23);
const b4 = byte(data, 24);
return {
width: 1 + (((b2 & 0x3f) << 8) | b1),
height: 1 + (((b4 & 0x0f) << 10) | (b3 << 2) | ((b2 & 0xc0) >> 6)),
};
}

return undefined;
}

async function readImageDimensions(filePath: string): Promise<ImageDimensions> {
const data = await readFile(filePath);
const dimensions = parsePng(data) ?? parseGif(data) ?? parseJpeg(data) ?? parseWebp(data);
if (!dimensions) {
throw new Error('Cannot read image dimensions: unsupported or invalid image file');
}

return dimensions;
}

export async function readLocalImageMeta(
filePath: string,
role: VideoReferenceImageRole,
): Promise<VideoReferenceImageMeta> {
const [{width, height}, fileStat] = await Promise.all([
readImageDimensions(filePath),
stat(filePath),
]);
return {role, width, height, size: fileStat.size};
}
11 changes: 9 additions & 2 deletions source/_shared/upload.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import {access, readFile, stat} from 'node:fs/promises';
import path from 'node:path';
import type {ListenHubClient} from '@marswave/listenhub-sdk';

type FileAcceptType = 'audio' | 'image' | 'video';

type FileUploadClient = {
createFileUpload(params: {
fileKey: string;
contentType: string;
category: string;
}): Promise<{presignedUrl: string; fileUrl: string}>;
};

const audioExtensions = new Set(['.mp3', '.wav', '.flac', '.m4a', '.ogg', '.aac']);
const imageExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
const videoExtensions = new Set(['.mp4', '.mov']);
Expand Down Expand Up @@ -43,7 +50,7 @@ function allowedExtensions(accept: FileAcceptType): Set<string> {
}

export async function resolveFileOrUrl(
client: ListenHubClient,
client: FileUploadClient,
input: string,
options: {accept: FileAcceptType; category?: string},
): Promise<string> {
Expand Down
60 changes: 60 additions & 0 deletions source/_shared/video-reference-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
export type VideoReferenceImageRole = 'first_frame' | 'last_frame' | 'reference_image';

export type VideoReferenceImageMeta = {
role: VideoReferenceImageRole;
width: number;
height: number;
size?: number;
};

export type VideoReferenceVideoMeta = {
role: 'reference_video';
width: number;
height: number;
duration?: number;
fps?: number;
size?: number;
};

export function parseImageMeta(
value: string,
role: VideoReferenceImageRole,
): VideoReferenceImageMeta {
const match = /^(\d+)x(\d+)(?::(\d+))?$/.exec(value.trim());
if (!match) {
throw new Error('Image metadata must be WIDTHxHEIGHT[:SIZE], for example 1080x1920:3600000');
}

const [, width, height, size] = match;
return {
role,
width: Number(width),
height: Number(height),
...(size !== undefined && {size: Number(size)}),
};
}

export function parseVideoMeta(value: string): VideoReferenceVideoMeta {
const match = /^(\d+)x(\d+)(?::(\d+(?:\.\d+)?))?(?::(\d+(?:\.\d+)?))?(?::(\d+))?$/.exec(
value.trim(),
);
if (!match) {
throw new Error(
'Video metadata must be WIDTHxHEIGHT[:DURATION[:FPS[:SIZE]]], for example 1280x720:5:30:8000000',
);
}

const [, width, height, duration, fps, size] = match;
return {
role: 'reference_video',
width: Number(width),
height: Number(height),
...(duration !== undefined && {duration: Number(duration)}),
...(fps !== undefined && {fps: Number(fps)}),
...(size !== undefined && {size: Number(size)}),
};
}

export function isSeedanceVideoModel(model: string | undefined, defaultModel: string): boolean {
return (model ?? defaultModel).startsWith('doubao-seedance');
}
13 changes: 10 additions & 3 deletions source/openapi/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,25 @@ import {OpenAPIClient} from '@marswave/listenhub-sdk';
import process from 'node:process';
import {loadOpenAPIConfig} from './config.js';

export async function getOpenAPIClient(): Promise<OpenAPIClient> {
const DEFAULT_OPENAPI_BASE_URL = 'https://api.marswave.ai/openapi';

export async function getOpenAPIOptions(): Promise<{apiKey: string; baseURL: string}> {
const envKey = process.env['LISTENHUB_API_KEY'];
const baseURL = process.env['LISTENHUB_OPENAPI_URL'] || DEFAULT_OPENAPI_BASE_URL;
if (envKey) {
return new OpenAPIClient({apiKey: envKey});
return {apiKey: envKey, baseURL};
}

const config = await loadOpenAPIConfig();
if (config?.apiKey) {
return new OpenAPIClient({apiKey: config.apiKey});
return {apiKey: config.apiKey, baseURL};
}

throw new Error(
'No API Key configured. Set LISTENHUB_API_KEY env var or run `listenhub openapi config set-key`.',
);
}

export async function getOpenAPIClient(): Promise<OpenAPIClient> {
return new OpenAPIClient(await getOpenAPIOptions());
}
Loading
Loading