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.
Execute the media workflow
Section titled “Execute the media workflow”From the repository root:
cargo run --locked -p ankiforge --example docs_workflow -- target/docs-examplescargo run --locked -p ankiforge --example target_api_mediaThe 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(())}Compose content and explicit assets
Section titled “Compose content and explicit assets”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©.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.
Names, MIME and budgets
Section titled “Names, MIME and budgets”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.
Typed image and sound usage
Section titled “Typed image and sound usage”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.
Batch media import
Section titled “Batch media import”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']).
Batch behavior and budgets
Section titled “Batch behavior and budgets”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.