diff --git a/include/libcamera/ipa/raspberrypi.h b/include/libcamera/ipa/raspberrypi.h
index c109469e..4ddf7493 100644
--- a/include/libcamera/ipa/raspberrypi.h
+++ b/include/libcamera/ipa/raspberrypi.h
@@ -51,6 +51,7 @@ static const ControlInfoMap RPiControls = {
 	{ &controls::Brightness, ControlInfo(-1.0f, 1.0f) },
 	{ &controls::Contrast, ControlInfo(0.0f, 32.0f) },
 	{ &controls::Saturation, ControlInfo(0.0f, 32.0f) },
+	{ &controls::FrameDurationLimits, ControlInfo(1.0e3f, 1.0e9f) }
 };
 
 } /* namespace libcamera */
diff --git a/src/ipa/raspberrypi/cam_helper.cpp b/src/ipa/raspberrypi/cam_helper.cpp
index a0c73f99..8bb53e68 100644
--- a/src/ipa/raspberrypi/cam_helper.cpp
+++ b/src/ipa/raspberrypi/cam_helper.cpp
@@ -11,6 +11,7 @@
 #include <map>
 #include <string.h>
 
+#include "libcamera/internal/utils.h"
 #include "libcamera/internal/v4l2_videodevice.h"
 
 #include "cam_helper.hpp"
@@ -34,8 +35,10 @@ CamHelper *CamHelper::Create(std::string const &cam_name)
 	return nullptr;
 }
 
-CamHelper::CamHelper(MdParser *parser)
-	: parser_(parser), initialized_(false)
+CamHelper::CamHelper(MdParser *parser, unsigned int maxFrameLength,
+		     unsigned int frameIntegrationDiff)
+	: parser_(parser), initialized_(false), maxFrameLength_(maxFrameLength),
+	  frameIntegrationDiff_(frameIntegrationDiff)
 {
 }
 
@@ -56,6 +59,37 @@ double CamHelper::Exposure(uint32_t exposure_lines) const
 	return exposure_lines * mode_.line_length / 1000.0;
 }
 
+uint32_t CamHelper::GetVBlanking(double &exposure, double minFrameDuration,
+				 double maxFrameDuration) const
+{
+	uint32_t frameLengthMin, frameLengthMax, vblank;
+	uint32_t exposureLines = ExposureLines(exposure);
+
+	assert(initialized_);
+	using libcamera::utils::clamp;
+	/*
+	 * Clip frame length by the frame duration range and the maximum allowable
+	 * value in the sensor, given by maxFrameLength_.
+	 */
+	frameLengthMin = clamp<uint32_t>(1e3 * minFrameDuration / mode_.line_length,
+					 mode_.height, maxFrameLength_);
+	frameLengthMax = clamp<uint32_t>(1e3 * maxFrameDuration / mode_.line_length,
+					 mode_.height, maxFrameLength_);
+	/*
+	 * Limit the exposure to the maximum frame duration requested, and
+	 * re-calculate if it has been clipped.
+	 */
+	exposureLines = std::min(frameLengthMax - frameIntegrationDiff_, exposureLines);
+	exposure = Exposure(exposureLines);
+
+	/* Limit the vblank to the range allowed by the frame length limits. */
+	vblank = std::max<uint32_t>(exposureLines + frameIntegrationDiff_, mode_.height);
+	vblank = clamp(vblank - mode_.height,
+		       frameLengthMin - mode_.height, frameLengthMax - mode_.height);
+
+	return vblank;
+}
+
 void CamHelper::SetCameraMode(const CameraMode &mode)
 {
 	mode_ = mode;
diff --git a/src/ipa/raspberrypi/cam_helper.hpp b/src/ipa/raspberrypi/cam_helper.hpp
index 6877f473..a499185e 100644
--- a/src/ipa/raspberrypi/cam_helper.hpp
+++ b/src/ipa/raspberrypi/cam_helper.hpp
@@ -68,12 +68,15 @@ class CamHelper
 {
 public:
 	static CamHelper *Create(std::string const &cam_name);
-	CamHelper(MdParser *parser);
+	CamHelper(MdParser *parser, unsigned int maxFrameLength,
+		  unsigned int frameIntegrationDiff);
 	virtual ~CamHelper();
 	void SetCameraMode(const CameraMode &mode);
 	MdParser &Parser() const { return *parser_; }
 	uint32_t ExposureLines(double exposure_us) const;
 	double Exposure(uint32_t exposure_lines) const; // in us
+	uint32_t GetVBlanking(double &exposure_us, double minFrameDuration,
+			      double maxFrameDuration) const;
 	virtual uint32_t GainCode(double gain) const = 0;
 	virtual double Gain(uint32_t gain_code) const = 0;
 	virtual void GetDelays(int &exposure_delay, int &gain_delay) const;
@@ -83,10 +86,20 @@ public:
 	virtual unsigned int MistrustFramesStartup() const;
 	virtual unsigned int MistrustFramesModeSwitch() const;
 	virtual CamTransform GetOrientation() const;
+
 protected:
 	MdParser *parser_;
 	CameraMode mode_;
+
+private:
 	bool initialized_;
+	/* Largest possible frame length, in units of lines. */
+	unsigned int maxFrameLength_;
+	/*
+	 * Smallest difference between the frame length and integration time,
+	 * in units of lines.
+	 */
+	unsigned int frameIntegrationDiff_;
 };
 
 // This is for registering camera helpers with the system, so that the
diff --git a/src/ipa/raspberrypi/cam_helper_imx219.cpp b/src/ipa/raspberrypi/cam_helper_imx219.cpp
index 35c6597c..ee43e9d1 100644
--- a/src/ipa/raspberrypi/cam_helper_imx219.cpp
+++ b/src/ipa/raspberrypi/cam_helper_imx219.cpp
@@ -50,13 +50,22 @@ public:
 	unsigned int MistrustFramesModeSwitch() const override;
 	bool SensorEmbeddedDataPresent() const override;
 	CamTransform GetOrientation() const override;
+
+private:
+	/*
+	 * Smallest difference between the frame length and integration time,
+	 * in units of lines.
+	 */
+	static constexpr int frameIntegrationDiff = 4;
+	/* Largest possible frame length, in units of lines. */
+	static constexpr int maxFrameLength = 0xffff;
 };
 
 CamHelperImx219::CamHelperImx219()
 #if ENABLE_EMBEDDED_DATA
-	: CamHelper(new MdParserImx219())
+	: CamHelper(new MdParserImx219(), maxFrameLength, frameIntegrationDiff)
 #else
-	: CamHelper(new MdParserRPi())
+	: CamHelper(new MdParserRPi(), maxFrameLength, frameIntegrationDiff)
 #endif
 {
 }
diff --git a/src/ipa/raspberrypi/cam_helper_imx477.cpp b/src/ipa/raspberrypi/cam_helper_imx477.cpp
index 69544456..4a1cab76 100644
--- a/src/ipa/raspberrypi/cam_helper_imx477.cpp
+++ b/src/ipa/raspberrypi/cam_helper_imx477.cpp
@@ -39,10 +39,19 @@ public:
 	double Gain(uint32_t gain_code) const override;
 	bool SensorEmbeddedDataPresent() const override;
 	CamTransform GetOrientation() const override;
+
+private:
+	/*
+	 * Smallest difference between the frame length and integration time,
+	 * in units of lines.
+	 */
+	static constexpr int frameIntegrationDiff = 22;
+	/* Largest possible frame length, in units of lines. */
+	static constexpr int maxFrameLength = 0xffdc;
 };
 
 CamHelperImx477::CamHelperImx477()
-	: CamHelper(new MdParserImx477())
+	: CamHelper(new MdParserImx477(), maxFrameLength, frameIntegrationDiff)
 {
 }
 
diff --git a/src/ipa/raspberrypi/cam_helper_ov5647.cpp b/src/ipa/raspberrypi/cam_helper_ov5647.cpp
index 3dbcb164..d814fa90 100644
--- a/src/ipa/raspberrypi/cam_helper_ov5647.cpp
+++ b/src/ipa/raspberrypi/cam_helper_ov5647.cpp
@@ -22,6 +22,15 @@ public:
 	unsigned int HideFramesModeSwitch() const override;
 	unsigned int MistrustFramesStartup() const override;
 	unsigned int MistrustFramesModeSwitch() const override;
+
+private:
+	/*
+	 * Smallest difference between the frame length and integration time,
+	 * in units of lines.
+	 */
+	static constexpr int frameIntegrationDiff = 4;
+	/* Largest possible frame length, in units of lines. */
+	static constexpr int maxFrameLength = 0xffff;
 };
 
 /*
@@ -30,7 +39,7 @@ public:
  */
 
 CamHelperOv5647::CamHelperOv5647()
-	: CamHelper(new MdParserRPi())
+	: CamHelper(new MdParserRPi(), maxFrameLength, frameIntegrationDiff)
 {
 }
 
diff --git a/src/ipa/raspberrypi/raspberrypi.cpp b/src/ipa/raspberrypi/raspberrypi.cpp
index 9669f212..811c40fe 100644
--- a/src/ipa/raspberrypi/raspberrypi.cpp
+++ b/src/ipa/raspberrypi/raspberrypi.cpp
@@ -54,6 +54,8 @@ namespace libcamera {
 /* Configure the sensor with these values initially. */
 #define DEFAULT_ANALOGUE_GAIN 1.0
 #define DEFAULT_EXPOSURE_TIME 20000
+#define DEFAULT_MIN_FRAME_DURATION (1e6 / 30.0)
+#define DEFAULT_MAX_FRAME_DURATION (1e6 / 0.01)
 
 LOG_DEFINE_CATEGORY(IPARPI)
 
@@ -137,6 +139,9 @@ private:
 	/* LS table allocation passed in from the pipeline handler. */
 	uint32_t lsTableHandle_;
 	void *lsTable_;
+
+	/* Frame duration (1/fps) given in microseconds. */
+	float minFrameDuration_, maxFrameDuration_;
 };
 
 int IPARPi::init(const IPASettings &settings)
@@ -253,13 +258,20 @@ void IPARPi::configure(const CameraSensorInfo &sensorInfo,
 		controller_.Initialise();
 		controllerInit_ = true;
 
-		/* Calculate initial values for gain and exposure. */
+		/* Calculate initial values for gain, vblank, and exposure */
+		minFrameDuration_ = DEFAULT_MIN_FRAME_DURATION;
+		maxFrameDuration_ = DEFAULT_MAX_FRAME_DURATION;
+
+		double exposure = DEFAULT_EXPOSURE_TIME;
+		int32_t vblank = helper_->GetVBlanking(exposure, minFrameDuration_,
+						       maxFrameDuration_);
+		int32_t exposure_lines = helper_->ExposureLines(exposure);
 		int32_t gain_code = helper_->GainCode(DEFAULT_ANALOGUE_GAIN);
-		int32_t exposure_lines = helper_->ExposureLines(DEFAULT_EXPOSURE_TIME);
 
 		ControlList ctrls(unicam_ctrls_);
-		ctrls.set(V4L2_CID_ANALOGUE_GAIN, gain_code);
+		ctrls.set(V4L2_CID_VBLANK, vblank);
 		ctrls.set(V4L2_CID_EXPOSURE, exposure_lines);
+		ctrls.set(V4L2_CID_ANALOGUE_GAIN, gain_code);
 
 		IPAOperationData op;
 		op.operation = RPI_IPA_ACTION_V4L2_SET_STAGGERED;
@@ -401,6 +413,8 @@ void IPARPi::reportMetadata()
 					 static_cast<int32_t>(blackLevelStatus->black_level_g),
 					 static_cast<int32_t>(blackLevelStatus->black_level_g),
 					 static_cast<int32_t>(blackLevelStatus->black_level_b) });
+
+	libcameraMetadata_.set(controls::FrameDurationLimits, { minFrameDuration_, maxFrameDuration_ });
 }
 
 /*
@@ -631,6 +645,15 @@ void IPARPi::queueRequest(const ControlList &controls)
 			break;
 		}
 
+		case controls::FRAME_DURATION_LIMITS: {
+			auto frameDurations = ctrl.second.get<Span<const float>>();
+
+			/* This will be applied once AGC recalculations occur. */
+			minFrameDuration_ = frameDurations[0];
+			maxFrameDuration_ = frameDurations[1];
+			break;
+		}
+
 		default:
 			LOG(IPARPI, Warning)
 				<< "Ctrl " << controls::controls.at(ctrl.first)->name()
@@ -796,7 +819,12 @@ void IPARPi::applyAGC(const struct AgcStatus *agcStatus)
 	op.operation = RPI_IPA_ACTION_V4L2_SET_STAGGERED;
 
 	int32_t gain_code = helper_->GainCode(agcStatus->analogue_gain);
-	int32_t exposure_lines = helper_->ExposureLines(agcStatus->shutter_time);
+
+	/* GetVBlanking might clip exposure time to the fps limits. */
+	double exposure = agcStatus->shutter_time;
+	int32_t vblanking = helper_->GetVBlanking(exposure, minFrameDuration_,
+						  maxFrameDuration_);
+	int32_t exposure_lines = helper_->ExposureLines(exposure);
 
 	if (unicam_ctrls_.find(V4L2_CID_ANALOGUE_GAIN) == unicam_ctrls_.end()) {
 		LOG(IPARPI, Error) << "Can't find analogue gain control";
@@ -808,14 +836,20 @@ void IPARPi::applyAGC(const struct AgcStatus *agcStatus)
 		return;
 	}
 
-	LOG(IPARPI, Debug) << "Applying AGC Exposure: " << agcStatus->shutter_time
-			   << " (Shutter lines: " << exposure_lines << ") Gain: "
+	LOG(IPARPI, Debug) << "Applying AGC Exposure: " << exposure
+			   << " (Shutter lines: " << exposure_lines << ", AGC requested "
+			   << agcStatus->shutter_time << ") Gain: "
 			   << agcStatus->analogue_gain << " (Gain Code: "
 			   << gain_code << ")";
 
 	ControlList ctrls(unicam_ctrls_);
-	ctrls.set(V4L2_CID_ANALOGUE_GAIN, gain_code);
+	/*
+	 * VBLANK must be set before EXPOSURE as the former will adjust the
+	 * limits of the latter control.
+	 */
+	ctrls.set(V4L2_CID_VBLANK, vblanking);
 	ctrls.set(V4L2_CID_EXPOSURE, exposure_lines);
+	ctrls.set(V4L2_CID_ANALOGUE_GAIN, gain_code);
 	op.controls.push_back(ctrls);
 	queueFrameAction.emit(0, op);
 }
diff --git a/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp b/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp
index e16a9c7f..d09f6c60 100644
--- a/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp
+++ b/src/libcamera/pipeline/raspberrypi/raspberrypi.cpp
@@ -1161,7 +1161,9 @@ void RPiCameraData::queueFrameAction(unsigned int frame, const IPAOperationData
 		if (!staggeredCtrl_) {
 			staggeredCtrl_.init(unicam_[Unicam::Image].dev(),
 					    { { V4L2_CID_ANALOGUE_GAIN, action.data[0] },
+					      { V4L2_CID_VBLANK, action.data[1] },
 					      { V4L2_CID_EXPOSURE, action.data[1] } });
+
 			sensorMetadata_ = action.data[2];
 		}
 
