# How Complex Conversion Paths Work in Convert: Image to Audio and Beyond

> Discover how Convert manages complex conversion paths like image to audio using a weighted directed graph and Dijkstra's algorithm to find optimal routes and avoid lossy transitions.

- Repository: [p2r3/convert](https://github.com/p2r3/convert)
- Tags: internals
- Published: 2026-02-19

---

**Convert manages complex conversion paths like image to audio by modeling formats as nodes and handlers as edges in a weighted directed graph, then running Dijkstra's algorithm to find the optimal multi-step route while penalizing lossy or illogical category transitions.**

The open-source project **p2r3/convert** automatically routes files through multi-step conversion pipelines without manual configuration. When direct conversion isn't available—such as converting a PNG image directly to an MP3 audio file—the system constructs **complex conversion paths** by chaining compatible format handlers. According to the Convert source code, this routing is handled by a graph traversal engine that balances conversion quality against path length.

## Building the Conversion Graph in [`TraversionGraph.ts`](https://github.com/p2r3/convert/blob/main/TraversionGraph.ts)

### Node and Edge Initialization

The `TraversionGraph` class in [`src/TraversionGraph.ts`](https://github.com/p2r3/convert/blob/main/src/TraversionGraph.ts) (lines 37-78) constructs the routing graph during initialization. For every registered `FormatHandler`, the system caches supported input (`from`) and output (`to`) formats in `window.supportedFormatCache`. The `init` method creates one node per unique MIME type and directed edges for every possible conversion a handler can perform.

```typescript
// src/TraversionGraph.ts – graph creation (simplified)
this.handlers = handlers;
...
supportedFormatCache.forEach((formats, handler) => {
  // …build nodes…
  fromIndices.forEach(from => {
    toIndices.forEach(to => {
      if (from.index === to.index) return;          // no self‑loops
      this.edges.push({
        from,
        to,
        handler,
        cost: this.costFunction(...),               // ← weight
      });
      this.nodes[from.index].edges.push(this.edges.length - 1);
    });
  });
});

```

Each edge stores a computed weight that reflects the "cost" of that conversion step, ensuring the pathfinder prefers efficient, high-quality routes.

### Edge Weight Calculation Factors

The `costFunction` (lines 88-140) calculates edge weights using several factors to ensure sensible routing:

| Factor | Implementation | Impact |
|--------|---------------|---------|
| **Base step cost** | `DEPTH_COST` constant | Guarantees every hop adds positive cost, favoring shorter paths |
| **Category-change cost** | `categoryChangeCosts` map (e.g., image→audio = 1.4) or `DEFAULT_CATEGORY_CHANGE_COST` | Penalizes conversions between unrelated media types; if `strictCategories` is true, sums intermediate category costs |
| **Handler priority** | `HANDLER_PRIORITY_COST * handlerIndex` | Slightly favors handlers registered earlier in the list |
| **Format priority** | `FORMAT_PRIORITY_COST * indexInSupportedFormats` | Slightly favors formats listed earlier in a handler's `supportedFormats` array |
| **Lossy multiplier** | `LOSSY_COST_MULTIPLIER` (1.4) applied if `!to.format.lossless` | Penalizes routes ending in lossy formats |

```typescript
// src/TraversionGraph.ts – cost calculation logic (lines 88-140)
let cost = DEPTH_COST;
...
// category change handling (default & custom)
if (strictCategories) { 
  // sum all intermediate category costs
} else if (!fromCategories.some(c => toCategories.includes(c))) { 
  // apply DEFAULT_CATEGORY_CHANGE_COST
}
...
cost += HANDLER_PRIORITY_COST * handlerIndex;
cost += FORMAT_PRIORITY_COST * (handlerObj?.supportedFormats?.findIndex(f => f.mime === to.format.mime) ?? 0);
if (!to.format.lossless) cost *= LOSSY_COST_MULTIPLIER;

```

## Penalizing Suboptimal Routes with Adaptive Costs

Some conversion sequences are particularly undesirable—such as image→video→audio when a direct image→audio route exists. The `calculateAdaptiveCost` method (lines 71-84) scans candidate paths for defined forbidden sequences in `categoryAdaptiveCosts` and adds massive penalty costs when detected.

```typescript
// src/TraversionGraph.ts – adaptive cost application
this.categoryAdaptiveCosts.forEach(c => {
  // look for the sequence of categories in the path
  if (found) cost += c.cost;  // c.cost is typically huge to force avoidance
});

```

This ensures Dijkstra's algorithm prunes nonsensical routes during the search phase, even if they technically connect the source and target formats.

## Dead-End Detection and Dynamic Path Pruning

If an intermediate conversion step fails at runtime, Convert marks the partial path as a dead end to prevent future attempts. In [`src/main.ts`](https://github.com/p2r3/convert/blob/main/src/main.ts) (lines 65-71), the `attemptConvertPath` function catches errors and registers the failing path segment:

```typescript
// src/main.ts – dead‑end handling
catch (e) {
  const deadEndPath = path.slice(0, i + 2);
  deadEndAttempts.push(deadEndPath);
  window.traversionGraph.addDeadEndPath(deadEndPath);
  …
}

```

The graph treats these dead-end paths as having infinite cost in subsequent searches via `calculateAdaptiveCost`, forcing the router to explore alternative sequences automatically.

## Executing the Search with `searchPath`

The `searchPath` method (lines 283-317) implements a priority-queue-backed Dijkstra search:

1. Start from the input format node
2. Pop the lowest-cost node from the queue
3. Expand all outgoing edges, accumulating costs including adaptive penalties
4. Yield the path when reaching the target MIME type

```typescript
// src/TraversionGraph.ts – Dijkstra main loop (lines 283-317)
while (queue.size() > 0) {
  const current = queue.poll()!;
  if (current.index === toIndex) {
    // safety checks & simple‑mode handling …
    yield current.path;
    continue;
  }
  visited.push(current.index);
  this.nodes[current.index].edges.forEach(edgeIndex => {
    const edge = this.edges[edgeIndex];
    // skip visited, missing handler, …
    queue.add({
      index: edge.to.index,
      cost: current.cost + edge.cost + this.calculateAdaptiveCost(path),
      path,
      visitedBorder: visited.length
    });
  });
}

```

## Complete Example: Image to Audio Conversion

The following demonstrates converting a PNG image to MP3 audio using the graph's automatic path discovery:

```typescript
// Example: convert an image (PNG) → audio (MP3) in the browser console
const imgFile = await (await fetch('sample.png')).blob();
const imgBytes = new Uint8Array(await imgFile.arrayBuffer());

// Build format nodes for the source and target
const inputOption = window.traversionGraph.handlers
  .flatMap(h => h.supportedFormats?.filter(f => f.mime.startsWith('image/')) ?? [])
  .find(f => f.extension === 'png');

const outputOption = window.traversionGraph.handlers
  .flatMap(h => h.supportedFormats?.filter(f => f.mime.startsWith('audio/')) ?? [])
  .find(f => f.extension === 'mp3');

if (!inputOption || !outputOption) throw 'Formats not supported';

const inputNode = new ConvertPathNode(
  window.traversionGraph.handlers.find(h => h.supportedFormats?.includes(inputOption))!,
  inputOption
);
const outputNode = new ConvertPathNode(
  window.traversionGraph.handlers.find(h => h.supportedFormats?.includes(outputOption))!,
  outputOption
);

const result = await window.tryConvertByTraversing(
  [{ name: 'sample.png', bytes: imgBytes }],
  inputNode,
  outputNode
);

if (result) {
  console.log('Conversion succeeded via path:',
    result.path.map(p => `${p.handler.name} → ${p.format.format}`).join(' → '));
  // `result.files[0].bytes` now holds the MP3 data
}

```

The graph automatically discovers a multi-hop route such as **image→video→audio** (or a direct image-to-audio handler if available). The category-change and adaptive costs ensure that highly lossy or indirect routes receive severe penalties, while dead-end detection allows the system to recover and find alternative paths if intermediate steps fail.

## Summary

- **Graph Model**: Convert builds a weighted directed graph where nodes are MIME types and edges are handler capabilities, defined in [`src/TraversionGraph.ts`](https://github.com/p2r3/convert/blob/main/src/TraversionGraph.ts).
- **Intelligent Routing**: Dijkstra's algorithm in `searchPath` finds the lowest-cost route, balancing path length against quality penalties.
- **Quality Preservation**: The `costFunction` penalizes category changes (e.g., image→audio), lossy output formats, and undesirable handler orders using multipliers like `LOSSY_COST_MULTIPLIER` (1.4).
- **Runtime Adaptation**: The `calculateAdaptiveCost` mechanism and dead-end tracking in [`src/main.ts`](https://github.com/p2r3/convert/blob/main/src/main.ts) prevent the system from retrying failed or suboptimal paths.

## Frequently Asked Questions

### How does Convert handle conversions between unrelated format categories?

Convert applies a **category-change cost** via the `costFunction` in [`src/TraversionGraph.ts`](https://github.com/p2r3/convert/blob/main/src/TraversionGraph.ts). When converting between categories like image and audio, the system either uses a specific cost from `categoryChangeCosts` (set to 1.4 for image→audio) or falls back to `DEFAULT_CATEGORY_CHANGE_COST`. If `strictCategories` is enabled, it sums the costs of every intermediate category crossed, making direct routes significantly cheaper than circuitous ones.

### What happens if an intermediate conversion step fails during execution?

When a step fails, the `attemptConvertPath` function in [`src/main.ts`](https://github.com/p2r3/convert/blob/main/src/main.ts) (lines 65-71) catches the error and registers the partial path as a dead end via `window.traversionGraph.addDeadEndPath`. Subsequent searches treat this path as having infinite cost, forcing the graph to explore alternative routes automatically without user intervention.

### Can the conversion path prioritize lossless formats?

Yes. The `costFunction` checks the `lossless` property of the target format. If `!to.format.lossless`, it multiplies the total edge cost by `LOSSY_COST_MULTIPLIER` (1.4). This penalty ensures that routes preserving lossless formats receive lower total costs, causing Dijkstra's algorithm to prefer them unless no lossless path exists.

### Where is the conversion graph stored and accessed in the browser?

The graph instance is stored globally at `window.traversionGraph` and initialized during application startup. It caches supported formats in `window.supportedFormatCache` and exposes the primary conversion entry point as `window.tryConvertByTraversing`, allowing console access for debugging and programmatic conversion as shown in the image-to-audio example.