# מדיה והעלאות

> העלאת קבצים, ספריית המדיה, ייבוא סרטונים מקישורים, קיצוץ קליפים, צריבת כתוביות ושילוב שכבות של תמונות וסרטונים עם ה-SDK של Nodaro.

Source: https://nodaro.ai/he/docs/developers/sdk/media-and-uploads

**`client.uploads`** שומר את הקבצים שלכם ב-Nodaro, **`client.library`** מחזיר את המדיה שכבר יש לכם, והמשאב **`client.gallery`** מאפשר לעיין בגלריה הציבורית ובמועדפים שלכם. המשאב **`client.media`** מכין ועורך מדיה: הוא מוריד סרטונים מקישורים, מקצץ קליפים, צורב כתוביות ומשלב שכבות של תמונות וסרטונים. רוב המתודות של `client.media` מתחילות משימה ומחזירות את ה-`jobId` שלה, ואת הסטטוס של המשימה בודקים מדי כמה שניות עם [`client.jobs.getStatus()`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions). המתודות קוראות לנקודות הקצה שבדפי ה-API [העלאות](https://nodaro.ai/docs/developers/api/uploads) ו[קול ומדיה](https://nodaro.ai/docs/developers/api/voice-and-media).

## מתודות
| מתודה | מה היא עושה |
| --- | --- |
| [`uploads.upload(file)`](#uploadsuploadfile) | העלאת קובץ אחד וקבלת הכתובת הציבורית שלו |
| [`uploads.uploadJson(json)`](#uploadsuploadjsonjson) | שמירת מסמך JSON וקבלת הכתובת שלו |
| [`uploads.importUrl(url, options?)`](#uploadsimporturlurl-options) | העתקת תמונה מכתובת אינטרנט לאחסון שלכם |
| [`library.list(params?)`](#librarylistparams) | רשימת המדיה השמורה שלכם וחיפוש בה |
| [`gallery.browse(params?)`](#gallerybrowseparams) | עיון בגלריה הציבורית |
| [`gallery.toggleFavorite(jobId)`](#gallerytogglefavoritejobid) | סימון יצירה כמועדפת, או הסרת הסימון |
| [`gallery.favorites()`](#galleryfavorites) | רשימת המזהים של המשימות שסימנתם כמועדפות |
| [`contentRights.declaration()`](#contentrightsdeclaration) | קריאת הצהרת זכויות התוכן |
| [`contentRights.confirm(input)`](#contentrightsconfirminput) | אישור ההצהרה לקישור אחד לפני הייבוא שלו |
| [`contentRights.authorization()`](#contentrightsauthorization) | קריאת ההרשאה הקבועה לייבוא דרך מפתחות וסוכנים |
| [`contentRights.authorize(input)`](#contentrightsauthorizeinput) | מתן הרשאה לייבוא דרך מפתחות וסוכנים, מתוך הסשן של בעל החשבון עצמו |
| [`contentRights.revokeAuthorization()`](#contentrightsrevokeauthorization) | ביטול ההרשאה הזו |
| [`media.downloadVideo(input)`](#mediadownloadvideoinput) | הורדת הסרטון שמאחורי קישור ושמירה שלו באחסון שלכם |
| [`media.downloadVideoProgress(downloadId, opts?)`](#mediadownloadvideoprogressdownloadid-opts) | קבלת ההתקדמות של הורדה בזמן אמת |
| [`media.videoMetadata(input)`](#mediavideometadatainput) | קריאת האורך והגודל של סרטון מרשת חברתית בלי להוריד אותו |
| [`media.saveToStorage(input)`](#mediasavetostorageinput) | העתקת קובץ מדיה מכתובת URL לאחסון שלכם |
| [`media.process(input)`](#mediaprocessinput) | קיצוץ או חיתוך של קובץ שמור, בחינם |
| [`media.discard(input)`](#mediadiscardinput) | מחיקת קובץ שיצרתם זה עתה ושכבר אין לכם צורך בו |
| [`media.trimVideo(input)`](#mediatrimvideoinput) | קיצוץ סרטון לטווח מסוים |
| [`media.trimAudio(input)`](#mediatrimaudioinput) | קיצוץ אודיו, או חילוץ שלו מסרטון |
| [`media.splitMedia(input)`](#mediasplitmediainput) | חיתוך סרטון או קובץ אודיו לחלקים שהאורך שלהם בין מינימום למקסימום, או חיתוך סרטון לחלק אחד לכל שוט |
| [`media.addCaptions(input)`](#mediaaddcaptionsinput) | צריבת כתוביות בסרטון |
| [`media.stillToVideo(input)`](#mediastilltovideoinput) | הפיכת תמונה ורצועת אודיו לסרטון |
| [`media.slideshow(input)`](#mediaslideshowinput) | הפיכת תמונות, ואודיו אופציונלי, למצגת |
| [`media.videoOverlay(input)`](#mediavideooverlayinput) | הצבת שכבות תמונה מתוזמנות מעל סרטון |
| [`media.imageCollage(input)`](#mediaimagecollageinput) | סידור של 2–30 תמונות בקולאז׳ אחד |
| [`media.imageOverlay(input)`](#mediaimageoverlayinput) | הצבת שכבות של תמונה, טקסט, קוד QR וצורה על תמונה |
| [`media.videoMask(input)`](#mediavideomaskinput) | מעקב אחרי נושא שמתואר בטקסט וקבלת סרטון מסכה |
| [`media.maskComposite(input)`](#mediamaskcompositeinput) | הצבת סרטון אחד מעל סרטון אחר דרך סרטון מסכה, או פיקסול או טשטוש של האזור הממוסך |
| [`media.suggestOverlayPlacement(input)`](#mediasuggestoverlayplacementinput) | בקשה ממודל ראייה להציע איפה למקם שכבה |

## client.uploads
### uploads.upload(file)‎
מעלה קובץ אחד (`POST /v1/upload`, בפורמט multipart) ומחזירה את הכתובת הציבורית שלו ואת פרטי האחסון. ה-SDK שולח את הקובץ כ-form data, ומשאיר לסביבת הריצה לקבוע את ה-boundary של ה-multipart.

```ts
upload(file: File): Promise<UploadResult>
```

<TypeTable
type={{
file: { type: 'File', required: true, description: "הקובץ להעלאה: תמונה, סרטון או קובץ אודיו. האובייקט File זמין גלובלית בדפדפנים וב-Node.js 20 ומעלה." },
}}
/>

```ts
const result = await client.uploads.upload(file)

const clip = await client.nodes.runAndWait("generate-video", {
prompt: "The camera slowly pushes in",
imageUrl: result.url,
})
```

| שדה | סוג | תיאור |
| --- | --- | --- |
| `url` | `string` | הכתובת הציבורית של הקובץ השמור. העבירו אותה בתור `imageUrl`, `videoUrl` או `audioUrl`. |
| `assetId` | `string \| null` | המזהה של הפריט השמור, או `null` בהעלאה אנונימית. |
| `thumbnailUrl` | `string \| null` | תמונה ממוזערת לתמונות ולווידאו, או `null`. |
| `category` | `string` | `image`, `video` או `audio`. |
| `filename` | `string` | שם התצוגה של הקובץ. |
| `mimeType` | `string` | סוג המדיה שהשרת קבע. הוא יכול להיות שונה מהסוג שהדפדפן הצהיר עליו, למשל `video/mp4` לקובץ `.mp4` שנשלח כ-`application/octet-stream`. |
| `sizeBytes` | `number` | הגודל של הקובץ השמור. |
| `r2Key` | `string` | המפתח של הקובץ באחסון. |

המתודה זורקת `StorageExceededError` כשנפח האחסון שלכם מלא. הרכיבים [העלאת תמונה](https://nodaro.ai/docs/nodes/image/upload-image), [העלאת וידאו](https://nodaro.ai/docs/nodes/video/upload-video) ו[העלאת אודיו](https://nodaro.ai/docs/nodes/audio/upload-audio) משתמשים באותו אחסון.

### uploads.uploadJson(json)‎
שומרת מסמך JSON של עד 25 MB (`POST /v1/upload-json`) ומחזירה את `{ url }`, הכתובת הציבורית שלו. אפשר לשמור כך, למשל, את מצב העיצוב של עורך תמונות: מעבירים את הכתובת שלו ל-[`jobs.fromUpload`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions) בתור `editStateUrl`, כדי שאפשר יהיה לפתוח את העריכה שוב.

```ts
uploadJson(json: Record<string, unknown> | readonly unknown[]): Promise<UploadJsonResult>
```

קשה לנחש את הכתובת, אבל היא ציבורית, ולכן לעולם אל תשמרו סוד במסמך כזה. המתודה לא יוצרת פריט בספרייה.

### uploads.importUrl(url, options?)‎
מעתיקה תמונה מכתובת אינטרנט לאחסון שלכם ומחזירה אותה (`POST /v1/media/import-url`, עם גוף הבקשה `{ url, confirmationId? }`). העותק השמור נחשב להעלאה שלכם, והוא מוגש כך שאלמנט `<canvas>` בדפדפן יכול לקרוא את הפיקסלים שלו, למשל כדי לחתוך אותו. תמונה באתר אחר לא מאפשרת את זה.

```ts
importUrl(url: string, options?: { confirmationId?: string }): Promise<ImportUrlResult>
```

<TypeTable
type={{
url: { type: 'string', required: true, description: "כתובת ה-http או ה-https של התמונה. כתובת פרטית נדחית. עד 20 MB." },
"options.confirmationId": { type: 'string', description: "אישור זכויות התוכן לכתובת הזו, מ-contentRights.confirm()‎. שלחו אותו כשהפריסה דורשת אישור." },
}}
/>

הפלטפורמה שומרת את הקובץ רק אם אפשר לפענח אותו כתמונה: JPEG, PNG, WebP או GIF. תמונת HEIC, HEIF או AVIF מומרת ל-JPEG או ל-PNG.

```ts
try {
const picture = await client.uploads.importUrl("https://example.org/photo.jpg")
console.log(picture.url, picture.width, picture.height)
} catch (err) {
if (err instanceof StorageExceededError && err.reason === "file_too_large") {
// one picture over 20 MB, not a full storage
}
}
```

| שדה | סוג | תיאור |
| --- | --- | --- |
| `url` | `string` | הכתובת הציבורית של התמונה השמורה. |
| `thumbnailUrl` | `string \| null` | תמונה ממוזערת, או `null` כשאי אפשר היה ליצור אותה. |
| `assetId` | `string \| null` | המזהה של הפריט השמור, או `null` כשלא נכתב פריט. |
| `mimeType` | `string` | סוג המדיה הסופי. מקור HEIC, HEIF או AVIF נשמר כ-JPEG או כ-PNG. |
| `sizeBytes` | `number` | הגודל של הקובץ השמור. |
| `filename` | `string` | החלק האחרון בנתיב של הכתובת, או `imported-<id>.<ext>`. |
| `width` | `number` | הרוחב בפיקסלים, או `0` כשאי אפשר היה לקרוא אותו. |
| `height` | `number` | הגובה בפיקסלים, או `0` כשאי אפשר היה לקרוא אותו. |

| סטטוס | שגיאה | מתי |
| --- | --- | --- |
| 400 | `NodaroError` עם הקוד `validation_error` | הכתובת היא לא http או https, או שהיא כתובת IP פרטית או שמורה. הקובץ הוא לא תמונה שאפשר לפענח, או שהפורמט שלו לא מתקבל, כגון SVG או TIFF. |
| 422 | `NodaroError` עם הקוד `fetch_failed` | אי אפשר היה להגיע לכתובת, או שהיא החזירה שגיאה. גם שם מארח שמפנה לכתובת פרטית נכלל במקרה הזה. |
| 403 | `ImportRefusedError`, שהיא גם `ForbiddenError` | ייבוא קישורים חסום בחשבון שלכם, או שהחשבון סגור, לפי מדיניות המפרים החוזרים של הפריסה (`imports_blocked`); הפריסה לא מאפשרת ייבוא מהאתר (`import_source_disabled`); או שאישור זכויות התוכן חסר כשהוא נדרש, שייך לקישור אחר או לחשבון אחר, לא עדכני, שתוקפו פג או שכבר נוצל (קוד `rights_confirmation_*`). |
| 413 | `StorageExceededError` עם `reason` בערך `file_too_large` | התמונה גדולה מ-20 MB. |
| 413 | `StorageExceededError` עם `reason` בערך `storage_limit_exceeded` | נפח האחסון שלכם מלא. ב-`details` נמצאים `usedBytes`, `quotaBytes`, `remainingBytes` ו-`tier`. |

רכיב [העלאת תמונה](https://nodaro.ai/docs/nodes/image/upload-image) משתמש באותו ייבוא כשמדביקים קישור לתמונה. כדי למחוק תמונה מיובאת שכבר אין לכם צורך בה, קראו ל-[`media.discard()`](#mediadiscardinput).

## client.library
### library.list(params?)‎
מחזירה את המדיה השמורה שלכם בעמודים, עם cursor (`GET /v1/library`). כברירת מחדל היא מחזירה את הפריטים ששמרתם בספרייה שלכם, ובנוסף פריטים ששותפו איתכם, כמו שחלון בחירת המדיה בעורך מציג אותם.

```ts
list(params?: ListLibraryParams): Promise<{ data: LibraryAsset[]; nextCursor: string | null; totalCount?: number }>
```

<TypeTable
type={{
type: { type: '"all" | "image" | "video" | "audio"', default: '"all"', description: "רק סוג מדיה אחד." },
q: { type: 'string', description: "מילות חיפוש: רק קבצים ששם הקובץ שלהם כולל את כל המילים, בלי הבדל בין אותיות גדולות לקטנות. רק 5 המילים הראשונות נספרות, ו-%, _ ו-* הם לא תווים כלליים." },
search: { type: 'string', description: "השם הקודם של q. הוא עדיין עובד, אבל הוא מיושן. כששולחים את שניהם, נשלח q." },
limit: { type: 'number', default: '40', description: "גודל העמוד, מ-1 עד 100." },
cursor: { type: 'string', description: "הערך nextCursor מהעמוד הקודם." },
owned: { type: 'boolean', default: 'false', description: "עם true, המתודה מחזירה כל קובץ שבבעלותכם, העלאות ויצירות, בין שנשמר בספרייה ובין שלא." },
source: { type: 'string', description: "רק מדיה שהגיעה מממשק אחד: internal, mcp, app, cli, sdk, extension, web או api. מדיה ישנה בלי מקור אף פעם לא תואמת." },
}}
/>

```ts
const { data: videos, nextCursor } = await client.library.list({ type: "video", limit: 20 })
for (const asset of videos) console.log(asset.filename, asset.url)
```

```ts
const { data, nextCursor } = await client.library.list({ type: "image", q: "poster" })
```

כל `LibraryAsset` כולל את `id`, `type`, `filename`, `mimeType`, `sizeBytes`, `url`, `thumbnailUrl`, `metadata`, `isLibraryItem`, `uploadSource`, `source`, `sourceDetail`, `jobId` ו-`createdAt`. השדה `jobId` הוא המשימה שיצרה את הקובץ, או `null` בהעלאה. שרתים מגרסאות ישנות יותר משמיטים אותו. השדה `totalCount` מופיע רק בעמוד הראשון.

## client.gallery
הגלריה הציבורית של יצירות שהושלמו, והמועדפים שלכם. מועדף הוא סימון של יצירה: יצירה שלכם, או יצירה ציבורית של כל אחד אחר. במשימות שלכם הסימון מופיע בשדה `Job.favorited`, והמתודה `client.jobs.list({ favorited: true })` מחזירה את המשימות המועדפות. בפריסה שמסתירה את הגלריה, הנתיבים האלה מחזירים 404.

### gallery.browse(params?)‎
מחזירה עמוד אחד מהגלריה הציבורית, מהחדש לישן (`GET /v1/gallery`). לא צריך אסימון.

```ts
browse(params?: BrowseGalleryParams): Promise
```

<TypeTable
type={{
type: { type: '"image" | "video" | "audio"', description: "רק סוג מדיה אחד." },
userId: { type: 'string', description: "רק העבודות של האדם הזה. עם המזהה שלכם, גם הפריטים הפרטיים שלכם נכללים." },
favoritesOnly: { type: 'boolean', description: "עם ה-userId שלכם, רק הפריטים שסימנתם כמועדפים." },
limit: { type: 'number', default: '20', description: "גודל העמוד, מ-1 עד 50." },
cursor: { type: 'string', description: "הערך nextCursor מהעמוד הקודם." },
}}
/>

```ts
const { data: items, nextCursor } = await client.gallery.browse({ type: "image", limit: 20 })
```

העמוד הוא `{ data: GalleryItem[], nextCursor, totalCount? }`, והשדה `totalCount` מופיע רק בעמוד הראשון. כל פריט הוא `{ id, type, jobName, outputUrl, thumbnailUrl, createdAt, prompt, model, preview? }`, והשדה `id` הוא מזהה המשימה. הפרמטר `favoritesOnly` עובד רק עם ה-`userId` שלכם, מ-[`client.me()`](https://nodaro.ai/docs/developers/sdk/client#me). עם `favoritesOnly` ובלי אסימון, המתודה זורקת `UnauthorizedError`. עם `userId` של אדם אחר, או בלי `userId`, היא זורקת `ForbiddenError`.

### gallery.toggleFavorite(jobId)‎
מסמנת את המשימה כמועדפת, או מסירה את הסימון כשהמשימה כבר מועדפת (`POST /v1/gallery/favorite`). השדה `favorited` מציין את המצב של המשימה אחרי הקריאה.

```ts
toggleFavorite(jobId: string): Promise<{ favorited: boolean }>
```

```ts
const { favorited } = await client.gallery.toggleFavorite(jobId)
```

אפשר לסמן כמועדפות את המשימות שלכם ואת המשימות הציבוריות. על משימה פרטית של אדם אחר, המתודה זורקת `NotFoundError`. אפליקציות OAuth צריכות את ההרשאה `assets:write`.

### gallery.favorites()‎
מחזירה את המזהים של המשימות שסימנתם כמועדפות, מהחדש לישן (`GET /v1/gallery/favorites`). אפליקציות OAuth צריכות את ההרשאה `assets:read` או `jobs:read`.

```ts
favorites(): Promise<string[]>
```

```ts
const ids = await client.gallery.favorites()
```

## client.contentRights
המשאב `client.contentRights` קורא את ההצהרה שמי שמייבא קישור מאשר לפני הייבוא, ורושם את האישור שמצורף אחר כך לייבוא (`/v1/content-rights`). הכללים מפורטים בדף ה-API [זכויות תוכן](https://nodaro.ai/docs/developers/api/content-rights).

### contentRights.declaration()‎
```ts
declaration(): Promise<{ version: number; text: string }>
```

מחזירה את ההצהרה הנוכחית (`GET /v1/content-rights/declaration`). הציגו את `text` כמו שהוא, עם תיבת סימון שמתחילה לא מסומנת. אישור מציין את ה-`version`. הקריאה לא דורשת פרטי גישה.

### contentRights.confirm(input)‎
```ts
confirm(input: { url: string; declarationVersion: number }): Promise<{
id: string
declarationVersion: number
expiresAt: string
}>
```

רושמת שהחשבון המחובר אישר את ההצהרה לקישור אחד (`POST /v1/content-rights/confirm`). אפשר לאשר כל קישור `http(s)` שייבוא מקבל: דף של סרטון, קובץ, תמונה או דף ששירות הדיבוב קורא. העבירו את ה-`id` שחזר בתור `confirmationId` ל-`media.downloadVideo()`, ל-`uploads.importUrl()` או לייבוא שמושך את הקישור, כגון `voices.dub()` ו-`audio.transcribe()`. אישור אחד פותח הורדה אחת של הקישור הזה, מאותו חשבון, תחת ההצהרה הנוכחית ובתוך שעה. תשובת `409 rights_confirmation_stale` פירושה שההצהרה השתנתה אחרי שקיבלתם אותה: טענו אותה שוב ובקשו את האישור שוב.

```ts
const { version, text } = await client.contentRights.declaration()
// Show `text` with a checkbox. Once it is ticked:
const { id } = await client.contentRights.confirm({ url, declarationVersion: version })
const { downloadId } = await client.media.downloadVideo({ url, maxHeight: 1080, confirmationId: id })
```

הגדרה של הפריסה קובעת אם הורדה בלי אישור נדחית. כשההגדרה מופעלת, הורדה כזו נדחית עם `403 rights_confirmation_required`. תמיד שלחו אישור.

### contentRights.authorization()‎
```ts
authorization(): Promise<{ id: string; declarationVersion: number; createdAt: string } | null>
```

מחזירה את ההרשאה הקבועה של החשבון לייבוא דרך מפתחות ה-API, האפליקציות המחוברות וסוכני ה-AI שלו (`GET /v1/content-rights/authorization`). כשאין הרשאה כזו, המתודה מחזירה `null`: ההרשאה לא ניתנה מעולם, בוטלה או ניתנה להצהרה ישנה יותר.

### contentRights.authorize(input)‎
```ts
authorize(input: { declarationVersion: number }): Promise<{
id: string
declarationVersion: number
createdAt: string
}>
```

רושמת שבעל החשבון נתן הרשאה לייבוא דרך המפתחות, האפליקציות והסוכנים שלו, לגרסת ההצהרה שהוא ראה (`POST /v1/content-rights/authorization`). קראו לה מתוך הסשן המחובר של בעל החשבון עצמו. קריאה דרך מפתח, אפליקציה או סוכן נדחית עם `403 forbidden`. תשובת `409 rights_confirmation_stale` פירושה שההצהרה השתנתה: טענו אותה שוב ובקשו את ההרשאה שוב.

### contentRights.revokeAuthorization()‎
```ts
revokeAuthorization(): Promise<{ revoked: boolean }>
```

מבטלת את ההרשאה הקבועה (`DELETE /v1/content-rights/authorization`). הרשומה נשמרת. השדה `revoked` הוא `false` כשלא היה מה לבטל. קראו לה מתוך הסשן של בעל החשבון עצמו.

## client.media: ייבוא והכנה
### media.downloadVideo(input)‎
מורידה לאחסון שלכם את הסרטון שמאחורי קישור: הדף של הסרטון או קישור ישיר לקובץ וידאו (`POST /v1/download-video`). המתודה מחזירה `downloadId`, ולא מזהה משימה. את ההתקדמות מקבלים עם `downloadVideoProgress()`. הקובץ המוגמר נשמר בספרייה שלכם.

```ts
downloadVideo(input: {
url: string
maxHeight?: number
sectionStartSec?: number
sectionEndSec?: number
exactSection?: boolean
requireAudio?: boolean
confirmationId?: string
}): Promise<{ downloadId: string }>
```

<TypeTable
type={{
url: { type: 'string', required: true, description: "הקישור לדף של הסרטון, או קישור ישיר לקובץ וידאו." },
maxHeight: { type: 'number', description: "הרזולוציה הגבוהה ביותר להורדה, בפיקסלים של גובה, כגון 720. השמיטו אותו כדי לקבל את הרזולוציה הטובה ביותר שזמינה." },
sectionStartSec: { type: 'number', description: "ההתחלה של החלק להורדה, בשניות: רק החלק הזה יורד. החלק מגיע עם כ-3 שניות נוספות בכל צד, כדי שתקצצו אותו בעצמכם. שלחו את שני שדות החלק או אף אחד מהם." },
sectionEndSec: { type: 'number', description: "הסוף של החלק הזה, בשניות." },
exactSection: { type: 'boolean', default: 'false', description: "העבירו true כדי לחתוך את החלק בדיוק בטווח שנבחר, בלי השוליים בכל צד." },
requireAudio: { type: 'boolean', default: 'true', description: "כברירת מחדל, הורדה בלי אודיו נכשלת, כי בדרך כלל זה אומר שהמקור לא ענה כראוי, ולפני כן מנסים דרכים אחרות. העבירו false כדי לקבל קליפ שבאמת אין בו אודיו." },
confirmationId: { type: 'string', description: "אישור זכויות התוכן לקישור הזה, מ-contentRights.confirm()‎. כשהאישור לא מתאים, המתודה זורקת ImportRefusedError." },
}}
/>

```ts
const { downloadId } = await client.media.downloadVideo({
url: "https://youtu.be/dQw4w9WgXcQ",
maxHeight: 720,
})
```

### media.downloadVideoProgress(downloadId, opts?)‎
מזרימה את ההתקדמות של הורדה בזמן אמת, כאיטרטור אסינכרוני שמקבל אירועי שרת (SSE) מ-`GET /v1/download-video/progress/:id`. היא מחזירה אירוע בערך כל 500 אלפיות שנייה, עד שההורדה מסתיימת או נכשלת, ואז האיטרטור מסתיים.

```ts
downloadVideoProgress(downloadId: string, opts?: { signal?: AbortSignal }): AsyncGenerator<DownloadVideoProgress>
```

<TypeTable
type={{
downloadId: { type: 'string', required: true, description: "המזהה ש-downloadVideo()‎ החזירה." },
signal: { type: 'AbortSignal', description: "מפסיק לקבל את ההתקדמות." },
}}
/>

```ts
for await (const event of client.media.downloadVideoProgress(downloadId)) {
console.log(`${event.phase} ${event.percent}%`)
if (event.phase === "completed") console.log("Stored at", event.videoUrl)
if (event.phase === "failed") console.error(event.error)
}
```

כל אירוע הוא `{ phase, percent, videoUrl?, thumbnailUrl?, error? }`. התחילו לעבור על האירועים זמן קצר אחרי ש-`downloadVideo()` מחזירה תשובה, כי רשומת ההתקדמות פגה זמן קצר אחרי שההורדה מסתיימת. ה-`timeoutMs` של הלקוח לא חל כאן, כי הורדות גדולות נמשכות כמה דקות.

### media.videoMetadata(input)‎
מחזירה את האורך, הממדים והכותרת של סרטון מרשת חברתית, וגם אם הוא שידור חי, **בלי** להוריד אותו (`POST /v1/video-metadata`). התשובה מגיעה ישירות, בלי משימה. השתמשו בה כדי להחליט אם להוריד רק חלק מהסרטון.

```ts
videoMetadata(input: { url: string }): Promise<VideoMetadata>
```

<TypeTable
type={{
url: { type: 'string', required: true, description: "הקישור לסרטון ברשת החברתית." },
}}
/>

```ts
const meta = await client.media.videoMetadata({ url: "https://youtu.be/dQw4w9WgXcQ" })
```

השדות לא מובטחים: פלטפורמה מסוימת עשויה לא לדווח על כולם.

### media.saveToStorage(input)‎
מעתיקה קובץ מדיה מכתובת URL לאחסון שלכם ב-Nodaro (`POST /v1/save-to-storage`). השרת מושך את הקובץ, ולכן שום דבר לא עובר דרך הלקוח שלכם.

```ts
saveToStorage(input: { mediaUrl: string; filename?: string; mediaType?: "image" | "video" | "audio" }): Promise<{ jobId: string }>
```

<TypeTable
type={{
mediaUrl: { type: 'string', required: true, description: "הכתובת של הקובץ להעתקה." },
filename: { type: 'string', description: "שם הקובץ שתחתיו הוא יישמר." },
mediaType: { type: '"image" | "video" | "audio"', description: "סוג המדיה." },
}}
/>

```ts
const { jobId } = await client.media.saveToStorage({ mediaUrl: "https://example.com/intro.mp4", mediaType: "video" })
```

רכיב [שמירה לאחסון](https://nodaro.ai/docs/nodes/publish/save-to-storage) עושה את אותו הדבר בתוך תהליך.

### media.process(input)‎
מקצצת או חותכת קובץ שכבר נמצא באחסון שלכם (`POST /v1/media/process`). התשובה מגיעה ישירות, והקריאה לא עולה כלום. השתמשו בה כדי להכין מקור לפני שלב בתשלום.

```ts
process(input: {
sourceUrl: string
type: "video" | "audio"
trim?: { startTime: number; endTime: number }
crop?: { x: number; y: number; width: number; height: number }
format?: "mp4" | "webm" | "mp3" | "wav" | "m4a" | "aac"
deleteSource?: boolean
}, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<{ data: MediaProcessResult }>
```

<TypeTable
type={{
sourceUrl: { type: 'string', required: true, description: "הקובץ השמור לעיבוד." },
type: { type: '"video" | "audio"', required: true, description: "סוג הקובץ." },
trim: { type: '{ startTime: number; endTime: number }', description: "רק הטווח הזה נשאר, בשניות." },
crop: { type: '{ x, y, width, height }', description: "רק המלבן הזה מהפריים נשאר, בפיקסלים." },
format: { type: '"mp4" | "webm" | "mp3" | "wav" | "m4a" | "aac"', description: "פורמט הפלט." },
deleteSource: { type: 'boolean', default: 'false', description: "מחיקת המקור אחר כך, אם הוא שלכם ושום דבר אחר לא משתמש בו." },
timeoutMs: { type: 'number', description: "ב-options: כמה זמן לחכות לתשובה. בקובץ ארוך הפלטפורמה עשויה להזדקק לעד 300 שניות, וברירת המחדל של הלקוח היא 60 שניות." },
signal: { type: 'AbortSignal', description: "ב-options: מפסיק לחכות לתשובה." },
}}
/>

```ts
const { data } = await client.media.process({
sourceUrl: stored.url,
type: "video",
trim: { startTime: 12, endTime: 42 },
})
console.log(data.url, data.sizeBytes)
```

התוצאה כוללת את `url`, `thumbnailUrl`, `assetId`, `sizeBytes`, `mimeType` ו-`metadata`.

### media.discard(input)‎
מוחקת קובץ שיצרתם זה עתה ושכבר אין לכם צורך בו (`POST /v1/media/discard`). למשל, הורדה מקישור שהחלפתם לפני שהשתמשתם בה, קטע מקוצץ שהוחלף בקטע חדש יותר ותמונה שייבאתם ואז ביטלתם.

```ts
discard(input: { url: string }): Promise<{ data: { discarded: boolean } }>
```

<TypeTable
type={{
url: { type: 'string', required: true, description: "הכתובת של הקובץ למחיקה." },
}}
/>

המתודה `discard()` לא מבצעת מחיקה כללית. הקובץ נמחק רק כשכל התנאים האלה מתקיימים:

- הקובץ הוא שלכם.
- הקובץ נוצר ב-`downloadVideo()`, ב-`process()` או בייבוא מכתובת URL, כגון `uploads.importUrl()`.
- הקובץ נוצר לפני פחות מ-24 שעות.
- אף משימה שלכם לא משתמשת בקובץ, לא כפלט ולא כקלט, כגון ה-`videoUrl` של הרצה.

במקרה כזה התשובה היא `{ data: { discarded: true } }`. אחרת שום דבר לא משתנה, והקריאה נכשלת עם שגיאת 409 שהקוד שלה הוא `not_discardable`. הערך `discarded: false` פירושו שבכתובת הזו לא היה קובץ שלכם: הקובץ כבר נמחק, או שהוא לא שלכם.

```ts
await client.media.discard({ url: oldCut.url })
```

### media.trimVideo(input)‎
מקצצת סרטון לטווח מסוים (`POST /v1/trim-video`), כמו רכיב [קיצוץ וידאו](https://nodaro.ai/docs/nodes/video/trim-video). אפשר לציין את הטווח בכל יחידה שנוחה לכם.

```ts
trimVideo(input: {
videoUrl: string
startTime?: number
endTime?: number
trimStartFrames?: number
trimEndFrames?: number
trimStartSeconds?: number
trimEndSeconds?: number
keepFirstSeconds?: number
keepLastSeconds?: number
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "הסרטון לקיצוץ." },
startTime: { type: 'number', description: "איפה החלק שנשמר מתחיל, בשניות." },
endTime: { type: 'number', description: "איפה החלק שנשמר מסתיים, בשניות." },
trimStartFrames: { type: 'number', description: "מספר הפריימים לחיתוך מההתחלה." },
trimEndFrames: { type: 'number', description: "מספר הפריימים לחיתוך מהסוף." },
trimStartSeconds: { type: 'number', description: "מספר השניות לחיתוך מההתחלה." },
trimEndSeconds: { type: 'number', description: "מספר השניות לחיתוך מהסוף." },
keepFirstSeconds: { type: 'number', description: "רק מספר השניות הזה מההתחלה נשמר." },
keepLastSeconds: { type: 'number', description: "רק מספר השניות הזה מהסוף נשמר." },
}}
/>

```ts
const { jobId } = await client.media.trimVideo({ videoUrl, keepFirstSeconds: 15 })
```

### media.trimAudio(input)‎
מקצצת אודיו לטווח מסוים, או מחלצת אותו מסרטון (`POST /v1/trim-audio`), כמו רכיב [קיצוץ אודיו](https://nodaro.ai/docs/nodes/audio/trim-audio).

```ts
trimAudio(input: {
videoUrl?: string
audioUrl?: string
audioFormat?: "mp3" | "wav" | "aac"
startTime?: number
endTime?: number
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', description: "סרטון שממנו לוקחים את האודיו. שלחו videoUrl או audioUrl." },
audioUrl: { type: 'string', description: "קובץ אודיו לקיצוץ." },
audioFormat: { type: '"mp3" | "wav" | "aac"', default: '"mp3"', description: "פורמט הפלט." },
startTime: { type: 'number', description: "תחילת הטווח, בשניות." },
endTime: { type: 'number', description: "סוף הטווח, בשניות." },
}}
/>

```ts
const { jobId } = await client.media.trimAudio({ videoUrl, startTime: 5, endTime: 35 })
```

### media.splitMedia(input)‎
חותכת סרטון, קובץ אודיו או את שניהם לחלקים רצופים (`POST /v1/split-media`), כמו רכיב [פיצול למקטעים](https://nodaro.ai/docs/nodes/video/split-into-chunks). כל קלט מפוצל בנפרד.

```ts
splitMedia(input:
| {
videoUrl?: string
audioUrl?: string
mode?: "duration"
chunkDuration: number
minPartDuration?: number
audioFormat?: "mp3" | "wav" | "aac"
}
| { videoUrl: string; mode: "shots" }
): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', description: "סרטון לפיצול. שלחו videoUrl, audioUrl או את שניהם." },
audioUrl: { type: 'string', description: "קובץ אודיו לפיצול." },
mode: { type: '"duration" | "shots"', default: '"duration"', description: 'הערך "shots" חותך סרטון לחלק אחד לכל שוט, ואז אפשר לשלוח רק videoUrl.' },
chunkDuration: { type: 'number', description: 'האורך המרבי של חלק, בשניות. לפחות 1. נדרש, אלא אם mode הוא "shots".' },
minPartDuration: { type: 'number', default: '4', description: "האורך המינימלי של חלק, בשניות. ערך גבוה מ-chunkDuration נדחה עם 400." },
audioFormat: { type: '"mp3" | "wav" | "aac"', default: '"mp3"', description: "הפורמט של חלקי האודיו." },
}}
/>

הרצה עולה 22 קרדיטים, לא משנה כמה חלקים נוצרים. בפלט של המשימה המוגמרת נמצאים `videoUrls` ו-`audioUrls` (החלקים לפי הסדר), `chunkCount` ו-`partDurationsSec` (האורך המדויק של כל חלק). כששני הקלטים פוצלו, השדה `audioPartDurationsSec` מפרט את האורכים של חלקי האודיו. קובץ יכול להיות באורך של עד 3 שעות, ולהתפצל ל-200 חלקים לכל היותר.

```ts
const { jobId } = await client.media.splitMedia({ videoUrl, chunkDuration: 60, minPartDuration: 30 })
```

עם `mode: "shots"`, הסרטון נחתך במקום זאת לחלק אחד לכל שוט, בנקודות החיתוך שנמדדות מהקובץ. המצב הזה לא מקבל אורך חלק, ומקבל רק סרטון: שליחה של `audioUrl` נפרד נדחית עם `400`. כל חלק שומר על האודיו של הסרטון עצמו. סרטונים יכולים להיות באורך של עד 30 דקות, עם 200 שוטים לכל היותר, באותו מחיר. הפלט כולל אז גם את `shotCount` ואת `shots`, שכל פריט בו הוא `{ index, startSec, endSec, durationSec }`.

```ts
const { jobId } = await client.media.splitMedia({ videoUrl: "https://example.com/ad.mp4", mode: "shots" })
```

## client.media: כתוביות
### media.addCaptions(input)‎
צורבת כתוביות בסרטון (`POST /v1/add-captions`), כמו רכיב [הוספת כתוביות](https://nodaro.ai/docs/nodes/video/add-captions). אפשר לשלוח את המילים ב-`text`, לשלוח `captions` מתוזמנות לפי מילה, או לתת ל-Nodaro לתמלל את הדיבור, וזו ברירת המחדל.

```ts
addCaptions(input: AddCaptionsInput): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "הסרטון שאליו מוסיפים כתוביות." },
style: { type: '"subtitle" | "word-highlight" | "karaoke" | "tiktok-words" | "word-pop" | "bouncy"', description: "הסגנון subtitle הוא גוש סטטי. כל השאר הם סגנונות מונפשים, עם אנימציה מילה אחר מילה." },
text: { type: 'string', description: "בסגנון subtitle, הכתובית עצמה, שמוצגת כגוש אחד לכל אורך הסרטון. בסגנון מונפש, רק חלופה למקרה שהתמלול לא מוצא כלום." },
captions: { type: '{ text, startMs, endMs }[]', description: "כתוביות מתוזמנות לפי מילה, רשומה אחת לכל מילה בסגנונות המונפשים. כשהן נשלחות, אין תמלול." },
autoTranscribe: { type: 'boolean', default: 'true', description: "תמלול הדיבור כששום מקור אחר לא מספק את המילים." },
transcribeProvider: { type: '"whisper" | "incredibly-fast-whisper" | "elevenlabs-stt"', default: '"incredibly-fast-whisper"', description: "מנוע התמלול. סגנונות מונפשים צריכים מנוע עם תזמון לכל מילה." },
position: { type: '"bottom" | "top" | "center"', description: "איפה הכתוביות ממוקמות." },
positionY: { type: 'number', description: "המרכז האנכי של הכתובית, באחוזים מהגובה. גובר על position." },
fontSize: { type: 'number', description: "גודל הגופן." },
color: { type: 'string', description: "צבע הטקסט." },
backgroundColor: { type: 'string', description: "צבע הרקע מאחורי הטקסט." },
look: { type: '"outline" | "clean"', description: "ערכת עיצוב. הערך outline הוא טקסט עבה באותיות רישיות עם קו מתאר שחור, והמילה הנאמרת בצהוב." },
fontFamily: { type: 'string', description: "אחד הגופנים הנתמכים." },
fontWeight: { type: 'number', description: "מ-100 עד 900, בקפיצות של 100." },
strokeColor: { type: 'string', description: "צבע קו המתאר." },
strokeWidth: { type: 'number', description: "עובי קו המתאר." },
uppercase: { type: 'boolean', description: "הצגת הטקסט באותיות רישיות." },
maxWordsPerLine: { type: 'number', description: "מספר המילים המרבי בשורה, 1–20." },
highlightColor: { type: 'string', description: "בסגנונות מונפשים בלבד: הצבע של המילה שנאמרת." },
animate: { type: 'boolean', default: 'true', description: "בסגנונות מונפשים בלבד: עם false, התנועה של המילים קופאת." },
segments: { type: 'CaptionSegmentInput[]', description: "עיצוב שונה לטווחי זמן נפרדים. לכל מקטע יש startMs ו-endMs, וגם סגנון, מראה, מילים ואפשרויות עיצוב משלו." },
}}
/>

```ts
const { jobId } = await client.media.addCaptions({
videoUrl: "https://example.com/talk.mp4",
style: "word-highlight",
maxWordsPerLine: 2,
})
```

**מאיפה המילים מגיעות.** כשבקריאה יש כמה מקורות, `captions` המתוזמנות לפי מילה גוברות, אחריהן `text` בסגנון `subtitle`, ורק אחר כך התמלול. בסגנון `subtitle`, השדה `text` הוא הכתובית: הוא נצרב כגוש סטטי אחד לכל אורך הסרטון, ותמלול אף פעם לא מחליף אותו. כתבו בו `\n` כדי לכפות מעבר שורה. בסגנון מונפש, השדה `text` הוא רק חלופה. הוא משמש כשהתמלול לא מוצא כלום או כש-`autoTranscribe` הוא `false`, ואז המילים שלו מתחלקות באופן שווה לאורך הסרטון.

**סגנונות ומראות.** בסגנונות המונפשים, כש-`look` לא מוגדר, הכתוביות מרונדרות כ-`outline`. בסגנון `subtitle`, הן מרונדרות כ-`clean`. אפשרויות עיצוב שמגדירים במפורש גוברות על שדות בודדים של המראה. מקטע שמציין `look` משלו מתחיל מהערכה הזו, ולא יורש את אפשרויות העיצוב שברמה העליונה.

**אפשרויות עיצוב בסגנון `subtitle`.** אפשרויות העיצוב (`look`, `fontFamily`, `fontWeight`, `strokeColor`, `strokeWidth`, `uppercase`, `positionY` ו-`maxWordsPerLine`) עובדות גם בסגנון `subtitle`. על כתובית עם אחת מהן משלמים את המחיר של סגנון מונפש, ועל כתובית של טקסט פשוט משלמים את המחיר הנמוך יותר. האפשרויות `highlightColor` ו-`animate` קיימות רק בסגנונות מונפשים, ובסגנון `subtitle` הן נדחות עם 400. גם על כתובית שנוצרה מתמלול אוטומטי משלמים את המחיר של סגנון מונפש.

**שורות.** הסגנונות `word-highlight`, `karaoke` ו-`bouncy` מציגים שורה אחת בכל פעם. שורה מסתיימת בסוף משפט, בהפסקה של 0.5 שנייה או יותר, כשהיא ממלאת כ-85% מרוחב הפריים או כשהיא מגיעה ל-`maxWordsPerLine` מילים. הסגנון `word-pop` מציג מילה אחת בכל פעם, ולכן ל-`maxWordsPerLine` אין בו השפעה. עמוד של `tiktok-words` אף פעם לא חוצה סוף משפט או הפסקה. גם השורות וגם העמודים נשארים על המסך 1.5 שניות לכל היותר אחרי המילה האחרונה שנאמרה. השדות `startMs` ו-`endMs` של מילה מתזמנים את האפקט שלה, לא את משך הזמן שהיא נשארת על המסך.

**מנועי תמלול.** סגנון מונפש צריך תזמון לכל מילה, ולכן הערך של `transcribeProvider` חייב להיות `incredibly-fast-whisper`, ברירת המחדל כאן, או `elevenlabs-stt`. למנוע `whisper` אין תזמון לכל מילה. הוא נדחה עם `400 validation_error` רק כשהתמלול הוא המקור האפשרי היחיד של המילים. בסגנון `subtitle` כל מנוע עובד, כי כתובית צריכה רק תזמון ברמת המשפט.

**קצב פריימים.** רינדור מעוצב שומר על קצב הפריימים של סרטון המקור, מעוגל למספר שלם בין 15 ל-60. מקור עם קצב פריימים משתנה, או קליפ ארוך מדי למגבלת הפריימים, מרונדר ב-30 פריימים לשנייה.

### תמלול, תיקון ואז צריבה
לשדה `words` של משימת [`client.audio.transcribe()`](https://nodaro.ai/docs/developers/sdk/voices-and-audio) יש בדיוק המבנה שהשדה `captions` מקבל. תקנו מילה, ואז צרבו את הכתוביות בלי תמלול נוסף:

```ts

const { jobId } = await client.audio.transcribe({
audioUrl: "https://example.com/talk.mp3",
provider: "elevenlabs-stt", // always returns word timings
})
// ...poll until the job completes, then:
const { data: job } = await client.jobs.get(jobId)
const { words = [] } = job.output_data as TranscribeJobOutput

const corrected = words.map((w, i) => (i === 7 ? { ...w, text: "Nodaro" } : w))
await client.media.addCaptions({
videoUrl: "https://example.com/talk.mp4",
captions: corrected,
autoTranscribe: false,
style: "word-highlight",
})
```

## client.media: רינדור ושילוב שכבות
### media.stillToVideo(input)‎
הופכת תמונת סטילס אחת ורצועת אודיו אחת לקובץ MP4 (`POST /v1/still-to-video`), כמו רכיב [תמונה נייחת לווידאו](https://nodaro.ai/docs/nodes/video/still-to-video). הסרטון מרונדר בשרת בלי מודל AI, והקריאה לא עולה קרדיטים. הסרטון ארוך בדיוק כמו האודיו, ואין שדה לאורך.

```ts
stillToVideo(input: {
imageUrl: string
audioUrl: string
motion?: "none" | "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "ken-burns"
intensity?: number
resolution?: "720p" | "1080p" | "4K"
aspectRatio?: "16:9" | "9:16" | "1:1" | "4:3"
fps?: 24 | 30
fit?: "cover" | "contain"
padColor?: string
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
imageUrl: { type: 'string', required: true, description: "תמונת הסטילס." },
audioUrl: { type: 'string', required: true, description: "רצועת האודיו. האורך שלה קובע את אורך הסרטון." },
motion: { type: '"none" | "zoom-in" | "zoom-out" | "pan-left" | "pan-right" | "ken-burns"', default: '"none"', description: "איך המצלמה נעה על פני התמונה." },
intensity: { type: 'number', description: "עוצמת התנועה, מ-1 עד 10." },
resolution: { type: '"720p" | "1080p" | "4K"', description: "רזולוציית הפלט." },
aspectRatio: { type: '"16:9" | "9:16" | "1:1" | "4:3"', description: "יחס הגובה־רוחב של הפלט." },
fps: { type: '24 | 30', description: "קצב הפריימים." },
fit: { type: '"cover" | "contain"', description: "הערך cover חותך את התמונה כדי למלא את הפריים. הערך contain מציג את כולה, עם פסים בצבע של padColor." },
padColor: { type: 'string', description: "צבע הפסים כש-fit הוא contain." },
}}
/>

```ts
const { jobId } = await client.media.stillToVideo({ imageUrl: coverUrl, audioUrl: podcastUrl, motion: "ken-burns" })
```

### media.slideshow(input)‎
הופכת 2–100 תמונות ורצועת אודיו אופציונלית למצגת בקובץ MP4 (`POST /v1/slideshow`), כמו רכיב [מצגת](https://nodaro.ai/docs/nodes/video/slideshow). הקריאה לא עולה קרדיטים. לתמונה אחת, קראו ל-`stillToVideo()`.

```ts
slideshow(input: {
imageUrls: string[]
audioUrl?: string
imageDurations?: Array<number | null>
perImageDuration?: number
transition?: string
transitionDuration?: number
motion?: "none" | "zoom-in" | "zoom-out" | "ken-burns" | "alternate"
intensity?: number
resolution?: "720p" | "1080p" | "4K"
aspectRatio?: "16:9" | "9:16" | "1:1" | "4:3"
fps?: 24 | 30
fit?: "cover" | "contain"
padColor?: string
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
imageUrls: { type: 'string[]', required: true, description: "2–100 תמונות, לפי הסדר." },
audioUrl: { type: 'string', description: "רצועת אודיו. כשיש אותה, הסרטון ארוך בדיוק כמו האודיו." },
imageDurations: { type: 'Array<number | null>', description: "מספר השניות לכל תמונה, באותו סדר. הערך null פירושו אוטומטי. עם אודיו, ערכים קבועים שהסכום שלהם לא מתאים מותאמים באופן יחסי." },
perImageDuration: { type: 'number', description: "מספר השניות לכל תמונה כשאין אודיו." },
transition: { type: 'string', description: "המעבר בין התמונות." },
transitionDuration: { type: 'number', description: "האורך של כל מעבר, בשניות." },
motion: { type: '"none" | "zoom-in" | "zoom-out" | "ken-burns" | "alternate"', description: "איך המצלמה נעה על פני כל תמונה." },
intensity: { type: 'number', description: "עוצמת התנועה." },
resolution: { type: '"720p" | "1080p" | "4K"', description: "רזולוציית הפלט." },
aspectRatio: { type: '"16:9" | "9:16" | "1:1" | "4:3"', description: "יחס הגובה־רוחב של הפלט." },
fps: { type: '24 | 30', description: "קצב הפריימים." },
fit: { type: '"cover" | "contain"', description: "חיתוך כדי למלא את הפריים, או הצגה של כל תמונה במלואה עם פסים." },
padColor: { type: 'string', description: "צבע הפסים." },
}}
/>

```ts
const { jobId } = await client.media.slideshow({ imageUrls: frames, audioUrl: musicUrl, motion: "alternate" })
```

עם אודיו, הזמן מתחלק באופן שווה, אלא אם השדה `imageDurations` קובע אורך קבוע לחלק מהתמונות. בלי אודיו, אורך הסרטון הוא מספר התמונות כפול `perImageDuration`, ואין בסרטון אודיו.

### media.videoOverlay(input)‎
מציבה 1–20 שכבות תמונה מתוזמנות מעל סרטון במעבר אחד (`POST /v1/video-overlay`), כמו רכיב [שכבה על וידאו](https://nodaro.ai/docs/nodes/video/video-overlay). הרצה עולה **22 קרדיטים**, לא משנה כמה שכבות יש ומה האורך. האודיו של הסרטון עצמו נשמר כמו שהוא.

```ts
videoOverlay(input: VideoOverlayRequest): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "סרטון הבסיס." },
layers: { type: 'Array<{ imageUrl, start, end?, ... }>', required: true, description: "1–20 שכבות. לכל שכבה יש imageUrl ו-start, ושדות אופציונליים: end, preset, corner, anchor, x, y, width, height, fit, opacity, animate ו-zIndex." },
outputAspect: { type: '"16:9" | "9:16" | "1:1" | "4:5"', description: "מסגור מחדש של הפלט ליחס הגובה־רוחב הזה. השמיטו אותו כדי לשמור על הגודל ועל קצב הפריימים של הסרטון עצמו." },
baseFit: { type: '"contain" | "cover"', default: '"cover"', description: "איך הסרטון ממלא את הצורה החדשה, עם outputAspect." },
backgroundColor: { type: 'string', default: '"#000000"', description: "צבע הרקע, בפורמט ‎#RRGGBB, עם outputAspect." },
}}
/>

כל שכבה מקבלת `imageUrl` ו-`start`, בשניות מ-0 עד 3,600, ו-`end` אופציונלי. בלי `end`, השכבה נשארת עד סוף הסרטון. הערך של `preset` הוא `card`, `corner-badge` או `full-frame`. שדות התיבה `x` ו-`y` נעים מ-`-100` עד 100, והשדות `width` ו-`height` מ-1 עד 100, כולם באחוזים מפריים הפלט. שדה תיבה שמוגדר במפורש גובר על `preset`. שכבה בלי אף אחד מהם היא תג פינה, למטה מימין או בפינה שמצוינת ב-`corner`. השדה `opacity` נע מ-0 עד 1, השדה `animate` מופעל כברירת מחדל, והשדה `zIndex` נע מ-0 עד 100.

```ts
const { jobId } = await client.media.videoOverlay({
videoUrl,
layers: [
{ imageUrl: logoUrl, start: 0, preset: "corner-badge", corner: "top-right" },
{ imageUrl: offerCardUrl, start: 8, end: 14, preset: "card" },
],
})
```

בפלט של המשימה המוגמרת נמצאים `videoUrl`, `thumbnailUrl`, `width`, `height`, `durationSec` ו-`warnings`. כל אזהרה היא `{ layer?, slot?, code, detail }`, עם קוד כגון `clipped`, `skipped`, `animated_first_frame` או `audio_reencoded`.

### media.imageCollage(input)‎
מסדרת 2–30 תמונות בקולאז׳ אחד גדול, ב-2K או ב-4K (`POST /v1/image-collage`), כמו רכיב [קולאז׳ תמונות](https://nodaro.ai/docs/nodes/image/image-collage).

```ts
imageCollage(input: {
imageUrls: string[]
imageSizes?: Array<0 | 1 | 2 | 3>
numbered?: boolean
imageLabels?: Array<string | null>
badgePosition?: "top-left" | "top-right"
layout?: "smart" | "grid"
resolution?: "2K" | "4K"
aspectRatio?: string
gap?: number
backgroundColor?: string
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
imageUrls: { type: 'string[]', required: true, description: "2–30 תמונות, לפי הסדר." },
layout: { type: '"smart" | "grid"', default: '"smart"', description: "הערך smart בונה שורות לפי יחס הגובה־רוחב של כל תמונה, בלי חיתוך, ולכן הגובה משתנה. הערך grid משתמש בתאים שווים עם פסים." },
imageSizes: { type: 'Array<0 | 1 | 2 | 3>', description: "גדלים יחסיים לפריסה החכמה, באותו סדר כמו imageUrls: 0 אוטומטי, 1 גדול, 2 בינוני, 3 קטן. פריסת הרשת מתעלמת מהם." },
numbered: { type: 'boolean', default: 'false', description: "הטבעת מספר סידורי על כל תמונה, כמו בסטוריבורד." },
imageLabels: { type: 'Array<string | null>', description: "כיתוב לכל תמונה, עד 80 תווים, שמוצג אחרי המספר. הערך null או מחרוזת ריקה פירושם שאין כיתוב." },
badgePosition: { type: '"top-left" | "top-right"', default: '"top-left"', description: "הפינה של המספרים והכיתובים." },
resolution: { type: '"2K" | "4K"', description: "גודל הקולאז׳." },
aspectRatio: { type: 'string', description: "יחס הגובה־רוחב של הקולאז׳." },
gap: { type: 'number', description: "המרווח בין התמונות." },
backgroundColor: { type: 'string', description: "צבע הרקע." },
}}
/>

```ts
const { jobId } = await client.media.imageCollage({
imageUrls: shotUrls,
numbered: true,
imageLabels: ["Wide", "Medium", "Close-up"],
})
```

מספרים וכיתובים אף פעם לא משנים את הפריסה, את גודל הפלט או את המחיר. כיתוב ארוך מדי לתמונה שלו מקוצר, עם שלוש נקודות בסופו.

### media.imageOverlay(input)‎
מציבה 1–12 שכבות על תמונת בסיס, בדיוק ברמת הפיקסל (`POST /v1/image-overlay`), כמו רכיב [שכבת תמונה](https://nodaro.ai/docs/nodes/image/image-overlay). המתודה משלבת שכבות בלי מודל AI. הקריאה עולה **11 קרדיטים**, וכל גודל נוסף של פלטפורמה ב-`variants` מוסיף למחיר.

```ts
imageOverlay(input: {
imageUrl: string
layers: Array<{
kind?: "image" | "text" | "qr" | "shape"
imageUrl?: string
anchor?: "top-left" | "top" | "top-right" | "left" | "center" | "right" | "bottom-left" | "bottom" | "bottom-right"
x?: number
y?: number
width?: number
height?: number
opacity?: number
rotation?: number
blend?: "over" | "multiply" | "screen"
fit?: "contain" | "cover" | "stretch"
shadow?: { blur: number; offsetX: number; offsetY: number; color: string; opacity: number }
roundedCorners?: number
zIndex?: number
text?: OverlayTextStyle
qr?: OverlayQrStyle
shape?: OverlayShapeStyle
effects?: OverlayImageEffects
}>
canvas?: { width: number; height: number; backgroundColor?: string }
baseFit?: "contain" | "cover"
outputFormat?: "png" | "jpg" | "webp"
variants?: string[]
maskMode?: "none" | "layers" | "around" | "outside"
maskSpread?: number
qrText?: string
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
imageUrl: { type: 'string', required: true, description: "תמונת הבסיס." },
layers: { type: 'Array<{ kind?, imageUrl?, anchor?, ... }>', required: true, description: "1–12 שכבות. ראו את שדות השכבה בהמשך." },
canvas: { type: '{ width: number; height: number; backgroundColor?: string }', description: "גודל הפלט. בלי השדה הזה, הפלט שומר על גודל הפיקסלים של תמונת הבסיס." },
baseFit: { type: '"contain" | "cover"', description: "איך תמונת הבסיס ממלאת את הגודל שב-canvas." },
outputFormat: { type: '"png" | "jpg" | "webp"', default: '"png"', description: "פורמט הפלט. הפורמט png שומר על שקיפות." },
variants: { type: 'string[]', description: "גדלים נוספים של פלטפורמות לרינדור באותה הרצה, לפי מזהה הפלטפורמה, כגון youtube-thumbnail. כל אחת מ-12 הפלטפורמות." },
maskMode: { type: '"around" | "layers" | "outside" | "none"', description: "מה פלט המסכה מציג: טבעת סביב השכבות (ברירת המחדל בעורך), את השכבות עצמן, את כל מה שמחוץ לשכבות או שאין מסכה בכלל." },
maskSpread: { type: 'number', description: "רוחב הטבעת, בפיקסלים, כש-maskMode הוא around." },
qrText: { type: 'string', description: "הטקסט לקידוד בשכבת QR שמגדירה את qr.fromInput." },
}}
/>

**שכבות.** לכל שכבה יש אחד מארבעה סוגים:

- **תמונה**: הערך `kind: "image"`, ברירת המחדל, עם `imageUrl`.
- **טקסט אמיתי**: הערך `kind: "text"` עם אובייקט `text`. האובייקט קובע את התוכן, הגופן, העובי, הצבע, היישור, קו המתאר ותיבת הרקע, וגם גודל באחוזים מגובה הבסיס.
- **קוד QR**: הערך `kind: "qr"` עם `qr: { text }`.
- **צורה שטוחה**: הערך `kind: "shape"` עם `shape: { shape, color }`.

**המיקומים הם באחוזים מתמונת הבסיס**, ולכן אותה קריאה עובדת על תצוגה מקדימה ב-1K ועל רינדור ב-4K:

- השדה `anchor` הוא אחד מתשעה מיקומים, `center` כברירת מחדל.
- השדות `x` ו-`y` מזיזים את השכבה מהעוגן, באחוזים מהרוחב ומהגובה של הבסיס. בעוגן ימני או תחתון, ערך שלילי מזיז אותה פנימה.
- השדה `width` הוא באחוזים מרוחב הבסיס, 25 כברירת מחדל. הגובה נקבע לפי יחס הגובה־רוחב של השכבה, אלא אם מגדירים `height`, ואז השדה `fit` קובע איך השכבה ממלאת את התיבה.
- השדה `opacity` נע מ-0 עד 1, השדה `rotation` הוא במעלות סביב מרכז השכבה והשדה `blend` הוא `over` (ברירת המחדל), `multiply` או `screen`.
- השדה `shadow` מוסיף צל רך, השדה `roundedCorners` מעגל את הפינות בפיקסלים והשדה `zIndex` קובע את סדר השכבות.

```ts
const { jobId } = await client.media.imageOverlay({
imageUrl: productShotUrl,
layers: [{ imageUrl: logoUrl, anchor: "bottom-right", x: -4, y: -6, width: 12, opacity: 0.95 }],
variants: ["youtube-thumbnail"],
})
```

בפלט של המשימה המוגמרת נמצאים `imageUrl`, ה-`width` וה-`height` שלה, `maskUrl` ו-`variants`, שכל פריט בו הוא `{ id, label, width, height, url }`. שכבות SVG מצוירות בחדות בגודל היעד. שכבת QR עם `qr.fromInput: true` לוקחת את התוכן שלה מ-`qrText`, והרצה בלי `qrText` נדחית עם שגיאת 400 שמציינת אותו.

### media.videoMask(input)‎
עוקבת אחרי נושא שמתואר בטקסט פשוט לאורך סרטון, ומחזירה סרטון מסכה בשחור־לבן, שבו הנושא לבן (`POST /v1/video-mask`), כמו רכיב [מסכת וידאו](https://nodaro.ai/docs/nodes/video/video-mask).

```ts
videoMask(input: {
videoUrl: string
prompt: string
threshold?: number
provider?: "sam-3.1" | "sam-3"
holdLastMask?: boolean
minSubjectHeightPct?: number
maxSubjectHeightPct?: number
zone?: { leftPct?: number; rightPct?: number; topPct?: number; bottomPct?: number } | null
perShot?: boolean
redetectEverySec?: number
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
videoUrl: { type: 'string', required: true, description: "הסרטון שבו עוקבים אחרי הנושא. עד 5 דקות ב-30 פריימים לשנייה." },
prompt: { type: 'string', required: true, description: "הנושא, עד 500 תווים. הפרידו בין כמה נושאים בפסיקים." },
threshold: { type: 'number', default: '0.5', description: "מ-0.05 עד 0.95. בערך נמוך יותר מתגלים יותר אזורים." },
provider: { type: '"sam-3.1" | "sam-3"', default: '"sam-3.1"', description: "SAM 3 עולה חצי מהמחיר של SAM 3.1." },
holdLastMask: { type: 'boolean', default: 'false', description: "בפריימים שבהם הנושא אובד, חוזרת המסכה האחרונה שלא הייתה ריקה, לשנייה אחת לכל היותר. הפלט כולל אז גם heldFrames." },
minSubjectHeightPct: { type: 'number', default: '0', description: "מ-0 עד 100. משמיט אזורים שהגובה שלהם קטן מהחלק הזה של גובה הפריים. הערך 0 פירושו כבוי. הפלט כולל אז גם droppedRegions." },
maxSubjectHeightPct: { type: 'number', default: '0', description: "מ-0 עד 100. משמיט אזורים שהגובה שלהם גדול מהחלק הזה של גובה הפריים. הערך 0 פירושו כבוי. מינימום גבוה מהמקסימום נדחה עם שגיאת 400." },
zone: { type: '{ leftPct?, rightPct?, topPct?, bottomPct? } | null', default: 'null', description: "שוליים שנחתכים מכל צד, מ-0% עד 49% מהפריים בכל צד. צד שלא צוין מקבל 0. בכל פריים, אזורים שמרכז התיבה התוחמת שלהם נמצא מחוץ למלבן שנשאר מושמטים, אחרי סינון הגודל ולפני holdLastMask. הערך null פירושו כבוי." },
perShot: { type: 'boolean', default: 'false', description: "מוצא קודם את נקודות החיתוך בין הסצנות בקליפ, ומפלח כל שוט בנפרד. הנושא מזוהה מחדש אחרי כל חיתוך, ההגדרה holdLastMask אף פעם לא חוצה חיתוך, והמסכה של כל שוט מותאמת למספר הפריימים המדויק של השוט. בקליפ בלי חיתוך, המסכה זהה, ומותאמת למספר הפריימים המדויק של הקליפ." },
redetectEverySec: { type: 'number', default: '0', description: "הערך 0 פירושו כבוי, ואפשר גם ערך מ-1 עד 10. הקליפ, או כל שוט עם perShot, נשלח למודל בחלקים של עד מספר השניות הזה, כך שמי שנכנס לפריים או מסתובב למצלמה באמצע שוט מקבל מסכה מהחלק הבא ואילך. חלק אחרון של פחות מ-8 פריימים מצטרף לחלק שלפניו. ההגדרה holdLastMask ממשיכה לפעול גם מעבר לגבולות האלה. כל חלק הוא בקשה נוספת, ולכן ההרצה איטית יותר. המחיר לא משתנה." },
}}
/>

המחיר נקבע לפי אורך הקליפ: ב-SAM 3.1 הוא נע מ-55 עד 3,108 קרדיטים. קליפ בקצב של יותר מ-30 פריימים לשנייה נספר כארוך יותר, באופן יחסי. בפלט של המשימה המוגמרת נמצאים `videoUrl` (המסכה), `width`, `height`, `frames`, `chunks`, `shots`, `coverage`, `kept` ו-`recipe`. השדה `chunks` סופר את הבקשות שנשלחו למודל. השדה `shots` הוא 1, אלא אם נמצאו חיתוכים במצב `perShot`. השדה `recipe` הוא הגרסה של שיטת המיסוך, והוא משתנה בכל פעם שאותו קליפ עם אותן הגדרות היה נותן מסכה אחרת. השדה `kept` הוא `false` רק כשמסנני הגודל והאזור לא השאירו כלום באף פריים. בדקו את `kept` ולא את `coverage`, שמעוגל ל-4 ספרות אחרי הנקודה.

### media.maskComposite(input)‎
מציבה סרטון חזית מעל סרטון רקע דרך סרטון מסכה (`POST /v1/mask-composite`), כמו רכיב [שילוב לפי מסכה](https://nodaro.ai/docs/nodes/video/mask-composite). לבן במסכה מציג את החזית. עם `fill: "pixelate"` או `"blur"`, האזור הממוסך מציג את הרקע עצמו, מפוקסל או מטושטש, ולא צריך חזית.

```ts
maskComposite(input: {
foregroundUrl?: string
backgroundUrl: string
maskUrl: string
invertMask?: boolean
feather?: number
grow?: number
opacity?: number
fill?: "video" | "pixelate" | "blur"
pixelSizePct?: number
blurStrengthPct?: number
backgroundFit?: "cover" | "contain" | "stretch"
audioSource?: "foreground" | "background" | "mix" | "none"
}): Promise<{ jobId: string }>
```

<TypeTable
type={{
foregroundUrl: { type: 'string', description: "הסרטון העליון. נדרש כש-fill הוא video, ברירת המחדל. עד 300 שניות. הגודל, קצב הפריימים והאורך של התוצאה נקבעים לפיו. לא נלקח בחשבון עם pixelate או blur." },
backgroundUrl: { type: 'string', required: true, description: "הסרטון שמאחור. הוא מותאם לחזית, ורץ בלולאה כשהוא קצר ממנה." },
maskUrl: { type: 'string', required: true, description: "סרטון המסכה. לבן מציג את החזית. כשהמסכה קצרה יותר, היא קופאת על הפריים האחרון שלה." },
invertMask: { type: 'boolean', default: 'false', description: "החלפה של מה שהמסכה מציגה." },
feather: { type: 'number', default: '2', description: "ריכוך קצה המסכה, מ-0 עד 20 פיקסלים." },
grow: { type: 'number', default: '0', description: "הרחבה או כיווץ של המסכה, בין ‎-10 ל-10 פיקסלים." },
opacity: { type: 'number', default: '1', description: "עד כמה המילוי מכסה את הרקע בתוך המסכה, מ-0 עד 1." },
fill: { type: '"video" | "pixelate" | "blur"', default: '"video"', description: "מה ממלא את המסכה. הערכים pixelate ו-blur משתמשים ברקע עצמו. אז התוצאה נקבעת לפי הרקע, גם המחיר מחושב לפיו, והתוצאה שומרת על האודיו של הרקע." },
pixelSizePct: { type: 'number', default: '1.25', description: "עם pixelate בלבד. גודל הריבוע, מ-0.2% עד 10% מהרוחב." },
blurStrengthPct: { type: 'number', default: '2', description: "עם blur בלבד. עוצמת הטשטוש, מ-0.1% עד 5% מהרוחב." },
backgroundFit: { type: '"cover" | "contain" | "stretch"', default: '"cover"', description: "איך הרקע ממלא את הפריים." },
audioSource: { type: '"foreground" | "background" | "mix" | "none"', default: '"foreground"', description: "איזה אודיו התוצאה שומרת. לא נלקח בחשבון עם pixelate או blur." },
}}
/>

הרצה עולה 11 קרדיטים לכל 5 שניות של התוצאה, או חלק מהן.

```ts
const { jobId: maskJobId } = await client.media.videoMask({ videoUrl: talkUrl, prompt: "the person" })
// Poll the mask job. Its output videoUrl is the mask.
const { jobId } = await client.media.maskComposite({ foregroundUrl: talkUrl, backgroundUrl: beachUrl, maskUrl })
```

### media.suggestOverlayPlacement(input)‎
שואלת מודל ראייה **איפה** למקם שכבה אחת על תמונת בסיס (`POST /v1/image-overlay/suggest-placement`). המודל מרחיק את השכבה מפנים, מהנושא המרכזי ומטקסטורות עמוסות. התשובה מגיעה ישירות, בלי משימה שצריך לבדוק את הסטטוס שלה, והקריאה מתומחרת כמו קריאה אחת של רכיב תיאור תמונה. שום שכבה לא משולבת בתמונה: את המיקום מחילים בעצמכם.

```ts
suggestOverlayPlacement(input: {
imageUrl: string
layerAspect?: number
intent?: string
safeArea?: { x: number; y: number; w: number; h: number }
llmModel?: string
}): Promise<{ jobId: string; placement: OverlayPlacement }>
```

<TypeTable
type={{
imageUrl: { type: 'string', required: true, description: "תמונת הבסיס." },
layerAspect: { type: 'number', default: '1', description: "הרוחב של השכבה חלקי הגובה שלה, כדי שהתיבה המוצעת תשמור על הפרופורציות שלה." },
intent: { type: 'string', description: "מה יש בשכבה, כגון לוגו או תג מחיר." },
safeArea: { type: '{ x, y, w, h }', description: "האזור שתמיד נראה, כשברים מגודל התמונה. המיקום נשאר בתוכו." },
llmModel: { type: 'string', description: "מודל הראייה שבו להשתמש." },
}}
/>

```ts
const { placement } = await client.media.suggestOverlayPlacement({
imageUrl: baseUrl,
intent: "a logo",
layerAspect: 2.5,
})
const { reason, ...box } = placement // anchor, x, y and width, in imageOverlay's units
await client.media.imageOverlay({ imageUrl: baseUrl, layers: [{ imageUrl: logoUrl, ...box }] })
```

ב-`placement` נמצאים `anchor`, `x`, `y` ו-`width`, ביחידות האחוזים של `imageOverlay`, ובנוסף `reason`, משפט אחד שאפשר להציג למשתמש. השדה `jobId` הוא רשומת החיוב.

## שאלות נפוצות

### איך מעלים קובץ עם ה-SDK של Nodaro?

קראו ל-client.uploads.upload(file)‎ עם אובייקט File. המתודה מחזירה כתובת ציבורית, ואפשר להעביר אותה לכל רכיב שמקבל כתובת של תמונה, של סרטון או של אודיו.

### איך מוסיפים כתוביות לסרטון מקוד?

קראו ל-client.media.addCaptions עם videoUrl ועם style. כשאין text ואין captions, Nodaro מתמלל את הדיבור. הקריאה מחזירה jobId, ובמשימה המוגמרת נמצא הסרטון עם הכתוביות.

### אילו מתודות מדיה לא עולות קרדיטים?

המתודות stillToVideo ו-slideshow מרנדרות בלי מודל AI ולא עולות קרדיטים, וגם המתודה media.process לא עולה כלום. המתודה videoOverlay עולה 22 קרדיטים להרצה, והמתודה imageOverlay עולה 11 קרדיטים, וכל גודל נוסף של פלטפורמה מוסיף למחיר שלה.

### איך מסמנים יצירה כמועדפת מקוד?

קראו ל-client.gallery.toggleFavorite(jobId)‎. המתודה מסמנת את המשימה כמועדפת, או מסירה את הסימון כשהמשימה כבר מועדפת. את המועדפים שלכם מקבלים עם client.gallery.favorites()‎ או עם client.jobs.list({ favorited: true })‎.

### איך בודקים את ההתקדמות של הורדת סרטון?

המתודה client.media.downloadVideo מחזירה downloadId. עברו בלולאה על client.media.downloadVideoProgress(downloadId)‎ כדי לקבל את השלב ואת האחוז, עד שההורדה מסתיימת או נכשלת.
