PR #22592 opened by MarcosAsh
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/22592
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/22592.patch

Closes #22537.

Adds a new `-update_filemtime` boolean option to the image2 muxer. When 
enabled, each output file's modification time is set to the value of the 
creation_time metadata plus the frame's PTS offset.

The use case is extracting frames from dashcam or action camera footage where 
the wall-clock timestamp matters -- photo managers and file browsers use mtime 
for sorting, and right now extracted frames all get the current time instead of 
the capture time.

  Usage example:

```
ffmpeg -i dashcam.mp4 -metadata creation_time="2024-06-15T14:30:00Z" \ 
-update_filemtime 1 frame_%04d.jpg
```

If creation_time metadata is missing or unparseable, a warning is logged and 
the option is silently disabled. When PTS is unavailable for a frame, the base 
creation time is used without offset.

The implementation uses utimes() on POSIX (microsecond precision) and _utime() 
on Windows (second precision). File paths are saved during the open loop and 
timestamps are applied after writes complete (and after atomic rename, when 
-atomic_writing is also used). Split-planes mode is also handled.

Includes a FATE roundtrip test: writes frames with a known creation_time, reads 
them back with the demuxer's -ts_from_file option, and verifies the PTS values 
match expected timestamps.



>From 9d0087fa72b22d8e8b859d2cefe1cff411765757 Mon Sep 17 00:00:00 2001
From: marcos ashton <[email protected]>
Date: Mon, 23 Mar 2026 16:03:40 +0000
Subject: [PATCH] libavformat/img2enc: add update_filemtime option

Add a new boolean option -update_filemtime to the image2 muxer that
sets each output file's modification time based on the creation_time
metadata plus the frame's PTS offset.

This is useful when extracting frames from dashcam or action camera
footage where wall-clock timestamps should be preserved on the output
files, allowing photo management tools to sort frames by capture time
without post-processing.

The option requires creation_time metadata to be set (via -metadata
creation_time=...). If not present, a warning is logged and the
option is silently disabled. When PTS is unavailable, the creation
time is used as-is without frame offset.

Uses utimes() on POSIX and _utime() on Windows to set file timestamps
with microsecond and second precision respectively.

Includes a FATE roundtrip test that writes frames with a known
creation_time, reads them back using the demuxer's -ts_from_file
option, and verifies the PTS values match the expected timestamps.

Closes: https://code.ffmpeg.org/FFmpeg/FFmpeg/issues/22537
Signed-off-by: marcos ashton <[email protected]>
---
 libavformat/img2enc.c                | 69 ++++++++++++++++++++++++++++
 tests/fate-run.sh                    | 15 ++++++
 tests/fate/image.mak                 | 10 +++-
 tests/ref/fate/img2-update-filemtime |  3 ++
 4 files changed, 96 insertions(+), 1 deletion(-)
 create mode 100644 tests/ref/fate/img2-update-filemtime

diff --git a/libavformat/img2enc.c b/libavformat/img2enc.c
index b11f62d85d..4ae8bcc976 100644
--- a/libavformat/img2enc.c
+++ b/libavformat/img2enc.c
@@ -21,16 +21,24 @@
  */
 
 #include <time.h>
+#ifdef _WIN32
+#include <sys/utime.h>
+#else
+#include <sys/time.h>
+#endif
 
 #include "config_components.h"
 
+#include "libavutil/avutil.h"
 #include "libavutil/intreadwrite.h"
 #include "libavutil/avstring.h"
 #include "libavutil/bprint.h"
 #include "libavutil/dict.h"
 #include "libavutil/log.h"
+#include "libavutil/mathematics.h"
 #include "libavutil/mem.h"
 #include "libavutil/opt.h"
+#include "libavutil/parseutils.h"
 #include "libavutil/pixdesc.h"
 #include "libavutil/time_internal.h"
 #include "avformat.h"
@@ -50,8 +58,28 @@ typedef struct VideoMuxData {
     const char *muxer;
     int use_rename;
     AVDictionary *protocol_opts;
+    int update_filemtime;
+    int64_t creation_ts;    /**< creation_time in microseconds since epoch */
 } VideoMuxData;
 
+static void set_file_mtime(AVFormatContext *s, const char *path, int64_t ts_us)
+{
+#ifdef _WIN32
+    struct _utimbuf ut;
+    ut.actime  = ts_us / 1000000;
+    ut.modtime = ts_us / 1000000;
+    if (_utime(path, &ut) < 0)
+#else
+    struct timeval times[2] = {
+        { .tv_sec = ts_us / 1000000, .tv_usec = ts_us % 1000000 },
+        { .tv_sec = ts_us / 1000000, .tv_usec = ts_us % 1000000 },
+    };
+    if (utimes(path, times) < 0)
+#endif
+        av_log(s, AV_LOG_WARNING,
+               "Failed to set file modification time for %s\n", path);
+}
+
 static int write_header(AVFormatContext *s)
 {
     VideoMuxData *img = s->priv_data;
@@ -75,6 +103,21 @@ static int write_header(AVFormatContext *s)
     }
     img->img_number = img->start_img_number;
 
+    if (img->update_filemtime) {
+        AVDictionaryEntry *entry;
+        int64_t parsed_ts;
+
+        entry = av_dict_get(s->metadata, "creation_time", NULL, 0);
+        if (!entry || av_parse_time(&parsed_ts, entry->value, 0) < 0) {
+            av_log(s, AV_LOG_WARNING,
+                   "No valid creation_time metadata found, "
+                   "update_filemtime will be ignored\n");
+            img->update_filemtime = 0;
+        } else {
+            img->creation_ts = parsed_ts;
+        }
+    }
+
     return 0;
 }
 
@@ -143,6 +186,7 @@ static int write_packet(AVFormatContext *s, AVPacket *pkt)
     AVIOContext *pb[4] = {0};
     char* target[4]    = {0};
     char* tmp[4]       = {0};
+    char* filepaths[4] = {0};
     AVCodecParameters *par = s->streams[pkt->stream_index]->codecpar;
     const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(par->format);
     int ret, i;
@@ -204,6 +248,14 @@ static int write_packet(AVFormatContext *s, AVPacket *pkt)
             goto fail;
         }
 
+        if (img->update_filemtime) {
+            filepaths[i] = av_strdup(filename.str);
+            if (!filepaths[i]) {
+                ret = AVERROR(ENOMEM);
+                goto fail;
+            }
+        }
+
         if (!img->split_planes || i+1 >= desc->nb_components)
             break;
         filename.str[filename.len - 1] = "UVAx"[i];
@@ -246,6 +298,21 @@ static int write_packet(AVFormatContext *s, AVPacket *pkt)
         av_freep(&target[i]);
     }
 
+    if (img->update_filemtime) {
+        AVStream *st = s->streams[pkt->stream_index];
+        int64_t frame_ts = img->creation_ts;
+
+        if (pkt->pts != AV_NOPTS_VALUE)
+            frame_ts += av_rescale_q(pkt->pts, st->time_base,
+                                     AV_TIME_BASE_Q);
+
+        for (i = 0; i < 4 && filepaths[i]; i++)
+            set_file_mtime(s, filepaths[i], frame_ts);
+    }
+
+    for (i = 0; i < FF_ARRAY_ELEMS(filepaths); i++)
+        av_freep(&filepaths[i]);
+
     img->img_number++;
     return 0;
 
@@ -255,6 +322,7 @@ fail:
     for (i = 0; i < FF_ARRAY_ELEMS(pb); i++) {
         av_freep(&tmp[i]);
         av_freep(&target[i]);
+        av_freep(&filepaths[i]);
         if (pb[i])
             ff_format_io_close(s, &pb[i]);
     }
@@ -281,6 +349,7 @@ static const AVOption muxoptions[] = {
     { "frame_pts",    "use current frame pts for filename", OFFSET(frame_pts), 
 AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, ENC },
     { "atomic_writing", "write files atomically (using temporary files and 
renames)", OFFSET(use_rename), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, ENC },
     { "protocol_opts", "specify protocol options for the opened files", 
OFFSET(protocol_opts), AV_OPT_TYPE_DICT, {0}, 0, 0, ENC },
+    { "update_filemtime", "set output file mtime from creation_time metadata 
plus frame offset", OFFSET(update_filemtime), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 
0, 1, ENC },
     { NULL },
 };
 
diff --git a/tests/fate-run.sh b/tests/fate-run.sh
index cd66cd059c..816772c700 100755
--- a/tests/fate-run.sh
+++ b/tests/fate-run.sh
@@ -469,6 +469,21 @@ lavf_image2pipe(){
     do_avconv_crc $file -auto_conversion_filters $DEC_OPTS -f image2pipe -i 
$target_path/$file
 }
 
+img2_update_filemtime(){
+    outdir="tests/data/lavf"
+    file=${outdir}/img2_mtime_%03d.pgm
+    cleanfiles="$cleanfiles ${outdir}/img2_mtime_001.pgm 
${outdir}/img2_mtime_002.pgm ${outdir}/img2_mtime_003.pgm"
+    ffmpeg -f lavfi -i color=c=black:s=2x2:r=1:d=3 \
+           -c:v pgm \
+           -metadata creation_time="2024-01-01T00:00:00.000000Z" \
+           -update_filemtime 1 \
+           -y $file || return
+    probe -f image2 -ts_from_file sec \
+          -show_entries packet=pts \
+          -of csv=p=0 \
+          -i $file
+}
+
 lavf_video(){
     t="${test#lavf-}"
     outdir="tests/data/lavf"
diff --git a/tests/fate/image.mak b/tests/fate/image.mak
index 334c421140..962af8c72b 100644
--- a/tests/fate/image.mak
+++ b/tests/fate/image.mak
@@ -607,8 +607,16 @@ FATE_IMAGE += $(FATE_IMAGE-yes)
 FATE_IMAGE_PROBE += $(FATE_IMAGE_PROBE-yes)
 FATE_IMAGE_TRANSCODE += $(FATE_IMAGE_TRANSCODE-yes)
 
+FATE_IMG2_MUXER-$(call ALLYES, LAVFI_INDEV COLOR_FILTER PGM_ENCODER \
+    IMAGE2_MUXER IMAGE2_DEMUXER FILE_PROTOCOL FFPROBE) \
+    += fate-img2-update-filemtime
+fate-img2-update-filemtime: CMD = img2_update_filemtime
+fate-img2-update-filemtime: REF = 
$(SRC_PATH)/tests/ref/fate/img2-update-filemtime
+
+FATE_AVCONV += $(FATE_IMG2_MUXER-yes)
+
 FATE_SAMPLES_FFMPEG += $(FATE_IMAGE)
 FATE_SAMPLES_FFPROBE += $(FATE_IMAGE_PROBE)
 FATE_SAMPLES_FFMPEG_FFPROBE += $(FATE_IMAGE_TRANSCODE)
 
-fate-image: $(FATE_IMAGE) $(FATE_IMAGE_PROBE) $(FATE_IMAGE_TRANSCODE)
+fate-image: $(FATE_IMAGE) $(FATE_IMAGE_PROBE) $(FATE_IMAGE_TRANSCODE) 
$(FATE_IMG2_MUXER-yes)
diff --git a/tests/ref/fate/img2-update-filemtime 
b/tests/ref/fate/img2-update-filemtime
new file mode 100644
index 0000000000..630eccf559
--- /dev/null
+++ b/tests/ref/fate/img2-update-filemtime
@@ -0,0 +1,3 @@
+1704067200
+1704067201
+1704067202
-- 
2.52.0

_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to