Recursive AI

Remove GPS location and metadata from a video in C#

All onemedia recipes

OneMedia’s privacy strip removes the GPS location and device serial numbers it knows where to find, the fields listed in its strip page, and reports what it removed and what it found and left. It is a preview in 0.4.0-preview.1.

Do not rely on it as proof that a file is clean. That is the README’s own warning. In its words, the strip “Strips the location and device identifiers listed in docs/strip.md and reports what it found and could not remove. It does not search free text, unknown vendor atoms or trailers.” In the project’s own review of the strip (strip review), “on a 65-file adversarial set 39 files were fully clean and 26 still held a secret in a form the strip does not look for (free text, unlisted names, unread placements, two cases it now reports)”.

What it touches: location and serial-number fields in MP4/MOV user data, QuickTime keys, XMP and embedded Exif; GPS and serials in GoPro (GPMF), Google CAMM, Apple mebx and DJI djmd telemetry tracks; coordinates written as text in MP4 subtitle tracks; GPS in AVCHD camera data; and tags whose names say location or serial in Matroska, MP3, FLAC, Ogg, WAV and ASF.

What it doesn’t: coordinates or serials in free text (a title, comment or description), names it doesn’t know, unknown vendor atoms and data tracks, bytes appended after the file’s last box (trailers), sidecar files, thumbnails, dates, make and model, and the file name. The program below shows one of these: a position typed into a comment survives, and the report doesn’t mention it.

Terminal
dotnet new console -o StripGps && cd StripGps
dotnet add package RecursiveAI.OneMedia --version 0.4.0-preview.1
curl -L -o hero6.mp4 https://raw.githubusercontent.com/gopro/gpmf-parser/9a7150632892c7356c91145c889016f07b0ed48d/samples/hero6.mp4

hero6.mp4 is a 23-second clip recorded on a GoPro HERO6 Black, from GoPro’s own gpmf-parser repository, which GoPro publishes under the Apache License 2.0 or the MIT licence (the samples carry no separate terms). It holds a real GPS fix in its user data, 417 GPS samples in its telemetry track, and the camera’s and lens’s serial numbers. Put this in Program.cs and run dotnet run:

C#
using RecursiveAI.OneMedia;
using RecursiveAI.OneMedia.Privacy;
 
// A copy with the place typed into its comment, the way a person might note it.
using (MediaFile media = Media.Open("hero6.mp4"))
{
    media.Tags.Comment = "Filmed at 33.12665, -117.32721";
    media.SaveAs("hero6-note.mp4");
}
 
// Strip location and device identifiers (both by default) into a new file.
StripReport report = PrivacyStrip.SaveAs("hero6-note.mp4", "hero6-stripped.mp4");
foreach (StripEntry entry in report.Entries)
    Console.WriteLine($"{entry.Action,-18} {entry.Category,-9} {entry.System}:{entry.Name} x{entry.Count}");
foreach (StripLeftover left in report.NotRemoved)
    Console.WriteLine($"not removed: {left.System}:{left.Name}: {left.Reason}");
Console.WriteLine($"IsComplete: {report.IsComplete}");
foreach (string note in report.Notes)
    Console.WriteLine($"note: {note}");
Text
ConvertedToPadding Location  udta:©xyz x1
ConvertedToPadding DeviceIds udta:LENS x1
ConvertedToPadding DeviceIds udta:CAME x1
ConvertedToPadding DeviceIds udta:MUID x1
ConvertedToPadding DeviceIds udta:BCID x1
ConvertedToPadding DeviceIds udta:GUMI x1
Zeroed             DeviceIds udta:LINF x1
Zeroed             DeviceIds udta:CINF x1
Zeroed             DeviceIds udta:CASN x1
Zeroed             DeviceIds udta:MUID x1
Zeroed             Location  gpmd:GPSF x23
Zeroed             Location  gpmd:GPSU x23
Zeroed             Location  gpmd:GPSP x23
Zeroed             Location  gpmd:GPS5 x23
Zeroed             DeviceIds mdat-gap:gopro settings: lens serial number x2
Zeroed             DeviceIds mdat-gap:gopro settings: camera serial hash x2
Zeroed             DeviceIds mdat-gap:gopro settings: camera serial number x2
Zeroed             Location  mdat-gap:udta:©xyz x1
Zeroed             DeviceIds mdat-gap:udta:LENS x2
Zeroed             DeviceIds mdat-gap:udta:CAME x4
Zeroed             DeviceIds mdat-gap:udta:MUID (id) x4
Zeroed             DeviceIds mdat-gap:GPMF:LINF x2
Zeroed             DeviceIds mdat-gap:GPMF:CINF x4
Zeroed             DeviceIds mdat-gap:GPMF:CASN x2
Zeroed             Location  mdat-gap:facts:udta:©xyz x1
IsComplete: True
note: Data track 5 (fdsc) has no known layout: only the values of fields removed elsewhere were searched for in it; a position or serial number stored in another form stays.
note: Subtitle and text tracks are scanned for coordinates and serial numbers written as text (docs/strip.md lists the patterns); other text in them stays.
note: Key names in a QuickTime metadata key table (keys box) and in mebx sample entries stay; their values are gone.
note: Layout is unchanged: every changed range keeps its length and no other byte moved.

Each line is one field or telemetry stream: what was done (ConvertedToPadding: the atom became a free box of the same size; Zeroed: its value bytes are now zeros), the category, where it was, and how many times it occurred. udta:©xyz is the position in the user data; LENS, CAME, MUID, BCID, GUMI and the GPMF CASN, LINF, CINF fields hold serials and IDs; gpmd:GPS5 and the other gpmd keys are the 23 GPS payloads of the telemetry track; the mdat-gap lines are the copies GoPro writes into its settings block, found by searching for the values read elsewhere. NotRemoved is empty, so IsComplete is true.

IsComplete covers only what the strip looks for. The comment still holds the position, as ExifTool and the byte search below show, and nothing in the report says so. That is what “does not search free text” means. Clear free-text fields yourself, for example with media.Tags.Comment = null (Edit MP4, MKV and MP3 tags), and check the output with another tool.

Check it with ExifTool

ExifTool 13.60 with -ee (read the telemetry samples too), -a (every copy), -G3 (which document each tag comes from) and -u (unknown tags), counting each location, serial and comment tag before and after. ExifTool prints a Perl warning about a GoPro AACD field on this file; 2>/dev/null hides it.

Terminal
for f in hero6-note.mp4 hero6-stripped.mp4; do
  echo "$f"
  exiftool -ee -a -G3 -u "$f" 2>/dev/null | grep -iE 'gps|serial|unique|comment' | sed -E 's/^\[[^]]*\] *//; s/ *: .*//' | sort | uniq -c
done
Text
hero6-note.mp4
      1 Camera Serial Number
      1 Camera Serial Number Hash
      1 Comment
      1 Comments
    417 GPS Altitude
      1 GPS Coordinates
     23 GPS Date Time
     23 GPS Horizontal Positioning Error
    418 GPS Latitude
    418 GPS Longitude
     23 GPS Measure Mode
      1 GPS Position
    417 GPS Speed
    417 GPS Speed 3D
      1 Lens Serial Number
      1 Media Unique ID
      1 Other Serial Number
      1 Serial Number
     23 Stream Name
hero6-stripped.mp4
      1 Comment
      1 Comments
      1 Other Serial Number
      1 Serial Number
     23 Stream Name
Terminal
exiftool -a -u -G1 -GPSCoordinates -LensSerialNumber -CameraSerialNumber -MediaUID -Comment hero6-note.mp4 hero6-stripped.mp4 2>/dev/null
Text
======== hero6-note.mp4
[UserData]      GPS Coordinates                 : 33 deg 7' 35.40" N, 117 deg 19' 37.92" W
[UserData]      Lens Serial Number              : LAJ7041318601982
[GoPro]         Camera Serial Number            : C32213DVT14067
[UserData]      Media UID                       : 49f0f8cc4de0aba700d740c5e5c141a107e2160b000506e02d7a000000000000
[UserData]      Comment                         : Filmed at 33.12665, -117.32721
======== hero6-stripped.mp4
[UserData]      Comment                         : Filmed at 33.12665, -117.32721
    2 image files read

Every GPS reading is gone, and so are the serial numbers and the media ID. Two names remain: ExifTool still lists Serial Number and Other Serial Number from the GoPro settings sample, with empty values (the bytes are zeros), and Stream Name is the telemetry stream’s label (“GPS (Lat., Long., Alt., 2D speed, 3D speed)”), not a position. Comments is the label of GoPro’s face-detection stream. The Comment with the typed position stays.

Search the bytes

ExifTool shows what it can parse. To look for the secrets wherever they might sit, this script counts them in each file: the two serial numbers and the position as text, the typed comment, and every GPS fix of the original as GPMF stores it (a big-endian 32-bit integer of degrees × 10,000,000).

Terminal
cat > find-secrets.py <<'EOF'
import json, struct, subprocess, sys
meta = json.loads(subprocess.run(["exiftool", "-j", "-n", "-ee", "-a", "-G3", "-GPSLatitude", "-GPSLongitude", "hero6.mp4"],
                                 capture_output=True, text=True).stdout)[0]
fixes = {struct.pack(">i", round(v * 1e7)) for k, v in meta.items() if k.startswith("Doc")}
texts = ["C32213DVT14067", "LAJ7041318601982", "+33.1265-117.3272", "33.12665, -117.32721"]
for path in sys.argv[1:]:
    data = open(path, "rb").read()
    print(path)
    for t in texts:
        print(f"  {t:<22} {data.count(t.encode())}")
    print(f"  GPS fixes (int32)      {sum(data.count(f) for f in fixes)} hits for {len(fixes)} values")
EOF
python3 find-secrets.py hero6.mp4 hero6-stripped.mp4
Text
hero6.mp4
  C32213DVT14067         3
  LAJ7041318601982       4
  +33.1265-117.3272      2
  33.12665, -117.32721   0
  GPS fixes (int32)      835 hits for 765 values
hero6-stripped.mp4
  C32213DVT14067         0
  LAJ7041318601982       0
  +33.1265-117.3272      0
  33.12665, -117.32721   1
  GPS fixes (int32)      1 hits for 765 values

The serials and the position text are gone; the typed comment is still there. One four-byte match for a GPS value remains: it lies inside an H.264 video packet (at byte 8,491,773, in the packet that starts at 8,472,347), the same bytes as in the original. It is compressed picture data that happens to equal one fix’s latitude, not metadata, and the strip doesn’t change video packets. The project’s review found the same kind of chance match in HERO6 and HERO8 accelerometer data.

The picture and sound are unchanged

The strip changes only the reported ranges, at their own length, so the file keeps its size and the audio and video packets keep their bytes. ffprobe’s packet hashes for both:

Terminal
for f in hero6.mp4 hero6-stripped.mp4; do
  for s in v:0 a:0; do
    echo "$f $s $(ffprobe -v error -select_streams $s -show_packets -show_data_hash sha256 -show_entries packet=data_hash -of csv=p=0 $f | sha256sum | cut -c1-16)"
  done
done
stat -c '%s %n' hero6.mp4 hero6-stripped.mp4
Text
hero6.mp4 v:0 4eb13c3f7a7c539a
hero6.mp4 a:0 36dd73a41e2108fc
hero6-stripped.mp4 v:0 4eb13c3f7a7c539a
hero6-stripped.mp4 a:0 36dd73a41e2108fc
8878253 hero6.mp4
8878253 hero6-stripped.mp4

From the command line

onemedia strip in.mp4 out.mp4 does the same and prints the report; --location or --device-ids limits it to one category, and it exits 3 when something was found and could not be removed. Install the tool with dotnet tool install --global RecursiveAI.OneMedia.Tool --version 0.4.0-preview.1.

Limits

  • A preview, with listed gaps. The strip page lists every field it looks for and everything it doesn’t cover; the strip review is the project’s own attempt to break it (not an external audit), with its open findings. In the README’s words: “Do not rely on it as proof that a file is clean”.
  • Free text, unknown names and placements stay without a word in the report: a coordinate in a title or comment, a vendor’s own atom or data track (Sony rtmd, Samsung trailers, NMEA gps atoms), a GPS directory reached from the Exif directory instead of the first one, a second ID3 tag in an MP3, the later links of a chained Ogg file.
  • Non-ISO containers. In Matroska, MP3, FLAC, Ogg, WAV and ASF only tags are stripped, through the tag writer; subtitle text in those files that matches the coordinate rules is reported in NotRemoved, not edited. Standalone subtitle files and sidecars (.SRT, XMP, Sony clip XML) are not opened.
  • What stays on purpose. Make, model, firmware and orientation; the recording dates (see Change a video’s recorded date); the names in key tables, whose values are gone.
  • Saving. PrivacyStrip.SaveAs writes a new file and leaves the input alone; Save replaces the file through a temporary file; SaveInPlace overwrites only the changed ranges and works only when nothing moves.