Ctrl K
ComunidadGuías…

packages/ai/src/media.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
export * as Media from "./media.js"

import { Effect, FileSystem, Result, Schema, SchemaGetter } from "effect"
import { Base64 } from "effect/encoding"
import { HttpClientRequest } from "effect/http"
import { ProviderID } from "./schema/ids.js"
import { AIError, InvalidProviderOutputError, InvalidRequestError } from "./schema/errors.js"
import { ProviderMetadata } from "./schema/options.js"
import { responseHttp, Service } from "./route/executor-service.js"
import { detectMediaType, fileMediaType } from "./utils/media-type.js"

export { detectMediaType } from "./utils/media-type.js"

const OCTET_STREAM = "application/octet-stream"

// ---------------------------------------------------------------------------
// Source — the serializable wire/persistence form of a media asset
// ---------------------------------------------------------------------------

const BytesSource = Schema.Struct({
  type: Schema.Literal("bytes"),
  data: Schema.Uint8Array,
  mediaType: Schema.String,
})

const Base64Source = Schema.Struct({
  type: Schema.Literal("base64"),
  data: Schema.String,
  mediaType: Schema.String,
})

const UrlSource = Schema.Struct({
  type: Schema.Literal("url"),
  url: Schema.String,
  mediaType: Schema.optional(Schema.String),
  /** Epoch milliseconds after which the provider no longer serves the URL. */
  expiresAt: Schema.optional(Schema.Number),
})

/** A provider-side handle: OpenAI `file_id`, Gemini file URI, `gs://`, `runway://`, or a prior generation id. */
const RefSource = Schema.Struct({
  type: Schema.Literal("ref"),
  provider: ProviderID,
  id: Schema.String,
  mediaType: Schema.optional(Schema.String),
})

export const Source = Schema.Union([BytesSource, Base64Source, UrlSource, RefSource])
  .pipe(Schema.toTaggedUnion("type"))
  .annotate({ identifier: "Media.Source" })
export type Source = Schema.Schema.Type<typeof Source>

// ---------------------------------------------------------------------------
// Kind, Info
// ---------------------------------------------------------------------------

export type AspectRatio = `${number}:${number}`
export const AspectRatio = Schema.declare<AspectRatio>(
  (value): value is AspectRatio => typeof value === "string" && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value),
  { title: "Media.AspectRatio" },
)

export const Kind = Schema.Literals(["image", "video", "audio", "document", "other"])
export type Kind = Schema.Schema.Type<typeof Kind>

export const kindOf = (mediaType: string): Kind => {
  const lower = mediaType.toLowerCase()
  if (lower.startsWith("image/")) return "image"
  if (lower.startsWith("video/")) return "video"
  if (lower.startsWith("audio/")) return "audio"
  if (lower === "application/pdf" || lower.startsWith("text/")) return "document"
  return "other"
}

/** Container-independent facts about the payload; raw PCM audio relies on these because it has no header. */
export const Info = Schema.Struct({
  width: Schema.optional(Schema.Number),
  height: Schema.optional(Schema.Number),
  durationSeconds: Schema.optional(Schema.Number),
  sampleRate: Schema.optional(Schema.Number),
  channels: Schema.optional(Schema.Number),
  encoding: Schema.optional(Schema.String),
  format: Schema.optional(Schema.String),
}).annotate({ identifier: "Media.Info" })
export type Info = Schema.Schema.Type<typeof Info>

// ---------------------------------------------------------------------------
// Asset
// ---------------------------------------------------------------------------

const invalid = (message: string, cause?: unknown) =>
  new AIError({ reason: new InvalidRequestError({ message, cause }) })

/** Synchronous view of an inline payload; `undefined` for `url` and `ref` sources, which carry no local bytes. */
export interface Inline {
  readonly mime: string
  readonly base64: string
  readonly dataUrl: string
}

export class Asset {
  readonly source: Source
  /** Derived from the source: declared type, sniffed magic bytes, then `application/octet-stream`. */
  readonly mediaType: string
  readonly kind: Kind
  readonly info?: Info
  /** Epoch milliseconds after which a `url` source stops resolving. */
  readonly expiresAt?: number
  readonly providerMetadata?: ProviderMetadata
  /** Transient download credentials for `url` sources; see `Asset.Input.headers`. */
  readonly headers?: Record<string, string>

  // Derived payload forms are cached on the instance because every protocol lowering re-reads the same payload. The
  // cache is check-then-set (concurrent first reads of a `url` source may both download) and is never observable
  // through `source`, so round-tripping through `Media.from(asset.source)` stays lossless.
  #bytes: Uint8Array | undefined
  #base64: string | undefined

  constructor(input: Asset.Input) {
    this.source = input.source
    this.mediaType =
      input.source.mediaType ??
      (input.source.type === "bytes" ? detectMediaType(input.source.data) : undefined) ??
      OCTET_STREAM
    this.kind = kindOf(this.mediaType)
    this.info = input.info
    this.expiresAt = input.source.type === "url" ? input.source.expiresAt : undefined
    this.providerMetadata = input.providerMetadata
    this.headers = input.source.type === "url" ? input.headers : undefined
  }

  /** Inline payload without effects, for protocols that embed base64 or data URLs directly. */
  inline(): Inline | undefined {
    const source = this.source
    if (source.type !== "bytes" && source.type !== "base64") return undefined
    const base64 = source.type === "base64" ? source.data : (this.#base64 ??= Base64.encode(source.data))
    const mime = this.mediaType.toLowerCase()
    return { mime, base64, dataUrl: `data:${mime};base64,${base64}` }
  }

  /** Decoded payload; downloads `url` sources through the request executor and caches the result. */
  bytes(): Effect.Effect<Uint8Array, AIError, Service> {
    return Effect.suspend(() => {
      const source = this.source
      if (source.type === "bytes") return Effect.succeed(source.data)
      if (this.#bytes !== undefined) return Effect.succeed(this.#bytes)
      if (source.type === "ref")
        return Effect.fail(invalid(`Cannot materialize provider ref ${source.provider}:${source.id}`))
      const decoded =
        source.type === "base64"
          ? Effect.fromResult(Base64.decode(source.data)).pipe(
              Effect.mapError((cause) => invalid(`Media asset contains invalid base64 data`, cause)),
            )
          : download(source, this.headers)
      return decoded.pipe(Effect.tap((data) => Effect.sync(() => (this.#bytes = data))))
    })
  }

  base64(): Effect.Effect<string, AIError, Service> {
    return Effect.suspend(() => {
      const source = this.source
      if (source.type === "base64") return Effect.succeed(source.data)
      if (this.#base64 !== undefined) return Effect.succeed(this.#base64)
      return this.bytes().pipe(Effect.map((data) => (this.#base64 = Base64.encode(data))))
    })
  }

  dataUrl(): Effect.Effect<string, AIError, Service> {
    return this.base64().pipe(Effect.map((data) => `data:${this.mediaType};base64,${data}`))
  }

  /**
   * The `AssetEncoded` JSON form with `bytes` sources as base64, matching `Schema.toCodecJson(AssetSchema)`, so a
   * plain `JSON.stringify` of messages or events stays lossless and decodes back through the JSON codec.
   */
  toJSON() {
    const source = this.source
    return {
      source: source.type === "bytes" ? { ...source, data: Base64.encode(source.data) } : source,
      info: this.info,
      providerMetadata: this.providerMetadata,
    }
  }

  /** Pull `url` sources into owned bytes before the URL expires. Inline sources return themselves. */
  materialize(): Effect.Effect<Asset, AIError, Service> {
    if (this.source.type === "bytes" || this.source.type === "base64") return Effect.succeed(this)
    return this.bytes().pipe(
      Effect.map((data) =>
        bytes(data, this.source.mediaType, { info: this.info, providerMetadata: this.providerMetadata }),
      ),
    )
  }
}

export namespace Asset {
  export interface Input {
    readonly source: Source
    readonly info?: Info
    readonly providerMetadata?: ProviderMetadata
    /**
     * Headers required to download a `url` source, such as the provider API key Veo demands for its file URIs.
     * They are runtime-only: never part of `source`, `toJSON()`, or `AssetSchema`, so a persisted asset cannot leak
     * credentials and cannot be downloaded again after a round-trip. Call `materialize()` before persisting.
     */
    readonly headers?: Record<string, string>
  }
}

/** JSON form of an asset: the serializable `Source` plus caller-supplied metadata. `bytes` sources encode as base64. */
export const AssetEncoded = Schema.Struct({
  source: Source,
  info: Schema.optional(Info),
  providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "Media.AssetEncoded" })

const encodeAsset = (asset: Asset): typeof AssetEncoded.Type => ({
  source: asset.source,
  info: asset.info,
  providerMetadata: asset.providerMetadata,
})

const AssetInstance = Schema.declare((value): value is Asset => value instanceof Asset, {
  expected: "Media.Asset",
})

/** `Asset` in the type domain and `AssetEncoded` on the wire, so messages and events holding assets serialize. */
export const AssetSchema = AssetEncoded.pipe(
  Schema.decodeTo(AssetInstance, {
    decode: SchemaGetter.transform((encoded) => new Asset(encoded)),
    encode: SchemaGetter.transform(encodeAsset),
  }),
)

const download = Effect.fn("Media.download")(function* (
  source: Extract<Source, { readonly type: "url" }>,
  headers: Record<string, string> | undefined,
) {
  const executor = yield* Service
  const response = yield* executor.execute(
    HttpClientRequest.get(source.url).pipe(HttpClientRequest.setHeaders(headers ?? {})),
  )
  const buffer = yield* response.arrayBuffer.pipe(
    Effect.mapError(
      (cause) =>
        new AIError({
          reason: new InvalidProviderOutputError({
            message: `Failed to read media from ${source.url}`,
            http: responseHttp(response),
            cause,
          }),
        }),
    ),
  )
  return new Uint8Array(buffer)
})

// ---------------------------------------------------------------------------
// Constructors
// ---------------------------------------------------------------------------

export type AssetOptions = Omit<Asset.Input, "source">

export const from = (source: Source, options?: AssetOptions) => new Asset({ ...options, source })

export const bytes = (data: Uint8Array, mediaType?: string, options?: AssetOptions) =>
  from({ type: "bytes", data, mediaType: mediaType ?? detectMediaType(data) ?? OCTET_STREAM }, options)

export const base64 = (data: string, mediaType: string, options?: AssetOptions) =>
  from({ type: "base64", data, mediaType }, options)

export const url = (
  value: string,
  options?: AssetOptions & Omit<Extract<Source, { readonly type: "url" }>, "type" | "url">,
) => {
  const { mediaType, expiresAt, ...rest } = options ?? {}
  return from({ type: "url", url: value, mediaType, expiresAt }, rest)
}

export const ref = (provider: string | ProviderID, id: string, mediaType?: string, options?: AssetOptions) =>
  from({ type: "ref", provider: ProviderID.make(provider), id, mediaType }, options)

const DATA_URL = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/is

/** Parse a `data:<mime>;base64,<data>` URL, or `undefined` when the value is not a base64 data URL. */
export const parseDataUrl = (value: string, options?: AssetOptions) => {
  const match = DATA_URL.exec(value)
  return match === null ? undefined : base64(match[2], match[1], options)
}

/** Parse a `data:<mime>;base64,<data>` URL. Malformed input throws a typed `AIError` because constructors are sync. */
export const fromDataUrl = (dataUrl: string, options?: AssetOptions) => {
  const asset = parseDataUrl(dataUrl, options)
  if (asset === undefined) throw invalid("Media data URLs must contain a MIME type and base64 data")
  return asset
}

/** Plain file data: bytes, a URL, or a string holding a data URL, an http(s) URL or base64. */
export type Data = Uint8Array | ArrayBuffer | URL | string

/** What file inputs accept: an `Asset` or plain `Data`. */
export type Input = Asset | Data

/**
 * Normalize file input into an `Asset`. Data URLs carry their media type and bytes or base64 are sniffed from magic
 * bytes; `mediaType` is required when sniffing fails. Assets pass through unchanged.
 */
export const make = (input: Input, mediaType?: string): Asset => {
  if (input instanceof Asset) return input
  if (input instanceof ArrayBuffer) return make(new Uint8Array(input), mediaType)
  if (input instanceof Uint8Array) return bytes(input, mediaType ?? requireMediaType(detectMediaType(input)))
  const value = input instanceof URL ? input.href : input
  if (/^data:/i.test(value)) {
    const match = DATA_URL.exec(value)
    if (match === null) throw invalid("Media data URLs must contain a MIME type and base64 data")
    return base64(match[2], mediaType ?? match[1])
  }
  if (input instanceof URL || /^https?:\/\//i.test(value)) return url(value, { mediaType })
  if (!BASE64.test(value))
    throw invalid("Media strings must be base64, a data URL or an http(s) URL; read files with Media.file")
  return base64(value, mediaType ?? requireMediaType(sniffBase64(value)))
}

const BASE64 = /^[A-Za-z0-9+/\-_\s]*={0,2}\s*$/

// 64 characters decode to 48 bytes, enough for every signature `detectMediaType` reads.
const sniffBase64 = (value: string) => {
  const head = Result.getOrUndefined(Base64.decode(value.slice(0, 64)))
  return head === undefined ? undefined : detectMediaType(head)
}

const requireMediaType = (detected: string | undefined) => {
  if (detected === undefined) throw invalid("Media data needs a mediaType because its type cannot be detected")
  return detected
}

/** Read a file through `FileSystem` and sniff its media type from magic bytes, then the extension. */
export const file = (path: string, options?: AssetOptions): Effect.Effect<Asset, AIError, FileSystem.FileSystem> =>
  Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const data = yield* fs
      .readFile(path)
      .pipe(Effect.mapError((cause) => invalid(`Failed to read media file ${path}`, cause)))
    return bytes(data, fileMediaType(data, path), options)
  })

/** Materialize an asset and write its bytes through `FileSystem`. */
export const write = (asset: Asset, path: string): Effect.Effect<void, AIError, FileSystem.FileSystem | Service> =>
  Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const data = yield* asset.bytes()
    yield* fs
      .writeFile(path, data)
      .pipe(Effect.mapError((cause) => invalid(`Failed to write media file ${path}`, cause)))
  })