diff --git a/meson.build b/meson.build
index 0f89b45a..c2c4ba5f 100644
--- a/meson.build
+++ b/meson.build
@@ -177,6 +177,7 @@ pipelines_support = {
     'simple':       arch_arm,
     'uvcvideo':     ['any'],
     'vimc':         ['test'],
+    'virtual':      ['test'],
 }
 
 if pipelines.contains('all')
diff --git a/meson_options.txt b/meson_options.txt
index 78a78b72..a0a75e7f 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -47,7 +47,8 @@ option('pipelines',
             'rkisp1',
             'simple',
             'uvcvideo',
-            'vimc'
+            'vimc',
+            'virtual'
         ],
         description : 'Select which pipeline handlers to build. If this is set to "auto", all the pipelines applicable to the target architecture will be built. If this is set to "all", all the pipelines will be built. If both are selected then "all" will take precedence.')
 
diff --git a/src/libcamera/pipeline/virtual/meson.build b/src/libcamera/pipeline/virtual/meson.build
new file mode 100644
index 00000000..ba7ff754
--- /dev/null
+++ b/src/libcamera/pipeline/virtual/meson.build
@@ -0,0 +1,5 @@
+# SPDX-License-Identifier: CC0-1.0
+
+libcamera_sources += files([
+    'virtual.cpp',
+])
diff --git a/src/libcamera/pipeline/virtual/virtual.cpp b/src/libcamera/pipeline/virtual/virtual.cpp
new file mode 100644
index 00000000..78616fab
--- /dev/null
+++ b/src/libcamera/pipeline/virtual/virtual.cpp
@@ -0,0 +1,302 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2022, Google Inc.
+ *
+ * fake.cpp - Pipeline handler for fake cameras
+ */
+
+#include <libcamera/base/log.h>
+
+#include <libcamera/camera.h>
+#include <libcamera/control_ids.h>
+#include <libcamera/controls.h>
+#include <libcamera/formats.h>
+#include <libcamera/heap_allocator.h>
+#include <libcamera/property_ids.h>
+
+#include "libcamera/internal/camera.h"
+#include "libcamera/internal/formats.h"
+#include "libcamera/internal/pipeline_handler.h"
+
+namespace libcamera {
+
+LOG_DEFINE_CATEGORY(VIRTUAL)
+
+static const ControlInfoMap::Map VirtualControls = {
+	{ &controls::draft::PipelineDepth, ControlInfo(2, 3) },
+};
+
+uint64_t CurrentTimestamp()
+{
+	struct timespec ts;
+	if (clock_gettime(CLOCK_MONOTONIC, &ts) < 0) {
+		LOG(VIRTUAL, Error) << "Get clock time fails";
+		return 0;
+	}
+
+	return ts.tv_sec * 1'000'000'000LL + ts.tv_nsec;
+}
+
+class VirtualCameraData : public Camera::Private
+{
+public:
+	struct Resolution {
+		Size size;
+		std::vector<int> frame_rates;
+		std::vector<std::string> formats;
+	};
+	VirtualCameraData(PipelineHandler *pipe)
+		: Camera::Private(pipe)
+	{
+	}
+
+	~VirtualCameraData() = default;
+
+	std::vector<Resolution> supportedResolutions_;
+
+	Stream stream_;
+};
+
+class VirtualCameraConfiguration : public CameraConfiguration
+{
+public:
+	static constexpr unsigned int kBufferCount = 4; // 4~6
+
+	VirtualCameraConfiguration(VirtualCameraData *data);
+
+	Status validate() override;
+
+private:
+	const VirtualCameraData *data_;
+};
+
+class PipelineHandlerVirtual : public PipelineHandler
+{
+public:
+	PipelineHandlerVirtual(CameraManager *manager);
+
+	std::unique_ptr<CameraConfiguration> generateConfiguration(Camera *camera,
+								   const StreamRoles &roles) override;
+	int configure(Camera *camera, CameraConfiguration *config) override;
+
+	int exportFrameBuffers(Camera *camera, Stream *stream,
+			       std::vector<std::unique_ptr<FrameBuffer>> *buffers) override;
+
+	int start(Camera *camera, const ControlList *controls) override;
+	void stopDevice(Camera *camera) override;
+
+	int queueRequestDevice(Camera *camera, Request *request) override;
+
+	bool match(DeviceEnumerator *enumerator) override;
+
+private:
+	VirtualCameraData *cameraData(Camera *camera)
+	{
+		return static_cast<VirtualCameraData *>(camera->_d());
+	}
+
+	HeapAllocator heapAllocator_;
+};
+
+VirtualCameraConfiguration::VirtualCameraConfiguration(VirtualCameraData *data)
+	: CameraConfiguration(), data_(data)
+{
+}
+
+CameraConfiguration::Status VirtualCameraConfiguration::validate()
+{
+	Status status = Valid;
+
+	if (config_.empty()) {
+		LOG(VIRTUAL, Error) << "Empty config";
+		return Invalid;
+	}
+
+	// TODO: check if we should limit |config_.size()|
+
+	Size maxSize;
+	for (const auto &resolution : data_->supportedResolutions_)
+		maxSize = std::max(maxSize, resolution.size);
+
+	for (StreamConfiguration &cfg : config_) {
+		bool found = false;
+		for (const auto &resolution : data_->supportedResolutions_) {
+			if (resolution.size.width >= cfg.size.width && resolution.size.height >= cfg.size.height) {
+				found = true;
+				break;
+			}
+		}
+
+		if (!found) {
+			cfg.size = maxSize;
+			status = Adjusted;
+		}
+
+		const PixelFormatInfo &info = PixelFormatInfo::info(cfg.pixelFormat);
+		cfg.stride = info.stride(cfg.size.width, 0, 1);
+		cfg.frameSize = info.frameSize(cfg.size, 1);
+
+		cfg.setStream(const_cast<Stream *>(&data_->stream_));
+
+		cfg.bufferCount = VirtualCameraConfiguration::kBufferCount;
+	}
+
+	return status;
+}
+
+PipelineHandlerVirtual::PipelineHandlerVirtual(CameraManager *manager)
+	: PipelineHandler(manager)
+{
+}
+
+std::unique_ptr<CameraConfiguration> PipelineHandlerVirtual::generateConfiguration(Camera *camera,
+										   const StreamRoles &roles)
+{
+	VirtualCameraData *data = cameraData(camera);
+	auto config =
+		std::make_unique<VirtualCameraConfiguration>(data);
+
+	if (roles.empty())
+		return config;
+
+	Size minSize, sensorResolution;
+	for (const auto &resolution : data->supportedResolutions_) {
+		if (minSize.isNull() || minSize > resolution.size)
+			minSize = resolution.size;
+
+		sensorResolution = std::max(sensorResolution, resolution.size);
+	}
+
+	for (const StreamRole role : roles) {
+		std::map<PixelFormat, std::vector<SizeRange>> streamFormats;
+		unsigned int bufferCount;
+		PixelFormat pixelFormat;
+
+		switch (role) {
+		case StreamRole::StillCapture:
+			pixelFormat = formats::NV12;
+			bufferCount = VirtualCameraConfiguration::kBufferCount;
+			streamFormats[pixelFormat] = { { minSize, sensorResolution } };
+
+			break;
+
+		case StreamRole::Raw: {
+			// TODO: check
+			pixelFormat = formats::SBGGR10;
+			bufferCount = VirtualCameraConfiguration::kBufferCount;
+			streamFormats[pixelFormat] = { { minSize, sensorResolution } };
+
+			break;
+		}
+
+		case StreamRole::Viewfinder:
+		case StreamRole::VideoRecording: {
+			pixelFormat = formats::NV12;
+			bufferCount = VirtualCameraConfiguration::kBufferCount;
+			streamFormats[pixelFormat] = { { minSize, sensorResolution } };
+
+			break;
+		}
+
+		default:
+			LOG(VIRTUAL, Error)
+				<< "Requested stream role not supported: " << role;
+			config.reset();
+			return config;
+		}
+
+		StreamFormats formats(streamFormats);
+		StreamConfiguration cfg(formats);
+		cfg.size = sensorResolution;
+		cfg.pixelFormat = pixelFormat;
+		cfg.bufferCount = bufferCount;
+		config->addConfiguration(cfg);
+	}
+
+	if (config->validate() == CameraConfiguration::Invalid) {
+		config.reset();
+		return config;
+	}
+
+	return config;
+}
+
+int PipelineHandlerVirtual::configure(Camera *camera, CameraConfiguration *config)
+{
+	(void)camera;
+	(void)config;
+	// Nothing to be done.
+	return 0;
+}
+
+int PipelineHandlerVirtual::exportFrameBuffers(Camera *camera, Stream *stream,
+					       std::vector<std::unique_ptr<FrameBuffer>> *buffers)
+{
+	if (!heapAllocator_.isValid())
+		return -ENOBUFS;
+
+	return heapAllocator_.exportFrameBuffers(camera, stream, buffers);
+}
+
+int PipelineHandlerVirtual::start(Camera *camera, const ControlList *controls)
+{
+	(void)camera;
+	(void)controls;
+	// TODO: Start reading the virtual video if any.
+	return 0;
+}
+
+void PipelineHandlerVirtual::stopDevice(Camera *camera)
+{
+	(void)camera;
+	// TODO: Reset the virtual video if any.
+}
+
+int PipelineHandlerVirtual::queueRequestDevice(Camera *camera, Request *request)
+{
+	(void)camera;
+
+	// TODO: Read from the virtual video if any.
+	for (auto it : request->buffers())
+		completeBuffer(request, it.second);
+
+	request->metadata().set(controls::SensorTimestamp, CurrentTimestamp());
+	completeRequest(request);
+
+	return 0;
+}
+
+bool PipelineHandlerVirtual::match(DeviceEnumerator *enumerator)
+{
+	(void)enumerator;
+
+	// TODO: Add virtual cameras according to a config file.
+
+	std::unique_ptr<VirtualCameraData> data = std::make_unique<VirtualCameraData>(this);
+
+	data->supportedResolutions_.resize(2);
+	data->supportedResolutions_[0] = { .size = Size(1920, 1080), .frame_rates = { 30 }, .formats = { "YCbCr_420_888" } };
+	data->supportedResolutions_[1] = { .size = Size(1280, 720), .frame_rates = { 30, 60 }, .formats = { "YCbCr_420_888" } };
+
+	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 = VirtualControls;
+	int64_t min_frame_duration = 30, max_frame_duration = 60;
+	controls[&controls::FrameDurationLimits] = ControlInfo(min_frame_duration, max_frame_duration);
+	data->controlInfo_ = ControlInfoMap(std::move(controls), controls::controls);
+
+	/* Create and register the camera. */
+	std::set<Stream *> streams{ &data->stream_ };
+	const std::string id = "Virtual0";
+	std::shared_ptr<Camera> camera = Camera::create(std::move(data), id, streams);
+	registerCamera(std::move(camera));
+
+	return false; // Prevent infinite loops for now
+}
+
+REGISTER_PIPELINE_HANDLER(PipelineHandlerVirtual)
+
+} /* namespace libcamera */
