diff --git a/src/libcamera/pipeline/virtual/config_parser.cpp b/src/libcamera/pipeline/virtual/config_parser.cpp
index 5169fd39..fcce70c8 100644
--- a/src/libcamera/pipeline/virtual/config_parser.cpp
+++ b/src/libcamera/pipeline/virtual/config_parser.cpp
@@ -156,13 +156,20 @@ int ConfigParser::parseFrameGenerator(const ValueNode &cameraConfigData, Virtual
 {
 	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;
-		}
+	const std::string rawFramesKey = "raw_frames";
+
+	/* Ensure only one frame source is specified */
+	int sourcesSpecified = cameraConfigData.contains(testPatternKey) +
+			       cameraConfigData.contains(framesKey) +
+			       cameraConfigData.contains(rawFramesKey);
+	if (sourcesSpecified > 1) {
+		LOG(Virtual, Error) << "A camera should use only one of "
+				    << testPatternKey << ", " << framesKey
+				    << ", or " << rawFramesKey;
+		return -EINVAL;
+	}
 
+	if (cameraConfigData.contains(testPatternKey)) {
 		auto testPattern = cameraConfigData[testPatternKey].get<std::string>("");
 
 		if (testPattern == "bars") {
@@ -178,6 +185,87 @@ int ConfigParser::parseFrameGenerator(const ValueNode &cameraConfigData, Virtual
 		return 0;
 	}
 
+	if (cameraConfigData.contains(rawFramesKey)) {
+		const ValueNode &rawFrames = cameraConfigData[rawFramesKey];
+
+		if (!rawFrames.isDictionary()) {
+			LOG(Virtual, Error) << "'raw_frames' is not a dictionary.";
+			return -EINVAL;
+		}
+
+		auto path = rawFrames["path"].get<std::string>();
+		if (!path) {
+			LOG(Virtual, Error) << "raw_frames: path must 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) << "raw_frames directory has no files: " << *path;
+				return -EINVAL;
+			}
+			break;
+		default:
+			LOG(Virtual, Error) << "raw_frames path: " << *path << " is not supported";
+			return -EINVAL;
+		}
+
+		/* Parse bayer_order */
+		auto bayerOrder = rawFrames["bayer_order"].get<std::string>();
+		if (!bayerOrder) {
+			LOG(Virtual, Error) << "raw_frames: bayer_order must be specified.";
+			return -EINVAL;
+		}
+
+		static const std::map<std::string, uint32_t> bayerOrderMap = {
+			{ "RGGB", properties::draft::ColorFilterArrangementEnum::RGGB },
+			{ "BGGR", properties::draft::ColorFilterArrangementEnum::BGGR },
+			{ "GRBG", properties::draft::ColorFilterArrangementEnum::GRBG },
+			{ "GBRG", properties::draft::ColorFilterArrangementEnum::GBRG },
+		};
+
+		auto it = bayerOrderMap.find(*bayerOrder);
+		if (it == bayerOrderMap.end()) {
+			LOG(Virtual, Error) << "raw_frames: unsupported bayer_order: "
+					    << *bayerOrder
+					    << ", must be one of RGGB, BGGR, GRBG, GBRG";
+			return -EINVAL;
+		}
+
+		/* Parse bit_depth */
+		auto bitDepth = rawFrames["bit_depth"].get<unsigned int>();
+		if (!bitDepth) {
+			LOG(Virtual, Error) << "raw_frames: bit_depth must be specified.";
+			return -EINVAL;
+		}
+
+		static const std::set<unsigned int> supportedBitDepths = { 8, 10, 12, 14, 16 };
+		if (supportedBitDepths.find(*bitDepth) == supportedBitDepths.end()) {
+			LOG(Virtual, Error) << "raw_frames: bit_depth unsupported: " << *bitDepth
+					    << ", must be one of 8, 10, 12, 14, 16";
+			return -EINVAL;
+		}
+
+		data->config_.frame = RawFrames{ std::move(files), it->second, *bitDepth };
+
+		return 0;
+	}
+
 	const ValueNode &frames = cameraConfigData[framesKey];
 
 	/* When there is no frames provided in the config file, use color bar test pattern */
diff --git a/src/libcamera/pipeline/virtual/virtual.cpp b/src/libcamera/pipeline/virtual/virtual.cpp
index 81d2ddda..beec2f5a 100644
--- a/src/libcamera/pipeline/virtual/virtual.cpp
+++ b/src/libcamera/pipeline/virtual/virtual.cpp
@@ -19,6 +19,7 @@
 #include <string>
 #include <time.h>
 #include <utility>
+#include <variant>
 #include <vector>
 
 #include <libcamera/base/flags.h>
@@ -37,7 +38,6 @@
 #include "libcamera/internal/framebuffer.h"
 #include "libcamera/internal/pipeline_handler.h"
 #include "libcamera/internal/request.h"
-#include "libcamera/internal/value_node.h"
 
 #include "pipeline/virtual/config_parser.h"
 
@@ -202,21 +202,28 @@ CameraConfiguration::Status VirtualCameraConfiguration::validate()
 			adjusted = true;
 		}
 
-		if (cfg.pixelFormat != formats::NV12) {
-			cfg.pixelFormat = formats::NV12;
-			status = Adjusted;
-			adjusted = true;
-		}
+		const PixelFormatInfo &fmtInfo = PixelFormatInfo::info(cfg.pixelFormat);
+		const bool rawStream = fmtInfo.colourEncoding == PixelFormatInfo::ColourEncodingRAW;
 
-		if (cfg.colorSpace != ColorSpace::Rec709) {
-			cfg.colorSpace = ColorSpace::Rec709;
-			status = Adjusted;
-			adjusted = true;
-		}
+		if (!rawStream) {
+			if (cfg.pixelFormat != formats::NV12) {
+				cfg.pixelFormat = formats::NV12;
+				status = Adjusted;
+				adjusted = true;
+			}
 
-		if (validateColorSpaces() == Adjusted) {
-			status = Adjusted;
-			adjusted = true;
+			if (cfg.colorSpace != ColorSpace::Rec709) {
+				cfg.colorSpace = ColorSpace::Rec709;
+				status = Adjusted;
+				adjusted = true;
+			}
+
+			if (validateColorSpaces() == Adjusted) {
+				status = Adjusted;
+				adjusted = true;
+			}
+		} else {
+			cfg.colorSpace = ColorSpace::Raw;
 		}
 
 		if (adjusted)
@@ -267,7 +274,64 @@ PipelineHandlerVirtual::generateConfiguration(Camera *camera,
 		case StreamRole::Viewfinder:
 			break;
 
-		case StreamRole::Raw:
+		case StreamRole::Raw: {
+			if (!std::holds_alternative<RawFrames>(data->config_.frame)) {
+				LOG(Virtual, Error)
+					<< "StreamRole::Raw requested but camera is not configured with raw_frames";
+				return {};
+			}
+
+			const auto &rawFrames = std::get<RawFrames>(data->config_.frame);
+			PixelFormat rawFormat;
+
+			/*
+			 * \todo Possibly replace with a 2D lookup table to
+			 * be able to just index by (cfaPatter, bitDepth),
+			 * might be cleaner.
+			 */
+			auto bayerFormat = [&](PixelFormat f8, PixelFormat f10,
+					       PixelFormat f12, PixelFormat f14,
+					       PixelFormat f16) {
+				switch (rawFrames.bitDepth) {
+				case 8:
+					return f8;
+				case 10:
+					return f10;
+				case 12:
+					return f12;
+				case 14:
+					return f14;
+				default:
+					return f16;
+				}
+			};
+
+			/* Map bayer order and bit depth to pixel format */
+			if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::RGGB)
+				rawFormat = bayerFormat(formats::SRGGB8, formats::SRGGB10, formats::SRGGB12, formats::SRGGB14, formats::SRGGB16);
+			else if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::BGGR)
+				rawFormat = bayerFormat(formats::SBGGR8, formats::SBGGR10, formats::SBGGR12, formats::SBGGR14, formats::SBGGR16);
+			else if (rawFrames.cfaPattern == properties::draft::ColorFilterArrangementEnum::GRBG)
+				rawFormat = bayerFormat(formats::SGRBG8, formats::SGRBG10, formats::SGRBG12, formats::SGRBG14, formats::SGRBG16);
+			else
+				rawFormat = bayerFormat(formats::SGBRG8, formats::SGBRG10, formats::SGBRG12, formats::SGBRG14, formats::SGBRG16);
+
+			/*
+			 * Use the Bayer format matching the raw frame
+			 * configuration.
+			 */
+			std::map<PixelFormat, std::vector<SizeRange>> rawStreamFormats;
+			rawStreamFormats[rawFormat] = { { data->config_.minResolutionSize, data->config_.maxResolutionSize } };
+			StreamFormats rawFormats(rawStreamFormats);
+			StreamConfiguration rawCfg(rawFormats);
+			rawCfg.pixelFormat = rawFormat;
+			rawCfg.size = data->config_.maxResolutionSize;
+			rawCfg.bufferCount = VirtualCameraConfiguration::kBufferCount;
+			rawCfg.colorSpace = ColorSpace::Raw;
+			config->addConfiguration(rawCfg);
+			continue;
+		}
+
 		default:
 			LOG(Virtual, Error)
 				<< "Requested stream role not supported: " << role;
@@ -401,6 +465,28 @@ bool PipelineHandlerVirtual::match([[maybe_unused]] DeviceEnumerator *enumerator
 		std::set<Stream *> streams;
 		for (auto &streamConfig : data->streamConfigs_)
 			streams.insert(&streamConfig.stream);
+
+		/* Add sensor properties and controls required by SoftISP for raw streams */
+		if (std::holds_alternative<RawFrames>(data->config_.frame)) {
+			const auto &rawFrames = std::get<RawFrames>(data->config_.frame);
+			data->properties_.set(properties::draft::ColorFilterArrangement, static_cast<int32_t>(rawFrames.cfaPattern));
+			data->properties_.set(properties::PixelArraySize, data->config_.maxResolutionSize);
+			data->properties_.set(properties::UnitCellSize, Size(1000, 1000));
+
+			/* Extends existing controlInfo_ with SoftISP required controls */
+			ControlInfoMap::Map controls;
+			for (const auto &[id, info] : data->controlInfo_)
+				controls[id] = info;
+
+			/* \todo Allow configuration via YAML */
+			controls[&controls::AnalogueGain] = ControlInfo(1.0f, 16.0f, 1.0f);
+			controls[&controls::ExposureTime] = ControlInfo(100, 33333, 10000);
+			controls[&controls::AeEnable] = ControlInfo(false, true, true);
+			controls[&controls::AwbEnable] = ControlInfo(false, true, true);
+
+			data->controlInfo_ = ControlInfoMap(std::move(controls), controls::controls);
+		}
+
 		std::string id = data->config_.id;
 		std::shared_ptr<Camera> camera = Camera::create(std::move(data), id, streams);
 
@@ -434,7 +520,12 @@ bool PipelineHandlerVirtual::initFrameGenerator(Camera *camera)
 			   [&](ImageFrames &imageFrames) {
 				   for (auto &streamConfig : data->streamConfigs_)
 					   streamConfig.frameGenerator = ImageFrameGenerator::create(imageFrames);
-			   } },
+			   },
+			   [&](RawFrames &rawFrames) {
+				   for (auto &streamConfig : data->streamConfigs_)
+					   streamConfig.frameGenerator = RawFrameGenerator::create(rawFrames);
+			   },
+		   },
 		   frame);
 
 	for (auto &streamConfig : data->streamConfigs_)
diff --git a/src/libcamera/pipeline/virtual/virtual.h b/src/libcamera/pipeline/virtual/virtual.h
index 215e56fa..48392808 100644
--- a/src/libcamera/pipeline/virtual/virtual.h
+++ b/src/libcamera/pipeline/virtual/virtual.h
@@ -23,11 +23,12 @@
 
 #include "frame_generator.h"
 #include "image_frame_generator.h"
+#include "raw_frame_generator.h"
 #include "test_pattern_generator.h"
 
 namespace libcamera {
 
-using VirtualFrame = std::variant<TestPattern, ImageFrames>;
+using VirtualFrame = std::variant<TestPattern, ImageFrames, RawFrames>;
 
 class VirtualCameraData : public Camera::Private,
 			  public Thread,
