Add UploadAttachment, a unary RPC that accepts a file in bounded chunks and streams it to a temp file instead of holding the whole blob in one request body and in memory. A call carrying a spec starts an upload and returns an opaque upload_id; later calls carry that id with write_offset, data, and finish_write, following the Cloud Storage WriteObject shape. Retrying the last chunk after a lost response is idempotent, uploads are bound to their owner, and they expire after 30 minutes of inactivity. CreateAttachment and the chunked finalize now share one processing pipeline for motion-photo detection, EXIF stripping, and storage. The motion-photo detector works over an io.ReaderAt so large JPEGs are never loaded whole, and the request body cap is a single per-procedure lookup. The web editor uploads through the new RPC in 2 MiB chunks with bounded concurrency under the server's per-user limit.
83 lines
1.9 KiB
Go
83 lines
1.9 KiB
Go
package motionphoto
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"regexp"
|
|
"strconv"
|
|
)
|
|
|
|
type Detection struct {
|
|
VideoStart int
|
|
PresentationTimestampUs int64
|
|
}
|
|
|
|
var (
|
|
motionPhotoMarkerRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhoto|GCamera:MotionPhoto|MicroVideo)["'=:\s>]+1`)
|
|
presentationRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhotoPresentationTimestampUs|GCamera:MotionPhotoPresentationTimestampUs)["'=:\s>]+(-?\d+)`)
|
|
microVideoOffsetRegex = regexp.MustCompile(`(?i)(?:Camera:MicroVideoOffset|GCamera:MicroVideoOffset)["'=:\s>]+(\d+)`)
|
|
)
|
|
|
|
const maxMetadataScanBytes = 256 * 1024
|
|
|
|
// DetectJPEG detects embedded video in an in-memory JPEG.
|
|
func DetectJPEG(blob []byte) *Detection {
|
|
// bytes.Reader never fails within bounds, so the error is unreachable.
|
|
detection, _ := DetectJPEGReader(bytes.NewReader(blob), int64(len(blob)))
|
|
return detection
|
|
}
|
|
|
|
func ExtractVideo(blob []byte) ([]byte, *Detection) {
|
|
detection := DetectJPEG(blob)
|
|
if detection == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
videoBlob := blob[detection.VideoStart:]
|
|
if !looksLikeMP4(videoBlob) {
|
|
return nil, nil
|
|
}
|
|
|
|
return videoBlob, detection
|
|
}
|
|
|
|
func parsePresentationTimestampUs(text string) int64 {
|
|
matches := presentationRegex.FindStringSubmatch(text)
|
|
if len(matches) != 2 {
|
|
return 0
|
|
}
|
|
|
|
value, err := strconv.ParseInt(matches[1], 10, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return value
|
|
}
|
|
|
|
func findEmbeddedMP4Start(blob []byte) int {
|
|
searchFrom := len(blob)
|
|
for searchFrom > 8 {
|
|
index := bytes.LastIndex(blob[:searchFrom], []byte("ftyp"))
|
|
if index < 4 {
|
|
return -1
|
|
}
|
|
|
|
start := index - 4
|
|
if looksLikeMP4(blob[start:]) {
|
|
return start
|
|
}
|
|
|
|
searchFrom = index - 1
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
func looksLikeMP4(blob []byte) bool {
|
|
if len(blob) < 12 || !bytes.Equal(blob[4:8], []byte("ftyp")) {
|
|
return false
|
|
}
|
|
|
|
size := binary.BigEndian.Uint32(blob[:4])
|
|
return size == 1 || size >= 8
|
|
}
|