diff --git a/src/libcamera/pipeline/virtual/README.md b/src/libcamera/pipeline/virtual/README.md
new file mode 100644
index 000000000..5801e79b5
--- /dev/null
+++ b/src/libcamera/pipeline/virtual/README.md
@@ -0,0 +1,65 @@
+# Virtual Pipeline Handler
+
+Virtual pipeline handler emulates fake external camera(s) on ChromeOS for testing.
+
+## Parse config file and register cameras
+
+- The config file is located at `src/libcamera/pipeline/virtual/data/virtual.yaml`
+
+### Config File Format
+The config file contains the information about cameras' properties to register.
+The config file should be a yaml file with dictionary of the cameraIds
+associated with their properties as top level. The default value will be applied
+when any property is empty.
+
+Each camera block is a dictionary, containing the following keys:
+- `supported_formats` (list of `VirtualCameraData::Resolution`, optional) :
+  List of supported resolution and frame rates of the emulated camera
+    - `width` (`unsigned int`, default=1920): Width of the window resolution.
+      This needs to be even.
+    - `height` (`unsigned int`, default=1080): Height of the window resolution.
+    - `frame_rates` (list of `int`, default=`[30,60]` ): Range of the frame
+      rate (per second). If the list contains one value, it's the lower bound
+      and the upper bound. If the list contains two values, the first is the
+      lower bound and the second is the upper bound. No other number of values
+      is allowed.
+- `test_pattern` (`string`): Which test pattern to use as frames. The options
+  are "bars", "lines". Cannot be set with `frames`.
+- `frames` (dictionary):
+  - `path` (`string`): Path to an image, or path to a directory of a series of
+    images. Cannot be set with `test_pattern`.
+    - The test patterns are "bars" which means color bars, and "lines" which
+      means diagonal lines.
+    - The path to an image has ".jpg" extension.
+    - The path to a directory ends with "/". The name of the images in the
+      directory are "{n}.jpg" with {n} is the sequence of images starting with 0.
+- `location` (`string`, default="front"): The location of the camera. Support
+  "front" and "back". This is displayed in qcam camera selection window but
+  this does not change the output.
+- `model` (`string`, default="Unknown"): The model name of the camera.
+  This is displayed in qcam camera selection window but this does not change the output.
+
+Check `data/virtual.yaml` as the sample config file.
+
+### Implementation
+
+`Parser` class provides methods to parse the config file to register cameras
+in Virtual Pipeline Handler. `parseConfigFile()` is exposed to use in
+Virtual Pipeline Handler.
+
+This is the procedure of the Parser class:
+1. `parseConfigFile()` parses the config file to `YamlObject` using `YamlParser::parse()`.
+    - Parse the top level of config file which are the camera ids and look into
+      each camera properties.
+2. For each camera, `parseCameraConfigData()` returns a camera with the configuration.
+    - The methods in the next step fill the data with the pointer to the Camera object.
+    - If the config file contains invalid configuration, this method returns
+      nullptr. The camera will be skipped.
+3. Parse each property and register the data.
+    - `parseSupportedFormats()`: Parses `supported_formats` in the config, which
+      contains resolutions and frame rates.
+    - `parseFrameGenerator()`: Parses `test_pattern` or `frames` in the config.
+    - `parseLocation()`: Parses `location` in the config.
+    - `parseModel()`: Parses `model` in the config.
+4. Back to `parseConfigFile()` and append the camera configuration.
+5. Returns a list of camera configurations.
diff --git a/src/libcamera/pipeline/virtual/config_parser.cpp b/src/libcamera/pipeline/virtual/config_parser.cpp
new file mode 100644
index 000000000..467dde485
--- /dev/null
+++ b/src/libcamera/pipeline/virtual/config_parser.cpp
@@ -0,0 +1,263 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2024, Google Inc.
+ *
+ * config_parser.cpp - Virtual cameras helper to parse config file
+ */
+
+#include "config_parser.h"
+
+#include <string.h>
+#include <utility>
+
+#include <libcamera/base/log.h>
+
+#include <libcamera/control_ids.h>
+#include <libcamera/property_ids.h>
+
+#include "libcamera/internal/pipeline_handler.h"
+#include "libcamera/internal/yaml_parser.h"
+
+#include "virtual.h"
+
+namespace libcamera {
+
+LOG_DECLARE_CATEGORY(Virtual)
+
+std::vector<std::unique_ptr<VirtualCameraData>>
+ConfigParser::parseConfigFile(File &file, PipelineHandler *pipe)
+{
+	std::vector<std::unique_ptr<VirtualCameraData>> configurations;
+
+	std::unique_ptr<YamlObject> cameras = YamlParser::parse(file);
+	if (!cameras) {
+		LOG(Virtual, Error) << "Failed to pass config file.";
+		return configurations;
+	}
+
+	if (!cameras->isDictionary()) {
+		LOG(Virtual, Error) << "Config file is not a dictionary at the top level.";
+		return configurations;
+	}
+
+	/* Look into the configuration of each camera */
+	for (const auto &[cameraId, cameraConfigData] : cameras->asDict()) {
+		std::unique_ptr<VirtualCameraData> data =
+			parseCameraConfigData(cameraConfigData, pipe);
+		/* Parse configData to data */
+		if (!data) {
+			/* Skip the camera if it has invalid config */
+			LOG(Virtual, Error) << "Failed to parse config of the camera: "
+					    << cameraId;
+			continue;
+		}
+
+		data->config_.id = cameraId;
+		ControlInfoMap::Map controls;
+		/* todo: Check which resolution's frame rate to be reported */
+		controls[&controls::FrameDurationLimits] =
+			ControlInfo(1000000 / data->config_.resolutions[0].frameRates[1],
+				    1000000 / data->config_.resolutions[0].frameRates[0]);
+
+		std::vector<ControlValue> supportedFaceDetectModes{
+			static_cast<int32_t>(controls::draft::FaceDetectModeOff),
+		};
+		controls[&controls::draft::FaceDetectMode] = ControlInfo(supportedFaceDetectModes);
+
+		data->controlInfo_ = ControlInfoMap(std::move(controls), controls::controls);
+		configurations.push_back(std::move(data));
+	}
+
+	return configurations;
+}
+
+std::unique_ptr<VirtualCameraData>
+ConfigParser::parseCameraConfigData(const YamlObject &cameraConfigData,
+				    PipelineHandler *pipe)
+{
+	std::vector<VirtualCameraData::Resolution> resolutions;
+	if (parseSupportedFormats(cameraConfigData, &resolutions))
+		return nullptr;
+
+	std::unique_ptr<VirtualCameraData> data =
+		std::make_unique<VirtualCameraData>(pipe, resolutions);
+
+	if (parseFrameGenerator(cameraConfigData, data.get()))
+		return nullptr;
+
+	if (parseLocation(cameraConfigData, data.get()))
+		return nullptr;
+
+	if (parseModel(cameraConfigData, data.get()))
+		return nullptr;
+
+	return data;
+}
+
+int ConfigParser::parseSupportedFormats(const YamlObject &cameraConfigData,
+					std::vector<VirtualCameraData::Resolution> *resolutions)
+{
+	if (cameraConfigData.contains("supported_formats")) {
+		const YamlObject &supportedResolutions = cameraConfigData["supported_formats"];
+
+		for (const YamlObject &supportedResolution : supportedResolutions.asList()) {
+			unsigned int width = supportedResolution["width"].get<unsigned int>(1920);
+			unsigned int height = supportedResolution["height"].get<unsigned int>(1080);
+			if (width == 0 || height == 0) {
+				LOG(Virtual, Error) << "Invalid width or/and height";
+				return -EINVAL;
+			}
+			if (width % 2 != 0) {
+				LOG(Virtual, Error) << "Invalid width: width needs to be even";
+				return -EINVAL;
+			}
+
+			std::vector<int64_t> frameRates;
+			if (supportedResolution.contains("frame_rates")) {
+				auto frameRatesList =
+					supportedResolution["frame_rates"].getList<int>();
+				if (!frameRatesList || (frameRatesList->size() != 1 &&
+							frameRatesList->size() != 2)) {
+					LOG(Virtual, Error) << "Invalid frame_rates: either one or two values";
+					return -EINVAL;
+				}
+
+				if (frameRatesList->size() == 2 &&
+				    frameRatesList.value()[0] > frameRatesList.value()[1]) {
+					LOG(Virtual, Error) << "frame_rates's first value(lower bound)"
+							    << " is higher than the second value(upper bound)";
+					return -EINVAL;
+				}
+				/*
+                                 * Push the min and max framerates. A
+                                 * single rate is duplicated.
+                                 */
+				frameRates.push_back(frameRatesList.value().front());
+				frameRates.push_back(frameRatesList.value().back());
+			} else {
+				frameRates.push_back(30);
+				frameRates.push_back(60);
+			}
+
+			resolutions->emplace_back(
+				VirtualCameraData::Resolution{ Size{ width, height },
+							       frameRates });
+		}
+	} else {
+		resolutions->emplace_back(
+			VirtualCameraData::Resolution{ Size{ 1920, 1080 },
+						       { 30, 60 } });
+	}
+
+	return 0;
+}
+
+int ConfigParser::parseFrameGenerator(const YamlObject &cameraConfigData, VirtualCameraData *data)
+{
+	const std::string testPatternKey = "test_pattern";
+	const std::string framesKey = "frames";
+	if (cameraConfigData.contains(testPatternKey)) {
+		if (cameraConfigData.contains(framesKey)) {
+			LOG(Virtual, Error) << "A camera should use either "
+					    << testPatternKey << " or " << framesKey;
+			return -EINVAL;
+		}
+
+		auto testPattern = cameraConfigData[testPatternKey].get<std::string>("");
+
+		if (testPattern == "bars") {
+			data->config_.frame = TestPattern::ColorBars;
+		} else if (testPattern == "lines") {
+			data->config_.frame = TestPattern::DiagonalLines;
+		} else {
+			LOG(Virtual, Debug) << "Test pattern: " << testPattern
+					    << " is not supported";
+			return -EINVAL;
+		}
+
+		return 0;
+	}
+
+	const YamlObject &frames = cameraConfigData[framesKey];
+
+	/* When there is no frames provided in the config file, use color bar test pattern */
+	if (!frames) {
+		data->config_.frame = TestPattern::ColorBars;
+		return 0;
+	}
+
+	if (!frames.isDictionary()) {
+		LOG(Virtual, Error) << "'frames' is not a dictionary.";
+		return -EINVAL;
+	}
+
+	auto path = frames["path"].get<std::string>();
+
+	if (!path) {
+		LOG(Virtual, Error) << "Test pattern or path should be specified.";
+		return -EINVAL;
+	}
+
+	std::vector<std::filesystem::path> files;
+
+	switch (std::filesystem::symlink_status(*path).type()) {
+	case std::filesystem::file_type::regular:
+		files.push_back(*path);
+		break;
+
+	case std::filesystem::file_type::directory:
+		for (const auto &dentry : std::filesystem::directory_iterator{ *path }) {
+			if (dentry.is_regular_file())
+				files.push_back(dentry.path());
+		}
+
+		std::sort(files.begin(), files.end(), [](const auto &a, const auto &b) {
+			return ::strverscmp(a.c_str(), b.c_str()) < 0;
+		});
+
+		if (files.empty()) {
+			LOG(Virtual, Error) << "Directory has no files: " << *path;
+			return -EINVAL;
+		}
+		break;
+
+	default:
+		LOG(Virtual, Error) << "Frame: " << *path << " is not supported";
+		return -EINVAL;
+	}
+
+	data->config_.frame = ImageFrames{ std::move(files) };
+
+	return 0;
+}
+
+int ConfigParser::parseLocation(const YamlObject &cameraConfigData, VirtualCameraData *data)
+{
+	std::string location = cameraConfigData["location"].get<std::string>("front");
+
+	/* Default value is properties::CameraLocationFront */
+	if (location == "front") {
+		data->properties_.set(properties::Location,
+				      properties::CameraLocationFront);
+	} else if (location == "back") {
+		data->properties_.set(properties::Location,
+				      properties::CameraLocationBack);
+	} else {
+		LOG(Virtual, Error) << "location: " << location
+				    << " is not supported";
+		return -EINVAL;
+	}
+
+	return 0;
+}
+
+int ConfigParser::parseModel(const YamlObject &cameraConfigData, VirtualCameraData *data)
+{
+	std::string model = cameraConfigData["model"].get<std::string>("Unknown");
+
+	data->properties_.set(properties::Model, model);
+
+	return 0;
+}
+
+} /* namespace libcamera */
diff --git a/src/libcamera/pipeline/virtual/config_parser.h b/src/libcamera/pipeline/virtual/config_parser.h
new file mode 100644
index 000000000..9abba62d0
--- /dev/null
+++ b/src/libcamera/pipeline/virtual/config_parser.h
@@ -0,0 +1,39 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2024, Google Inc.
+ *
+ * config_parser.h - Virtual cameras helper to parse config file
+ */
+
+#pragma once
+
+#include <memory>
+#include <vector>
+
+#include <libcamera/base/file.h>
+
+#include "libcamera/internal/pipeline_handler.h"
+#include "libcamera/internal/yaml_parser.h"
+
+#include "virtual.h"
+
+namespace libcamera {
+
+class ConfigParser
+{
+public:
+	std::vector<std::unique_ptr<VirtualCameraData>>
+	parseConfigFile(File &file, PipelineHandler *pipe);
+
+private:
+	std::unique_ptr<VirtualCameraData>
+	parseCameraConfigData(const YamlObject &cameraConfigData, PipelineHandler *pipe);
+
+	int parseSupportedFormats(const YamlObject &cameraConfigData,
+				  std::vector<VirtualCameraData::Resolution> *resolutions);
+	int parseFrameGenerator(const YamlObject &cameraConfigData, VirtualCameraData *data);
+	int parseLocation(const YamlObject &cameraConfigData, VirtualCameraData *data);
+	int parseModel(const YamlObject &cameraConfigData, VirtualCameraData *data);
+};
+
+} /* namespace libcamera */
diff --git a/src/libcamera/pipeline/virtual/data/virtual.yaml b/src/libcamera/pipeline/virtual/data/virtual.yaml
new file mode 100644
index 000000000..6b73ddf2b
--- /dev/null
+++ b/src/libcamera/pipeline/virtual/data/virtual.yaml
@@ -0,0 +1,36 @@
+# SPDX-License-Identifier: CC0-1.0
+%YAML 1.1
+---
+"Virtual0":
+  supported_formats:
+  - width: 1920
+    height: 1080
+    frame_rates:
+    - 30
+    - 60
+  - width: 1680
+    height: 1050
+    frame_rates:
+    - 70
+    - 80
+  test_pattern: "lines"
+  location: "front"
+  model: "Virtual Video Device"
+"Virtual1":
+  supported_formats:
+  - width: 800
+    height: 600
+    frame_rates:
+    - 60
+  test_pattern: "bars"
+  location: "back"
+  model: "Virtual Video Device1"
+"Virtual2":
+  supported_formats:
+  - width: 400
+    height: 300
+  test_pattern: "lines"
+  location: "front"
+  model: "Virtual Video Device2"
+"Virtual3":
+  test_pattern: "bars"
diff --git a/src/libcamera/pipeline/virtual/image_frame_generator.cpp b/src/libcamera/pipeline/virtual/image_frame_generator.cpp
index 04382beb7..36bdc20e5 100644
--- a/src/libcamera/pipeline/virtual/image_frame_generator.cpp
+++ b/src/libcamera/pipeline/virtual/image_frame_generator.cpp
@@ -40,15 +40,7 @@ ImageFrameGenerator::create(ImageFrames &imageFrames)
 	 * For each file in the directory, load the image,
 	 * convert it to NV12, and store the pointer.
 	 */
-	for (unsigned int i = 0; i < imageFrames.number.value_or(1); i++) {
-		std::filesystem::path path;
-		if (!imageFrames.number)
-			/* If the path is to an image */
-			path = imageFrames.path;
-		else
-			/* If the path is to a directory */
-			path = imageFrames.path / (std::to_string(i) + ".jpg");
-
+	for (std::filesystem::path path : imageFrames.files) {
 		File file(path);
 		if (!file.open(File::OpenModeFlag::ReadOnly)) {
 			LOG(Virtual, Error) << "Failed to open image file " << file.fileName()
@@ -88,6 +80,8 @@ ImageFrameGenerator::create(ImageFrames &imageFrames)
 					Size(width, height) });
 	}
 
+	ASSERT(!imageFrameGenerator->imageFrameDatas_.empty());
+
 	return imageFrameGenerator;
 }
 
@@ -104,7 +98,7 @@ void ImageFrameGenerator::configure(const Size &size)
 	frameIndex_ = 0;
 	parameter_ = 0;
 
-	for (unsigned int i = 0; i < imageFrames_->number.value_or(1); i++) {
+	for (unsigned int i = 0; i < imageFrameDatas_.size(); i++) {
 		/* Scale the imageFrameDatas_ to scaledY and scaledUV */
 		unsigned int halfSizeWidth = (size.width + 1) / 2;
 		unsigned int halfSizeHeight = (size.height + 1) / 2;
@@ -139,7 +133,7 @@ int ImageFrameGenerator::generateFrame(const Size &size, const FrameBuffer *buff
 	auto planes = mappedFrameBuffer.planes();
 
 	/* Loop only around the number of images available */
-	frameIndex_ %= imageFrames_->number.value_or(1);
+	frameIndex_ %= imageFrameDatas_.size();
 
 	/* Write the scaledY and scaledUV to the mapped frame buffer */
 	libyuv::NV12Copy(scaledFrameDatas_[frameIndex_].Y.get(), size.width,
diff --git a/src/libcamera/pipeline/virtual/image_frame_generator.h b/src/libcamera/pipeline/virtual/image_frame_generator.h
index a68094a66..8554d647d 100644
--- a/src/libcamera/pipeline/virtual/image_frame_generator.h
+++ b/src/libcamera/pipeline/virtual/image_frame_generator.h
@@ -10,9 +10,9 @@
 
 #include <filesystem>
 #include <memory>
-#include <optional>
 #include <stdint.h>
 #include <sys/types.h>
+#include <vector>
 
 #include "frame_generator.h"
 
@@ -20,8 +20,7 @@ namespace libcamera {
 
 /* Frame configuration provided by the config file */
 struct ImageFrames {
-	std::filesystem::path path;
-	std::optional<unsigned int> number;
+	std::vector<std::filesystem::path> files;
 };
 
 class ImageFrameGenerator : public FrameGenerator
diff --git a/src/libcamera/pipeline/virtual/meson.build b/src/libcamera/pipeline/virtual/meson.build
index bb38c193c..4786fe2e0 100644
--- a/src/libcamera/pipeline/virtual/meson.build
+++ b/src/libcamera/pipeline/virtual/meson.build
@@ -1,6 +1,7 @@
 # SPDX-License-Identifier: CC0-1.0
 
 libcamera_internal_sources += files([
+    'config_parser.cpp',
     'image_frame_generator.cpp',
     'test_pattern_generator.cpp',
     'virtual.cpp',
diff --git a/src/libcamera/pipeline/virtual/virtual.cpp b/src/libcamera/pipeline/virtual/virtual.cpp
index 1ad7417c7..cafd395c3 100644
--- a/src/libcamera/pipeline/virtual/virtual.cpp
+++ b/src/libcamera/pipeline/virtual/virtual.cpp
@@ -36,6 +36,9 @@
 #include "libcamera/internal/formats.h"
 #include "libcamera/internal/framebuffer.h"
 #include "libcamera/internal/pipeline_handler.h"
+#include "libcamera/internal/yaml_parser.h"
+
+#include "pipeline/virtual/config_parser.h"
 
 namespace libcamera {
 
@@ -54,6 +57,13 @@ uint64_t currentTimestamp()
 
 } /* namespace */
 
+template<class... Ts>
+struct overloaded : Ts... {
+	using Ts::operator()...;
+};
+template<class... Ts>
+overloaded(Ts...) -> overloaded<Ts...>;
+
 class VirtualCameraConfiguration : public CameraConfiguration
 {
 public:
@@ -95,7 +105,7 @@ private:
 		return static_cast<VirtualCameraData *>(camera->_d());
 	}
 
-	void initFrameGenerator(Camera *camera);
+	bool initFrameGenerator(Camera *camera);
 
 	DmaBufAllocator dmaBufAllocator_;
 
@@ -104,15 +114,19 @@ private:
 
 VirtualCameraData::VirtualCameraData(PipelineHandler *pipe,
 				     const std::vector<Resolution> &supportedResolutions)
-	: Camera::Private(pipe), supportedResolutions_(supportedResolutions)
+	: Camera::Private(pipe)
 {
-	for (const auto &resolution : supportedResolutions_) {
-		if (minResolutionSize_.isNull() || minResolutionSize_ > resolution.size)
-			minResolutionSize_ = resolution.size;
+	config_.resolutions = supportedResolutions;
+	for (const auto &resolution : config_.resolutions) {
+		if (config_.minResolutionSize.isNull() || config_.minResolutionSize > resolution.size)
+			config_.minResolutionSize = resolution.size;
 
-		maxResolutionSize_ = std::max(maxResolutionSize_, resolution.size);
+		config_.maxResolutionSize = std::max(config_.maxResolutionSize, resolution.size);
 	}
 
+	properties_.set(properties::PixelArrayActiveAreas,
+			{ Rectangle(config_.maxResolutionSize) });
+
 	/* \todo Support multiple streams and pass multi_stream_test */
 	streamConfigs_.resize(kMaxStream);
 }
@@ -140,7 +154,7 @@ CameraConfiguration::Status VirtualCameraConfiguration::validate()
 	for (StreamConfiguration &cfg : config_) {
 		bool adjusted = false;
 		bool found = false;
-		for (const auto &resolution : data_->supportedResolutions_) {
+		for (const auto &resolution : data_->config_.resolutions) {
 			if (resolution.size.width == cfg.size.width &&
 			    resolution.size.height == cfg.size.height) {
 				found = true;
@@ -155,7 +169,7 @@ CameraConfiguration::Status VirtualCameraConfiguration::validate()
 			 * Defining the default logic in PipelineHandler to
 			 * find the closest resolution would be nice.
 			 */
-			cfg.size = data_->maxResolutionSize_;
+			cfg.size = data_->config_.maxResolutionSize;
 			status = Adjusted;
 			adjusted = true;
 		}
@@ -224,12 +238,12 @@ PipelineHandlerVirtual::generateConfiguration(Camera *camera,
 
 		std::map<PixelFormat, std::vector<SizeRange>> streamFormats;
 		PixelFormat pixelFormat = formats::NV12;
-		streamFormats[pixelFormat] = { { data->minResolutionSize_,
-						 data->maxResolutionSize_ } };
+		streamFormats[pixelFormat] = { { data->config_.minResolutionSize,
+						 data->config_.maxResolutionSize } };
 		StreamFormats formats(streamFormats);
 		StreamConfiguration cfg(formats);
 		cfg.pixelFormat = pixelFormat;
-		cfg.size = data->maxResolutionSize_;
+		cfg.size = data->config_.maxResolutionSize;
 		cfg.bufferCount = VirtualCameraConfiguration::kBufferCount;
 
 		config->addConfiguration(cfg);
@@ -246,6 +260,7 @@ int PipelineHandlerVirtual::configure(Camera *camera,
 	VirtualCameraData *data = cameraData(camera);
 	for (auto [i, c] : utils::enumerate(*config)) {
 		c.setStream(&data->streamConfigs_[i].stream);
+		/* Start reading the images/generating test patterns */
 		data->streamConfigs_[i].frameGenerator->configure(c.size);
 	}
 
@@ -315,56 +330,67 @@ bool PipelineHandlerVirtual::match([[maybe_unused]] DeviceEnumerator *enumerator
 
 	created_ = true;
 
-	/* \todo Add virtual cameras according to a config file. */
-
-	std::vector<VirtualCameraData::Resolution> supportedResolutions;
-	supportedResolutions.resize(2);
-	supportedResolutions[0] = { .size = Size(1920, 1080), .frameRates = { 30 } };
-	supportedResolutions[1] = { .size = Size(1280, 720), .frameRates = { 30 } };
-
-	std::unique_ptr<VirtualCameraData> data =
-		std::make_unique<VirtualCameraData>(this, supportedResolutions);
-
-	data->properties_.set(properties::Location, properties::CameraLocationFront);
-	data->properties_.set(properties::Model, "Virtual Video Device");
-	data->properties_.set(properties::PixelArrayActiveAreas, { Rectangle(Size(1920, 1080)) });
-
-	/* \todo Set FrameDurationLimits based on config. */
-	ControlInfoMap::Map controls;
-	int64_t min_frame_duration = 33333, max_frame_duration = 33333;
-	controls[&controls::FrameDurationLimits] = ControlInfo(min_frame_duration, max_frame_duration);
-	std::vector<ControlValue> supportedFaceDetectModes{
-		static_cast<int32_t>(controls::draft::FaceDetectModeOff),
-	};
-	controls[&controls::draft::FaceDetectMode] = ControlInfo(supportedFaceDetectModes);
-	data->controlInfo_ = ControlInfoMap(std::move(controls), controls::controls);
-
-	/* Create and register the camera. */
-	std::set<Stream *> streams;
-	for (auto &streamConfig : data->streamConfigs_)
-		streams.insert(&streamConfig.stream);
+	File file(configurationFile("virtual", "virtual.yaml"));
+	bool isOpen = file.open(File::OpenModeFlag::ReadOnly);
+	if (!isOpen) {
+		LOG(Virtual, Error) << "Failed to open config file: " << file.fileName();
+		return false;
+	}
 
-	const std::string id = "Virtual0";
-	std::shared_ptr<Camera> camera = Camera::create(std::move(data), id, streams);
+	ConfigParser parser;
+	auto configData = parser.parseConfigFile(file, this);
+	if (configData.size() == 0) {
+		LOG(Virtual, Error) << "Failed to parse any cameras from the config file: "
+				    << file.fileName();
+		return false;
+	}
 
-	initFrameGenerator(camera.get());
+	/* Configure and register cameras with configData */
+	for (auto &data : configData) {
+		std::set<Stream *> streams;
+		for (auto &streamConfig : data->streamConfigs_)
+			streams.insert(&streamConfig.stream);
+		std::string id = data->config_.id;
+		std::shared_ptr<Camera> camera = Camera::create(std::move(data), id, streams);
+
+		if (!initFrameGenerator(camera.get())) {
+			LOG(Virtual, Error) << "Failed to initialize frame "
+					    << "generator for camera: " << id;
+			continue;
+		}
 
-	registerCamera(std::move(camera));
+		registerCamera(std::move(camera));
+	}
 
 	resetCreated_ = true;
 
 	return true;
 }
 
-void PipelineHandlerVirtual::initFrameGenerator(Camera *camera)
+bool PipelineHandlerVirtual::initFrameGenerator(Camera *camera)
 {
 	auto data = cameraData(camera);
-	for (auto &streamConfig : data->streamConfigs_) {
-		if (data->testPattern_ == TestPattern::DiagonalLines)
-			streamConfig.frameGenerator = std::make_unique<DiagonalLinesGenerator>();
-		else
-			streamConfig.frameGenerator = std::make_unique<ColorBarsGenerator>();
-	}
+	auto &frame = data->config_.frame;
+	std::visit(overloaded{
+			   [&](TestPattern &testPattern) {
+				   for (auto &streamConfig : data->streamConfigs_) {
+					   if (testPattern == TestPattern::DiagonalLines)
+						   streamConfig.frameGenerator = std::make_unique<DiagonalLinesGenerator>();
+					   else
+						   streamConfig.frameGenerator = std::make_unique<ColorBarsGenerator>();
+				   }
+			   },
+			   [&](ImageFrames &imageFrames) {
+				   for (auto &streamConfig : data->streamConfigs_)
+					   streamConfig.frameGenerator = ImageFrameGenerator::create(imageFrames);
+			   } },
+		   frame);
+
+	for (auto &streamConfig : data->streamConfigs_)
+		if (!streamConfig.frameGenerator)
+			return false;
+
+	return true;
 }
 
 REGISTER_PIPELINE_HANDLER(PipelineHandlerVirtual, "virtual")
diff --git a/src/libcamera/pipeline/virtual/virtual.h b/src/libcamera/pipeline/virtual/virtual.h
index 0b15f8d9c..8c4d6ae64 100644
--- a/src/libcamera/pipeline/virtual/virtual.h
+++ b/src/libcamera/pipeline/virtual/virtual.h
@@ -7,6 +7,8 @@
 
 #pragma once
 
+#include <string>
+#include <variant>
 #include <vector>
 
 #include <libcamera/geometry.h>
@@ -15,10 +17,14 @@
 #include "libcamera/internal/camera.h"
 #include "libcamera/internal/pipeline_handler.h"
 
+#include "frame_generator.h"
+#include "image_frame_generator.h"
 #include "test_pattern_generator.h"
 
 namespace libcamera {
 
+using VirtualFrame = std::variant<TestPattern, ImageFrames>;
+
 class VirtualCameraData : public Camera::Private
 {
 public:
@@ -26,23 +32,28 @@ public:
 
 	struct Resolution {
 		Size size;
-		std::vector<int> frameRates;
+		std::vector<int64_t> frameRates;
 	};
 	struct StreamConfig {
 		Stream stream;
 		std::unique_ptr<FrameGenerator> frameGenerator;
 	};
+	/* The config file is parsed to the Configuration struct */
+	struct Configuration {
+		std::string id;
+		std::vector<Resolution> resolutions;
+		VirtualFrame frame;
+
+		Size maxResolutionSize;
+		Size minResolutionSize;
+	};
 
 	VirtualCameraData(PipelineHandler *pipe,
 			  const std::vector<Resolution> &supportedResolutions);
 
 	~VirtualCameraData() = default;
 
-	TestPattern testPattern_ = TestPattern::ColorBars;
-
-	const std::vector<Resolution> supportedResolutions_;
-	Size maxResolutionSize_;
-	Size minResolutionSize_;
+	Configuration config_;
 
 	std::vector<StreamConfig> streamConfigs_;
 };
