Automate repetitive exports by keeping the editor UI out of the loop. On Android, you start the Engine headlessly, apply data to a reusable scene contract, and export each result sequentially on the main thread.
What You’ll Learn#
- Decide whether a workflow should stay on-device, pause for approval, or hand off to a backend runtime.
- Build a reusable template contract with tokenized text and named media slots.
- Populate that contract with record data and export variants sequentially.
- Keep Android-specific constraints in mind when you scale up a workflow.
Choose a Workflow Pattern#
| Pattern | Android does | Use it when |
|---|---|---|
| Client-only | Load a scene, set variables, export, and save the file locally. | The batch is short, assets already live on-device or on your CDN, and the user expects an immediate result. |
| Hybrid approval | Generate a populated scene first, then hand that scene to an editor flow for review or touch-ups. | Automation prepares most of the design, but a person still approves the final output. |
| Backend handoff | Assemble the job payload, template identifier, and record data, then let another runtime render the assets. | The batch is large, long-running, or better handled outside the device lifecycle. |
Android is the client runtime in these flows. If a job needs background orchestration, queueing, or server-triggered rendering, keep the same scene contract and move the rendering step to your backend runtime.
Define the Batch Input#
Keep each export job small and explicit. The example uses one record per output file, carrying the file name, text variables, and replacement media URI.
private data class AutomationJob( val fileStem: String, val headline: String, val subline: String, val cta: String, val heroImageUri: String,)Start the Headless Engine Once#
Start the Engine once for the whole workflow, bind an offscreen surface, and reuse that same instance throughout the batch. The example below follows the same asset-loading pattern as the Android Starter Kits: it calls populateAssetSource(...) for each required default source instead of the deprecated addDefaultAssetSources(...) helper.
engine.start( license = license, userId = "automation-guide",)engine.bindOffscreen(width = 1080, height = 1350)val existingAssetSources = engine.asset.findAllSources().toSet()listOf( DefaultAssetSource.COLORS_DEFAULT_PALETTE.key, DefaultAssetSource.TYPEFACE.key,).filterNot(existingAssetSources::contains) .forEach { assetSource -> engine.populateAssetSource( id = assetSource, jsonUri = defaultBaseUri.buildUpon() .appendPath(assetSource) .appendPath("content.json") .build(), replaceBaseUri = defaultBaseUri, ) }- The examples app passes its configured license into this guide screen before starting the Engine.
- Initialize the Engine once in your application bootstrap before starting a headless engine instance.
- Engine operations stay on the main thread. Use
withContext(Dispatchers.IO)only for file I/O after export. - The sample checks which CE.SDK default asset sources are already registered on the shared Engine instance, then calls
populateAssetSource(...)only for missing sources so revisiting the screen does not add the same default palette and font assets twice.
Build a Reusable Scene Contract#
The example creates its template scene in code so the workflow stays self-contained. In production, you would usually load the same structure from a saved .scene or archive instead.
val scene = engine.scene.create()val page = engine.block.create(DesignBlockType.Page)engine.block.appendChild(parent = scene, child = page)engine.block.setWidth(page, value = 1080F)engine.block.setHeight(page, value = 1350F)
val background = engine.block.create(DesignBlockType.Graphic)engine.block.setShape(background, shape = engine.block.createShape(ShapeType.Rect))val backgroundFill = engine.block.createFill(FillType.Color)engine.block.setColor( block = backgroundFill, property = "fill/color/value", value = EngineColor.fromRGBA(r = 0.96F, g = 0.94F, b = 0.90F, a = 1F),)engine.block.setFill(background, fill = backgroundFill)engine.block.setWidth(background, value = 1080F)engine.block.setHeight(background, value = 1350F)engine.block.appendChild(parent = page, child = background)
val heroImage = engine.block.create(DesignBlockType.Graphic)engine.block.setName(heroImage, name = "hero-image")engine.block.setShape(heroImage, shape = engine.block.createShape(ShapeType.Rect))val heroFill = engine.block.createFill(FillType.Image)engine.block.setString( block = heroFill, property = "fill/image/imageFileURI", value = "https://img.ly/static/ubq_samples/sample_2.jpg",)engine.block.setFill(heroImage, fill = heroFill)engine.block.setWidth(heroImage, value = 860F)engine.block.setHeight(heroImage, value = 720F)engine.block.setPositionX(heroImage, value = 110F)engine.block.setPositionY(heroImage, value = 100F)engine.block.appendChild(parent = page, child = heroImage)
val copyPanel = engine.block.create(DesignBlockType.Graphic)engine.block.setShape(copyPanel, shape = engine.block.createShape(ShapeType.Rect))val copyPanelFill = engine.block.createFill(FillType.Color)engine.block.setColor( block = copyPanelFill, property = "fill/color/value", value = EngineColor.fromRGBA(r = 1F, g = 1F, b = 1F, a = 0.92F),)engine.block.setFill(copyPanel, fill = copyPanelFill)engine.block.setWidth(copyPanel, value = 860F)engine.block.setHeight(copyPanel, value = 360F)engine.block.setPositionX(copyPanel, value = 110F)engine.block.setPositionY(copyPanel, value = 860F)engine.block.appendChild(parent = page, child = copyPanel)
val headline = engine.block.create(DesignBlockType.Text)engine.block.setName(headline, name = "headline-copy")engine.block.setString(headline, property = "text/text", value = "{{headline}}")engine.block.setTextFontSize(headline, fontSize = 14F)engine.block.setTextColor( headline, color = EngineColor.fromRGBA(r = 0.12F, g = 0.10F, b = 0.15F, a = 1F),)engine.block.setWidth(headline, value = 700F)engine.block.setWidthMode(headline, mode = SizeMode.ABSOLUTE)engine.block.setHeightMode(headline, mode = SizeMode.AUTO)engine.block.setBoolean(headline, property = "text/clipLinesOutsideOfFrame", value = false)engine.block.setPositionX(headline, value = 160F)engine.block.setPositionY(headline, value = 915F)engine.block.appendChild(parent = page, child = headline)
val subline = engine.block.create(DesignBlockType.Text)engine.block.setName(subline, name = "subline-copy")engine.block.setString(subline, property = "text/text", value = "{{subline}}")engine.block.setTextFontSize(subline, fontSize = 8F)engine.block.setTextColor( subline, color = EngineColor.fromRGBA(r = 0.28F, g = 0.24F, b = 0.32F, a = 1F),)engine.block.setWidth(subline, value = 700F)engine.block.setWidthMode(subline, mode = SizeMode.ABSOLUTE)engine.block.setHeightMode(subline, mode = SizeMode.AUTO)engine.block.setBoolean(subline, property = "text/clipLinesOutsideOfFrame", value = false)engine.block.setPositionX(subline, value = 160F)engine.block.setPositionY(subline, value = 1000F)engine.block.appendChild(parent = page, child = subline)
val cta = engine.block.create(DesignBlockType.Text)engine.block.setName(cta, name = "cta-copy")engine.block.setString(cta, property = "text/text", value = "{{cta}}")engine.block.setTextFontSize(cta, fontSize = 9F)engine.block.setTextColor( cta, color = EngineColor.fromRGBA(r = 0.16F, g = 0.29F, b = 0.82F, a = 1F),)engine.block.setWidth(cta, value = 700F)engine.block.setWidthMode(cta, mode = SizeMode.ABSOLUTE)engine.block.setHeightMode(cta, mode = SizeMode.AUTO)engine.block.setBoolean(cta, property = "text/clipLinesOutsideOfFrame", value = false)engine.block.setPositionX(cta, value = 160F)engine.block.setPositionY(cta, value = 1090F)engine.block.appendChild(parent = page, child = cta)
val serializedTemplate = engine.scene.saveToString(scene = scene)This template contract does two important things:
- Text blocks contain
{{headline}},{{subline}}, and{{cta}}tokens. Those tokens resolve against the Engine’s variable store at render time. - The hero image block is named
hero-image, giving the automation step a stable handle for media replacement. - A dedicated footer panel reserves readable copy space below the image, so the exported variants stay legible on-device even in evaluation mode.
Validate What the Template Exposes#
On Android, engine.variable.findAll() only lists keys that are already present in the variable store. It does not discover {{token}} references directly from the scene. Before you run a batch, keep the expected variable keys in your own app contract and use block inspection to verify which named blocks still reference variables.
return engine.block.findAll() .filter { block -> engine.block.referencesAnyVariables(block) } .map { block -> engine.block.getName(block) } .filter(String::isNotBlank) .sorted()This gives you a lightweight structure check without mutating the scene. It is especially useful when designers iterate on a template and you want a fast sanity check before exporting a larger batch.
Apply Record Data and Replace Media#
For each record, reload the reusable template, set the variable values, then update the named media slot.
engine.scene.load( scene = templateScene, waitForResources = true,)engine.variable.set(key = "headline", value = job.headline)engine.variable.set(key = "subline", value = job.subline)engine.variable.set(key = "cta", value = job.cta)
val heroImage = engine.block.findByName(name = "hero-image").first()val heroFill = engine.block.getFill(heroImage)engine.block.setString( block = heroFill, property = "fill/image/imageFileURI", value = job.heroImageUri,)engine.block.resetCrop(heroImage)- Reloading the serialized template keeps each export isolated from the previous record.
- Variable keys are case-sensitive. Treat them like part of your API contract between the template and your app.
resetCrop()reapplies the placeholder framing after a new image URI is assigned.
Export Sequentially on Android#
Export the current page, write the buffer to disk, and move on to the next record. Keeping the pipeline sequential avoids unnecessary memory pressure on the device.
val exportData = engine.block.export( block = page, mimeType = MimeType.PNG,)val outputFile = File(outputDirectory, "${job.fileStem}.png")withContext(Dispatchers.IO) { outputFile.outputStream().channel.use { channel -> while (exportData.hasRemaining()) { channel.write(exportData) } }}The sample exports PNG previews because they are easy to inspect in-app. The same pattern works with MimeType.JPEG or MimeType.PDF when your downstream workflow expects a different output format.
To process more than one record, keep the Engine alive and run the same steps in order:
val exportedFiles = jobs.map { job -> exportAutomationJob( engine = engine, templateScene = templateScene, job = job, outputDirectory = outputDirectory, )}Add a Human Approval Step#
If a design still needs review, stop after populating the scene instead of exporting immediately. Serialize that populated scene with engine.scene.saveToString(...), then open the saved scene in an editor flow. This keeps one template contract for both automated generation and manual approval.
Next Steps#
- Headless Mode – use the Engine directly when no prebuilt UI is needed.
- Batch Processing – repeat the same automation flow across many records.
- Create Templates – design the reusable scenes your workflow populates.
- Text Variables – manage the variable store and tokenized text safely.