Skip to content

Add images and audio to Anki cards

Package images and audio into generated Anki cards, such as vocabulary cards with pronunciation audio or diagram questions. Anki Forge copies the files you provide into the APKG; creating the audio or images, for example with text-to-speech, requires a separate tool. The examples use Rust; batch import below also shows the Python and Node.js calls.

A Media value owns an immutable snapshot. Import files with Media::file or native bytes with an explicit MIME type via Media::bytes. After successful import, modifying or deleting the source file cannot change the asset. Clones share storage and can be used across projects.

From the repository root:

Terminal window
cargo run --locked -p ankiforge --example docs_workflow -- target/docs-examples
cargo run --locked -p ankiforge --example target_api_media

The workflow uses repository PNG/WAV fixtures and writes media.apkg with one note, one card and three assets. The second example generates its own tiny image and audio, includes CSS-linked and explicit media, and writes spanish-media.apkg.

fn media(output: &Path, image: &Path, sound: &Path) -> anyhow::Result<()> {
let image = Media::file(image)?.with_export_name("cell.png")?;
let sound = Media::file(sound)?;
let mut project = Project::new("docs-media")?;
project.add("cell", Note::basic(image.image(), sound.sound()))?;
let css = Media::bytes(b".card { color: navy; }".to_vec(), "text/css")?
.with_export_name("course.css")?;
project.add_asset(css)?;
let built = project.build(BuildOptions::to(output.join("media.apkg")))?;
verify(&built, 1, 1, 3)?;
Ok(())
}

This complete program uses fixtures/pixel.png, fixtures/silence.wav, and fixtures/labels.woff. In your application, pass your own source paths. The fixture font is the repository’s redistributable test font.

use ankiforge::{BuildOptions, Content, Field, Media, NoteType, Project, Template};
fn main() -> anyhow::Result<()> {
let image = Media::file("fixtures/pixel.png")?.with_export_name("badge.png")?;
let sound = Media::file("fixtures/silence.wav")?;
let font = Media::file("fixtures/labels.woff")?.with_export_name("labels.woff")?;
let model = NoteType::builder("media-card")
.field(Field::new("front"))
.field(Field::new("back"))
.template(Template::new("card")
.front(r#"{{front}}<img src="badge.png">"#)
.back("{{FrontSide}}<hr>{{back}}"))
.css("@font-face { font-family: labels; src: url('labels.woff'); } .card { font-family: labels; }")
.asset(font)
.asset(image.clone())
.build()?;
let mut project = Project::new("media-guide")?;
project.add("cell", model.note()
.field("front", Content::sequence([Content::text("Cell "), image.image()]))
.field("back", sound.sound()))?;
let output = project.build(BuildOptions::to("media-guide.apkg"))?;
assert_eq!(output.report().counts().media, 3);
Ok(())
}

Typed image() and sound() values retain dependencies until export. Manual HTML/CSS/script references need builder.asset(media) or project.add_asset(media); the library does not infer file contents from arbitrary strings. An explicit asset is included even without a statically detectable reference.

Pass the literal filename to with_export_name. Typed sound references escape HTML entities when rendered, so a name such as tone&copy;.mp3 still refers to that exact asset. Do not pre-escape the filename. Percent signs and fragment characters in sound filenames remain literal; they are not URL paths.

Default export names are content-derived and independent of the source path. Use with_export_name before creating references when a fixed filename is needed. Renaming a clone does not rename earlier clones or content nodes.

Names cannot contain paths, controls or nonportable reserved names. Conflict checks use Unicode NFC plus case folding: case-only or normalization-only variants also conflict. Exactly identical name/content pairs are idempotent; different contents under one name fail without partial registration.

The complete export filename, including its extension, is limited to 255 UTF-8 bytes. Project and template-bundle JSON Schemas check filename syntax and character count; the loaders additionally check this byte budget. For example, 128 repetitions of é pass the schema’s character limit but occupy 256 bytes and return MEDIA.EXPORT_NAME_INVALID when loaded. Use tools::load_project or NoteType::from_bundle to validate the complete input before building.

MIME syntax and identifiable content must agree. Native bytes have no 64 KiB inline restriction. The default import budget is 256 MiB per asset; file_with_limits and bytes_with_limits accept MediaLimits { max_bytes } before reading. Large snapshots may spill to temporary storage, cleaned up when the last owner disappears. Snapshot ownership does not promise an atomic filesystem view of a source being modified concurrently during import.

Project JSON applies the same default per-asset budget while decoding an inline bytes array. It stops at the first excess byte without collecting the rest of the array, and retains a MediaError with the limit and observed byte count. The count at rejection is a lower bound; it is not the unread array’s total size.

A valid media package does not prove codec playback on every Anki client. Test images, sound and fonts on your target clients. See troubleshooting.

Constructors stay infallible. Project::add checks the retained import MIME: images require image/*, sound references accept audio/* and video/*. application/octet-stream, text, fonts and PDF fail typed usage with NOTE.MEDIA_USAGE_INVALID; register them as explicit assets for raw HTML/CSS. Valid explicit unknown image/video MIME declarations pass category validation, without certifying that a decoder or player supports the bytes. Renaming a file never changes retained MIME. Build still sniffs staged bytes and validates the export extension independently: PNG named wrong.mp3 can pass image addition and fail build with MEDIA.DECLARED_MIME_MISMATCH.

These methods are included in the public 0.3.1 package.

Import a collection with bounded concurrency, keep input order, and retain owned media snapshots for later export:

use ankiforge::Media;
let images = Media::files(["first.png", "second.png"])?;

Node uses await Media.files(['first.png', 'second.png']); Python uses Media.files(['first.png', 'second.png']).

Use Media::files(paths) in Rust, await Media.files(paths) in Node, or Media.files(paths) in Python to import files in input order with at most four workers per call. Batches below 16 files run serially. Rust also provides files_with_limits; Node and Python accept the same limits as file. The limit applies to each asset. If an import fails, the call waits for started work, drops the batch’s snapshots, and returns the first error in input order. Existing media owners remain valid. Python releases the GIL during import; Node performs the work off the event loop. Single-file imports remain immediate owned snapshots; callers must opt into the batch method for import concurrency.

Live snapshots share a 64 MiB process-wide memory budget. Individual assets larger than 1 MiB still stream to temporary files. Encoded media share a lazily allocated 16 MiB buffer pool per preparation and spill when it fills. These budgets exclude other working memory and are not an RSS cap; concurrent builds can each own an encoding pool.

View Markdown